tmux-team 2.0.0-alpha.1 → 2.0.0-alpha.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +4 -2
- package/src/cli.ts +25 -3
- package/src/commands/config.ts +186 -0
- package/src/commands/help.ts +44 -12
- package/src/commands/preamble.ts +153 -0
- package/src/commands/talk.test.ts +160 -5
- package/src/commands/talk.ts +359 -22
- package/src/config.test.ts +1 -1
- package/src/config.ts +70 -6
- package/src/pm/commands.test.ts +1061 -91
- package/src/pm/commands.ts +77 -8
- package/src/pm/manager.ts +12 -6
- package/src/pm/permissions.test.ts +332 -0
- package/src/pm/permissions.ts +279 -0
- package/src/pm/storage/fs.ts +3 -2
- package/src/pm/storage/github.ts +47 -35
- package/src/pm/types.ts +6 -0
- package/src/types.ts +11 -0
- package/src/ui.ts +13 -4
package/src/pm/commands.ts
CHANGED
|
@@ -5,6 +5,12 @@
|
|
|
5
5
|
import type { Context } from '../types.js';
|
|
6
6
|
import { ExitCodes } from '../exits.js';
|
|
7
7
|
import { colors } from '../ui.js';
|
|
8
|
+
import {
|
|
9
|
+
checkPermission,
|
|
10
|
+
buildPermissionPath,
|
|
11
|
+
PermissionChecks,
|
|
12
|
+
type PermissionCheck,
|
|
13
|
+
} from './permissions.js';
|
|
8
14
|
import {
|
|
9
15
|
findCurrentTeamId,
|
|
10
16
|
getStorageAdapter,
|
|
@@ -62,11 +68,28 @@ function parseStatus(s: string): TaskStatus {
|
|
|
62
68
|
throw new Error(`Invalid status: ${s}. Use: pending, in_progress, done`);
|
|
63
69
|
}
|
|
64
70
|
|
|
71
|
+
function requirePermission(ctx: Context, check: PermissionCheck): void {
|
|
72
|
+
const result = checkPermission(ctx.config, check);
|
|
73
|
+
|
|
74
|
+
// Display warning if there's an identity conflict
|
|
75
|
+
if (result.warning) {
|
|
76
|
+
ctx.ui.warn(result.warning);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (!result.allowed) {
|
|
80
|
+
const permPath = buildPermissionPath(check);
|
|
81
|
+
ctx.ui.error(`Permission denied: ${result.actor} cannot perform ${permPath}`);
|
|
82
|
+
ctx.exit(ExitCodes.ERROR);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
65
86
|
// ─────────────────────────────────────────────────────────────
|
|
66
87
|
// Commands
|
|
67
88
|
// ─────────────────────────────────────────────────────────────
|
|
68
89
|
|
|
69
90
|
export async function cmdPmInit(ctx: Context, args: string[]): Promise<void> {
|
|
91
|
+
requirePermission(ctx, PermissionChecks.teamCreate());
|
|
92
|
+
|
|
70
93
|
const { ui, flags, paths } = ctx;
|
|
71
94
|
|
|
72
95
|
// Parse flags: --name, --backend, --repo
|
|
@@ -157,6 +180,8 @@ export async function cmdPmMilestone(ctx: Context, args: string[]): Promise<void
|
|
|
157
180
|
}
|
|
158
181
|
|
|
159
182
|
async function cmdMilestoneAdd(ctx: Context, args: string[]): Promise<void> {
|
|
183
|
+
requirePermission(ctx, PermissionChecks.milestoneCreate());
|
|
184
|
+
|
|
160
185
|
const { ui, flags } = ctx;
|
|
161
186
|
const { storage } = await requireTeam(ctx);
|
|
162
187
|
|
|
@@ -184,6 +209,8 @@ async function cmdMilestoneAdd(ctx: Context, args: string[]): Promise<void> {
|
|
|
184
209
|
}
|
|
185
210
|
|
|
186
211
|
async function cmdMilestoneList(ctx: Context, _args: string[]): Promise<void> {
|
|
212
|
+
requirePermission(ctx, PermissionChecks.milestoneList());
|
|
213
|
+
|
|
187
214
|
const { ui, flags } = ctx;
|
|
188
215
|
const { storage } = await requireTeam(ctx);
|
|
189
216
|
|
|
@@ -208,6 +235,8 @@ async function cmdMilestoneList(ctx: Context, _args: string[]): Promise<void> {
|
|
|
208
235
|
}
|
|
209
236
|
|
|
210
237
|
async function cmdMilestoneDone(ctx: Context, args: string[]): Promise<void> {
|
|
238
|
+
requirePermission(ctx, PermissionChecks.milestoneUpdate(['status']));
|
|
239
|
+
|
|
211
240
|
const { ui, flags } = ctx;
|
|
212
241
|
const { storage } = await requireTeam(ctx);
|
|
213
242
|
|
|
@@ -264,11 +293,14 @@ export async function cmdPmTask(ctx: Context, args: string[]): Promise<void> {
|
|
|
264
293
|
}
|
|
265
294
|
|
|
266
295
|
async function cmdTaskAdd(ctx: Context, args: string[]): Promise<void> {
|
|
296
|
+
requirePermission(ctx, PermissionChecks.taskCreate());
|
|
297
|
+
|
|
267
298
|
const { ui, flags } = ctx;
|
|
268
299
|
const { storage } = await requireTeam(ctx);
|
|
269
300
|
|
|
270
|
-
// Parse args: <title> [--milestone <id>] [--assignee <name>]
|
|
301
|
+
// Parse args: <title> [--milestone <id>] [--assignee <name>] [--body <text>]
|
|
271
302
|
let title = '';
|
|
303
|
+
let body: string | undefined;
|
|
272
304
|
let milestone: string | undefined;
|
|
273
305
|
let assignee: string | undefined;
|
|
274
306
|
|
|
@@ -281,6 +313,10 @@ async function cmdTaskAdd(ctx: Context, args: string[]): Promise<void> {
|
|
|
281
313
|
assignee = args[++i];
|
|
282
314
|
} else if (args[i].startsWith('--assignee=')) {
|
|
283
315
|
assignee = args[i].slice(11);
|
|
316
|
+
} else if (args[i] === '--body' || args[i] === '-b') {
|
|
317
|
+
body = args[++i];
|
|
318
|
+
} else if (args[i].startsWith('--body=')) {
|
|
319
|
+
body = args[i].slice(7);
|
|
284
320
|
} else if (!title) {
|
|
285
321
|
title = args[i];
|
|
286
322
|
}
|
|
@@ -291,7 +327,7 @@ async function cmdTaskAdd(ctx: Context, args: string[]): Promise<void> {
|
|
|
291
327
|
ctx.exit(ExitCodes.ERROR);
|
|
292
328
|
}
|
|
293
329
|
|
|
294
|
-
const task = await storage.createTask({ title, milestone, assignee });
|
|
330
|
+
const task = await storage.createTask({ title, body, milestone, assignee });
|
|
295
331
|
|
|
296
332
|
await storage.appendEvent({
|
|
297
333
|
event: 'task_created',
|
|
@@ -310,6 +346,8 @@ async function cmdTaskAdd(ctx: Context, args: string[]): Promise<void> {
|
|
|
310
346
|
}
|
|
311
347
|
|
|
312
348
|
async function cmdTaskList(ctx: Context, args: string[]): Promise<void> {
|
|
349
|
+
requirePermission(ctx, PermissionChecks.taskList());
|
|
350
|
+
|
|
313
351
|
const { ui, flags } = ctx;
|
|
314
352
|
const { storage } = await requireTeam(ctx);
|
|
315
353
|
|
|
@@ -350,6 +388,8 @@ async function cmdTaskList(ctx: Context, args: string[]): Promise<void> {
|
|
|
350
388
|
}
|
|
351
389
|
|
|
352
390
|
async function cmdTaskShow(ctx: Context, args: string[]): Promise<void> {
|
|
391
|
+
requirePermission(ctx, PermissionChecks.taskShow());
|
|
392
|
+
|
|
353
393
|
const { ui, flags } = ctx;
|
|
354
394
|
const { storage } = await requireTeam(ctx);
|
|
355
395
|
|
|
@@ -382,7 +422,6 @@ async function cmdTaskShow(ctx: Context, args: string[]): Promise<void> {
|
|
|
382
422
|
|
|
383
423
|
async function cmdTaskUpdate(ctx: Context, args: string[]): Promise<void> {
|
|
384
424
|
const { ui, flags } = ctx;
|
|
385
|
-
const { storage } = await requireTeam(ctx);
|
|
386
425
|
|
|
387
426
|
// Parse: <id> --status <status> [--assignee <name>]
|
|
388
427
|
const id = args[0];
|
|
@@ -406,6 +445,15 @@ async function cmdTaskUpdate(ctx: Context, args: string[]): Promise<void> {
|
|
|
406
445
|
}
|
|
407
446
|
}
|
|
408
447
|
|
|
448
|
+
// Check permissions based on which fields are being updated
|
|
449
|
+
const fields: string[] = [];
|
|
450
|
+
if (status) fields.push('status');
|
|
451
|
+
if (assignee) fields.push('assignee');
|
|
452
|
+
if (fields.length > 0) {
|
|
453
|
+
requirePermission(ctx, PermissionChecks.taskUpdate(fields));
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
const { storage } = await requireTeam(ctx);
|
|
409
457
|
const task = await storage.getTask(id);
|
|
410
458
|
if (!task) {
|
|
411
459
|
ui.error(`Task ${id} not found`);
|
|
@@ -443,6 +491,8 @@ async function cmdTaskUpdate(ctx: Context, args: string[]): Promise<void> {
|
|
|
443
491
|
}
|
|
444
492
|
|
|
445
493
|
async function cmdTaskDone(ctx: Context, args: string[]): Promise<void> {
|
|
494
|
+
requirePermission(ctx, PermissionChecks.taskUpdate(['status']));
|
|
495
|
+
|
|
446
496
|
const { ui, flags } = ctx;
|
|
447
497
|
const { storage } = await requireTeam(ctx);
|
|
448
498
|
|
|
@@ -479,7 +529,6 @@ async function cmdTaskDone(ctx: Context, args: string[]): Promise<void> {
|
|
|
479
529
|
|
|
480
530
|
export async function cmdPmDoc(ctx: Context, args: string[]): Promise<void> {
|
|
481
531
|
const { ui, flags } = ctx;
|
|
482
|
-
const { teamId, storage } = await requireTeam(ctx);
|
|
483
532
|
|
|
484
533
|
const id = args[0];
|
|
485
534
|
if (!id) {
|
|
@@ -487,13 +536,22 @@ export async function cmdPmDoc(ctx: Context, args: string[]): Promise<void> {
|
|
|
487
536
|
ctx.exit(ExitCodes.ERROR);
|
|
488
537
|
}
|
|
489
538
|
|
|
539
|
+
const printOnly = args.includes('--print') || args.includes('-p');
|
|
540
|
+
|
|
541
|
+
// Check permission based on mode
|
|
542
|
+
if (printOnly || flags.json) {
|
|
543
|
+
requirePermission(ctx, PermissionChecks.docRead());
|
|
544
|
+
} else {
|
|
545
|
+
requirePermission(ctx, PermissionChecks.docUpdate());
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
const { teamId, storage } = await requireTeam(ctx);
|
|
490
549
|
const task = await storage.getTask(id);
|
|
491
550
|
if (!task) {
|
|
492
551
|
ui.error(`Task ${id} not found`);
|
|
493
552
|
ctx.exit(ExitCodes.PANE_NOT_FOUND);
|
|
494
553
|
}
|
|
495
554
|
|
|
496
|
-
const printOnly = args.includes('--print') || args.includes('-p');
|
|
497
555
|
const doc = await storage.getTaskDoc(id);
|
|
498
556
|
|
|
499
557
|
if (printOnly || flags.json) {
|
|
@@ -516,6 +574,8 @@ export async function cmdPmDoc(ctx: Context, args: string[]): Promise<void> {
|
|
|
516
574
|
}
|
|
517
575
|
|
|
518
576
|
export async function cmdPmLog(ctx: Context, args: string[]): Promise<void> {
|
|
577
|
+
requirePermission(ctx, PermissionChecks.logRead());
|
|
578
|
+
|
|
519
579
|
const { ui, flags } = ctx;
|
|
520
580
|
const { storage } = await requireTeam(ctx);
|
|
521
581
|
|
|
@@ -553,12 +613,15 @@ export async function cmdPmLog(ctx: Context, args: string[]): Promise<void> {
|
|
|
553
613
|
}
|
|
554
614
|
|
|
555
615
|
export async function cmdPmList(ctx: Context, _args: string[]): Promise<void> {
|
|
616
|
+
requirePermission(ctx, PermissionChecks.teamList());
|
|
617
|
+
|
|
556
618
|
const { ui, flags, paths } = ctx;
|
|
557
619
|
|
|
558
620
|
const teams = listTeams(paths.globalDir);
|
|
621
|
+
const currentTeamId = findCurrentTeamId(process.cwd(), paths.globalDir);
|
|
559
622
|
|
|
560
623
|
if (flags.json) {
|
|
561
|
-
ui.json(teams);
|
|
624
|
+
ui.json({ teams, currentTeamId });
|
|
562
625
|
return;
|
|
563
626
|
}
|
|
564
627
|
|
|
@@ -569,8 +632,14 @@ export async function cmdPmList(ctx: Context, _args: string[]): Promise<void> {
|
|
|
569
632
|
|
|
570
633
|
console.log();
|
|
571
634
|
ui.table(
|
|
572
|
-
['ID', 'NAME', 'CREATED'],
|
|
573
|
-
teams.map((t) => [
|
|
635
|
+
['', 'ID', 'NAME', 'BACKEND', 'CREATED'],
|
|
636
|
+
teams.map((t) => [
|
|
637
|
+
t.id === currentTeamId ? colors.green('→') : ' ',
|
|
638
|
+
t.id.slice(0, 8) + '...',
|
|
639
|
+
t.name,
|
|
640
|
+
t.backend === 'github' ? colors.cyan('github') : colors.dim('fs'),
|
|
641
|
+
t.createdAt.slice(0, 10),
|
|
642
|
+
])
|
|
574
643
|
);
|
|
575
644
|
console.log();
|
|
576
645
|
}
|
package/src/pm/manager.ts
CHANGED
|
@@ -8,7 +8,7 @@ import crypto from 'crypto';
|
|
|
8
8
|
import type { StorageAdapter } from './storage/adapter.js';
|
|
9
9
|
import { createFSAdapter } from './storage/fs.js';
|
|
10
10
|
import { createGitHubAdapter } from './storage/github.js';
|
|
11
|
-
import type { Team, TeamConfig, StorageBackend } from './types.js';
|
|
11
|
+
import type { Team, TeamConfig, TeamWithConfig, StorageBackend } from './types.js';
|
|
12
12
|
|
|
13
13
|
/**
|
|
14
14
|
* Resolve the teams directory from global config path.
|
|
@@ -107,21 +107,27 @@ export function generateTeamId(): string {
|
|
|
107
107
|
}
|
|
108
108
|
|
|
109
109
|
/**
|
|
110
|
-
* List all teams.
|
|
110
|
+
* List all teams with their backend config.
|
|
111
111
|
*/
|
|
112
|
-
export function listTeams(globalDir: string):
|
|
112
|
+
export function listTeams(globalDir: string): TeamWithConfig[] {
|
|
113
113
|
const teamsDir = getTeamsDir(globalDir);
|
|
114
114
|
if (!fs.existsSync(teamsDir)) return [];
|
|
115
115
|
|
|
116
|
-
const teams:
|
|
116
|
+
const teams: TeamWithConfig[] = [];
|
|
117
117
|
const dirs = fs.readdirSync(teamsDir);
|
|
118
118
|
|
|
119
119
|
for (const dir of dirs) {
|
|
120
|
-
const
|
|
120
|
+
const teamDir = path.join(teamsDir, dir);
|
|
121
|
+
const teamFile = path.join(teamDir, 'team.json');
|
|
121
122
|
if (fs.existsSync(teamFile)) {
|
|
122
123
|
try {
|
|
123
124
|
const team = JSON.parse(fs.readFileSync(teamFile, 'utf-8')) as Team;
|
|
124
|
-
|
|
125
|
+
const config = getTeamConfig(teamDir);
|
|
126
|
+
teams.push({
|
|
127
|
+
...team,
|
|
128
|
+
backend: config?.backend || 'fs',
|
|
129
|
+
repo: config?.repo,
|
|
130
|
+
});
|
|
125
131
|
} catch {
|
|
126
132
|
// Skip malformed team files
|
|
127
133
|
}
|
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
// ─────────────────────────────────────────────────────────────
|
|
2
|
+
// Permission System Tests
|
|
3
|
+
// ─────────────────────────────────────────────────────────────
|
|
4
|
+
|
|
5
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
6
|
+
import {
|
|
7
|
+
buildPermissionPath,
|
|
8
|
+
checkPermission,
|
|
9
|
+
getCurrentActor,
|
|
10
|
+
resolveActor,
|
|
11
|
+
PermissionChecks,
|
|
12
|
+
} from './permissions.js';
|
|
13
|
+
import type { ResolvedConfig } from '../types.js';
|
|
14
|
+
|
|
15
|
+
function createMockConfig(agents: Record<string, { deny?: string[] }>): ResolvedConfig {
|
|
16
|
+
return {
|
|
17
|
+
mode: 'polling',
|
|
18
|
+
preambleMode: 'always',
|
|
19
|
+
defaults: { timeout: 60, pollInterval: 1, captureLines: 100 },
|
|
20
|
+
agents,
|
|
21
|
+
paneRegistry: {},
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
describe('buildPermissionPath', () => {
|
|
26
|
+
it('builds path without fields', () => {
|
|
27
|
+
expect(buildPermissionPath({ resource: 'task', action: 'list', fields: [] })).toBe(
|
|
28
|
+
'pm:task:list'
|
|
29
|
+
);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('builds path with single field', () => {
|
|
33
|
+
expect(buildPermissionPath({ resource: 'task', action: 'update', fields: ['status'] })).toBe(
|
|
34
|
+
'pm:task:update(status)'
|
|
35
|
+
);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('builds path with multiple fields sorted alphabetically', () => {
|
|
39
|
+
expect(
|
|
40
|
+
buildPermissionPath({ resource: 'task', action: 'update', fields: ['status', 'assignee'] })
|
|
41
|
+
).toBe('pm:task:update(assignee,status)');
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('builds path with fields already sorted', () => {
|
|
45
|
+
expect(
|
|
46
|
+
buildPermissionPath({ resource: 'task', action: 'update', fields: ['assignee', 'status'] })
|
|
47
|
+
).toBe('pm:task:update(assignee,status)');
|
|
48
|
+
});
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
describe('getCurrentActor', () => {
|
|
52
|
+
const originalEnv = { ...process.env };
|
|
53
|
+
|
|
54
|
+
afterEach(() => {
|
|
55
|
+
process.env = { ...originalEnv };
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('returns TMT_AGENT_NAME if set', () => {
|
|
59
|
+
process.env.TMT_AGENT_NAME = 'codex';
|
|
60
|
+
delete process.env.TMUX_TEAM_ACTOR;
|
|
61
|
+
expect(getCurrentActor()).toBe('codex');
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('returns TMUX_TEAM_ACTOR if TMT_AGENT_NAME not set', () => {
|
|
65
|
+
delete process.env.TMT_AGENT_NAME;
|
|
66
|
+
process.env.TMUX_TEAM_ACTOR = 'gemini';
|
|
67
|
+
expect(getCurrentActor()).toBe('gemini');
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('returns human if no env vars set', () => {
|
|
71
|
+
delete process.env.TMT_AGENT_NAME;
|
|
72
|
+
delete process.env.TMUX_TEAM_ACTOR;
|
|
73
|
+
expect(getCurrentActor()).toBe('human');
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('prefers TMT_AGENT_NAME over TMUX_TEAM_ACTOR', () => {
|
|
77
|
+
process.env.TMT_AGENT_NAME = 'codex';
|
|
78
|
+
process.env.TMUX_TEAM_ACTOR = 'gemini';
|
|
79
|
+
expect(getCurrentActor()).toBe('codex');
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
describe('checkPermission', () => {
|
|
84
|
+
const originalEnv = { ...process.env };
|
|
85
|
+
|
|
86
|
+
beforeEach(() => {
|
|
87
|
+
process.env = { ...originalEnv };
|
|
88
|
+
// Disable pane detection in tests by unsetting TMUX
|
|
89
|
+
delete process.env.TMUX;
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
afterEach(() => {
|
|
93
|
+
process.env = { ...originalEnv };
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('allows everything for human actor', () => {
|
|
97
|
+
delete process.env.TMT_AGENT_NAME;
|
|
98
|
+
delete process.env.TMUX_TEAM_ACTOR;
|
|
99
|
+
|
|
100
|
+
const config = createMockConfig({
|
|
101
|
+
codex: { deny: ['pm:task:update(status)'] },
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
expect(checkPermission(config, PermissionChecks.taskUpdate(['status'])).allowed).toBe(true);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it('allows when no deny patterns for agent', () => {
|
|
108
|
+
process.env.TMT_AGENT_NAME = 'codex';
|
|
109
|
+
|
|
110
|
+
const config = createMockConfig({
|
|
111
|
+
codex: {}, // No deny patterns
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
expect(checkPermission(config, PermissionChecks.taskUpdate(['status'])).allowed).toBe(true);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it('allows when agent not in config', () => {
|
|
118
|
+
process.env.TMT_AGENT_NAME = 'unknown-agent';
|
|
119
|
+
|
|
120
|
+
const config = createMockConfig({
|
|
121
|
+
codex: { deny: ['pm:task:update(status)'] },
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
expect(checkPermission(config, PermissionChecks.taskUpdate(['status'])).allowed).toBe(true);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it('denies when pattern matches exactly', () => {
|
|
128
|
+
process.env.TMT_AGENT_NAME = 'codex';
|
|
129
|
+
|
|
130
|
+
const config = createMockConfig({
|
|
131
|
+
codex: { deny: ['pm:task:update(status)'] },
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
expect(checkPermission(config, PermissionChecks.taskUpdate(['status'])).allowed).toBe(false);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it('denies when pattern matches any field', () => {
|
|
138
|
+
process.env.TMT_AGENT_NAME = 'codex';
|
|
139
|
+
|
|
140
|
+
const config = createMockConfig({
|
|
141
|
+
codex: { deny: ['pm:task:update(status)'] },
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
// Using both status and assignee, should still be denied because status is in deny list
|
|
145
|
+
expect(
|
|
146
|
+
checkPermission(config, PermissionChecks.taskUpdate(['status', 'assignee'])).allowed
|
|
147
|
+
).toBe(false);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it('allows when fields do not match', () => {
|
|
151
|
+
process.env.TMT_AGENT_NAME = 'codex';
|
|
152
|
+
|
|
153
|
+
const config = createMockConfig({
|
|
154
|
+
codex: { deny: ['pm:task:update(status)'] },
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
// Only updating assignee, not status
|
|
158
|
+
expect(checkPermission(config, PermissionChecks.taskUpdate(['assignee'])).allowed).toBe(true);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it('denies entire action when pattern has no fields', () => {
|
|
162
|
+
process.env.TMT_AGENT_NAME = 'codex';
|
|
163
|
+
|
|
164
|
+
const config = createMockConfig({
|
|
165
|
+
codex: { deny: ['pm:task:update'] },
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
// Any update should be denied
|
|
169
|
+
expect(checkPermission(config, PermissionChecks.taskUpdate(['status'])).allowed).toBe(false);
|
|
170
|
+
expect(checkPermission(config, PermissionChecks.taskUpdate(['assignee'])).allowed).toBe(false);
|
|
171
|
+
expect(checkPermission(config, PermissionChecks.taskUpdate([])).allowed).toBe(false);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it('denies when wildcard pattern matches any field', () => {
|
|
175
|
+
process.env.TMT_AGENT_NAME = 'codex';
|
|
176
|
+
|
|
177
|
+
const config = createMockConfig({
|
|
178
|
+
codex: { deny: ['pm:task:update(*)'] },
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
expect(checkPermission(config, PermissionChecks.taskUpdate(['status'])).allowed).toBe(false);
|
|
182
|
+
expect(checkPermission(config, PermissionChecks.taskUpdate(['assignee'])).allowed).toBe(false);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it('allows no-field action when wildcard is used', () => {
|
|
186
|
+
process.env.TMT_AGENT_NAME = 'codex';
|
|
187
|
+
|
|
188
|
+
const config = createMockConfig({
|
|
189
|
+
codex: { deny: ['pm:task:update(*)'] },
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
// Wildcard only matches when fields are present
|
|
193
|
+
expect(checkPermission(config, PermissionChecks.taskUpdate([])).allowed).toBe(true);
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
it('allows different resource', () => {
|
|
197
|
+
process.env.TMT_AGENT_NAME = 'codex';
|
|
198
|
+
|
|
199
|
+
const config = createMockConfig({
|
|
200
|
+
codex: { deny: ['pm:task:update(status)'] },
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
expect(checkPermission(config, PermissionChecks.milestoneUpdate(['status'])).allowed).toBe(
|
|
204
|
+
true
|
|
205
|
+
);
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
it('allows different action', () => {
|
|
209
|
+
process.env.TMT_AGENT_NAME = 'codex';
|
|
210
|
+
|
|
211
|
+
const config = createMockConfig({
|
|
212
|
+
codex: { deny: ['pm:task:update(status)'] },
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
expect(checkPermission(config, PermissionChecks.taskCreate()).allowed).toBe(true);
|
|
216
|
+
expect(checkPermission(config, PermissionChecks.taskList()).allowed).toBe(true);
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
it('handles multiple deny patterns', () => {
|
|
220
|
+
process.env.TMT_AGENT_NAME = 'codex';
|
|
221
|
+
|
|
222
|
+
const config = createMockConfig({
|
|
223
|
+
codex: {
|
|
224
|
+
deny: ['pm:task:update(status)', 'pm:milestone:update(status)', 'pm:task:delete'],
|
|
225
|
+
},
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
expect(checkPermission(config, PermissionChecks.taskUpdate(['status'])).allowed).toBe(false);
|
|
229
|
+
expect(checkPermission(config, PermissionChecks.milestoneUpdate(['status'])).allowed).toBe(
|
|
230
|
+
false
|
|
231
|
+
);
|
|
232
|
+
expect(checkPermission(config, PermissionChecks.taskDelete()).allowed).toBe(false);
|
|
233
|
+
expect(checkPermission(config, PermissionChecks.taskUpdate(['assignee'])).allowed).toBe(true);
|
|
234
|
+
});
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
describe('PermissionChecks helpers', () => {
|
|
238
|
+
it('creates correct task checks', () => {
|
|
239
|
+
expect(PermissionChecks.taskList()).toEqual({ resource: 'task', action: 'list', fields: [] });
|
|
240
|
+
expect(PermissionChecks.taskShow()).toEqual({ resource: 'task', action: 'show', fields: [] });
|
|
241
|
+
expect(PermissionChecks.taskCreate()).toEqual({
|
|
242
|
+
resource: 'task',
|
|
243
|
+
action: 'create',
|
|
244
|
+
fields: [],
|
|
245
|
+
});
|
|
246
|
+
expect(PermissionChecks.taskUpdate(['status'])).toEqual({
|
|
247
|
+
resource: 'task',
|
|
248
|
+
action: 'update',
|
|
249
|
+
fields: ['status'],
|
|
250
|
+
});
|
|
251
|
+
expect(PermissionChecks.taskDelete()).toEqual({
|
|
252
|
+
resource: 'task',
|
|
253
|
+
action: 'delete',
|
|
254
|
+
fields: [],
|
|
255
|
+
});
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
it('creates correct milestone checks', () => {
|
|
259
|
+
expect(PermissionChecks.milestoneList()).toEqual({
|
|
260
|
+
resource: 'milestone',
|
|
261
|
+
action: 'list',
|
|
262
|
+
fields: [],
|
|
263
|
+
});
|
|
264
|
+
expect(PermissionChecks.milestoneCreate()).toEqual({
|
|
265
|
+
resource: 'milestone',
|
|
266
|
+
action: 'create',
|
|
267
|
+
fields: [],
|
|
268
|
+
});
|
|
269
|
+
expect(PermissionChecks.milestoneUpdate(['status'])).toEqual({
|
|
270
|
+
resource: 'milestone',
|
|
271
|
+
action: 'update',
|
|
272
|
+
fields: ['status'],
|
|
273
|
+
});
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
it('creates correct doc checks', () => {
|
|
277
|
+
expect(PermissionChecks.docRead()).toEqual({ resource: 'doc', action: 'read', fields: [] });
|
|
278
|
+
expect(PermissionChecks.docUpdate()).toEqual({ resource: 'doc', action: 'update', fields: [] });
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
it('creates correct team checks', () => {
|
|
282
|
+
expect(PermissionChecks.teamCreate()).toEqual({
|
|
283
|
+
resource: 'team',
|
|
284
|
+
action: 'create',
|
|
285
|
+
fields: [],
|
|
286
|
+
});
|
|
287
|
+
expect(PermissionChecks.teamList()).toEqual({ resource: 'team', action: 'list', fields: [] });
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
it('creates correct log checks', () => {
|
|
291
|
+
expect(PermissionChecks.logRead()).toEqual({ resource: 'log', action: 'read', fields: [] });
|
|
292
|
+
});
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
describe('resolveActor', () => {
|
|
296
|
+
const originalEnv = { ...process.env };
|
|
297
|
+
|
|
298
|
+
afterEach(() => {
|
|
299
|
+
process.env = { ...originalEnv };
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
it('returns human when not in tmux and no env var', () => {
|
|
303
|
+
delete process.env.TMUX;
|
|
304
|
+
delete process.env.TMT_AGENT_NAME;
|
|
305
|
+
delete process.env.TMUX_TEAM_ACTOR;
|
|
306
|
+
|
|
307
|
+
const result = resolveActor({});
|
|
308
|
+
expect(result.actor).toBe('human');
|
|
309
|
+
expect(result.source).toBe('default');
|
|
310
|
+
expect(result.warning).toBeUndefined();
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
it('uses env var when not in tmux', () => {
|
|
314
|
+
delete process.env.TMUX;
|
|
315
|
+
process.env.TMT_AGENT_NAME = 'codex';
|
|
316
|
+
|
|
317
|
+
const result = resolveActor({});
|
|
318
|
+
expect(result.actor).toBe('codex');
|
|
319
|
+
expect(result.source).toBe('env');
|
|
320
|
+
expect(result.warning).toBeUndefined();
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
it('prefers TMT_AGENT_NAME over TMUX_TEAM_ACTOR', () => {
|
|
324
|
+
delete process.env.TMUX;
|
|
325
|
+
process.env.TMT_AGENT_NAME = 'codex';
|
|
326
|
+
process.env.TMUX_TEAM_ACTOR = 'gemini';
|
|
327
|
+
|
|
328
|
+
const result = resolveActor({});
|
|
329
|
+
expect(result.actor).toBe('codex');
|
|
330
|
+
expect(result.source).toBe('env');
|
|
331
|
+
});
|
|
332
|
+
});
|