zerogterm 0.8.0-alpha.1 → 0.9.0-alpha.1

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 CHANGED
@@ -30,6 +30,7 @@ ZeroG Terminal is an alpha project, but it is already useful as a multi-session
30
30
  - AI command suggestions from any OpenAI-compatible endpoint — Ollama, LM Studio, llama.cpp, vLLM, OpenRouter, or OpenAI itself — configured with a base URL, a model and an optional key in Settings. Ask what you want, get one command with an explanation, and approve it before it runs.
31
31
  - Sandboxed Electron renderer, context isolation, disabled Node integration, and a narrow typed preload API.
32
32
  - Safe argument-array handling and validation around SSH and `screen` session operations.
33
+ - Optional local MCP control for inspecting and restoring workspaces, and creating explicit project workspaces. It is loopback-only, bearer-authenticated, lease-controlled, and never executes terminal commands; see [docs/mcp.md](docs/mcp.md).
33
34
 
34
35
  The project is particularly useful for terminal-based AI development workflows: start an agent in a persistent session, disconnect or suffer an interrupted connection, and reconnect later to see what it has done and continue working.
35
36
 
@@ -37,7 +38,7 @@ See the project walkthrough on [YouTube](https://youtu.be/4aJZCxLHD14).
37
38
 
38
39
  ## Release status
39
40
 
40
- ZeroG Terminal is currently a public alpha. The current release is `0.8.0-alpha.1`; the version history is tracked in [versions.txt](versions.txt). The running version is shown beside the wordmark in the title bar, read from the app itself rather than written into the interface, so it is accurate in a packaged build too.
41
+ ZeroG Terminal is currently a public alpha. The current release is `0.9.0-alpha.1`; the version history is tracked in [versions.txt](versions.txt). The running version is shown beside the wordmark in the title bar, read from the app itself rather than written into the interface, so it is accurate in a packaged build too.
41
42
 
42
43
  The GitHub Releases page provides a Windows x64 installer and portable executable for each desktop release. These alpha builds are intended for early adopters and testing rather than production use. The npm package remains available for developers who prefer to launch ZeroG from Node.js.
43
44
 
@@ -1,5 +1,6 @@
1
1
  import { app, BrowserWindow, clipboard, ipcMain, Menu, safeStorage, session, shell } from 'electron';
2
2
  import { join } from 'node:path';
3
+ import { randomUUID } from 'node:crypto';
3
4
  import { fileURLToPath } from 'node:url';
4
5
  import { writeClipboardText } from './clipboard.js';
5
6
  import { ScreenService, parseWslDistributions } from './session-service.js';
@@ -16,6 +17,13 @@ import { decideExternalLink, isApplicationUrl } from './external-links.js';
16
17
  import { SftpService } from './sftp-service.js';
17
18
  import { AI_API_KEY, SPEECH_API_KEY, SecretStore, defaultSecretsPath } from './secret-store.js';
18
19
  import { AiService } from './ai-service.js';
20
+ import { McpControl } from './mcp-control.js';
21
+ import { McpServerHost } from './mcp-server.js';
22
+ import { McpExecutionBroker } from './mcp-execution-broker.js';
23
+ import { McpAudit } from './mcp-audit.js';
24
+ import { classifyMcpPrompt } from './prompt-classifier.js';
25
+ import { isSafeRemoteCommand } from './mcp-execution-policy.js';
26
+ import { requireWorkspaceName } from './mcp-protocol.js';
19
27
  const __dirname = fileURLToPath(new URL('.', import.meta.url));
20
28
  const history = new SessionHistoryStore({ filePath: defaultHistoryPath(app.getPath('userData')) });
21
29
  // The only store holding what the user typed. Nothing reaches it that has not
@@ -35,6 +43,90 @@ const sftp = new SftpService({ onEvent: (event) => win?.webContents.send('sftp:e
35
43
  // API keys for speech servers. safeStorage is only usable after the app is
36
44
  // ready, which every IPC call here already is.
37
45
  const secrets = new SecretStore({ filePath: defaultSecretsPath(app.getPath('userData')), crypto: safeStorage });
46
+ const mcpControl = new McpControl({ onChange: (status) => win?.webContents.send('mcp:status', status) });
47
+ const mcpExecutions = new McpExecutionBroker();
48
+ const mcpAudit = new McpAudit();
49
+ const mcpActiveExecutions = new Map();
50
+ const mcpExecutionOutput = new Map();
51
+ const mcpExecutionTimers = new Map();
52
+ function clearMcpExecution(requestId, sessionId) {
53
+ const timer = mcpExecutionTimers.get(requestId);
54
+ if (timer)
55
+ clearTimeout(timer);
56
+ mcpExecutionTimers.delete(requestId);
57
+ mcpExecutionOutput.delete(requestId);
58
+ if (sessionId) {
59
+ if (mcpActiveExecutions.get(sessionId) === requestId)
60
+ mcpActiveExecutions.delete(sessionId);
61
+ }
62
+ else {
63
+ for (const [activeSessionId, activeRequestId] of Array.from(mcpActiveExecutions.entries())) {
64
+ if (activeRequestId === requestId)
65
+ mcpActiveExecutions.delete(activeSessionId);
66
+ }
67
+ }
68
+ }
69
+ let mcpHost;
70
+ async function restoreWorkspace(workspaceId) {
71
+ const file = await workspaceStore.load();
72
+ const workspace = file.workspaces.find((item) => item.id === workspaceId);
73
+ if (!workspace)
74
+ throw new Error(`Unknown workspace: ${workspaceId}`);
75
+ const existing = await service.list();
76
+ const restored = [];
77
+ for (const member of workspace.members) {
78
+ const match = existing.find((session) => session.id === member.sessionId ||
79
+ (member.kind === 'ssh' && Boolean(member.sshTarget) && session.sshTarget === member.sshTarget) ||
80
+ (member.kind === 'local' && Boolean(member.screenName) && session.screenName === member.screenName));
81
+ if (match) {
82
+ restored.push({ member, session: match, action: 'reused' });
83
+ continue;
84
+ }
85
+ const session = member.kind === 'ssh' && member.sshTarget
86
+ ? await service.createSsh(member.sshTarget, member.name)
87
+ : await service.createLocal({ name: member.name, ...(member.cwd ? { cwd: member.cwd } : {}) });
88
+ restored.push({ member, session, action: 'created' });
89
+ }
90
+ win?.webContents.send('mcp:workspace-restored', { workspaceId, workspaceName: workspace.name, restored });
91
+ return { workspaceId, workspaceName: workspace.name, restored };
92
+ }
93
+ async function createWorkspace() {
94
+ const file = await workspaceStore.load();
95
+ const names = new Set(file.workspaces.map((workspace) => workspace.name.toLowerCase()));
96
+ let index = file.workspaces.length + 1;
97
+ while (names.has(`workspace-${index}`))
98
+ index += 1;
99
+ const workspace = { id: `ws-${randomUUID()}`, name: `workspace-${index}`, members: [], view: { layout: 'stack', lastSplit: 'split-v', maximizedSessionId: null } };
100
+ await workspaceStore.save({ version: 1, workspaces: [...file.workspaces, workspace], activeWorkspaceId: workspace.id });
101
+ win?.webContents.send('mcp:workspace-restored', { workspaceId: workspace.id, workspaceName: workspace.name, restored: [] });
102
+ return { workspaceId: workspace.id, workspaceName: workspace.name, restored: [] };
103
+ }
104
+ async function createProjectWorkspace(request) {
105
+ const name = requireWorkspaceName(request.name);
106
+ const file = await workspaceStore.load();
107
+ const existing = file.workspaces.find((item) => item.name === name);
108
+ const running = await service.list();
109
+ const workspaceId = existing?.id ?? `ws-${randomUUID()}`;
110
+ const sessions = [];
111
+ for (const project of request.projects) {
112
+ const reused = running.find((session) => session.kind === 'local' && session.name === project.name && session.cwd === project.cwd);
113
+ const session = reused ?? await service.createLocal({ name: project.name, cwd: project.cwd, ...(project.backend ? { backend: project.backend } : {}) });
114
+ sessions.push(session);
115
+ }
116
+ const members = sessions.map((session) => ({
117
+ sessionId: session.id,
118
+ kind: session.kind,
119
+ name: session.name,
120
+ ...(session.host ? { host: session.host } : {}),
121
+ ...(session.screenName ? { screenName: session.screenName } : {}),
122
+ ...(session.backend ? { backend: session.backend } : {}),
123
+ ...(session.kind === 'local' && session.cwd ? { cwd: session.cwd } : {})
124
+ }));
125
+ const workspace = { id: workspaceId, name, members, view: { layout: 'grid', lastSplit: 'grid', maximizedSessionId: null } };
126
+ await workspaceStore.save({ version: 1, workspaces: [...file.workspaces.filter((item) => item.id !== workspaceId), workspace], activeWorkspaceId: workspaceId });
127
+ win?.webContents.send('mcp:workspace-restored', { workspaceId, workspaceName: name, restored: sessions.map((session, index) => ({ member: members[index], session, action: 'created' })) });
128
+ return { workspaceId, workspaceName: name, restored: sessions };
129
+ }
38
130
  /** A pane's measured size, as it arrives from the renderer. */
39
131
  function parsePtySize(value) {
40
132
  if (!value || typeof value !== 'object')
@@ -229,6 +321,126 @@ ipcMain.handle('forwards:load', () => forwardStore.load());
229
321
  ipcMain.handle('forwards:save', (_event, file) => forwardStore.save(file));
230
322
  ipcMain.handle('workspaces:load', () => workspaceStore.load());
231
323
  ipcMain.handle('workspaces:save', (_event, file) => workspaceStore.save(file));
324
+ ipcMain.handle('mcp:status', () => mcpControl.status());
325
+ ipcMain.handle('mcp:revoke', () => { mcpControl.revoke(); mcpExecutions.revokeAll(); for (const requestId of Array.from(mcpExecutionTimers.keys()))
326
+ clearMcpExecution(requestId); win?.webContents.send('mcp:execution', mcpExecutions.listAll()); });
327
+ ipcMain.handle('mcp:executions:list', () => mcpExecutions.listAll());
328
+ ipcMain.handle('mcp:audit:list', () => mcpAudit.list());
329
+ async function runMcpExecution(requestId) {
330
+ const request = mcpExecutions.listAll().find((item) => item.requestId === requestId);
331
+ if (!request || !('sessionId' in request))
332
+ throw new Error('Unknown command request.');
333
+ const sessions = await service.list();
334
+ const session = sessions.find((item) => item.id === request.sessionId);
335
+ if (!session)
336
+ throw new Error('Only attached sessions may execute approved commands.');
337
+ const running = mcpExecutions.start(requestId);
338
+ mcpActiveExecutions.set(session.id, requestId);
339
+ mcpExecutionOutput.set(requestId, '');
340
+ mcpExecutionTimers.set(requestId, setTimeout(() => {
341
+ const output = mcpExecutionOutput.get(requestId) ?? '';
342
+ service.write(session.id, '\u0003');
343
+ const result = mcpExecutions.finishRunning(requestId, 'timed-out', output, output.length >= running.policy.maxOutputBytes, 'Execution timed out.');
344
+ clearMcpExecution(requestId, session.id);
345
+ win?.webContents.send('mcp:execution', result);
346
+ win?.webContents.send('terminal:status', session.id, 'MCP command timed out.');
347
+ }, running.policy.maxRuntimeMs));
348
+ service.write(session.id, `${running.command}\n`);
349
+ win?.webContents.send('mcp:execution', running);
350
+ return running;
351
+ }
352
+ ipcMain.handle('mcp:execution:approve', async (_event, requestId) => {
353
+ if (typeof requestId !== 'string' || !requestId)
354
+ throw new Error('A command request id is required.');
355
+ const pending = mcpExecutions.listAll().find((item) => item.requestId === requestId);
356
+ const result = mcpExecutions.decideFromUser(requestId, 'approve');
357
+ if (pending && 'clientId' in pending)
358
+ mcpAudit.record({ at: Date.now(), requestId, clientId: pending.clientId, capability: 'session:execute', sessionId: pending.sessionId, state: 'approved', command: pending.displayCommand });
359
+ mcpControl.require(result.clientId, 'session:execute');
360
+ return runMcpExecution(requestId);
361
+ });
362
+ ipcMain.handle('mcp:execution:reject', (_event, requestId) => {
363
+ if (typeof requestId !== 'string' || !requestId)
364
+ throw new Error('A command request id is required.');
365
+ const pending = mcpExecutions.listAll().find((item) => item.requestId === requestId);
366
+ const result = mcpExecutions.decideFromUser(requestId, 'reject');
367
+ if (pending && 'clientId' in pending)
368
+ mcpAudit.record({ at: Date.now(), requestId, clientId: pending.clientId, capability: 'session:execute', sessionId: pending.sessionId, state: 'rejected', command: pending.displayCommand });
369
+ clearMcpExecution(requestId);
370
+ win?.webContents.send('mcp:execution', result);
371
+ return result;
372
+ });
373
+ ipcMain.handle('mcp:execution:cancel', (_event, requestId) => {
374
+ if (typeof requestId !== 'string' || !requestId)
375
+ throw new Error('A command request id is required.');
376
+ const pending = mcpExecutions.listAll().find((item) => item.requestId === requestId);
377
+ const result = mcpExecutions.cancelFromUser(requestId);
378
+ if (pending && 'clientId' in pending)
379
+ mcpAudit.record({ at: Date.now(), requestId, clientId: pending.clientId, capability: 'session:execute', sessionId: pending.sessionId, state: 'cancelled', command: pending.displayCommand });
380
+ clearMcpExecution(requestId);
381
+ win?.webContents.send('mcp:execution', result);
382
+ return result;
383
+ });
384
+ ipcMain.handle('mcp:start', async () => {
385
+ mcpHost ??= new McpServerHost({
386
+ control: mcpControl,
387
+ providers: {
388
+ listWorkspaces: () => workspaceStore.load(),
389
+ listSessions: () => service.list(),
390
+ createWorkspace,
391
+ restoreWorkspace,
392
+ createSshSession: async (request) => {
393
+ const session = await service.createSsh(request.target, request.name);
394
+ const attached = service.attach(session.id, (data) => {
395
+ const requestId = mcpActiveExecutions.get(session.id);
396
+ if (requestId) {
397
+ const output = `${mcpExecutionOutput.get(requestId) ?? ''}${data}`.slice(0, 16 * 1024);
398
+ mcpExecutionOutput.set(requestId, output);
399
+ if ((output.match(/ExitCode=\d+/g) ?? []).length >= 2) {
400
+ const result = mcpExecutions.finishRunning(requestId, 'completed', output, false, 'Command completed.');
401
+ clearMcpExecution(requestId, session.id);
402
+ win?.webContents.send('mcp:execution', result);
403
+ }
404
+ }
405
+ win?.webContents.send('terminal:data', session.id, data);
406
+ }, (message) => win?.webContents.send('terminal:status', session.id, message));
407
+ win?.webContents.send('mcp:ssh-session-created', { session: attached });
408
+ return attached;
409
+ },
410
+ createProjectWorkspace,
411
+ requestCommand: async (request) => {
412
+ const sessions = await service.list();
413
+ const session = sessions.find((item) => item.id === request.sessionId);
414
+ if (!session)
415
+ throw new Error('Unknown session.');
416
+ const result = mcpExecutions.create({ clientId: request.clientId, sessionId: request.sessionId, sessionKind: session.kind, command: request.command, policy: { allowRemoteSessions: session.kind === 'ssh', ...(request.timeoutMs === undefined ? {} : { maxRuntimeMs: request.timeoutMs }), ...(request.outputBytes === undefined ? {} : { maxOutputBytes: request.outputBytes }) } });
417
+ const safeRemote = session.kind === 'ssh' && isSafeRemoteCommand(result.command);
418
+ if (safeRemote) {
419
+ const approved = mcpExecutions.approveAutomatically(result.requestId);
420
+ mcpAudit.record({ at: Date.now(), requestId: approved.requestId, clientId: approved.clientId, capability: 'session:execute', sessionId: approved.sessionId, state: 'approved', command: approved.displayCommand });
421
+ await runMcpExecution(approved.requestId);
422
+ return mcpExecutions.get(approved.requestId, request.clientId);
423
+ }
424
+ mcpAudit.record({ at: Date.now(), requestId: result.requestId, clientId: result.clientId, capability: 'session:execute', sessionId: result.sessionId, state: 'requested', command: result.displayCommand });
425
+ win?.webContents.send('mcp:execution', result);
426
+ return result;
427
+ },
428
+ getCommandResult: async (request) => mcpExecutions.get(request.requestId, request.clientId),
429
+ cancelCommand: async (request) => {
430
+ const current = mcpExecutions.get(request.requestId, request.clientId);
431
+ const result = mcpExecutions.cancel(request.requestId, request.clientId);
432
+ const session = 'sessionId' in current ? (await service.list()).find((item) => item.id === current.sessionId) : undefined;
433
+ if (session?.kind === 'local')
434
+ service.write(session.id, '\u0003');
435
+ clearMcpExecution(request.requestId);
436
+ return result;
437
+ }
438
+ },
439
+ version: app.getVersion()
440
+ });
441
+ return mcpHost.start();
442
+ });
443
+ ipcMain.handle('mcp:stop', async () => { await mcpHost?.stop(); });
232
444
  ipcMain.handle('sessions:backends', () => discoverShellBackends());
233
445
  ipcMain.handle('sessions:wslDistributions', async () => {
234
446
  try {
@@ -280,7 +492,30 @@ ipcMain.handle('screens:attachRemote', (_event, input, screenName) => {
280
492
  ipcMain.handle('sessions:attach', (_event, id, size) => {
281
493
  if (typeof id !== 'string' || !id)
282
494
  throw new Error('attachSession requires a session id');
283
- return service.attach(id, (data) => win?.webContents.send('terminal:data', id, data), (message) => win?.webContents.send('terminal:status', id, message), parsePtySize(size));
495
+ return service.attach(id, (data) => {
496
+ const requestId = mcpActiveExecutions.get(id);
497
+ if (requestId) {
498
+ const existing = mcpExecutionOutput.get(requestId) ?? '';
499
+ const next = `${existing}${data}`;
500
+ mcpExecutionOutput.set(requestId, next.slice(0, 16 * 1024));
501
+ if (next.length >= 16 * 1024) {
502
+ service.write(id, '\u0003');
503
+ const result = mcpExecutions.finishRunning(requestId, 'failed', next.slice(0, 16 * 1024), true, 'Execution stopped after reaching the output limit.');
504
+ clearMcpExecution(requestId, id);
505
+ win?.webContents.send('mcp:execution', result);
506
+ win?.webContents.send('terminal:status', id, 'MCP execution stopped: output limit reached.');
507
+ return;
508
+ }
509
+ const prompt = classifyMcpPrompt(data);
510
+ if (prompt.kind !== 'unknown') {
511
+ const result = mcpExecutions.cancelFromUser(requestId, `Execution paused for a ${prompt.kind} prompt; MCP cannot answer prompts.`);
512
+ clearMcpExecution(requestId, id);
513
+ win?.webContents.send('mcp:execution', result);
514
+ win?.webContents.send('terminal:status', id, `MCP execution paused: ${prompt.kind} prompt requires user input.`);
515
+ }
516
+ }
517
+ win?.webContents.send('terminal:data', id, data);
518
+ }, (message) => win?.webContents.send('terminal:status', id, message), parsePtySize(size));
284
519
  });
285
520
  ipcMain.handle('sessions:close', (_event, id) => {
286
521
  if (typeof id !== 'string' || !id)
@@ -469,6 +704,65 @@ app.whenReady().then(() => {
469
704
  Menu.setApplicationMenu(null);
470
705
  watchEventLoop();
471
706
  createWindow();
707
+ if (process.env.ZEROG_MCP_ENABLED === '1') {
708
+ mcpHost ??= new McpServerHost({
709
+ control: mcpControl,
710
+ providers: {
711
+ listWorkspaces: () => workspaceStore.load(),
712
+ listSessions: () => service.list(),
713
+ createWorkspace,
714
+ restoreWorkspace,
715
+ createSshSession: async (request) => {
716
+ const session = await service.createSsh(request.target, request.name);
717
+ const attached = service.attach(session.id, (data) => {
718
+ const requestId = mcpActiveExecutions.get(session.id);
719
+ if (requestId) {
720
+ const output = `${mcpExecutionOutput.get(requestId) ?? ''}${data}`.slice(0, 16 * 1024);
721
+ mcpExecutionOutput.set(requestId, output);
722
+ if ((output.match(/ExitCode=\d+/g) ?? []).length >= 2) {
723
+ const result = mcpExecutions.finishRunning(requestId, 'completed', output, false, 'Command completed.');
724
+ clearMcpExecution(requestId, session.id);
725
+ win?.webContents.send('mcp:execution', result);
726
+ }
727
+ }
728
+ win?.webContents.send('terminal:data', session.id, data);
729
+ }, (message) => win?.webContents.send('terminal:status', session.id, message));
730
+ win?.webContents.send('mcp:ssh-session-created', { session: attached });
731
+ return attached;
732
+ },
733
+ createProjectWorkspace,
734
+ requestCommand: async (request) => {
735
+ const sessions = await service.list();
736
+ const session = sessions.find((item) => item.id === request.sessionId);
737
+ if (!session)
738
+ throw new Error('Unknown session.');
739
+ const result = mcpExecutions.create({ clientId: request.clientId, sessionId: request.sessionId, sessionKind: session.kind, command: request.command, policy: { allowRemoteSessions: session.kind === 'ssh', ...(request.timeoutMs === undefined ? {} : { maxRuntimeMs: request.timeoutMs }), ...(request.outputBytes === undefined ? {} : { maxOutputBytes: request.outputBytes }) } });
740
+ const safeRemote = session.kind === 'ssh' && isSafeRemoteCommand(result.command);
741
+ if (safeRemote) {
742
+ const approved = mcpExecutions.approveAutomatically(result.requestId);
743
+ mcpAudit.record({ at: Date.now(), requestId: approved.requestId, clientId: approved.clientId, capability: 'session:execute', sessionId: approved.sessionId, state: 'approved', command: approved.displayCommand });
744
+ await runMcpExecution(approved.requestId);
745
+ return mcpExecutions.get(approved.requestId, request.clientId);
746
+ }
747
+ mcpAudit.record({ at: Date.now(), requestId: result.requestId, clientId: result.clientId, capability: 'session:execute', sessionId: result.sessionId, state: 'requested', command: result.displayCommand });
748
+ win?.webContents.send('mcp:execution', result);
749
+ return result;
750
+ },
751
+ getCommandResult: async (request) => mcpExecutions.get(request.requestId, request.clientId),
752
+ cancelCommand: async (request) => {
753
+ const current = mcpExecutions.get(request.requestId, request.clientId);
754
+ const result = mcpExecutions.cancel(request.requestId, request.clientId);
755
+ const session = 'sessionId' in current ? (await service.list()).find((item) => item.id === current.sessionId) : undefined;
756
+ if (session?.kind === 'local')
757
+ service.write(session.id, '\u0003');
758
+ clearMcpExecution(request.requestId);
759
+ return result;
760
+ }
761
+ },
762
+ version: app.getVersion()
763
+ });
764
+ void mcpHost.start();
765
+ }
472
766
  app.on('activate', () => {
473
767
  if (!BrowserWindow.getAllWindows().length)
474
768
  createWindow();
@@ -0,0 +1,12 @@
1
+ import { createHash } from 'node:crypto';
2
+ const MAX_EVENTS = 256;
3
+ const MAX_COMMAND = 256;
4
+ export class McpAudit {
5
+ events = [];
6
+ record(input) {
7
+ this.events.push({ ...input, client: createHash('sha256').update(input.clientId).digest('hex').slice(0, 16), command: input.command.slice(0, MAX_COMMAND) });
8
+ if (this.events.length > MAX_EVENTS)
9
+ this.events.splice(0, this.events.length - MAX_EVENTS);
10
+ }
11
+ list() { return this.events.map((event) => ({ ...event })); }
12
+ }
@@ -0,0 +1,95 @@
1
+ import { requireMcpCapability } from './mcp-protocol.js';
2
+ /**
3
+ * The single-client control lease. This class deliberately knows nothing about
4
+ * MCP or Electron so revocation and expiry can be tested without a running app.
5
+ */
6
+ export class McpControl {
7
+ leaseMs;
8
+ now;
9
+ onChange;
10
+ lease;
11
+ state = { state: 'disabled', capabilities: [] };
12
+ constructor(options = {}) {
13
+ this.leaseMs = options.leaseMs ?? 5 * 60_000;
14
+ this.now = options.now ?? Date.now;
15
+ this.onChange = options.onChange;
16
+ }
17
+ status() {
18
+ this.expireIfNeeded();
19
+ return { ...this.state, capabilities: [...this.state.capabilities] };
20
+ }
21
+ enable(endpoint) {
22
+ this.state = { state: 'listening', endpoint, capabilities: [] };
23
+ this.emit();
24
+ return this.status();
25
+ }
26
+ disable() {
27
+ this.lease = undefined;
28
+ this.state = { state: 'disabled', capabilities: [] };
29
+ this.emit();
30
+ }
31
+ connect(clientId, clientName, capabilities) {
32
+ if (this.state.state === 'disabled')
33
+ throw new Error('MCP control is disabled in ZeroG settings.');
34
+ if (this.lease && this.lease.clientId !== clientId && this.isLeaseActive()) {
35
+ throw new Error('Another AI client already holds the ZeroG control lease.');
36
+ }
37
+ const unique = Array.from(new Set(capabilities));
38
+ this.lease = { clientId, ...(clientName ? { clientName } : {}), capabilities: unique, expiresAt: this.now() + this.leaseMs };
39
+ this.state = { ...this.state, state: 'connected', clientName, leaseExpiresAt: this.lease.expiresAt, capabilities: unique };
40
+ this.emit();
41
+ return { ...this.lease, capabilities: [...this.lease.capabilities] };
42
+ }
43
+ renew(clientId) {
44
+ this.requireClient(clientId);
45
+ const lease = this.lease;
46
+ lease.expiresAt = this.now() + this.leaseMs;
47
+ this.state = { ...this.state, leaseExpiresAt: lease.expiresAt };
48
+ this.emit();
49
+ return { ...lease, capabilities: [...lease.capabilities] };
50
+ }
51
+ upgrade(clientId, capabilities) {
52
+ this.requireClient(clientId);
53
+ const lease = this.lease;
54
+ const unique = Array.from(new Set([...lease.capabilities, ...capabilities]));
55
+ lease.capabilities = unique;
56
+ lease.expiresAt = this.now() + this.leaseMs;
57
+ this.state = { ...this.state, leaseExpiresAt: lease.expiresAt, capabilities: unique };
58
+ this.emit();
59
+ return { ...lease, capabilities: [...lease.capabilities] };
60
+ }
61
+ revoke(reason = 'AI control revoked by the user.') {
62
+ this.lease = undefined;
63
+ this.state = { ...this.state, state: 'revoked', leaseExpiresAt: undefined, capabilities: [] };
64
+ this.emit();
65
+ void reason;
66
+ }
67
+ require(clientId, capability) {
68
+ this.expireIfNeeded();
69
+ const lease = this.lease;
70
+ if (!lease || lease.clientId !== clientId)
71
+ throw new Error('This AI client does not hold the active control lease.');
72
+ requireMcpCapability(lease, capability, this.now());
73
+ }
74
+ requireClient(clientId) {
75
+ this.expireIfNeeded();
76
+ if (!this.lease || this.lease.clientId !== clientId)
77
+ throw new Error('This AI client does not hold the active control lease.');
78
+ }
79
+ isLeaseActive() {
80
+ return Boolean(this.lease && this.lease.expiresAt > this.now());
81
+ }
82
+ expireIfNeeded() {
83
+ if (this.lease && this.lease.expiresAt <= this.now()) {
84
+ this.lease = undefined;
85
+ this.state = { ...this.state, state: 'revoked', leaseExpiresAt: undefined, capabilities: [] };
86
+ this.emit();
87
+ }
88
+ }
89
+ emit() {
90
+ this.onChange?.(this.statusWithoutExpiry());
91
+ }
92
+ statusWithoutExpiry() {
93
+ return { ...this.state, capabilities: [...this.state.capabilities] };
94
+ }
95
+ }
@@ -0,0 +1,126 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { validateMcpCommand } from './mcp-execution-policy.js';
3
+ /** Approval state only. Execution remains owned by the main-process PTY layer. */
4
+ export class McpExecutionBroker {
5
+ maxPending;
6
+ pending = new Map();
7
+ constructor(options = {}) {
8
+ this.maxPending = options.maxPending ?? 8;
9
+ }
10
+ create(input) {
11
+ const decision = validateMcpCommand(input);
12
+ if (!decision.allowed || !decision.command || !decision.displayCommand)
13
+ throw new Error(decision.reason ?? 'Command rejected.');
14
+ if (Array.from(this.pending.values()).some((item) => item.request.clientId === input.clientId && item.request.sessionId === input.sessionId && item.request.state === 'pending')) {
15
+ throw new Error('A command approval is already pending for this session.');
16
+ }
17
+ if (Array.from(this.pending.values()).filter((item) => item.request.state === 'pending').length >= this.maxPending)
18
+ throw new Error('Too many pending command approvals.');
19
+ const now = input.now ?? Date.now();
20
+ const request = {
21
+ requestId: randomUUID(), clientId: input.clientId, sessionId: input.sessionId,
22
+ command: decision.command, displayCommand: decision.displayCommand, state: 'pending',
23
+ createdAt: now, expiresAt: now + Math.min(input.approvalTtlMs ?? 60_000, 5 * 60_000), policy: decision.policy
24
+ };
25
+ this.pending.set(request.requestId, { request });
26
+ return { ...request, policy: { ...request.policy } };
27
+ }
28
+ get(requestId, clientId, now = Date.now()) {
29
+ const item = this.owned(requestId, clientId);
30
+ this.expire(item, now);
31
+ return item.result ? { ...item.result } : { ...item.request, policy: { ...item.request.policy } };
32
+ }
33
+ decide(requestId, clientId, decision, now = Date.now()) {
34
+ const item = this.owned(requestId, clientId);
35
+ this.expire(item, now);
36
+ if (item.request.state !== 'pending')
37
+ throw new Error('Command approval is no longer pending.');
38
+ item.request.state = decision === 'approve' ? 'approved' : 'rejected';
39
+ if (decision === 'reject')
40
+ this.finish(item, 'rejected', 'Rejected by the user.', now);
41
+ return { ...item.request, policy: { ...item.request.policy } };
42
+ }
43
+ decideFromUser(requestId, decision, now = Date.now()) {
44
+ const item = this.pending.get(requestId);
45
+ if (!item)
46
+ throw new Error('Unknown command request.');
47
+ this.expire(item, now);
48
+ if (item.request.state !== 'pending')
49
+ throw new Error('Command approval is no longer pending.');
50
+ item.request.state = decision === 'approve' ? 'approved' : 'rejected';
51
+ if (decision === 'reject')
52
+ this.finish(item, 'rejected', 'Rejected by the user.', now);
53
+ return { ...item.request, policy: { ...item.request.policy } };
54
+ }
55
+ approveAutomatically(requestId, now = Date.now()) {
56
+ const item = this.pending.get(requestId);
57
+ if (!item)
58
+ throw new Error('Unknown command request.');
59
+ this.expire(item, now);
60
+ if (item.request.state !== 'pending')
61
+ throw new Error('Command approval is no longer pending.');
62
+ item.request.state = 'approved';
63
+ return { ...item.request, policy: { ...item.request.policy } };
64
+ }
65
+ start(requestId, now = Date.now()) {
66
+ const item = this.pending.get(requestId);
67
+ if (!item)
68
+ throw new Error('Unknown command request.');
69
+ this.expire(item, now);
70
+ if (item.request.state !== 'approved')
71
+ throw new Error('Command must be approved before execution.');
72
+ item.request.state = 'running';
73
+ return { ...item.request, policy: { ...item.request.policy } };
74
+ }
75
+ finishRunning(requestId, state, output, truncated, message, now = Date.now()) {
76
+ const item = this.pending.get(requestId);
77
+ if (!item)
78
+ throw new Error('Unknown command request.');
79
+ if (item.request.state !== 'running')
80
+ throw new Error('Command is not running.');
81
+ this.finish(item, state, message, now, output, truncated);
82
+ return item.result;
83
+ }
84
+ cancel(requestId, clientId, message = 'Cancelled by the user.', now = Date.now()) {
85
+ const item = this.owned(requestId, clientId);
86
+ this.finish(item, 'cancelled', message, now);
87
+ return item.result;
88
+ }
89
+ cancelFromUser(requestId, message = 'Cancelled by the user.', now = Date.now()) {
90
+ const item = this.pending.get(requestId);
91
+ if (!item)
92
+ throw new Error('Unknown command request.');
93
+ this.finish(item, 'cancelled', message, now);
94
+ return item.result;
95
+ }
96
+ revoke(clientId, now = Date.now()) {
97
+ for (const item of Array.from(this.pending.values()))
98
+ if (item.request.clientId === clientId && !item.result)
99
+ this.finish(item, 'cancelled', 'AI control was revoked.', now);
100
+ }
101
+ revokeAll(now = Date.now()) {
102
+ for (const item of Array.from(this.pending.values()))
103
+ if (!item.result)
104
+ this.finish(item, 'cancelled', 'AI control was revoked.', now);
105
+ }
106
+ list(clientId, now = Date.now()) {
107
+ return Array.from(this.pending.values()).filter((item) => item.request.clientId === clientId).map((item) => this.get(item.request.requestId, clientId, now));
108
+ }
109
+ listAll(now = Date.now()) {
110
+ return Array.from(this.pending.values()).map((item) => { this.expire(item, now); return item.result ? { ...item.result } : { ...item.request, policy: { ...item.request.policy } }; });
111
+ }
112
+ owned(requestId, clientId) {
113
+ const item = this.pending.get(requestId);
114
+ if (!item || item.request.clientId !== clientId)
115
+ throw new Error('Unknown command request.');
116
+ return item;
117
+ }
118
+ expire(item, now) {
119
+ if (!item.result && item.request.state === 'pending' && item.request.expiresAt <= now)
120
+ this.finish(item, 'timed-out', 'Approval expired.', now);
121
+ }
122
+ finish(item, state, message, now, output, truncated = false) {
123
+ item.request.state = state;
124
+ item.result = { requestId: item.request.requestId, state, ...(output ? { output } : {}), ...(truncated ? { truncated: true } : {}), message, completedAt: now };
125
+ }
126
+ }
@@ -0,0 +1,43 @@
1
+ export const DEFAULT_MCP_EXECUTION_POLICY = {
2
+ allowShellOperators: false,
3
+ allowRemoteSessions: false,
4
+ maxCommandLength: 512,
5
+ maxRuntimeMs: 30_000,
6
+ maxOutputBytes: 16 * 1024
7
+ };
8
+ const CONTROL = /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/;
9
+ const SHELL_OPERATORS = /[|;&<>`$(){}\\]/;
10
+ const CREDENTIAL_SHAPE = /(?:bearer\s+|password\s*=|token\s*=|api[_-]?key\s*=|-----begin\s+[^\r\n]+-----)/i;
11
+ const SAFE_REMOTE_COMMANDS = /^(?:pwd|cd(?:\s+\.?\/?[A-Za-z0-9_./~-]+)?|whoami|hostname|uname(?:\s+-a)?|ls(?:\s+(?:-[al]{1,2}|\.?\/?[A-Za-z0-9_./~-]+))?|df(?:\s+-h)?(?:\s+\.?\/?[A-Za-z0-9_./~-]+)?)$/;
12
+ export function validateMcpCommand(input) {
13
+ const policy = { ...DEFAULT_MCP_EXECUTION_POLICY, ...(input.policy ?? {}) };
14
+ if (typeof input.command !== 'string')
15
+ return denied(policy, 'Command must be text.');
16
+ const command = input.command.trim();
17
+ if (!command)
18
+ return denied(policy, 'Command is required.');
19
+ if (command.length > policy.maxCommandLength)
20
+ return denied(policy, 'Command is too long.');
21
+ if (/[\r\n]/.test(command))
22
+ return denied(policy, 'Only one command line may be requested.');
23
+ if (CONTROL.test(command))
24
+ return denied(policy, 'Command contains control characters.');
25
+ if (CREDENTIAL_SHAPE.test(command))
26
+ return denied(policy, 'Credential-like command content is disabled.');
27
+ if (!policy.allowShellOperators && SHELL_OPERATORS.test(command))
28
+ return denied(policy, 'Shell operators are disabled.');
29
+ if (input.sessionKind === 'ssh' && !policy.allowRemoteSessions)
30
+ return denied(policy, 'Remote command execution is disabled.');
31
+ return { allowed: true, command, displayCommand: redactCommand(command), policy };
32
+ }
33
+ export function isSafeRemoteCommand(command) {
34
+ return SAFE_REMOTE_COMMANDS.test(command.trim());
35
+ }
36
+ function redactCommand(command) {
37
+ if (CREDENTIAL_SHAPE.test(command))
38
+ return '[redacted command: credential-like content]';
39
+ return command;
40
+ }
41
+ function denied(policy, reason) {
42
+ return { allowed: false, reason, policy };
43
+ }