Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .changeset/protected-branch-claim-rejected.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
'@colony/core': patch
'@colony/mcp-server': patch
---

Reject `task_claim_file` at the MCP layer when the task's branch is a protected base branch.

`guardedClaimFile` already returned `protected_branch_rejected` (controlled by the `rejectProtectedBranchClaims` setting, default `true`) but the MCP handler silently fell through and recorded the claim anyway. The handler now checks for that status and returns a distinct `PROTECTED_BRANCH_CLAIM_REJECTED` error code with a message directing the agent to start a sandbox worktree first.

`PROTECTED_BRANCH_CLAIM_REJECTED` is added to `TASK_THREAD_ERROR_CODES` in `@colony/core`. Two new integration tests cover the reject and allow cases.

Note: the same `guardedClaimFile` call in `task_plan_claim_subtask` has the same gap; that is out of scope for this patch.
10 changes: 9 additions & 1 deletion apps/mcp-server/src/tools/task.ts
Original file line number Diff line number Diff line change
Expand Up @@ -315,7 +315,7 @@ export function register(server: McpServer, ctx: ToolContext): void {

server.tool(
'task_claim_file',
'Claim a file before editing so other agents see ownership and overlap warnings. Use before editing to avoid conflict and make file ownership visible; claims are soft coordination and never block writes.',
'Claim a file before editing so other agents see ownership and overlap warnings. Use before editing to avoid conflict and make file ownership visible; claims are soft coordination and never block writes. Rejected with PROTECTED_BRANCH_CLAIM_REJECTED when the task branch is a protected base branch (main/master/dev/develop/production/release) — start a sandbox worktree first.',
{
task_id: z.number().int().positive(),
session_id: z.string().min(1),
Expand Down Expand Up @@ -356,6 +356,14 @@ export function register(server: McpServer, ctx: ToolContext): void {
if (guarded.status === 'task_not_found') {
return mcpErrorResponse('TASK_NOT_FOUND', `task ${task_id} not found`);
}
if (guarded.status === 'protected_branch_rejected') {
return mcpErrorResponse(
'PROTECTED_BRANCH_CLAIM_REJECTED',
guarded.recommendation ??
`task ${task_id} is on protected branch ${guarded.protected_branch?.branch}; start a sandbox worktree first`,
{ ...guarded },
);
}
new TaskThread(store, task_id).join(session_id, agentForTaskClaim(session_id));
const id = store.addObservation({
session_id,
Expand Down
59 changes: 59 additions & 0 deletions apps/mcp-server/test/task-threads.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1517,3 +1517,62 @@ describe('task threads — handoff lifecycle', () => {
expect(afterMeta.status).toBe('expired');
});
});

describe('task_claim_file — protected-branch guard', () => {
// Isolated store + server with the guard enabled (default setting).
// The module-level beforeEach uses rejectProtectedBranchClaims:false so the
// existing fixtures keep working; this suite needs the default-on behavior.
let guardedDir: string;
let guardedStore: MemoryStore;
let guardedClient: Client;

beforeEach(async () => {
guardedDir = mkdtempSync(join(tmpdir(), 'colony-protected-branch-'));
const settings = { ...defaultSettings, rejectProtectedBranchClaims: true };
guardedStore = new MemoryStore({ dbPath: join(guardedDir, 'data.db'), settings });
const server = buildServer(guardedStore, settings);
const [clientT, serverT] = InMemoryTransport.createLinkedPair();
guardedClient = new Client({ name: 'test-guard', version: '0.0.0' });
await Promise.all([server.connect(serverT), guardedClient.connect(clientT)]);
});

afterEach(async () => {
await guardedClient.close();
guardedStore.close();
rmSync(guardedDir, { recursive: true, force: true });
});

it('rejects task_claim_file with PROTECTED_BRANCH_CLAIM_REJECTED when task branch is main', async () => {
guardedStore.startSession({ id: 'S1', ide: 'claude-code', cwd: '/repo' });
const thread = TaskThread.open(guardedStore, {
repo_root: '/repo',
branch: 'main',
session_id: 'S1',
});
const res = await guardedClient.callTool({
name: 'task_claim_file',
arguments: { task_id: thread.task_id, session_id: 'S1', file_path: '/repo/src/index.ts' },
});
expect(res.isError).toBe(true);
const body = JSON.parse((res.content as Array<{ type: string; text: string }>)[0]?.text ?? '{}');
expect(body.code).toBe(TASK_THREAD_ERROR_CODES.PROTECTED_BRANCH_CLAIM_REJECTED);
// No claim row written.
expect(guardedStore.storage.getClaim(thread.task_id, '/repo/src/index.ts')).toBeFalsy();
});

it('allows task_claim_file when task branch is an agent/* branch', async () => {
guardedStore.startSession({ id: 'S2', ide: 'claude-code', cwd: '/repo' });
const thread = TaskThread.open(guardedStore, {
repo_root: '/repo',
branch: 'agent/claude/my-fix',
session_id: 'S2',
});
const res = await guardedClient.callTool({
name: 'task_claim_file',
arguments: { task_id: thread.task_id, session_id: 'S2', file_path: '/repo/src/index.ts' },
});
expect(res.isError).toBeFalsy();
const body = JSON.parse((res.content as Array<{ type: string; text: string }>)[0]?.text ?? '{}');
expect(body.claim_status).toBe('claimed');
});
});
1 change: 1 addition & 0 deletions packages/core/src/task-thread.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,7 @@ export const TASK_THREAD_ERROR_CODES = {
CLAIM_BATON_MISSING: 'CLAIM_BATON_MISSING',
CLAIM_BATON_CONFLICT: 'CLAIM_BATON_CONFLICT',
INVALID_CLAIM_PATH: 'INVALID_CLAIM_PATH',
PROTECTED_BRANCH_CLAIM_REJECTED: 'PROTECTED_BRANCH_CLAIM_REJECTED',
INTERNAL_ERROR: 'INTERNAL_ERROR',
} as const;

Expand Down
Loading