tmux-team 1.1.0 → 2.0.0-alpha.3
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/README.md +195 -22
- package/bin/tmux-team +31 -430
- package/package.json +28 -6
- package/src/cli.ts +222 -0
- package/src/commands/add.ts +38 -0
- package/src/commands/check.ts +34 -0
- package/src/commands/completion.ts +118 -0
- package/src/commands/config.ts +187 -0
- package/src/commands/help.ts +66 -0
- package/src/commands/init.ts +24 -0
- package/src/commands/list.ts +27 -0
- package/src/commands/preamble.ts +153 -0
- package/src/commands/remove.ts +25 -0
- package/src/commands/talk.test.ts +679 -0
- package/src/commands/talk.ts +274 -0
- package/src/commands/update.ts +47 -0
- package/src/config.test.ts +246 -0
- package/src/config.ts +223 -0
- package/src/context.ts +38 -0
- package/src/exits.ts +14 -0
- package/src/pm/commands.test.ts +1127 -0
- package/src/pm/commands.ts +723 -0
- package/src/pm/manager.test.ts +377 -0
- package/src/pm/manager.ts +146 -0
- package/src/pm/permissions.test.ts +332 -0
- package/src/pm/permissions.ts +278 -0
- package/src/pm/storage/adapter.ts +55 -0
- package/src/pm/storage/fs.test.ts +384 -0
- package/src/pm/storage/fs.ts +256 -0
- package/src/pm/storage/github.ts +763 -0
- package/src/pm/types.ts +85 -0
- package/src/state.test.ts +311 -0
- package/src/state.ts +83 -0
- package/src/tmux.test.ts +205 -0
- package/src/tmux.ts +27 -0
- package/src/types.ts +97 -0
- package/src/ui.ts +76 -0
- package/src/version.ts +21 -0
|
@@ -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
|
+
});
|
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
// ─────────────────────────────────────────────────────────────
|
|
2
|
+
// Permission system for PM commands
|
|
3
|
+
// ─────────────────────────────────────────────────────────────
|
|
4
|
+
|
|
5
|
+
import { execSync } from 'child_process';
|
|
6
|
+
import type { ResolvedConfig, PaneEntry } from '../types.js';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Permission expression format:
|
|
10
|
+
* pm:<resource>:<action>(<field1>,<field2>,...)
|
|
11
|
+
*
|
|
12
|
+
* Examples:
|
|
13
|
+
* - pm:task:list
|
|
14
|
+
* - pm:task:update(status)
|
|
15
|
+
* - pm:task:update(assignee,status)
|
|
16
|
+
* - pm:task:update(*) - wildcard, any field
|
|
17
|
+
* - pm:task:update - no fields, entire action
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
export interface PermissionCheck {
|
|
21
|
+
resource: string; // task, milestone, team, doc, log
|
|
22
|
+
action: string; // list, show, create, update, read
|
|
23
|
+
fields: string[]; // sorted alphabetically
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Build permission path from command context.
|
|
28
|
+
*/
|
|
29
|
+
export function buildPermissionPath(check: PermissionCheck): string {
|
|
30
|
+
const base = `pm:${check.resource}:${check.action}`;
|
|
31
|
+
if (check.fields.length === 0) {
|
|
32
|
+
return base;
|
|
33
|
+
}
|
|
34
|
+
// Sort fields alphabetically for canonical form
|
|
35
|
+
const sortedFields = [...check.fields].sort();
|
|
36
|
+
return `${base}(${sortedFields.join(',')})`;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Parse a deny pattern into its components.
|
|
41
|
+
*/
|
|
42
|
+
function parsePattern(pattern: string): {
|
|
43
|
+
resource: string;
|
|
44
|
+
action: string;
|
|
45
|
+
fields: string[] | '*' | null;
|
|
46
|
+
} {
|
|
47
|
+
// Pattern format: pm:resource:action or pm:resource:action(fields) or pm:resource:action(*)
|
|
48
|
+
const match = pattern.match(/^pm:(\w+):(\w+)(?:\(([^)]*)\))?$/);
|
|
49
|
+
if (!match) {
|
|
50
|
+
return { resource: '', action: '', fields: null };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const [, resource, action, fieldsStr] = match;
|
|
54
|
+
|
|
55
|
+
if (fieldsStr === undefined) {
|
|
56
|
+
// No parentheses - blocks entire action
|
|
57
|
+
return { resource, action, fields: null };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if (fieldsStr === '*') {
|
|
61
|
+
// Wildcard - blocks any field
|
|
62
|
+
return { resource, action, fields: '*' };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Specific fields
|
|
66
|
+
const fields = fieldsStr
|
|
67
|
+
.split(',')
|
|
68
|
+
.map((f) => f.trim())
|
|
69
|
+
.filter(Boolean);
|
|
70
|
+
return { resource, action, fields };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Check if a permission path matches a deny pattern.
|
|
75
|
+
*/
|
|
76
|
+
function matchesPattern(path: PermissionCheck, pattern: string): boolean {
|
|
77
|
+
const parsed = parsePattern(pattern);
|
|
78
|
+
|
|
79
|
+
// Resource and action must match
|
|
80
|
+
if (parsed.resource !== path.resource || parsed.action !== path.action) {
|
|
81
|
+
return false;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// No fields in pattern = block entire action
|
|
85
|
+
if (parsed.fields === null) {
|
|
86
|
+
return true;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Wildcard = block if path has any fields
|
|
90
|
+
if (parsed.fields === '*') {
|
|
91
|
+
return path.fields.length > 0;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Specific fields = block if path uses ANY of the denied fields
|
|
95
|
+
return parsed.fields.some((f) => path.fields.includes(f));
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Get current tmux pane in "window.pane" format.
|
|
100
|
+
* Returns null if not running in tmux.
|
|
101
|
+
*/
|
|
102
|
+
function getCurrentPane(): string | null {
|
|
103
|
+
// Check if we're in tmux
|
|
104
|
+
if (!process.env.TMUX) {
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
try {
|
|
109
|
+
const result = execSync("tmux display-message -p '#{window_index}.#{pane_index}'", {
|
|
110
|
+
encoding: 'utf-8',
|
|
111
|
+
timeout: 1000,
|
|
112
|
+
}).trim();
|
|
113
|
+
return result || null;
|
|
114
|
+
} catch {
|
|
115
|
+
return null;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Look up agent name by pane ID in the registry.
|
|
121
|
+
*/
|
|
122
|
+
function findAgentByPane(paneRegistry: Record<string, PaneEntry>, paneId: string): string | null {
|
|
123
|
+
for (const [agentName, entry] of Object.entries(paneRegistry)) {
|
|
124
|
+
if (entry.pane === paneId) {
|
|
125
|
+
return agentName;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
return null;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export interface ActorResolution {
|
|
132
|
+
actor: string;
|
|
133
|
+
source: 'pane' | 'env' | 'default';
|
|
134
|
+
warning?: string;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Resolve current actor using pane registry as primary source.
|
|
139
|
+
*
|
|
140
|
+
* Priority:
|
|
141
|
+
* 1. Look up current tmux pane in pane registry → agent name
|
|
142
|
+
* 2. If not in registry → 'human' (full access)
|
|
143
|
+
*
|
|
144
|
+
* Warnings:
|
|
145
|
+
* - If TMT_AGENT_NAME is set but conflicts with pane registry → warn about spoofing
|
|
146
|
+
* - If TMT_AGENT_NAME is set but pane not in registry → warn about unregistered pane
|
|
147
|
+
*/
|
|
148
|
+
export function resolveActor(paneRegistry: Record<string, PaneEntry>): ActorResolution {
|
|
149
|
+
const envActor = process.env.TMT_AGENT_NAME || process.env.TMUX_TEAM_ACTOR;
|
|
150
|
+
const currentPane = getCurrentPane();
|
|
151
|
+
|
|
152
|
+
// Not in tmux - use env var or default to human
|
|
153
|
+
if (!currentPane) {
|
|
154
|
+
if (envActor) {
|
|
155
|
+
return { actor: envActor, source: 'env' };
|
|
156
|
+
}
|
|
157
|
+
return { actor: 'human', source: 'default' };
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// In tmux - look up pane in registry
|
|
161
|
+
const paneAgent = findAgentByPane(paneRegistry, currentPane);
|
|
162
|
+
|
|
163
|
+
if (paneAgent) {
|
|
164
|
+
// Pane is registered to an agent
|
|
165
|
+
if (envActor && envActor !== paneAgent) {
|
|
166
|
+
// Env var conflicts with pane registry - warn about potential spoofing
|
|
167
|
+
return {
|
|
168
|
+
actor: paneAgent,
|
|
169
|
+
source: 'pane',
|
|
170
|
+
warning: `⚠️ Identity mismatch: TMT_AGENT_NAME="${envActor}" but pane ${currentPane} is registered to "${paneAgent}". Using pane identity.`,
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
return { actor: paneAgent, source: 'pane' };
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// Pane not in registry
|
|
177
|
+
if (envActor) {
|
|
178
|
+
// Agent claims identity but pane not registered - warn
|
|
179
|
+
return {
|
|
180
|
+
actor: 'human',
|
|
181
|
+
source: 'default',
|
|
182
|
+
warning: `⚠️ Unregistered pane: TMT_AGENT_NAME="${envActor}" but pane ${currentPane} is not in registry. Treating as human (full access).`,
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// Not registered, no env var - human
|
|
187
|
+
return { actor: 'human', source: 'default' };
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Get current actor (agent name or 'human').
|
|
192
|
+
* Legacy function for backward compatibility.
|
|
193
|
+
* Reads from TMT_AGENT_NAME or TMUX_TEAM_ACTOR env vars.
|
|
194
|
+
*/
|
|
195
|
+
export function getCurrentActor(): string {
|
|
196
|
+
return process.env.TMT_AGENT_NAME || process.env.TMUX_TEAM_ACTOR || 'human';
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
export interface PermissionResult {
|
|
200
|
+
allowed: boolean;
|
|
201
|
+
actor: string;
|
|
202
|
+
source: 'pane' | 'env' | 'default';
|
|
203
|
+
warning?: string;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Check if an action is allowed for the current actor.
|
|
208
|
+
* Uses pane-based identity resolution with warnings for conflicts.
|
|
209
|
+
*/
|
|
210
|
+
export function checkPermission(config: ResolvedConfig, check: PermissionCheck): PermissionResult {
|
|
211
|
+
const resolution = resolveActor(config.paneRegistry);
|
|
212
|
+
const { actor, source, warning } = resolution;
|
|
213
|
+
|
|
214
|
+
// Human is always allowed (no deny patterns for human)
|
|
215
|
+
if (actor === 'human') {
|
|
216
|
+
return { allowed: true, actor, source, warning };
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// Get agent config
|
|
220
|
+
const agentConfig = config.agents[actor];
|
|
221
|
+
if (!agentConfig || !agentConfig.deny || agentConfig.deny.length === 0) {
|
|
222
|
+
// No deny patterns = allow all
|
|
223
|
+
return { allowed: true, actor, source, warning };
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// Check if any deny pattern matches
|
|
227
|
+
for (const pattern of agentConfig.deny) {
|
|
228
|
+
if (matchesPattern(check, pattern)) {
|
|
229
|
+
return { allowed: false, actor, source, warning };
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
return { allowed: true, actor, source, warning };
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Simple permission check (legacy, for tests).
|
|
238
|
+
* Returns true if allowed, false if denied.
|
|
239
|
+
*/
|
|
240
|
+
export function checkPermissionSimple(config: ResolvedConfig, check: PermissionCheck): boolean {
|
|
241
|
+
return checkPermission(config, check).allowed;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Build permission check for common PM operations.
|
|
246
|
+
*/
|
|
247
|
+
export const PermissionChecks = {
|
|
248
|
+
// Task operations
|
|
249
|
+
taskList: (): PermissionCheck => ({ resource: 'task', action: 'list', fields: [] }),
|
|
250
|
+
taskShow: (): PermissionCheck => ({ resource: 'task', action: 'show', fields: [] }),
|
|
251
|
+
taskCreate: (): PermissionCheck => ({ resource: 'task', action: 'create', fields: [] }),
|
|
252
|
+
taskUpdate: (fields: string[]): PermissionCheck => ({
|
|
253
|
+
resource: 'task',
|
|
254
|
+
action: 'update',
|
|
255
|
+
fields,
|
|
256
|
+
}),
|
|
257
|
+
taskDelete: (): PermissionCheck => ({ resource: 'task', action: 'delete', fields: [] }),
|
|
258
|
+
|
|
259
|
+
// Milestone operations
|
|
260
|
+
milestoneList: (): PermissionCheck => ({ resource: 'milestone', action: 'list', fields: [] }),
|
|
261
|
+
milestoneCreate: (): PermissionCheck => ({ resource: 'milestone', action: 'create', fields: [] }),
|
|
262
|
+
milestoneUpdate: (fields: string[]): PermissionCheck => ({
|
|
263
|
+
resource: 'milestone',
|
|
264
|
+
action: 'update',
|
|
265
|
+
fields,
|
|
266
|
+
}),
|
|
267
|
+
|
|
268
|
+
// Doc operations
|
|
269
|
+
docRead: (): PermissionCheck => ({ resource: 'doc', action: 'read', fields: [] }),
|
|
270
|
+
docUpdate: (): PermissionCheck => ({ resource: 'doc', action: 'update', fields: [] }),
|
|
271
|
+
|
|
272
|
+
// Team operations
|
|
273
|
+
teamCreate: (): PermissionCheck => ({ resource: 'team', action: 'create', fields: [] }),
|
|
274
|
+
teamList: (): PermissionCheck => ({ resource: 'team', action: 'list', fields: [] }),
|
|
275
|
+
|
|
276
|
+
// Log operations
|
|
277
|
+
logRead: (): PermissionCheck => ({ resource: 'log', action: 'read', fields: [] }),
|
|
278
|
+
};
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// ─────────────────────────────────────────────────────────────
|
|
2
|
+
// Storage adapter interface for PM backends
|
|
3
|
+
// ─────────────────────────────────────────────────────────────
|
|
4
|
+
|
|
5
|
+
import type {
|
|
6
|
+
Team,
|
|
7
|
+
Milestone,
|
|
8
|
+
Task,
|
|
9
|
+
AuditEvent,
|
|
10
|
+
CreateTaskInput,
|
|
11
|
+
UpdateTaskInput,
|
|
12
|
+
CreateMilestoneInput,
|
|
13
|
+
UpdateMilestoneInput,
|
|
14
|
+
ListTasksFilter,
|
|
15
|
+
} from '../types.js';
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Abstract storage adapter interface.
|
|
19
|
+
* Implemented by:
|
|
20
|
+
* - FSAdapter (Phase 4): Local filesystem storage
|
|
21
|
+
* - GitHubAdapter (Phase 5): GitHub Issues as storage
|
|
22
|
+
*/
|
|
23
|
+
export interface StorageAdapter {
|
|
24
|
+
// Team operations
|
|
25
|
+
initTeam(name: string, windowId?: string): Promise<Team>;
|
|
26
|
+
getTeam(): Promise<Team | null>;
|
|
27
|
+
updateTeam(updates: Partial<Team>): Promise<Team>;
|
|
28
|
+
|
|
29
|
+
// Milestone operations
|
|
30
|
+
createMilestone(input: CreateMilestoneInput): Promise<Milestone>;
|
|
31
|
+
getMilestone(id: string): Promise<Milestone | null>;
|
|
32
|
+
listMilestones(): Promise<Milestone[]>;
|
|
33
|
+
updateMilestone(id: string, input: UpdateMilestoneInput): Promise<Milestone>;
|
|
34
|
+
deleteMilestone(id: string): Promise<void>;
|
|
35
|
+
|
|
36
|
+
// Task operations
|
|
37
|
+
createTask(input: CreateTaskInput): Promise<Task>;
|
|
38
|
+
getTask(id: string): Promise<Task | null>;
|
|
39
|
+
listTasks(filter?: ListTasksFilter): Promise<Task[]>;
|
|
40
|
+
updateTask(id: string, input: UpdateTaskInput): Promise<Task>;
|
|
41
|
+
deleteTask(id: string): Promise<void>;
|
|
42
|
+
|
|
43
|
+
// Documentation
|
|
44
|
+
getTaskDoc(id: string): Promise<string | null>;
|
|
45
|
+
setTaskDoc(id: string, content: string): Promise<void>;
|
|
46
|
+
|
|
47
|
+
// Audit log
|
|
48
|
+
appendEvent(event: AuditEvent): Promise<void>;
|
|
49
|
+
getEvents(limit?: number): Promise<AuditEvent[]>;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Factory function type for creating storage adapters.
|
|
54
|
+
*/
|
|
55
|
+
export type StorageAdapterFactory = (teamDir: string) => StorageAdapter;
|