zerogterm 0.8.0-alpha.1 → 0.9.1-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.1-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
 
@@ -395,6 +396,59 @@ npm install --global zerogterm
395
396
  zerogterm
396
397
  ```
397
398
 
399
+ ## Connecting an AI agent through MCP
400
+
401
+ ZeroG Terminal can expose the running desktop instance as a local MCP server. This
402
+ lets an MCP-capable AI agent inspect workspaces and sessions, restore or create
403
+ project workspaces, open SSH sessions, and request commands for your approval.
404
+
405
+ 1. Start ZeroG Terminal with MCP enabled:
406
+
407
+ ```bash
408
+ ZEROG_MCP_ENABLED=1 npx zerogterm
409
+ ```
410
+
411
+ On Windows PowerShell, use:
412
+
413
+ ```powershell
414
+ $env:ZEROG_MCP_ENABLED = "1"
415
+ npx zerogterm
416
+ ```
417
+
418
+ 2. Open **Settings → AI & voice → MCP** in ZeroG Terminal. Copy the displayed
419
+ endpoint and bearer token into your MCP client. The default endpoint is
420
+ `http://127.0.0.1:60056/mcp`.
421
+
422
+ MCP clients that support Streamable HTTP generally need the equivalent of:
423
+
424
+ ```json
425
+ {
426
+ "url": "http://127.0.0.1:60056/mcp",
427
+ "headers": {
428
+ "Authorization": "Bearer <token-from-zero-g-settings>"
429
+ }
430
+ }
431
+ ```
432
+
433
+ The exact configuration key differs between clients; use its Streamable HTTP
434
+ or remote MCP server setting rather than a stdio command. The token belongs to
435
+ the current ZeroG process, is held only in memory, and must be copied again
436
+ after ZeroG restarts. Never put it in source control, prompts, issue reports,
437
+ or shared configuration.
438
+
439
+ 3. Ask the agent to call `zerog_get_connection_status`, then
440
+ `zerog_acquire_control` with a client ID and the capabilities it needs. Read
441
+ capabilities require no write access; workspace changes and command requests
442
+ require the corresponding lease capabilities. The lease is single-client and
443
+ expires unless renewed.
444
+
445
+ Every command request is shown in ZeroG Terminal for exact user approval before
446
+ anything is written to a shell. Rejection, cancellation, takeover, credential
447
+ prompts, uncertain prompts, and unsupported command shapes fail closed. MCP does
448
+ not receive terminal output, credentials, or keystroke-level control. See the
449
+ [full MCP protocol and security guide](docs/mcp.md) for tools, limits, and
450
+ troubleshooting.
451
+
398
452
  ## Development
399
453
 
400
454
  ```bash
@@ -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,129 @@ 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
+ function createMcpProviders() {
385
+ return {
386
+ listWorkspaces: () => workspaceStore.load(),
387
+ listSessions: () => service.list(),
388
+ createWorkspace,
389
+ restoreWorkspace,
390
+ createSshSession: async (request) => {
391
+ const session = await service.createSsh(request.target, request.name);
392
+ const attached = service.attach(session.id, (data) => {
393
+ const requestId = mcpActiveExecutions.get(session.id);
394
+ if (requestId) {
395
+ const output = `${mcpExecutionOutput.get(requestId) ?? ''}${data}`.slice(0, 16 * 1024);
396
+ mcpExecutionOutput.set(requestId, output);
397
+ if ((output.match(/ExitCode=\d+/g) ?? []).length >= 2) {
398
+ const result = mcpExecutions.finishRunning(requestId, 'completed', output, false, 'Command completed.');
399
+ clearMcpExecution(requestId, session.id);
400
+ win?.webContents.send('mcp:execution', result);
401
+ }
402
+ }
403
+ win?.webContents.send('terminal:data', session.id, data);
404
+ }, (message) => win?.webContents.send('terminal:status', session.id, message));
405
+ win?.webContents.send('mcp:ssh-session-created', { session: attached });
406
+ return attached;
407
+ },
408
+ createProjectWorkspace,
409
+ requestCommand: async (request) => {
410
+ const sessions = await service.list();
411
+ const session = sessions.find((item) => item.id === request.sessionId);
412
+ if (!session)
413
+ throw new Error('Unknown session.');
414
+ 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 }) } });
415
+ const safeRemote = session.kind === 'ssh' && isSafeRemoteCommand(result.command);
416
+ if (safeRemote) {
417
+ const approved = mcpExecutions.approveAutomatically(result.requestId);
418
+ mcpAudit.record({ at: Date.now(), requestId: approved.requestId, clientId: approved.clientId, capability: 'session:execute', sessionId: approved.sessionId, state: 'approved', command: approved.displayCommand });
419
+ await runMcpExecution(approved.requestId);
420
+ return mcpExecutions.get(approved.requestId, request.clientId);
421
+ }
422
+ mcpAudit.record({ at: Date.now(), requestId: result.requestId, clientId: result.clientId, capability: 'session:execute', sessionId: result.sessionId, state: 'requested', command: result.displayCommand });
423
+ win?.webContents.send('mcp:execution', result);
424
+ return result;
425
+ },
426
+ getCommandResult: async (request) => mcpExecutions.get(request.requestId, request.clientId),
427
+ cancelCommand: async (request) => {
428
+ const current = mcpExecutions.get(request.requestId, request.clientId);
429
+ const result = mcpExecutions.cancel(request.requestId, request.clientId);
430
+ const session = 'sessionId' in current ? (await service.list()).find((item) => item.id === current.sessionId) : undefined;
431
+ if (session?.kind === 'local')
432
+ service.write(session.id, '\u0003');
433
+ clearMcpExecution(request.requestId);
434
+ return result;
435
+ }
436
+ };
437
+ }
438
+ ipcMain.handle('mcp:start', async () => {
439
+ mcpHost ??= new McpServerHost({
440
+ control: mcpControl,
441
+ providers: createMcpProviders(),
442
+ version: app.getVersion()
443
+ });
444
+ return mcpHost.start();
445
+ });
446
+ ipcMain.handle('mcp:stop', async () => { await mcpHost?.stop(); });
232
447
  ipcMain.handle('sessions:backends', () => discoverShellBackends());
233
448
  ipcMain.handle('sessions:wslDistributions', async () => {
234
449
  try {
@@ -280,7 +495,30 @@ ipcMain.handle('screens:attachRemote', (_event, input, screenName) => {
280
495
  ipcMain.handle('sessions:attach', (_event, id, size) => {
281
496
  if (typeof id !== 'string' || !id)
282
497
  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));
498
+ return service.attach(id, (data) => {
499
+ const requestId = mcpActiveExecutions.get(id);
500
+ if (requestId) {
501
+ const existing = mcpExecutionOutput.get(requestId) ?? '';
502
+ const next = `${existing}${data}`;
503
+ mcpExecutionOutput.set(requestId, next.slice(0, 16 * 1024));
504
+ if (next.length >= 16 * 1024) {
505
+ service.write(id, '\u0003');
506
+ const result = mcpExecutions.finishRunning(requestId, 'failed', next.slice(0, 16 * 1024), true, 'Execution stopped after reaching the output limit.');
507
+ clearMcpExecution(requestId, id);
508
+ win?.webContents.send('mcp:execution', result);
509
+ win?.webContents.send('terminal:status', id, 'MCP execution stopped: output limit reached.');
510
+ return;
511
+ }
512
+ const prompt = classifyMcpPrompt(data);
513
+ if (prompt.kind !== 'unknown') {
514
+ const result = mcpExecutions.cancelFromUser(requestId, `Execution paused for a ${prompt.kind} prompt; MCP cannot answer prompts.`);
515
+ clearMcpExecution(requestId, id);
516
+ win?.webContents.send('mcp:execution', result);
517
+ win?.webContents.send('terminal:status', id, `MCP execution paused: ${prompt.kind} prompt requires user input.`);
518
+ }
519
+ }
520
+ win?.webContents.send('terminal:data', id, data);
521
+ }, (message) => win?.webContents.send('terminal:status', id, message), parsePtySize(size));
284
522
  });
285
523
  ipcMain.handle('sessions:close', (_event, id) => {
286
524
  if (typeof id !== 'string' || !id)
@@ -469,6 +707,14 @@ app.whenReady().then(() => {
469
707
  Menu.setApplicationMenu(null);
470
708
  watchEventLoop();
471
709
  createWindow();
710
+ if (process.env.ZEROG_MCP_ENABLED === '1') {
711
+ mcpHost ??= new McpServerHost({
712
+ control: mcpControl,
713
+ providers: createMcpProviders(),
714
+ version: app.getVersion()
715
+ });
716
+ void mcpHost.start();
717
+ }
472
718
  app.on('activate', () => {
473
719
  if (!BrowserWindow.getAllWindows().length)
474
720
  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
+ }