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.
- package/.github/workflows/release.yml +53 -3
- package/Gate.app +0 -0
- package/README.md +48 -14
- package/bin/poke-gate.js +17 -0
- package/clients/Poke macOS Gate/Poke macOS Gate/AboutView.swift +7 -1
- package/clients/Poke macOS Gate/Poke macOS Gate/AccessibilityPermissionView.swift +58 -0
- package/clients/Poke macOS Gate/Poke macOS Gate/GateService.swift +389 -23
- package/clients/Poke macOS Gate/Poke macOS Gate/Info.plist +2 -0
- package/clients/Poke macOS Gate/Poke macOS Gate/LogsView.swift +1 -1
- package/clients/Poke macOS Gate/Poke macOS Gate/MacVisualStyle.swift +89 -0
- package/clients/Poke macOS Gate/Poke macOS Gate/PermissionRowView.swift +55 -0
- package/clients/Poke macOS Gate/Poke macOS Gate/Poke_macOS_GateApp.swift +234 -91
- package/clients/Poke macOS Gate/Poke macOS Gate/SettingsView.swift +125 -81
- package/clients/Poke macOS Gate/Poke macOS Gate/SetupView.swift +157 -0
- package/clients/Poke macOS Gate/Poke macOS Gate.xcodeproj/project.pbxproj +31 -11
- package/docs/cli.md +19 -0
- package/docs/getting-started.md +9 -6
- package/docs/index.md +23 -18
- package/docs/macos-app.md +39 -4
- package/docs/security.md +62 -18
- package/examples/agents/battery.30m.js +1 -1
- package/examples/agents/screentime.24h.js +5 -6
- package/macOS +0 -0
- package/package.json +3 -1
- package/src/agents.js +5 -8
- package/src/app.js +29 -5
- package/src/mcp-server.js +502 -27
- package/src/permission-service.js +128 -0
- package/test/mcp-server-access-policy.test.js +40 -0
- package/test/mcp-server-loop-guard.test.js +57 -0
- package/test/mcp-server-sandbox-command.test.js +18 -0
- 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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
143
|
-
|
|
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,
|
|
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(
|
|
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,
|
|
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,
|
|
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(
|
|
168
|
-
writeFileSync(p,
|
|
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,
|
|
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,
|
|
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((
|
|
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,
|
|
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,
|
|
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,
|
|
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(
|
|
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,
|
|
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,
|
|
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,
|
|
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 =
|
|
295
|
-
? resolve(
|
|
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
|
|
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
|
-
|
|
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" });
|