poke-gate 0.1.9 → 0.2.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.
Files changed (32) hide show
  1. package/.github/workflows/release.yml +53 -3
  2. package/Gate.app +0 -0
  3. package/README.md +48 -14
  4. package/bin/poke-gate.js +17 -0
  5. package/clients/Poke macOS Gate/Poke macOS Gate/AboutView.swift +7 -1
  6. package/clients/Poke macOS Gate/Poke macOS Gate/AccessibilityPermissionView.swift +58 -0
  7. package/clients/Poke macOS Gate/Poke macOS Gate/GateService.swift +389 -23
  8. package/clients/Poke macOS Gate/Poke macOS Gate/Info.plist +2 -0
  9. package/clients/Poke macOS Gate/Poke macOS Gate/LogsView.swift +1 -1
  10. package/clients/Poke macOS Gate/Poke macOS Gate/MacVisualStyle.swift +89 -0
  11. package/clients/Poke macOS Gate/Poke macOS Gate/PermissionRowView.swift +55 -0
  12. package/clients/Poke macOS Gate/Poke macOS Gate/Poke_macOS_GateApp.swift +234 -91
  13. package/clients/Poke macOS Gate/Poke macOS Gate/SettingsView.swift +125 -81
  14. package/clients/Poke macOS Gate/Poke macOS Gate/SetupView.swift +157 -0
  15. package/clients/Poke macOS Gate/Poke macOS Gate.xcodeproj/project.pbxproj +31 -11
  16. package/docs/cli.md +19 -0
  17. package/docs/getting-started.md +9 -6
  18. package/docs/index.md +23 -18
  19. package/docs/macos-app.md +39 -4
  20. package/docs/security.md +62 -18
  21. package/examples/agents/battery.30m.js +1 -1
  22. package/examples/agents/screentime.24h.js +5 -6
  23. package/macOS +0 -0
  24. package/package.json +3 -1
  25. package/src/agents.js +5 -8
  26. package/src/app.js +29 -5
  27. package/src/mcp-server.js +502 -27
  28. package/src/permission-service.js +128 -0
  29. package/test/mcp-server-access-policy.test.js +40 -0
  30. package/test/mcp-server-loop-guard.test.js +57 -0
  31. package/test/mcp-server-sandbox-command.test.js +18 -0
  32. package/test/permission-service.test.js +97 -0
package/src/mcp-server.js CHANGED
@@ -1,15 +1,67 @@
1
1
  import http from "node:http";
2
+ import { randomBytes } from "node:crypto";
2
3
  import { exec } from "node:child_process";
3
4
  import { readFileSync, writeFileSync, readdirSync, statSync, existsSync, symlinkSync, lstatSync } from "node:fs";
4
5
  import { hostname, platform, arch, uptime, totalmem, freemem, homedir } from "node:os";
5
6
  import { join, resolve, extname } from "node:path";
7
+ import { PermissionService } from "./permission-service.js";
6
8
 
7
9
  const SERVER_INFO = { name: "poke-gate", version: "0.0.1" };
8
10
 
9
11
  const COMMAND_TIMEOUT = 30_000;
12
+ const RUN_COMMAND_LOOP_SUPPRESSION_MS = 60_000;
13
+ const PERMISSION_MODE = normalizePermissionMode(process.env.POKE_GATE_PERMISSION_MODE);
14
+ const SANDBOX_EXEC_PATH = "/usr/bin/sandbox-exec";
10
15
 
11
16
  let logEnabled = false;
12
17
 
18
+ const permissionSecret = process.env.POKE_GATE_HMAC_SECRET || randomBytes(32).toString("hex");
19
+ const permissionService = new PermissionService({ secret: permissionSecret });
20
+ const sessionAutoApproveAllRisky = new Set();
21
+ const runCommandLoopState = new Map();
22
+
23
+ const SAFE_TOOL_NAMES = new Set(["read_file", "read_image", "list_directory", "system_info", "network_speed"]);
24
+
25
+ const LIMITED_RUN_COMMANDS = new Set([
26
+ "curl", "yt-dlp", "youtube-dl",
27
+ "ls", "pwd", "cat", "grep", "find", "head", "tail", "wc", "sed", "awk",
28
+ "which", "command", "echo", "stat", "du", "df", "ps", "uname", "sw_vers", "whoami",
29
+ "jq", "diff",
30
+ ]);
31
+
32
+ const SANDBOX_RUN_COMMANDS = new Set([
33
+ "yt-dlp", "youtube-dl",
34
+ "ffmpeg", "ffprobe",
35
+ "brew", "node", "python", "python3",
36
+ "curl", "dd", "rm", "mktemp", "mkdir", "cp", "mv", "touch", "jq", "diff",
37
+ "ls", "pwd", "cat", "grep", "find", "head", "tail", "wc", "sed", "awk",
38
+ "which", "command", "echo", "stat", "du", "df", "ps", "uname", "sw_vers", "whoami",
39
+ ]);
40
+
41
+ const DANGEROUS_COMMAND_PATTERNS = [
42
+ /(^|\s)sudo(\s|$)/i,
43
+ /rm\s+-rf\b/i,
44
+ /rm\s+-fr\b/i,
45
+ /rm\s+-r\s+-f\b/i,
46
+ /diskutil\s+erase/i,
47
+ /mkfs(\.|\s|$)/i,
48
+ /shutdown(\s|$)/i,
49
+ /reboot(\s|$)/i,
50
+ /launchctl\s+bootout/i,
51
+ /chmod\s+777/i,
52
+ /curl\s+[^\n]*\|\s*(sh|bash|zsh)/i,
53
+ ];
54
+
55
+ function normalizePermissionMode(value) {
56
+ const mode = typeof value === "string" ? value.trim().toLowerCase() : "";
57
+ if (mode === "limited" || mode === "sandbox") return mode;
58
+ return "full";
59
+ }
60
+
61
+ export function getPermissionMode() {
62
+ return PERMISSION_MODE;
63
+ }
64
+
13
65
  export function enableLogging(enabled) {
14
66
  logEnabled = enabled;
15
67
  }
@@ -37,10 +89,30 @@ const TOOLS = [
37
89
  properties: {
38
90
  command: { type: "string", description: "The shell command to execute" },
39
91
  cwd: { type: "string", description: "Working directory (optional, defaults to home)" },
92
+ approval_token: { type: "string", description: "Approval token returned by a previous AWAITING_APPROVAL response" },
93
+ approve: { type: "boolean", description: "Set true after user approves in chat" },
94
+ remember_in_session: { type: "boolean", description: "If true, remember this command for this session" },
95
+ remember_all_risky: { type: "boolean", description: "If true, auto-approve all risky tools for this session" },
40
96
  },
41
97
  required: ["command"],
42
98
  },
43
99
  },
100
+ {
101
+ name: "network_speed",
102
+ description:
103
+ "Run a built-in internet speed test and return download/upload Mbps. " +
104
+ "Uses Cloudflare speed endpoints internally without requiring shell pipelines.",
105
+ inputSchema: {
106
+ type: "object",
107
+ properties: {
108
+ tests: {
109
+ type: "string",
110
+ description: "Which direction to test",
111
+ enum: ["download", "upload", "both"],
112
+ },
113
+ },
114
+ },
115
+ },
44
116
  {
45
117
  name: "read_file",
46
118
  description: "Read the contents of a file on the user's machine.",
@@ -60,6 +132,9 @@ const TOOLS = [
60
132
  properties: {
61
133
  path: { type: "string", description: "Absolute or relative path to the file" },
62
134
  content: { type: "string", description: "Content to write" },
135
+ approval_token: { type: "string", description: "Approval token returned by a previous AWAITING_APPROVAL response" },
136
+ approve: { type: "boolean", description: "Set true after user approves in chat" },
137
+ remember_all_risky: { type: "boolean", description: "If true, auto-approve all risky tools for this session" },
63
138
  },
64
139
  required: ["path", "content"],
65
140
  },
@@ -113,72 +188,468 @@ const TOOLS = [
113
188
  type: "object",
114
189
  properties: {
115
190
  path: { type: "string", description: "File path to save the screenshot (optional, defaults to ~/Desktop/screenshot-<timestamp>.png)" },
191
+ approval_token: { type: "string", description: "Approval token returned by a previous AWAITING_APPROVAL response" },
192
+ approve: { type: "boolean", description: "Set true after user approves in chat" },
193
+ remember_all_risky: { type: "boolean", description: "If true, auto-approve all risky tools for this session" },
116
194
  },
117
195
  },
118
196
  },
119
197
  ];
120
198
 
121
- function runCommand(command, cwd) {
199
+ function sanitizeToolArgs(args = {}) {
200
+ const {
201
+ approval_token: _approvalToken,
202
+ approve: _approve,
203
+ remember_in_session: _rememberInSession,
204
+ remember_all_risky: _rememberAllRisky,
205
+ ...cleanArgs
206
+ } = args;
207
+ return cleanArgs;
208
+ }
209
+
210
+ function extractSessionId(req) {
211
+ const sessionId = req.headers["mcp-session-id"];
212
+ if (typeof sessionId === "string" && sessionId.trim().length > 0) {
213
+ return sessionId.trim();
214
+ }
215
+ return "default";
216
+ }
217
+
218
+ function buildApprovalResponse(name, cleanArgs, approval) {
219
+ const summary = name === "run_command"
220
+ ? `Run command: ${cleanArgs.command}`
221
+ : name === "write_file"
222
+ ? `Write file: ${cleanArgs.path}`
223
+ : "Take screenshot";
224
+
225
+ return {
226
+ content: [{
227
+ type: "text",
228
+ text:
229
+ "AWAITING_APPROVAL: Ask the user in chat to approve this action. " +
230
+ "Re-call the same tool with approve=true and approval_token from structuredContent. " +
231
+ "Optional: remember_in_session=true (same command) or remember_all_risky=true (all risky tools for this session).",
232
+ }],
233
+ structuredContent: {
234
+ status: "AWAITING_APPROVAL",
235
+ approvalRequestId: approval.approvalRequestId,
236
+ approvalToken: approval.token,
237
+ expiresAt: new Date(approval.expiresAt).toISOString(),
238
+ toolName: name,
239
+ summary,
240
+ },
241
+ isError: true,
242
+ };
243
+ }
244
+
245
+ function buildPolicyDeniedResponse(message) {
246
+ return {
247
+ content: [{ type: "text", text: `Blocked by access mode policy: ${message}` }],
248
+ isError: true,
249
+ };
250
+ }
251
+
252
+ function splitCommandSegments(commandText) {
253
+ return commandText
254
+ .split(/&&|\|\||;|\n/)
255
+ .map((segment) => segment.trim())
256
+ .filter(Boolean);
257
+ }
258
+
259
+ function extractExecutable(segment) {
260
+ const withoutParens = segment.replace(/^[()\s]+/, "");
261
+ const withoutSudo = withoutParens.replace(/^sudo\s+/, "");
262
+ const match = withoutSudo.match(/^([A-Za-z0-9_./-]+)/);
263
+ if (!match) return "";
264
+ const raw = match[1];
265
+ const parts = raw.split("/");
266
+ return parts[parts.length - 1];
267
+ }
268
+
269
+ function hasDangerousPattern(commandText) {
270
+ return DANGEROUS_COMMAND_PATTERNS.some((pattern) => pattern.test(commandText));
271
+ }
272
+
273
+ function validateRunCommandAgainstAllowlist(commandText, allowlist) {
274
+ if (typeof commandText !== "string" || commandText.trim().length === 0) {
275
+ return "Command is empty.";
276
+ }
277
+
278
+ if (hasDangerousPattern(commandText)) {
279
+ return "Command matches a dangerous pattern.";
280
+ }
281
+
282
+ const segments = splitCommandSegments(commandText);
283
+ for (const segment of segments) {
284
+ const executable = extractExecutable(segment);
285
+ if (!executable || !allowlist.has(executable)) {
286
+ return `Command '${executable || "unknown"}' is not permitted in this mode.`;
287
+ }
288
+ }
289
+
290
+ return null;
291
+ }
292
+
293
+ export function evaluateAccessPolicy(toolName, cleanArgs, mode = PERMISSION_MODE) {
294
+ if (mode === "full") return null;
295
+
296
+ if (mode === "limited") {
297
+ if (SAFE_TOOL_NAMES.has(toolName)) return null;
298
+ if (toolName === "run_command") {
299
+ return validateRunCommandAgainstAllowlist(cleanArgs.command, LIMITED_RUN_COMMANDS);
300
+ }
301
+ if (toolName === "write_file" || toolName === "take_screenshot") {
302
+ return "This tool is disabled in Limited Permissions mode.";
303
+ }
304
+ return "This tool is not permitted in Limited Permissions mode.";
305
+ }
306
+
307
+ if (SAFE_TOOL_NAMES.has(toolName)) return null;
308
+
309
+ if (toolName === "run_command") {
310
+ return validateRunCommandAgainstAllowlist(cleanArgs.command, SANDBOX_RUN_COMMANDS);
311
+ }
312
+
313
+ if (toolName === "write_file" || toolName === "take_screenshot") {
314
+ return "This tool is disabled in Sandbox mode.";
315
+ }
316
+
317
+ return "This tool is not permitted in Sandbox mode.";
318
+ }
319
+
320
+ function getRunCommandFingerprint(cleanArgs) {
321
+ return JSON.stringify({
322
+ command: typeof cleanArgs.command === "string" ? cleanArgs.command : "",
323
+ cwd: typeof cleanArgs.cwd === "string" && cleanArgs.cwd.trim().length > 0 ? cleanArgs.cwd.trim() : "__HOME__",
324
+ });
325
+ }
326
+
327
+ function getRunCommandState(sessionId) {
328
+ if (!runCommandLoopState.has(sessionId)) {
329
+ runCommandLoopState.set(sessionId, {
330
+ inFlight: new Set(),
331
+ recentFailures: new Map(),
332
+ });
333
+ }
334
+
335
+ return runCommandLoopState.get(sessionId);
336
+ }
337
+
338
+ export function resetRunCommandLoopGuard() {
339
+ runCommandLoopState.clear();
340
+ }
341
+
342
+ export function prepareRunCommandAttempt(sessionId, cleanArgs, now = Date.now()) {
343
+ const state = getRunCommandState(sessionId);
344
+ const fingerprint = getRunCommandFingerprint(cleanArgs);
345
+ const recentFailure = state.recentFailures.get(fingerprint);
346
+
347
+ if (state.inFlight.has(fingerprint)) {
348
+ return {
349
+ suppressed: true,
350
+ reason: "already_running",
351
+ fingerprint,
352
+ };
353
+ }
354
+
355
+ if (recentFailure && now < recentFailure.suppressedUntil) {
356
+ return {
357
+ suppressed: true,
358
+ reason: "recent_failure",
359
+ fingerprint,
360
+ };
361
+ }
362
+
363
+ state.inFlight.add(fingerprint);
364
+ return {
365
+ suppressed: false,
366
+ fingerprint,
367
+ };
368
+ }
369
+
370
+ export function recordRunCommandOutcome(sessionId, cleanArgs, result, now = Date.now()) {
371
+ const state = getRunCommandState(sessionId);
372
+ const fingerprint = getRunCommandFingerprint(cleanArgs);
373
+
374
+ state.inFlight.delete(fingerprint);
375
+
376
+ if (result.exitCode === 0) {
377
+ state.recentFailures.delete(fingerprint);
378
+ return;
379
+ }
380
+
381
+ state.recentFailures.set(fingerprint, {
382
+ exitCode: result.exitCode,
383
+ suppressedUntil: now + RUN_COMMAND_LOOP_SUPPRESSION_MS,
384
+ });
385
+ }
386
+
387
+ function buildRunCommandSuppressionResponse(cleanArgs, reason) {
388
+ const detail = reason === "already_running"
389
+ ? "The same command is already running."
390
+ : "The same command just failed, so repeated retries are being suppressed for a short period.";
391
+
392
+ return {
393
+ content: [{
394
+ type: "text",
395
+ text: `${detail} Change the command or wait before retrying: ${cleanArgs.command}`,
396
+ }],
397
+ isError: true,
398
+ };
399
+ }
400
+
401
+ function quoteForSingleShellArg(value) {
402
+ return `'${String(value).replace(/'/g, `'"'"'`)}'`;
403
+ }
404
+
405
+ function buildSandboxProfile() {
406
+ const userHome = homedir();
407
+ return [
408
+ "(version 1)",
409
+ "(deny default)",
410
+ "(import \"system.sb\")",
411
+ "(allow process-exec)",
412
+ "(allow process-fork)",
413
+ "(allow file-read*)",
414
+ "(allow network-outbound)",
415
+ "(allow sysctl-read)",
416
+ "(allow file-write*",
417
+ ` (subpath "${userHome}/Downloads")`,
418
+ " (subpath \"/private/tmp\")",
419
+ " (subpath \"/tmp\")",
420
+ ")",
421
+ ].join("\n");
422
+ }
423
+
424
+ export function buildSandboxWrappedCommand(command) {
425
+ const profile = buildSandboxProfile();
426
+ return `${SANDBOX_EXEC_PATH} -p ${quoteForSingleShellArg(profile)} /bin/zsh -lc ${quoteForSingleShellArg(command)}`;
427
+ }
428
+
429
+ function runCommand(command, cwd, options = {}) {
122
430
  return new Promise((res) => {
123
431
  const dir = cwd || homedir();
124
- exec(command, {
432
+ const sandboxRequested = options.permissionMode === "sandbox";
433
+ const sandboxAvailable = existsSync(SANDBOX_EXEC_PATH);
434
+ const sandboxApplied = sandboxRequested && sandboxAvailable;
435
+ const commandToRun = sandboxApplied ? buildSandboxWrappedCommand(command) : command;
436
+
437
+ const start = Date.now();
438
+ exec(commandToRun, {
125
439
  cwd: dir,
126
440
  timeout: COMMAND_TIMEOUT,
127
441
  maxBuffer: 1024 * 1024,
128
442
  shell: true,
129
443
  }, (error, stdout, stderr) => {
444
+ const durationMs = Date.now() - start;
445
+ const note = sandboxRequested && !sandboxAvailable
446
+ ? "sandbox-exec unavailable; ran without OS sandbox"
447
+ : "";
448
+
130
449
  res({
131
450
  stdout: stdout.slice(0, 50_000),
132
- stderr: stderr.slice(0, 10_000),
451
+ stderr: `${stderr.slice(0, 10_000)}${note ? `${stderr ? "\n" : ""}${note}` : ""}`,
133
452
  exitCode: error ? (error.code ?? 1) : 0,
453
+ durationMs,
454
+ timedOut: Boolean(error?.killed && error?.signal === "SIGTERM"),
455
+ sandboxApplied,
134
456
  });
135
457
  });
136
458
  });
137
459
  }
138
460
 
139
- function handleToolCall(name, args) {
461
+ function previewText(text, limit = 220) {
462
+ if (typeof text !== "string") return "";
463
+ return text.trim().replace(/\s+/g, " ").slice(0, limit);
464
+ }
465
+
466
+ function logCommandPreview(args, result) {
467
+ if (!logEnabled) return;
468
+ const ts = new Date().toISOString().slice(11, 19);
469
+ const timeoutSuffix = result.timedOut ? " timeout" : "";
470
+ const sandboxSuffix = result.sandboxApplied ? " sandbox=os" : " sandbox=none";
471
+ const cwdText = args.cwd ? ` (in ${args.cwd})` : "";
472
+
473
+ console.log(`[${ts}] terminal preview:`);
474
+ console.log(`[${ts}] $ ${args.command}${cwdText}`);
475
+ console.log(`[${ts}] process: exit=${result.exitCode} duration=${result.durationMs}ms${timeoutSuffix}${sandboxSuffix}`);
476
+
477
+ const stdoutPreview = previewText(result.stdout);
478
+ const stderrPreview = previewText(result.stderr);
479
+ if (stdoutPreview) console.log(`[${ts}] stdout: ${stdoutPreview}`);
480
+ if (stderrPreview) console.log(`[${ts}] stderr: ${stderrPreview}`);
481
+ }
482
+
483
+ function toMbps(bytes, seconds) {
484
+ if (!Number.isFinite(bytes) || !Number.isFinite(seconds) || seconds <= 0) return null;
485
+ return (bytes * 8) / seconds / 1_000_000;
486
+ }
487
+
488
+ function runNetworkSpeedTests(testSelection = "both") {
489
+ const tests = typeof testSelection === "string" ? testSelection : "both";
490
+ const runDownload = tests === "download" || tests === "both";
491
+ const runUpload = tests === "upload" || tests === "both";
492
+
493
+ if (!runDownload && !runUpload) {
494
+ return Promise.resolve({
495
+ content: [{ type: "text", text: "Invalid test selection. Use download, upload, or both." }],
496
+ isError: true,
497
+ });
498
+ }
499
+
500
+ const downloadBytes = 25 * 1024 * 1024;
501
+ const uploadBytes = 10 * 1024 * 1024;
502
+ const parts = [];
503
+
504
+ if (runDownload) {
505
+ parts.push(`DL=$(curl -s -o /dev/null -w '%{time_total}' 'https://speed.cloudflare.com/__down?bytes=${downloadBytes}')`);
506
+ }
507
+ if (runUpload) {
508
+ parts.push("TMP=$(mktemp /tmp/poke-speed.XXXXXX)");
509
+ parts.push(`dd if=/dev/zero of="$TMP" bs=1m count=${Math.floor(uploadBytes / (1024 * 1024))} 2>/dev/null`);
510
+ parts.push("UL=$(curl -s -o /dev/null -w '%{time_total}' -X POST --data-binary @\"$TMP\" 'https://speed.cloudflare.com/__up')");
511
+ parts.push("rm -f \"$TMP\"");
512
+ }
513
+ parts.push("printf 'DL=%s\nUL=%s\n' \"${DL:-}\" \"${UL:-}\"");
514
+
515
+ const cmd = parts.join(" && ");
516
+
517
+ return runCommand(cmd, homedir(), { permissionMode: "full" }).then((result) => {
518
+ const raw = String(result.stdout || "");
519
+ const dlMatch = raw.match(/DL=([^\n]*)/);
520
+ const ulMatch = raw.match(/UL=([^\n]*)/);
521
+ const dlSeconds = dlMatch && dlMatch[1] ? Number.parseFloat(dlMatch[1]) : NaN;
522
+ const ulSeconds = ulMatch && ulMatch[1] ? Number.parseFloat(ulMatch[1]) : NaN;
523
+ const dlMbps = runDownload ? toMbps(downloadBytes, dlSeconds) : null;
524
+ const ulMbps = runUpload ? toMbps(uploadBytes, ulSeconds) : null;
525
+
526
+ const lines = ["Network Speed Test"];
527
+ if (runDownload) {
528
+ lines.push(dlMbps === null
529
+ ? "- Download: unavailable"
530
+ : `- Download: ${dlMbps.toFixed(2)} Mbps (${dlSeconds.toFixed(2)}s for 25 MiB)`);
531
+ }
532
+ if (runUpload) {
533
+ lines.push(ulMbps === null
534
+ ? "- Upload: unavailable"
535
+ : `- Upload: ${ulMbps.toFixed(2)} Mbps (${ulSeconds.toFixed(2)}s for 10 MiB)`);
536
+ }
537
+
538
+ const response = {
539
+ content: [{ type: "text", text: lines.join("\n") }],
540
+ structuredContent: {
541
+ downloadMbps: dlMbps,
542
+ uploadMbps: ulMbps,
543
+ downloadSeconds: Number.isFinite(dlSeconds) ? dlSeconds : null,
544
+ uploadSeconds: Number.isFinite(ulSeconds) ? ulSeconds : null,
545
+ },
546
+ };
547
+
548
+ if (result.exitCode !== 0 || (runDownload && dlMbps === null) || (runUpload && ulMbps === null)) {
549
+ response.isError = true;
550
+ response.content[0].text += `\n\nDetails: ${result.stderr || "speed test command failed"}`;
551
+ }
552
+
553
+ return response;
554
+ });
555
+ }
556
+
557
+ function handleToolCall(name, args, context = {}) {
558
+ const sessionId = context.sessionId || "default";
559
+ const cleanArgs = sanitizeToolArgs(args);
560
+
561
+ const policyRejection = evaluateAccessPolicy(name, cleanArgs);
562
+ if (policyRejection) {
563
+ const blocked = buildPolicyDeniedResponse(policyRejection);
564
+ logTool(name, cleanArgs, blocked);
565
+ return blocked;
566
+ }
567
+
568
+ if (permissionService.isRisky(name)) {
569
+ const commandText = typeof cleanArgs.command === "string" ? cleanArgs.command : "";
570
+ const alreadyAllowed = sessionAutoApproveAllRisky.has(sessionId) ||
571
+ (commandText && permissionService.isAllowedBySessionPattern(sessionId, commandText));
572
+
573
+ if (!alreadyAllowed) {
574
+ const hasApprovalToken = Boolean(args.approval_token);
575
+ const isApproved = args.approve === true && hasApprovalToken
576
+ ? permissionService.validateApprovalToken(sessionId, args.approval_token, name, cleanArgs)
577
+ : false;
578
+
579
+ if (!isApproved) {
580
+ const approval = permissionService.requestApproval(sessionId, name, cleanArgs);
581
+ return buildApprovalResponse(name, cleanArgs, approval);
582
+ }
583
+
584
+ if (name === "run_command" && args.remember_in_session === true && commandText) {
585
+ permissionService.allowPatternForSession(sessionId, commandText);
586
+ }
587
+
588
+ if (args.remember_all_risky === true) {
589
+ sessionAutoApproveAllRisky.add(sessionId);
590
+ }
591
+ }
592
+ }
593
+
140
594
  switch (name) {
595
+ case "network_speed": {
596
+ logTool(name, cleanArgs);
597
+ return runNetworkSpeedTests(cleanArgs.tests).then((response) => {
598
+ logTool(name, cleanArgs, response);
599
+ return response;
600
+ });
601
+ }
602
+
141
603
  case "run_command": {
142
- logTool(name, args);
143
- return runCommand(args.command, args.cwd).then((result) => {
604
+ const attempt = prepareRunCommandAttempt(sessionId, cleanArgs);
605
+ if (attempt.suppressed) {
606
+ const r = buildRunCommandSuppressionResponse(cleanArgs, attempt.reason);
607
+ logTool(name, cleanArgs, r);
608
+ return r;
609
+ }
610
+
611
+ logTool(name, cleanArgs);
612
+ return runCommand(cleanArgs.command, cleanArgs.cwd, { permissionMode: PERMISSION_MODE }).then((result) => {
613
+ recordRunCommandOutcome(sessionId, cleanArgs, result);
614
+ logCommandPreview(cleanArgs, result);
144
615
  const r = { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
145
616
  if (result.exitCode !== 0) r.isError = true;
146
- logTool(name, args, r);
617
+ logTool(name, cleanArgs, r);
147
618
  return r;
148
619
  });
149
620
  }
150
621
 
151
622
  case "read_file": {
152
623
  try {
153
- const p = resolve(args.path.replace(/^~/, homedir()));
624
+ const p = resolve(cleanArgs.path.replace(/^~/, homedir()));
154
625
  const text = readFileSync(p, "utf-8");
155
626
  const r = { content: [{ type: "text", text: text.slice(0, 100_000) }] };
156
- logTool(name, args, r);
627
+ logTool(name, cleanArgs, r);
157
628
  return r;
158
629
  } catch (err) {
159
630
  const r = { content: [{ type: "text", text: `Error: ${err.message}` }], isError: true };
160
- logTool(name, args, r);
631
+ logTool(name, cleanArgs, r);
161
632
  return r;
162
633
  }
163
634
  }
164
635
 
165
636
  case "write_file": {
166
637
  try {
167
- const p = resolve(args.path.replace(/^~/, homedir()));
168
- writeFileSync(p, args.content);
638
+ const p = resolve(cleanArgs.path.replace(/^~/, homedir()));
639
+ writeFileSync(p, cleanArgs.content);
169
640
  const r = { content: [{ type: "text", text: `Written to ${p}` }] };
170
- logTool(name, args, r);
641
+ logTool(name, cleanArgs, r);
171
642
  return r;
172
643
  } catch (err) {
173
644
  const r = { content: [{ type: "text", text: `Error: ${err.message}` }], isError: true };
174
- logTool(name, args, r);
645
+ logTool(name, cleanArgs, r);
175
646
  return r;
176
647
  }
177
648
  }
178
649
 
179
650
  case "list_directory": {
180
651
  try {
181
- const dir = resolve((args.path || "~").replace(/^~/, homedir()));
652
+ const dir = resolve((cleanArgs.path || "~").replace(/^~/, homedir()));
182
653
  const entries = readdirSync(dir).map((entry) => {
183
654
  try {
184
655
  const s = statSync(join(dir, entry));
@@ -188,11 +659,11 @@ function handleToolCall(name, args) {
188
659
  }
189
660
  });
190
661
  const r = { content: [{ type: "text", text: entries.join("\n") }] };
191
- logTool(name, args, r);
662
+ logTool(name, cleanArgs, r);
192
663
  return r;
193
664
  } catch (err) {
194
665
  const r = { content: [{ type: "text", text: `Error: ${err.message}` }], isError: true };
195
- logTool(name, args, r);
666
+ logTool(name, cleanArgs, r);
196
667
  return r;
197
668
  }
198
669
  }
@@ -208,13 +679,13 @@ function handleToolCall(name, args) {
208
679
  homeDir: homedir(),
209
680
  nodeVersion: process.version,
210
681
  };
211
- logTool(name, args);
682
+ logTool(name, cleanArgs);
212
683
  return { content: [{ type: "text", text: JSON.stringify(info, null, 2) }] };
213
684
  }
214
685
 
215
686
  case "read_image": {
216
687
  try {
217
- const p = resolve(args.path.replace(/^~/, homedir()));
688
+ const p = resolve(cleanArgs.path.replace(/^~/, homedir()));
218
689
  const ext = extname(p).toLowerCase().slice(1);
219
690
  const mimeMap = {
220
691
  png: "image/png", jpg: "image/jpeg", jpeg: "image/jpeg",
@@ -224,7 +695,7 @@ function handleToolCall(name, args) {
224
695
  const mimeType = mimeMap[ext] || "application/octet-stream";
225
696
  const buf = readFileSync(p);
226
697
  const base64 = buf.toString("base64");
227
- logTool(name, args);
698
+ logTool(name, cleanArgs);
228
699
 
229
700
  if (mimeType.startsWith("image/")) {
230
701
  return {
@@ -241,7 +712,7 @@ function handleToolCall(name, args) {
241
712
  };
242
713
  } catch (err) {
243
714
  const r = { content: [{ type: "text", text: `Error: ${err.message}` }], isError: true };
244
- logTool(name, args, r);
715
+ logTool(name, cleanArgs, r);
245
716
  return r;
246
717
  }
247
718
  }
@@ -281,7 +752,7 @@ function handleToolCall(name, args) {
281
752
  }
282
753
 
283
754
  case "take_screenshot": {
284
- logTool(name, args);
755
+ logTool(name, cleanArgs);
285
756
 
286
757
  return runCommand('open -Ra "Poke macOS Gate" 2>/dev/null', homedir()).then((appCheck) => {
287
758
  if (appCheck.exitCode === 0) {
@@ -291,8 +762,8 @@ function handleToolCall(name, args) {
291
762
  }
292
763
 
293
764
  const ts = new Date().toISOString().replace(/[:.]/g, "-");
294
- const dest = args.path
295
- ? resolve(args.path.replace(/^~/, homedir()))
765
+ const dest = cleanArgs.path
766
+ ? resolve(cleanArgs.path.replace(/^~/, homedir()))
296
767
  : join(homedir(), "Desktop", `screenshot-${ts}.png`);
297
768
  return runCommand(`/usr/sbin/screencapture -x "${dest}"`, homedir()).then((result) => {
298
769
  if (result.exitCode === 0) {
@@ -334,7 +805,7 @@ function handleJsonRpc(msg) {
334
805
  return { jsonrpc: "2.0", id, result: { tools: TOOLS } };
335
806
 
336
807
  case "tools/call": {
337
- const result = handleToolCall(params.name, params.arguments || {});
808
+ const result = handleToolCall(params.name, params.arguments || {}, msg.__context || {});
338
809
  if (result instanceof Promise) {
339
810
  return result.then((r) => ({ jsonrpc: "2.0", id, result: r }));
340
811
  }
@@ -384,17 +855,21 @@ export function startMcpServer(port = 0) {
384
855
  const body = await readBody(req);
385
856
  const parsed = JSON.parse(body);
386
857
 
858
+ const sessionId = extractSessionId(req);
859
+
387
860
  if (Array.isArray(parsed)) {
388
861
  const results = [];
389
862
  for (const msg of parsed) {
390
- const r = handleJsonRpc(msg);
863
+ const m = { ...msg, __context: { sessionId } };
864
+ const r = handleJsonRpc(m);
391
865
  const resolved = r instanceof Promise ? await r : r;
392
866
  if (resolved) results.push(resolved);
393
867
  }
394
868
  res.writeHead(200, { "Content-Type": "application/json" });
395
869
  res.end(JSON.stringify(results));
396
870
  } else {
397
- let result = handleJsonRpc(parsed);
871
+ const m = { ...parsed, __context: { sessionId } };
872
+ let result = handleJsonRpc(m);
398
873
  if (result instanceof Promise) result = await result;
399
874
  if (result) {
400
875
  res.writeHead(200, { "Content-Type": "application/json" });