Skip to content

Commit ed2bd7b

Browse files
Add permission system
1 parent 1ce3b31 commit ed2bd7b

9 files changed

Lines changed: 1373 additions & 8 deletions

File tree

server/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,8 @@
88
"dev": "vite dev",
99
"preview": "vite preview",
1010
"test:relay": "tsx test-relay-server.ts",
11-
"test:cli": "tsx test-cli.ts"
11+
"test:cli": "tsx test-cli.ts",
12+
"test:permissions": "tsx test-permission-cli.ts"
1213
},
1314
"keywords": [],
1415
"author": "",
Lines changed: 271 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,271 @@
1+
import type {ApprovalRequest, ApprovalStatus, PendingApproval,} from './types';
2+
3+
/**
4+
* Event listener for approval queue changes
5+
*/
6+
type ApprovalListener = (approval: PendingApproval) => void;
7+
8+
/**
9+
* Manages the approval queue for copilot mode tool calls
10+
*/
11+
export class ApprovalQueue {
12+
private pending: Map<string, PendingApproval>;
13+
private history: PendingApproval[];
14+
private readonly historyMaxSize: number;
15+
private nextId: number;
16+
17+
// Separate storage for non-serializable properties
18+
private resolvers: Map<string, (approved: boolean) => void>;
19+
private timeouts: Map<string, NodeJS.Timeout>;
20+
21+
// Event listeners for real-time updates
22+
private readonly addedListeners: Set<ApprovalListener>;
23+
private readonly resolvedListeners: Set<ApprovalListener>;
24+
25+
constructor(options?: { historyMaxSize?: number }) {
26+
this.pending = new Map();
27+
this.history = [];
28+
this.historyMaxSize = options?.historyMaxSize ?? 1000;
29+
this.nextId = 1;
30+
this.resolvers = new Map();
31+
this.timeouts = new Map();
32+
this.addedListeners = new Set();
33+
this.resolvedListeners = new Set();
34+
}
35+
36+
/**
37+
* Request approval from user and wait for their decision
38+
* Returns true if approved, false if rejected/timeout/cancelled
39+
*/
40+
async requestApproval(request: ApprovalRequest): Promise<boolean> {
41+
const id = this.generateId();
42+
43+
// Create the pending approval (pure data object, no functions)
44+
const approval: PendingApproval = {
45+
id,
46+
type: request.type,
47+
toolName: request.toolName,
48+
serverId: request.serverId,
49+
originalToolName: request.originalToolName,
50+
args: request.args,
51+
response: request.response,
52+
createdAt: new Date(),
53+
expiresAt: request.timeout ? new Date(Date.now() + request.timeout) : undefined,
54+
status: 'pending',
55+
};
56+
57+
// Create a promise that will be resolved when user approves/rejects
58+
const approvalPromise = new Promise<boolean>((resolve) => {
59+
// Store resolver separately (not on the approval object)
60+
this.resolvers.set(id, resolve);
61+
62+
// Setup timeout if specified
63+
if (request.timeout) {
64+
const timeoutId = setTimeout(() => {
65+
this.timeout(id);
66+
}, request.timeout);
67+
// Store timeout separately (not on the approval object)
68+
this.timeouts.set(id, timeoutId);
69+
}
70+
});
71+
72+
// Add to pending queue
73+
this.pending.set(id, approval);
74+
75+
// Notify listeners
76+
this.notifyAdded(approval);
77+
78+
// Wait for approval/rejection
79+
return approvalPromise;
80+
}
81+
82+
/**
83+
* Approve a pending approval
84+
*/
85+
approve(approvalId: string, message?: string): void {
86+
const approval = this.pending.get(approvalId);
87+
if (!approval || approval.status !== 'pending') {
88+
throw new Error(`No pending approval found with id: ${approvalId}`);
89+
}
90+
91+
this.resolve(approvalId, 'approved', true, message);
92+
}
93+
94+
/**
95+
* Reject a pending approval
96+
*/
97+
reject(approvalId: string, message?: string): void {
98+
const approval = this.pending.get(approvalId);
99+
if (!approval || approval.status !== 'pending') {
100+
throw new Error(`No pending approval found with id: ${approvalId}`);
101+
}
102+
103+
this.resolve(approvalId, 'rejected', false, message);
104+
}
105+
106+
/**
107+
* Cancel a pending approval (e.g., when tool is disabled)
108+
*/
109+
cancel(approvalId: string, message?: string): void {
110+
const approval = this.pending.get(approvalId);
111+
if (!approval || approval.status !== 'pending') {
112+
return; // Already resolved or doesn't exist
113+
}
114+
115+
this.resolve(approvalId, 'cancelled', false, message ?? 'Approval cancelled');
116+
}
117+
118+
/**
119+
* Cancel all pending approvals for a specific tool
120+
*/
121+
cancelForTool(toolName: string): void {
122+
const approvals = Array.from(this.pending.values()).filter(
123+
(a) => a.toolName === toolName && a.status === 'pending'
124+
);
125+
126+
for (const approval of approvals) {
127+
this.cancel(approval.id, `Tool ${toolName} was disabled or permission changed`);
128+
}
129+
}
130+
131+
/**
132+
* Get all pending approvals
133+
*/
134+
getPending(): PendingApproval[] {
135+
return Array.from(this.pending.values()).filter((a) => a.status === 'pending');
136+
}
137+
138+
/**
139+
* Get a specific approval by ID
140+
*/
141+
getApproval(approvalId: string): PendingApproval | undefined {
142+
return this.pending.get(approvalId);
143+
}
144+
145+
/**
146+
* Get approval history
147+
*/
148+
getHistory(limit?: number): PendingApproval[] {
149+
const history = [...this.history];
150+
return limit ? history.slice(0, limit) : history;
151+
}
152+
153+
/**
154+
* Clear approval history
155+
*/
156+
clearHistory(): void {
157+
this.history = [];
158+
}
159+
160+
/**
161+
* Subscribe to approval added events
162+
*/
163+
onApprovalAdded(listener: ApprovalListener): () => void {
164+
this.addedListeners.add(listener);
165+
return () => this.addedListeners.delete(listener);
166+
}
167+
168+
/**
169+
* Subscribe to approval resolved events
170+
*/
171+
onApprovalResolved(listener: ApprovalListener): () => void {
172+
this.resolvedListeners.add(listener);
173+
return () => this.resolvedListeners.delete(listener);
174+
}
175+
176+
/**
177+
* Get statistics about the approval queue
178+
*/
179+
getStats() {
180+
const pending = this.getPending();
181+
return {
182+
pendingCount: pending.length,
183+
historyCount: this.history.length,
184+
totalProcessed: this.nextId - 1,
185+
};
186+
}
187+
188+
// Private methods
189+
190+
private generateId(): string {
191+
return `approval-${this.nextId++}`;
192+
}
193+
194+
private timeout(approvalId: string): void {
195+
const approval = this.pending.get(approvalId);
196+
if (!approval || approval.status !== 'pending') {
197+
return; // Already resolved
198+
}
199+
200+
this.resolve(approvalId, 'timeout', false, 'Approval timed out');
201+
}
202+
203+
private resolve(
204+
approvalId: string,
205+
status: ApprovalStatus,
206+
approved: boolean,
207+
message?: string
208+
): void {
209+
const approval = this.pending.get(approvalId);
210+
if (!approval) {
211+
return;
212+
}
213+
214+
// Clear timeout if exists
215+
const timeoutId = this.timeouts.get(approvalId);
216+
if (timeoutId) {
217+
clearTimeout(timeoutId);
218+
this.timeouts.delete(approvalId);
219+
}
220+
221+
// Update approval
222+
approval.status = status;
223+
approval.resolvedAt = new Date();
224+
if (message) {
225+
approval.userMessage = message;
226+
}
227+
228+
// Resolve the promise
229+
const resolver = this.resolvers.get(approvalId);
230+
if (resolver) {
231+
resolver(approved);
232+
this.resolvers.delete(approvalId);
233+
}
234+
235+
// Move to history
236+
this.pending.delete(approvalId);
237+
this.addToHistory(approval);
238+
239+
// Notify listeners
240+
this.notifyResolved(approval);
241+
}
242+
243+
private addToHistory(approval: PendingApproval): void {
244+
this.history.unshift(approval); // Add to beginning
245+
246+
// Trim history if too large
247+
if (this.history.length > this.historyMaxSize) {
248+
this.history = this.history.slice(0, this.historyMaxSize);
249+
}
250+
}
251+
252+
private notifyAdded(approval: PendingApproval): void {
253+
for (const listener of this.addedListeners) {
254+
try {
255+
listener(approval);
256+
} catch (error) {
257+
console.error('Error in approval added listener:', error);
258+
}
259+
}
260+
}
261+
262+
private notifyResolved(approval: PendingApproval): void {
263+
for (const listener of this.resolvedListeners) {
264+
try {
265+
listener(approval);
266+
} catch (error) {
267+
console.error('Error in approval resolved listener:', error);
268+
}
269+
}
270+
}
271+
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
export {PermissionManager} from './permission-manager';
2+
export {ApprovalQueue} from './approval-queue';
3+
export type {
4+
PermissionMode,
5+
PermissionConfig,
6+
ToolPermission,
7+
CopilotSettings,
8+
ApprovalType,
9+
ApprovalStatus,
10+
PendingApproval,
11+
ApprovalRequest,
12+
PermissionManagerOptions,
13+
ToolWithPermission,
14+
} from './types';

0 commit comments

Comments
 (0)