task-write-security.test.mjs
166 lines 5.0 KB
Raw
sha256:b5f647cb9c409f563d4671fe3fc05ddea01fabfed9b41fc11cb923588e1c1baf mirror: GitHub Phase A durable MCP OAuth (#270) Human minor ⚠ breaking 10 days ago
1 /**
2 * Tier 7 — SECURITY: scope denial, no widening, injection inert, no secrets, stale concurrency.
3 */
4 import { describe, it, beforeEach, afterEach } from 'node:test';
5 import assert from 'node:assert/strict';
6 import fs from 'node:fs';
7 import path from 'node:path';
8 import { fileURLToPath } from 'node:url';
9
10 import {
11 handleTaskProposeRequest,
12 handleTaskLoopProposeRequest,
13 taskStateId,
14 loopStateId,
15 } from '../lib/task/task-write.mjs';
16 import { getTask, taskForClient } from '../lib/task/task-store.mjs';
17 import { getTaskLoop, taskLoopForClient } from '../lib/task/task-loop-store.mjs';
18 import { createProposal } from '../hub/proposals-store.mjs';
19 import {
20 approveTaskProposal,
21 emptyTaskStarterDir,
22 sampleLoopCreatePayload,
23 sampleTaskCreatePayload,
24 } from './fixtures/task/write-helpers.mjs';
25
26 const __dirname = path.dirname(fileURLToPath(import.meta.url));
27 const tmpRoot = path.join(__dirname, 'fixtures', 'tmp-task-write-sec');
28
29 describe('task write — security', () => {
30 const dataDir = path.join(tmpRoot, 'data');
31 let starterDir;
32
33 beforeEach(() => {
34 fs.rmSync(tmpRoot, { recursive: true, force: true });
35 fs.mkdirSync(dataDir, { recursive: true });
36 starterDir = emptyTaskStarterDir(dataDir);
37 process.env.TASK_WRITES_ENABLED = '1';
38 });
39 afterEach(() => {
40 delete process.env.TASK_WRITES_ENABLED;
41 });
42
43 it('personal writer cannot create project-scoped task', async () => {
44 const body = sampleTaskCreatePayload();
45 body.task.scope = 'project';
46 body.task.workspace_id = 'ws_project';
47 const result = await handleTaskProposeRequest({
48 dataDir,
49 vaultId: 'default',
50 cliScopes: ['personal'],
51 proposalKind: 'task_create',
52 body,
53 intent: 'widen',
54 createProposal,
55 });
56 assert.equal(result.ok, false);
57 assert.equal(result.code, 'TASK_SCOPE_DENIED');
58 });
59
60 it('assignment at project scope returns TASK_CLASSROOM_AUTHORITY_REQUIRED', async () => {
61 const body = sampleTaskCreatePayload();
62 body.task.kind = 'assignment';
63 body.task.scope = 'project';
64 body.task.workspace_id = 'ws_project';
65 const result = await handleTaskProposeRequest({
66 dataDir,
67 vaultId: 'default',
68 cliScopes: ['personal', 'project'],
69 proposalKind: 'task_create',
70 body,
71 intent: 'classroom',
72 createProposal,
73 });
74 assert.equal(result.ok, false);
75 assert.equal(result.code, 'TASK_CLASSROOM_AUTHORITY_REQUIRED');
76 });
77
78 it('out-of-scope task edit returns 404 unknown_task (no existence leak)', async () => {
79 const body = sampleTaskCreatePayload();
80 body.task.task_id = 'task_sec_hidden';
81 body.task.scope = 'org';
82 approveTaskProposal(
83 dataDir,
84 (await handleTaskProposeRequest({
85 dataDir,
86 vaultId: 'default',
87 cliScopes: ['personal', 'project', 'org'],
88 proposalKind: 'task_create',
89 body,
90 intent: 'seed org task',
91 createProposal,
92 })).payload.proposal_id,
93 );
94
95 const result = await handleTaskProposeRequest({
96 dataDir,
97 vaultId: 'default',
98 cliScopes: ['personal'],
99 proposalKind: 'task_status_update',
100 body: {
101 proposal_kind: 'task_status_update',
102 task_id: 'task_sec_hidden',
103 base_state_id: 'taskst1_deadbeefdeadbeef',
104 status: 'done',
105 },
106 intent: 'probe',
107 createProposal,
108 });
109 assert.equal(result.ok, false);
110 assert.equal(result.status, 404);
111 assert.equal(result.code, 'unknown_task');
112 });
113
114 it('stale base_state_id on pause returns TASK_LOOP_LINEAGE_CONFLICT', async () => {
115 const payload = sampleLoopCreatePayload();
116 payload.loop.loop_id = 'loop_sec_stale';
117 approveTaskProposal(
118 dataDir,
119 (await handleTaskLoopProposeRequest({
120 dataDir,
121 vaultId: 'default',
122 cliScopes: ['personal'],
123 proposalKind: 'task_loop_create',
124 body: payload,
125 intent: 'create',
126 starterDir,
127 createProposal,
128 })).payload.proposal_id,
129 );
130
131 const result = await handleTaskLoopProposeRequest({
132 dataDir,
133 vaultId: 'default',
134 cliScopes: ['personal'],
135 proposalKind: 'task_loop_pause',
136 body: {
137 proposal_kind: 'task_loop_pause',
138 loop_id: 'loop_sec_stale',
139 base_state_id: 'loopst1_deadbeefdeadbeef',
140 },
141 intent: 'stale pause',
142 starterDir,
143 createProposal,
144 });
145 assert.equal(result.ok, false);
146 assert.equal(result.code, 'TASK_LOOP_LINEAGE_CONFLICT');
147 });
148
149 it('proposal JSON contains no token/oauth patterns', async () => {
150 const body = sampleTaskCreatePayload();
151 body.task.title = '<script>alert(1)</script>';
152 const result = await handleTaskProposeRequest({
153 dataDir,
154 vaultId: 'default',
155 cliScopes: ['personal'],
156 proposalKind: 'task_create',
157 body,
158 intent: 'Bearer sk-secret-token oauth_ref=bad',
159 createProposal,
160 });
161 assert.equal(result.ok, true);
162 const serialized = JSON.stringify(result.payload);
163 assert.ok(!serialized.includes('sk-secret'));
164 assert.ok(!serialized.includes('oauth_ref'));
165 });
166 });
File History 1 commit
sha256:b5f647cb9c409f563d4671fe3fc05ddea01fabfed9b41fc11cb923588e1c1baf mirror: GitHub Phase A durable MCP OAuth (#270) Human minor 10 days ago