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
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { createHash, createHmac, randomUUID } from "node:crypto";
|
|
2
|
+
|
|
3
|
+
const SAFE_TOOLS = new Set(["read_file", "read_image", "list_directory", "system_info"]);
|
|
4
|
+
const RISKY_TOOLS = new Set(["run_command", "write_file", "take_screenshot"]);
|
|
5
|
+
|
|
6
|
+
const TOKEN_TTL_MS = 5 * 60 * 1000;
|
|
7
|
+
|
|
8
|
+
function nowMs(nowFn) {
|
|
9
|
+
const value = nowFn();
|
|
10
|
+
if (value instanceof Date) return value.getTime();
|
|
11
|
+
return Number(value);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function normalizeForStableJson(value) {
|
|
15
|
+
if (value === null || value === undefined) return value;
|
|
16
|
+
if (Array.isArray(value)) return value.map(normalizeForStableJson);
|
|
17
|
+
if (value instanceof Date) return value.toISOString();
|
|
18
|
+
if (typeof value === "object") {
|
|
19
|
+
const normalized = {};
|
|
20
|
+
for (const key of Object.keys(value).sort()) {
|
|
21
|
+
normalized[key] = normalizeForStableJson(value[key]);
|
|
22
|
+
}
|
|
23
|
+
return normalized;
|
|
24
|
+
}
|
|
25
|
+
if (typeof value === "bigint") return value.toString();
|
|
26
|
+
return value;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function hashArgs(toolArgs) {
|
|
30
|
+
const stable = JSON.stringify(normalizeForStableJson(toolArgs ?? {}));
|
|
31
|
+
return createHash("sha256").update(stable).digest("hex");
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function escapeRegex(text) {
|
|
35
|
+
return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function patternMatches(pattern, commandText) {
|
|
39
|
+
const regexSource = `^${pattern.split("*").map(escapeRegex).join(".*")}$`;
|
|
40
|
+
return new RegExp(regexSource).test(commandText);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export class PermissionService {
|
|
44
|
+
constructor({ secret, now } = {}) {
|
|
45
|
+
if (!secret) throw new Error("PermissionService requires a secret");
|
|
46
|
+
|
|
47
|
+
this.secret = secret;
|
|
48
|
+
this.now = typeof now === "function" ? now : () => Date.now();
|
|
49
|
+
this.pendingApprovals = new Map();
|
|
50
|
+
this.sessionWhitelist = new Map();
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
isRisky(toolName) {
|
|
54
|
+
return RISKY_TOOLS.has(toolName);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
requestApproval(sessionId, toolName, toolArgs) {
|
|
58
|
+
const argsHash = hashArgs(toolArgs);
|
|
59
|
+
const approvalRequestId = randomUUID();
|
|
60
|
+
const expiresAt = nowMs(this.now) + TOKEN_TTL_MS;
|
|
61
|
+
const tokenPayload = `${approvalRequestId}:${sessionId}:${toolName}:${argsHash}:${expiresAt}`;
|
|
62
|
+
const token = createHmac("sha256", this.secret).update(tokenPayload).digest("hex");
|
|
63
|
+
|
|
64
|
+
this.pendingApprovals.set(token, {
|
|
65
|
+
approvalRequestId,
|
|
66
|
+
sessionId,
|
|
67
|
+
toolName,
|
|
68
|
+
argsHash,
|
|
69
|
+
expiresAt,
|
|
70
|
+
consumed: false,
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
return { approvalRequestId, token, expiresAt };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
validateApprovalToken(sessionId, token, toolName, toolArgs) {
|
|
77
|
+
const record = this.pendingApprovals.get(token);
|
|
78
|
+
if (!record) return false;
|
|
79
|
+
if (record.consumed) return false;
|
|
80
|
+
|
|
81
|
+
if (nowMs(this.now) > record.expiresAt) {
|
|
82
|
+
this.pendingApprovals.delete(token);
|
|
83
|
+
return false;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const argsHash = hashArgs(toolArgs);
|
|
87
|
+
if (
|
|
88
|
+
record.sessionId !== sessionId ||
|
|
89
|
+
record.toolName !== toolName ||
|
|
90
|
+
record.argsHash !== argsHash
|
|
91
|
+
) {
|
|
92
|
+
return false;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
record.consumed = true;
|
|
96
|
+
return true;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
allowPatternForSession(sessionId, pattern) {
|
|
100
|
+
if (!this.sessionWhitelist.has(sessionId)) {
|
|
101
|
+
this.sessionWhitelist.set(sessionId, new Set());
|
|
102
|
+
}
|
|
103
|
+
this.sessionWhitelist.get(sessionId).add(pattern);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
isAllowedBySessionPattern(sessionId, commandText) {
|
|
107
|
+
const patterns = this.sessionWhitelist.get(sessionId);
|
|
108
|
+
if (!patterns) return false;
|
|
109
|
+
|
|
110
|
+
for (const pattern of patterns) {
|
|
111
|
+
if (patternMatches(pattern, commandText)) return true;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return false;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
clearSession(sessionId) {
|
|
118
|
+
this.sessionWhitelist.delete(sessionId);
|
|
119
|
+
|
|
120
|
+
for (const [token, record] of this.pendingApprovals.entries()) {
|
|
121
|
+
if (record.sessionId === sessionId) {
|
|
122
|
+
this.pendingApprovals.delete(token);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export { SAFE_TOOLS, RISKY_TOOLS, TOKEN_TTL_MS };
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
|
|
4
|
+
import { evaluateAccessPolicy } from "../src/mcp-server.js";
|
|
5
|
+
|
|
6
|
+
test("full mode allows all current tools", () => {
|
|
7
|
+
assert.equal(evaluateAccessPolicy("write_file", { path: "~/a.txt", content: "x" }, "full"), null);
|
|
8
|
+
assert.equal(evaluateAccessPolicy("take_screenshot", {}, "full"), null);
|
|
9
|
+
assert.equal(evaluateAccessPolicy("run_command", { command: "sudo reboot" }, "full"), null);
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
test("limited mode allows curated operational commands", () => {
|
|
13
|
+
assert.equal(evaluateAccessPolicy("run_command", { command: "yt-dlp https://youtu.be/demo" }, "limited"), null);
|
|
14
|
+
assert.equal(evaluateAccessPolicy("run_command", { command: "curl -I https://example.com" }, "limited"), null);
|
|
15
|
+
assert.equal(evaluateAccessPolicy("network_speed", {}, "limited"), null);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
test("limited mode blocks dangerous or restricted actions", () => {
|
|
19
|
+
const dangerous = evaluateAccessPolicy("run_command", { command: "sudo rm -rf /" }, "limited");
|
|
20
|
+
assert.equal(typeof dangerous, "string");
|
|
21
|
+
|
|
22
|
+
const ffmpegDenied = evaluateAccessPolicy("run_command", { command: "ffmpeg -i in.mkv out.mp4" }, "limited");
|
|
23
|
+
assert.equal(typeof ffmpegDenied, "string");
|
|
24
|
+
|
|
25
|
+
const ddDenied = evaluateAccessPolicy("run_command", { command: "dd if=/dev/zero of=/tmp/a bs=1m count=1" }, "limited");
|
|
26
|
+
assert.equal(typeof ddDenied, "string");
|
|
27
|
+
|
|
28
|
+
const restrictedTool = evaluateAccessPolicy("write_file", { path: "~/a.txt", content: "x" }, "limited");
|
|
29
|
+
assert.equal(restrictedTool, "This tool is disabled in Limited Permissions mode.");
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test("sandbox mode allows broader command set under OS sandbox", () => {
|
|
33
|
+
assert.equal(evaluateAccessPolicy("run_command", { command: "ffprobe -version" }, "sandbox"), null);
|
|
34
|
+
assert.equal(evaluateAccessPolicy("run_command", { command: "brew install yt-dlp" }, "sandbox"), null);
|
|
35
|
+
assert.equal(evaluateAccessPolicy("run_command", { command: "dd if=/dev/zero of=/tmp/a bs=1m count=1" }, "sandbox"), null);
|
|
36
|
+
assert.equal(evaluateAccessPolicy("network_speed", {}, "sandbox"), null);
|
|
37
|
+
|
|
38
|
+
const screenshotDenied = evaluateAccessPolicy("take_screenshot", {}, "sandbox");
|
|
39
|
+
assert.equal(screenshotDenied, "This tool is disabled in Sandbox mode.");
|
|
40
|
+
});
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
prepareRunCommandAttempt,
|
|
6
|
+
recordRunCommandOutcome,
|
|
7
|
+
resetRunCommandLoopGuard,
|
|
8
|
+
} from "../src/mcp-server.js";
|
|
9
|
+
|
|
10
|
+
const sessionId = "session-1";
|
|
11
|
+
const cleanArgs = { command: "while ps -p 58675 58676 > /dev/null 2>&1; do sleep 5; done", cwd: "~" };
|
|
12
|
+
|
|
13
|
+
test("run_command guard suppresses in-flight duplicates", () => {
|
|
14
|
+
resetRunCommandLoopGuard();
|
|
15
|
+
|
|
16
|
+
const first = prepareRunCommandAttempt(sessionId, cleanArgs, 1_000);
|
|
17
|
+
const second = prepareRunCommandAttempt(sessionId, cleanArgs, 1_001);
|
|
18
|
+
|
|
19
|
+
assert.equal(first.suppressed, false);
|
|
20
|
+
assert.equal(second.suppressed, true);
|
|
21
|
+
assert.equal(second.reason, "already_running");
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test("run_command guard suppresses repeats after failure and clears after success", () => {
|
|
25
|
+
resetRunCommandLoopGuard();
|
|
26
|
+
|
|
27
|
+
const initial = prepareRunCommandAttempt(sessionId, cleanArgs, 2_000);
|
|
28
|
+
assert.equal(initial.suppressed, false);
|
|
29
|
+
|
|
30
|
+
recordRunCommandOutcome(sessionId, cleanArgs, { exitCode: 1 }, 2_010);
|
|
31
|
+
|
|
32
|
+
const afterFailure = prepareRunCommandAttempt(sessionId, cleanArgs, 2_020);
|
|
33
|
+
assert.equal(afterFailure.suppressed, true);
|
|
34
|
+
assert.equal(afterFailure.reason, "recent_failure");
|
|
35
|
+
|
|
36
|
+
const afterCooldown = prepareRunCommandAttempt(sessionId, cleanArgs, 2_010 + 60_000 + 1);
|
|
37
|
+
assert.equal(afterCooldown.suppressed, false);
|
|
38
|
+
|
|
39
|
+
recordRunCommandOutcome(sessionId, cleanArgs, { exitCode: 0 }, 62_020);
|
|
40
|
+
const afterSuccess = prepareRunCommandAttempt(sessionId, cleanArgs, 62_021);
|
|
41
|
+
assert.equal(afterSuccess.suppressed, false);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test("run_command guard scopes suppression to the same command and cwd", () => {
|
|
45
|
+
resetRunCommandLoopGuard();
|
|
46
|
+
|
|
47
|
+
const first = prepareRunCommandAttempt(sessionId, cleanArgs, 3_000);
|
|
48
|
+
assert.equal(first.suppressed, false);
|
|
49
|
+
|
|
50
|
+
recordRunCommandOutcome(sessionId, cleanArgs, { exitCode: 1 }, 3_010);
|
|
51
|
+
|
|
52
|
+
const differentCwd = prepareRunCommandAttempt(sessionId, { ...cleanArgs, cwd: "/tmp" }, 3_020);
|
|
53
|
+
assert.equal(differentCwd.suppressed, false);
|
|
54
|
+
|
|
55
|
+
const differentSession = prepareRunCommandAttempt("session-2", cleanArgs, 3_020);
|
|
56
|
+
assert.equal(differentSession.suppressed, false);
|
|
57
|
+
});
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
|
|
4
|
+
import { buildSandboxWrappedCommand } from "../src/mcp-server.js";
|
|
5
|
+
|
|
6
|
+
test("buildSandboxWrappedCommand wraps command with sandbox-exec", () => {
|
|
7
|
+
const wrapped = buildSandboxWrappedCommand("ffmpeg -i in.mkv out.mp4");
|
|
8
|
+
|
|
9
|
+
assert.equal(wrapped.includes("/usr/bin/sandbox-exec -p"), true);
|
|
10
|
+
assert.equal(wrapped.includes("/bin/zsh -lc"), true);
|
|
11
|
+
assert.equal(wrapped.includes("ffmpeg -i in.mkv out.mp4"), true);
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
test("buildSandboxWrappedCommand escapes single quotes", () => {
|
|
15
|
+
const wrapped = buildSandboxWrappedCommand("echo 'hello'");
|
|
16
|
+
|
|
17
|
+
assert.equal(wrapped.includes("'\"'\"'"), true);
|
|
18
|
+
});
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
|
|
4
|
+
import { PermissionService } from "../src/permission-service.js";
|
|
5
|
+
|
|
6
|
+
function createClock(startMs = 0) {
|
|
7
|
+
let current = startMs;
|
|
8
|
+
return {
|
|
9
|
+
now: () => current,
|
|
10
|
+
advance: (ms) => {
|
|
11
|
+
current += ms;
|
|
12
|
+
},
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
test("safe and risky tool classification", () => {
|
|
17
|
+
const service = new PermissionService({ secret: "test-secret" });
|
|
18
|
+
|
|
19
|
+
assert.equal(service.isRisky("read_file"), false);
|
|
20
|
+
assert.equal(service.isRisky("read_image"), false);
|
|
21
|
+
assert.equal(service.isRisky("list_directory"), false);
|
|
22
|
+
assert.equal(service.isRisky("system_info"), false);
|
|
23
|
+
|
|
24
|
+
assert.equal(service.isRisky("run_command"), true);
|
|
25
|
+
assert.equal(service.isRisky("write_file"), true);
|
|
26
|
+
assert.equal(service.isRisky("take_screenshot"), true);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test("session whitelist match", () => {
|
|
30
|
+
const service = new PermissionService({ secret: "test-secret" });
|
|
31
|
+
|
|
32
|
+
service.allowPatternForSession("s1", "rm /tmp/*");
|
|
33
|
+
|
|
34
|
+
assert.equal(service.isAllowedBySessionPattern("s1", "rm /tmp/file-a"), true);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test("whitelist isolation across sessions", () => {
|
|
38
|
+
const service = new PermissionService({ secret: "test-secret" });
|
|
39
|
+
|
|
40
|
+
service.allowPatternForSession("s1", "rm /tmp/*");
|
|
41
|
+
|
|
42
|
+
assert.equal(service.isAllowedBySessionPattern("s2", "rm /tmp/file-a"), false);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test("expired token invalid", () => {
|
|
46
|
+
const clock = createClock(10_000);
|
|
47
|
+
const service = new PermissionService({ secret: "test-secret", now: clock.now });
|
|
48
|
+
|
|
49
|
+
const approval = service.requestApproval("s1", "run_command", { command: "ls" });
|
|
50
|
+
clock.advance(5 * 60 * 1000 + 1);
|
|
51
|
+
|
|
52
|
+
const ok = service.validateApprovalToken("s1", approval.token, "run_command", { command: "ls" });
|
|
53
|
+
assert.equal(ok, false);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test("hash mismatch invalid", () => {
|
|
57
|
+
const service = new PermissionService({ secret: "test-secret" });
|
|
58
|
+
|
|
59
|
+
const approval = service.requestApproval("s1", "run_command", { command: "echo hi" });
|
|
60
|
+
const ok = service.validateApprovalToken("s1", approval.token, "run_command", { command: "echo bye" });
|
|
61
|
+
|
|
62
|
+
assert.equal(ok, false);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test("consumed token cannot be reused", () => {
|
|
66
|
+
const service = new PermissionService({ secret: "test-secret" });
|
|
67
|
+
|
|
68
|
+
const approval = service.requestApproval("s1", "run_command", { command: "pwd" });
|
|
69
|
+
|
|
70
|
+
const first = service.validateApprovalToken("s1", approval.token, "run_command", { command: "pwd" });
|
|
71
|
+
const second = service.validateApprovalToken("s1", approval.token, "run_command", { command: "pwd" });
|
|
72
|
+
|
|
73
|
+
assert.equal(first, true);
|
|
74
|
+
assert.equal(second, false);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test("glob semantics with anchored wildcard", () => {
|
|
78
|
+
const service = new PermissionService({ secret: "test-secret" });
|
|
79
|
+
|
|
80
|
+
service.allowPatternForSession("s1", "rm /tmp/*");
|
|
81
|
+
|
|
82
|
+
assert.equal(service.isAllowedBySessionPattern("s1", "rm /tmp/a"), true);
|
|
83
|
+
assert.equal(service.isAllowedBySessionPattern("s1", "rm ~/a"), false);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test("concurrent approvals generate unique approvalRequestId", async () => {
|
|
87
|
+
const service = new PermissionService({ secret: "test-secret" });
|
|
88
|
+
|
|
89
|
+
const approvals = await Promise.all(
|
|
90
|
+
Array.from({ length: 100 }, (_, i) =>
|
|
91
|
+
Promise.resolve(service.requestApproval("s1", "run_command", { command: `echo ${i}` })),
|
|
92
|
+
),
|
|
93
|
+
);
|
|
94
|
+
|
|
95
|
+
const ids = new Set(approvals.map((item) => item.approvalRequestId));
|
|
96
|
+
assert.equal(ids.size, approvals.length);
|
|
97
|
+
});
|