task-loop-store-security.test.mjs
170 lines 5.4 KB
Raw
sha256:c2dbf04d56308f3bbf2d06e6d2eb022b8948b1e827195fe525a44e5e18d5f9c0 feat(auth): Phase B Connect cloud agent (RFC 8628) + Hermes… Human minor ⚠ breaking 11 days ago
1 /**
2 * Tier 7 — SECURITY: scope deny, malicious titles inert, no token leakage.
3 *
4 * @see docs/TASK-LOOP-STORE-CONTRACT-2G-c.md §6
5 */
6 import { describe, it, beforeEach, afterEach } from 'node:test';
7 import assert from 'node:assert/strict';
8 import fs from 'node:fs';
9 import path from 'node:path';
10 import { fileURLToPath } from 'node:url';
11 import { getRepoRoot } from '../lib/repo-root.mjs';
12 import { saveFlowStore } from '../lib/flow/flow-store.mjs';
13 import { listTaskLoops, getTaskLoop } from '../lib/task/task-loop-store.mjs';
14 import { handleTaskLoopListRequest } from '../lib/task/task-loop-handlers.mjs';
15
16 const __dirname = path.dirname(fileURLToPath(import.meta.url));
17 const tmpRoot = path.join(__dirname, 'fixtures', 'tmp-task-loop-security');
18 const loopStarterDir = path.join(getRepoRoot(), 'task-loops/starter');
19 const graphStarterDir = path.join(getRepoRoot(), 'orchestrator-graphs/starter');
20 const instancesDir = path.join(getRepoRoot(), 'task-loops/starter/instances');
21 const vaultId = 'vault-loop-security';
22
23 describe('Task loop store — security', () => {
24 beforeEach(() => {
25 fs.rmSync(tmpRoot, { recursive: true, force: true });
26 fs.mkdirSync(tmpRoot, { recursive: true });
27 });
28
29 afterEach(() => {
30 fs.rmSync(tmpRoot, { recursive: true, force: true });
31 });
32
33 it('personal scope never returns project/org loops from seeded fixtures', () => {
34 const dataDir = path.join(tmpRoot, 'data');
35 fs.mkdirSync(dataDir, { recursive: true });
36
37 saveFlowStore(dataDir, {
38 vaults: {
39 [vaultId]: {
40 flows: [],
41 steps: [],
42 runs: [],
43 candidates: [],
44 projections: [],
45 tasks: [],
46 task_loops: [
47 {
48 schema: 'knowtation.task_loop/v0',
49 loop_id: 'loop_org_only',
50 kind: 'org_work_job',
51 scope: 'org',
52 status: 'active',
53 title: 'Org loop',
54 workspace_id: 'ws-org',
55 recurrence: { kind: 'manual' },
56 timezone: 'UTC',
57 flow_id: null,
58 boundary_policy: 'observe_only',
59 memory_links: [],
60 created: '2026-06-01T00:00:00Z',
61 updated: '2026-06-01T00:00:00Z',
62 truncated: false,
63 },
64 ],
65 orchestrator_graphs: [],
66 },
67 },
68 });
69
70 const result = listTaskLoops(dataDir, vaultId, {
71 visibleScopes: new Set(['personal']),
72 filterScopes: new Set(['personal']),
73 effectiveScope: 'personal',
74 });
75
76 assert.ok(result.loops.every((l) => l.scope === 'personal'));
77 assert.equal(result.loops.some((l) => l.loop_id === 'loop_org_only'), false);
78 });
79
80 it('getTaskLoop for out-of-scope id returns null (404 path, no existence leak)', () => {
81 const dataDir = path.join(tmpRoot, 'deny');
82 fs.mkdirSync(dataDir, { recursive: true });
83
84 listTaskLoops(dataDir, vaultId, {
85 visibleScopes: new Set(['personal', 'project', 'org']),
86 filterScopes: new Set(['personal', 'project', 'org']),
87 effectiveScope: 'org',
88 starterDir: loopStarterDir,
89 graphsDir: graphStarterDir,
90 instancesDir,
91 });
92
93 const row = getTaskLoop(dataDir, vaultId, 'loop_school_trip', {
94 visibleScopes: new Set(['org']),
95 });
96 assert.equal(row, null);
97 });
98
99 it('malicious loop title returned as inert data without scope change', () => {
100 const dataDir = path.join(tmpRoot, 'inject');
101 fs.mkdirSync(dataDir, { recursive: true });
102
103 const maliciousTitle = '<script>alert("xss")</script>; DROP TABLE tasks;';
104 saveFlowStore(dataDir, {
105 vaults: {
106 [vaultId]: {
107 flows: [],
108 steps: [],
109 runs: [],
110 candidates: [],
111 projections: [],
112 tasks: [],
113 task_loops: [
114 {
115 schema: 'knowtation.task_loop/v0',
116 loop_id: 'loop_inject_test',
117 kind: 'personal',
118 scope: 'personal',
119 status: 'active',
120 title: maliciousTitle,
121 workspace_id: 'ws-personal',
122 recurrence: { kind: 'manual' },
123 timezone: 'UTC',
124 flow_id: null,
125 boundary_policy: 'observe_only',
126 memory_links: [],
127 created: '2026-06-01T00:00:00Z',
128 updated: '2026-06-01T00:00:00Z',
129 truncated: false,
130 },
131 ],
132 orchestrator_graphs: [],
133 },
134 },
135 });
136
137 const result = listTaskLoops(dataDir, vaultId, {
138 visibleScopes: new Set(['personal']),
139 filterScopes: new Set(['personal']),
140 effectiveScope: 'personal',
141 });
142
143 const row = result.loops.find((l) => l.loop_id === 'loop_inject_test');
144 assert.ok(row);
145 assert.equal(row.title, maliciousTitle);
146 assert.equal(row.scope, 'personal');
147 });
148
149 it('JSON.stringify of list response contains no token/oauth markers', () => {
150 const dataDir = path.join(tmpRoot, 'leak');
151 fs.mkdirSync(dataDir, { recursive: true });
152
153 const result = handleTaskLoopListRequest({
154 dataDir,
155 vaultId,
156 cliScopes: ['personal'],
157 starterDir: loopStarterDir,
158 graphsDir: graphStarterDir,
159 instancesDir,
160 });
161
162 assert.equal(result.ok, true);
163 if (!result.ok) return;
164
165 const serialized = JSON.stringify(result.payload).toLowerCase();
166 assert.equal(serialized.includes('oauth'), false);
167 assert.equal(serialized.includes('bearer'), false);
168 assert.equal(serialized.includes('token'), false);
169 });
170 });
File History 1 commit
sha256:93bcf8f9bd56d8c5b9339f4ec73b9ebd66571398d56262d38eedc2cfa9db9882 fix(test): align Band B landing assertion with desktop MCP … Human 11 days ago