Skip to content

Commit 080a7c3

Browse files
authored
Merge branch 'main' into fix/listchanged-overwrite
2 parents f5a99d9 + 65bbcea commit 080a7c3

File tree

5 files changed

+218
-14
lines changed

5 files changed

+218
-14
lines changed
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@modelcontextprotocol/core': patch
3+
---
4+
5+
Fix InMemoryTaskStore to enforce session isolation. Previously, sessionId was accepted but ignored on all TaskStore methods, allowing any session to enumerate, read, and mutate tasks created by other sessions. The store now persists sessionId at creation time and enforces ownership on all reads and writes.
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
# Source: https://github.com/anthropics/claude-code-action/blob/main/docs/code-review.md
2+
name: Claude Code Review
3+
4+
on:
5+
pull_request:
6+
types: [opened, synchronize, ready_for_review, reopened]
7+
8+
jobs:
9+
claude-review:
10+
if: github.event.pull_request.head.repo.fork == false && github.actor != 'dependabot[bot]'
11+
runs-on: ubuntu-latest
12+
permissions:
13+
contents: read
14+
pull-requests: read
15+
issues: read
16+
id-token: write
17+
18+
steps:
19+
- name: Checkout repository
20+
uses: actions/checkout@v6
21+
with:
22+
fetch-depth: 1
23+
24+
- name: Run Claude Code Review
25+
id: claude-review
26+
uses: anthropics/claude-code-action@v1
27+
with:
28+
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
29+
plugin_marketplaces: "https://github.com/anthropics/claude-code.git"
30+
plugins: "code-review@claude-code-plugins"
31+
prompt: "/code-review:code-review ${{ github.repository }}/pull/${{ github.event.pull_request.number }}"

.github/workflows/claude.yml

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
# Source: https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md
2+
name: Claude Code
3+
4+
on:
5+
issue_comment:
6+
types: [created]
7+
pull_request_review_comment:
8+
types: [created]
9+
issues:
10+
types: [opened, assigned]
11+
pull_request_review:
12+
types: [submitted]
13+
14+
jobs:
15+
claude:
16+
if: |
17+
(github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) ||
18+
(github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) ||
19+
(github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) ||
20+
(github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude')))
21+
runs-on: ubuntu-latest
22+
permissions:
23+
contents: read
24+
pull-requests: read
25+
issues: read
26+
id-token: write
27+
actions: read
28+
steps:
29+
- name: Checkout repository
30+
uses: actions/checkout@v6
31+
with:
32+
fetch-depth: 1
33+
34+
- name: Run Claude Code
35+
id: claude
36+
uses: anthropics/claude-code-action@v1
37+
with:
38+
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
39+
use_commit_signing: true
40+
additional_permissions: |
41+
actions: read

packages/core/src/experimental/tasks/stores/inMemory.ts

Lines changed: 42 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ interface StoredTask {
1111
task: Task;
1212
request: Request;
1313
requestId: RequestId;
14+
sessionId?: string;
1415
result?: Result;
1516
}
1617

@@ -30,7 +31,7 @@ export class InMemoryTaskStore implements TaskStore {
3031
return crypto.randomUUID().replaceAll('-', '');
3132
}
3233

33-
async createTask(taskParams: CreateTaskOptions, requestId: RequestId, request: Request, _sessionId?: string): Promise<Task> {
34+
async createTask(taskParams: CreateTaskOptions, requestId: RequestId, request: Request, sessionId?: string): Promise<Task> {
3435
// Generate a unique task ID
3536
const taskId = this.generateTaskId();
3637

@@ -55,7 +56,8 @@ export class InMemoryTaskStore implements TaskStore {
5556
this.tasks.set(taskId, {
5657
task,
5758
request,
58-
requestId
59+
requestId,
60+
sessionId
5961
});
6062

6163
// Schedule cleanup if ttl is specified
@@ -72,13 +74,30 @@ export class InMemoryTaskStore implements TaskStore {
7274
return task;
7375
}
7476

75-
async getTask(taskId: string, _sessionId?: string): Promise<Task | null> {
77+
/**
78+
* Retrieves a stored task, enforcing session ownership when a sessionId is provided.
79+
* Returns undefined if the task does not exist or belongs to a different session.
80+
*/
81+
private getStoredTask(taskId: string, sessionId?: string): StoredTask | undefined {
7682
const stored = this.tasks.get(taskId);
83+
if (!stored) {
84+
return undefined;
85+
}
86+
// Enforce session isolation: if a sessionId is provided and the task
87+
// was created with a sessionId, they must match.
88+
if (sessionId !== undefined && stored.sessionId !== undefined && stored.sessionId !== sessionId) {
89+
return undefined;
90+
}
91+
return stored;
92+
}
93+
94+
async getTask(taskId: string, sessionId?: string): Promise<Task | null> {
95+
const stored = this.getStoredTask(taskId, sessionId);
7796
return stored ? { ...stored.task } : null;
7897
}
7998

80-
async storeTaskResult(taskId: string, status: 'completed' | 'failed', result: Result, _sessionId?: string): Promise<void> {
81-
const stored = this.tasks.get(taskId);
99+
async storeTaskResult(taskId: string, status: 'completed' | 'failed', result: Result, sessionId?: string): Promise<void> {
100+
const stored = this.getStoredTask(taskId, sessionId);
82101
if (!stored) {
83102
throw new Error(`Task with ID ${taskId} not found`);
84103
}
@@ -110,8 +129,8 @@ export class InMemoryTaskStore implements TaskStore {
110129
}
111130
}
112131

113-
async getTaskResult(taskId: string, _sessionId?: string): Promise<Result> {
114-
const stored = this.tasks.get(taskId);
132+
async getTaskResult(taskId: string, sessionId?: string): Promise<Result> {
133+
const stored = this.getStoredTask(taskId, sessionId);
115134
if (!stored) {
116135
throw new Error(`Task with ID ${taskId} not found`);
117136
}
@@ -123,8 +142,8 @@ export class InMemoryTaskStore implements TaskStore {
123142
return stored.result;
124143
}
125144

126-
async updateTaskStatus(taskId: string, status: Task['status'], statusMessage?: string, _sessionId?: string): Promise<void> {
127-
const stored = this.tasks.get(taskId);
145+
async updateTaskStatus(taskId: string, status: Task['status'], statusMessage?: string, sessionId?: string): Promise<void> {
146+
const stored = this.getStoredTask(taskId, sessionId);
128147
if (!stored) {
129148
throw new Error(`Task with ID ${taskId} not found`);
130149
}
@@ -159,13 +178,22 @@ export class InMemoryTaskStore implements TaskStore {
159178
}
160179
}
161180

162-
async listTasks(cursor?: string, _sessionId?: string): Promise<{ tasks: Task[]; nextCursor?: string }> {
181+
async listTasks(cursor?: string, sessionId?: string): Promise<{ tasks: Task[]; nextCursor?: string }> {
163182
const PAGE_SIZE = 10;
164-
const allTaskIds = [...this.tasks.keys()];
183+
184+
// Filter tasks by session ownership before pagination
185+
const filteredTaskIds = [...this.tasks.entries()]
186+
.filter(([, stored]) => {
187+
if (sessionId === undefined || stored.sessionId === undefined) {
188+
return true;
189+
}
190+
return stored.sessionId === sessionId;
191+
})
192+
.map(([taskId]) => taskId);
165193

166194
let startIndex = 0;
167195
if (cursor) {
168-
const cursorIndex = allTaskIds.indexOf(cursor);
196+
const cursorIndex = filteredTaskIds.indexOf(cursor);
169197
if (cursorIndex === -1) {
170198
// Invalid cursor - throw error
171199
throw new Error(`Invalid cursor: ${cursor}`);
@@ -174,13 +202,13 @@ export class InMemoryTaskStore implements TaskStore {
174202
}
175203
}
176204

177-
const pageTaskIds = allTaskIds.slice(startIndex, startIndex + PAGE_SIZE);
205+
const pageTaskIds = filteredTaskIds.slice(startIndex, startIndex + PAGE_SIZE);
178206
const tasks = pageTaskIds.map(taskId => {
179207
const stored = this.tasks.get(taskId)!;
180208
return { ...stored.task };
181209
});
182210

183-
const nextCursor = startIndex + PAGE_SIZE < allTaskIds.length ? pageTaskIds.at(-1) : undefined;
211+
const nextCursor = startIndex + PAGE_SIZE < filteredTaskIds.length ? pageTaskIds.at(-1) : undefined;
184212

185213
return { tasks, nextCursor };
186214
}

packages/core/test/experimental/inMemory.test.ts

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -647,6 +647,105 @@ describe('InMemoryTaskStore', () => {
647647
});
648648
});
649649

650+
describe('session isolation', () => {
651+
const baseRequest: Request = { method: 'tools/call', params: { name: 'demo' } };
652+
653+
it('should not allow session-b to list tasks created by session-a', async () => {
654+
await store.createTask({}, 1, baseRequest, 'session-a');
655+
await store.createTask({}, 2, baseRequest, 'session-a');
656+
657+
const result = await store.listTasks(undefined, 'session-b');
658+
expect(result.tasks).toHaveLength(0);
659+
});
660+
661+
it('should not allow session-b to read a task created by session-a', async () => {
662+
const task = await store.createTask({}, 1, baseRequest, 'session-a');
663+
664+
const result = await store.getTask(task.taskId, 'session-b');
665+
expect(result).toBeNull();
666+
});
667+
668+
it('should not allow session-b to update a task created by session-a', async () => {
669+
const task = await store.createTask({}, 1, baseRequest, 'session-a');
670+
671+
await expect(store.updateTaskStatus(task.taskId, 'cancelled', undefined, 'session-b')).rejects.toThrow('not found');
672+
});
673+
674+
it('should not allow session-b to store a result on session-a task', async () => {
675+
const task = await store.createTask({}, 1, baseRequest, 'session-a');
676+
677+
await expect(store.storeTaskResult(task.taskId, 'completed', { content: [] }, 'session-b')).rejects.toThrow('not found');
678+
});
679+
680+
it('should not allow session-b to get the result of session-a task', async () => {
681+
const task = await store.createTask({}, 1, baseRequest, 'session-a');
682+
await store.storeTaskResult(task.taskId, 'completed', { content: [{ type: 'text', text: 'secret' }] }, 'session-a');
683+
684+
await expect(store.getTaskResult(task.taskId, 'session-b')).rejects.toThrow('not found');
685+
});
686+
687+
it('should allow the owning session to access its own tasks', async () => {
688+
const task = await store.createTask({}, 1, baseRequest, 'session-a');
689+
690+
const retrieved = await store.getTask(task.taskId, 'session-a');
691+
expect(retrieved).toBeDefined();
692+
expect(retrieved?.taskId).toBe(task.taskId);
693+
});
694+
695+
it('should list only tasks belonging to the requesting session', async () => {
696+
await store.createTask({}, 1, baseRequest, 'session-a');
697+
await store.createTask({}, 2, baseRequest, 'session-b');
698+
await store.createTask({}, 3, baseRequest, 'session-a');
699+
700+
const resultA = await store.listTasks(undefined, 'session-a');
701+
expect(resultA.tasks).toHaveLength(2);
702+
703+
const resultB = await store.listTasks(undefined, 'session-b');
704+
expect(resultB.tasks).toHaveLength(1);
705+
});
706+
707+
it('should allow access when no sessionId is provided (backward compatibility)', async () => {
708+
const task = await store.createTask({}, 1, baseRequest, 'session-a');
709+
710+
// No sessionId on read = no filtering
711+
const retrieved = await store.getTask(task.taskId);
712+
expect(retrieved).toBeDefined();
713+
});
714+
715+
it('should allow access when task was created without sessionId', async () => {
716+
const task = await store.createTask({}, 1, baseRequest);
717+
718+
// Any sessionId on read should still see the task
719+
const retrieved = await store.getTask(task.taskId, 'session-b');
720+
expect(retrieved).toBeDefined();
721+
});
722+
723+
it('should paginate correctly within a session', async () => {
724+
// Create 15 tasks for session-a, 5 for session-b
725+
for (let i = 1; i <= 15; i++) {
726+
await store.createTask({}, i, baseRequest, 'session-a');
727+
}
728+
for (let i = 16; i <= 20; i++) {
729+
await store.createTask({}, i, baseRequest, 'session-b');
730+
}
731+
732+
// First page for session-a should have 10
733+
const page1 = await store.listTasks(undefined, 'session-a');
734+
expect(page1.tasks).toHaveLength(10);
735+
expect(page1.nextCursor).toBeDefined();
736+
737+
// Second page for session-a should have 5
738+
const page2 = await store.listTasks(page1.nextCursor, 'session-a');
739+
expect(page2.tasks).toHaveLength(5);
740+
expect(page2.nextCursor).toBeUndefined();
741+
742+
// session-b should only see its 5
743+
const resultB = await store.listTasks(undefined, 'session-b');
744+
expect(resultB.tasks).toHaveLength(5);
745+
expect(resultB.nextCursor).toBeUndefined();
746+
});
747+
});
748+
650749
describe('cleanup', () => {
651750
it('should clear all timers and tasks', async () => {
652751
await store.createTask({ ttl: 1000 }, 1, {

0 commit comments

Comments
 (0)