poke-gate 0.2.1 → 0.3.0
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 +32 -27
- package/bin/poke-gate.js +3 -0
- package/clients/Poke macOS Gate/Poke macOS Gate/GateService.swift +133 -1
- package/clients/Poke macOS Gate/Poke macOS Gate/Poke_macOS_GateApp.swift +74 -4
- package/clients/Poke macOS Gate/Poke macOS Gate.xcodeproj/project.pbxproj +2 -2
- package/docs/cli.md +16 -0
- package/docs/security.md +4 -2
- package/package.json +1 -1
- package/src/app.js +16 -2
- package/src/download-macos.js +133 -0
- package/src/mcp-server.js +31 -6
package/README.md
CHANGED
|
@@ -28,6 +28,25 @@ Run Poke Gate on your Mac, then message Poke from iMessage, Telegram, or SMS to
|
|
|
28
28
|
brew install f/tap/poke-gate
|
|
29
29
|
```
|
|
30
30
|
|
|
31
|
+
**Install via npx**
|
|
32
|
+
|
|
33
|
+
If you have Node.js installed, you can download and install the macOS app with a single command:
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
npx poke-gate download-macos
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
This downloads the latest DMG from GitHub Releases, installs the app to `/Applications`, and clears the quarantine flag automatically.
|
|
40
|
+
|
|
41
|
+
**Don't have Node.js?** Install it first:
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
# Option 1: Homebrew
|
|
45
|
+
brew install node
|
|
46
|
+
|
|
47
|
+
# Option 2: Download from https://nodejs.org
|
|
48
|
+
```
|
|
49
|
+
|
|
31
50
|
**Manual download**
|
|
32
51
|
|
|
33
52
|
Download the latest **Poke.macOS.Gate.dmg** from [Releases](https://github.com/f/poke-gate/releases), open it, and drag to Applications. Since the app is not notarized, you may need to run:
|
|
@@ -36,7 +55,9 @@ Download the latest **Poke.macOS.Gate.dmg** from [Releases](https://github.com/f
|
|
|
36
55
|
xattr -cr /Applications/Poke\ macOS\ Gate.app
|
|
37
56
|
```
|
|
38
57
|
|
|
39
|
-
**CLI** (no macOS app needed)
|
|
58
|
+
**CLI only** (no macOS app needed)
|
|
59
|
+
|
|
60
|
+
If you just want to run poke-gate from the terminal without the menu bar app:
|
|
40
61
|
|
|
41
62
|
```bash
|
|
42
63
|
npx poke-gate
|
|
@@ -123,7 +144,9 @@ Hit **Run** in Xcode, or build from the command line:
|
|
|
123
144
|
|
|
124
145
|
## CLI usage
|
|
125
146
|
|
|
126
|
-
If you
|
|
147
|
+
The CLI requires [Node.js](https://nodejs.org) 18 or later. If you don't have it, install via `brew install node` or download from [nodejs.org](https://nodejs.org).
|
|
148
|
+
|
|
149
|
+
Start the gate:
|
|
127
150
|
|
|
128
151
|
```bash
|
|
129
152
|
npx poke-gate
|
|
@@ -142,6 +165,12 @@ npx poke-gate --mode limited
|
|
|
142
165
|
npx poke-gate --mode sandbox
|
|
143
166
|
```
|
|
144
167
|
|
|
168
|
+
Install or update the macOS app:
|
|
169
|
+
|
|
170
|
+
```bash
|
|
171
|
+
npx poke-gate download-macos
|
|
172
|
+
```
|
|
173
|
+
|
|
145
174
|
Config is stored at `~/.config/poke-gate/config.json`.
|
|
146
175
|
|
|
147
176
|
## Agents
|
|
@@ -241,7 +270,7 @@ Poke Gate supports three access modes that control what your agent can do:
|
|
|
241
270
|
|
|
242
271
|
| Mode | Description |
|
|
243
272
|
|------|-------------|
|
|
244
|
-
| **Full** (default) | All tools available.
|
|
273
|
+
| **Full** (default) | All tools available with no approval required. The agent can run commands, write files, and take screenshots directly. |
|
|
245
274
|
| **Limited** | Read-only tools plus a curated set of safe commands (`ls`, `cat`, `grep`, `curl`, etc.). `write_file` and `take_screenshot` are disabled. |
|
|
246
275
|
| **Sandbox** | Broader command support than Limited, but writes are restricted to `~/Downloads` and `/tmp` via macOS `sandbox-exec`. |
|
|
247
276
|
|
|
@@ -264,30 +293,6 @@ POKE_GATE_PERMISSION_MODE=limited npx poke-gate
|
|
|
264
293
|
|
|
265
294
|
Only run Poke Gate on machines and networks you trust. Use `limited` or `sandbox` mode if you want tighter restrictions.
|
|
266
295
|
|
|
267
|
-
## Project structure
|
|
268
|
-
|
|
269
|
-
```
|
|
270
|
-
clients/
|
|
271
|
-
Poke macOS Gate/ macOS menu bar app (SwiftUI)
|
|
272
|
-
bin/
|
|
273
|
-
poke-gate.js CLI entry point with --mode flag
|
|
274
|
-
src/
|
|
275
|
-
app.js Startup: MCP server + tunnel + agent scheduler
|
|
276
|
-
agents.js Agent discovery, scheduling, env loading, download
|
|
277
|
-
mcp-server.js JSON-RPC MCP handler, tools, access policy, sandbox
|
|
278
|
-
permission-service.js HMAC approval tokens, session whitelisting
|
|
279
|
-
tunnel.js PokeTunnel wrapper
|
|
280
|
-
test/
|
|
281
|
-
mcp-server-access-policy.test.js
|
|
282
|
-
mcp-server-loop-guard.test.js
|
|
283
|
-
mcp-server-sandbox-command.test.js
|
|
284
|
-
permission-service.test.js
|
|
285
|
-
examples/
|
|
286
|
-
agents/
|
|
287
|
-
beeper.1h.js Example: Beeper message digest agent
|
|
288
|
-
.env.beeper Example env file for beeper agent
|
|
289
|
-
```
|
|
290
|
-
|
|
291
296
|
## Credits
|
|
292
297
|
|
|
293
298
|
- [Poke](https://poke.com) by [The Interaction Company of California](https://interaction.co)
|
package/bin/poke-gate.js
CHANGED
|
@@ -39,6 +39,9 @@ async function main() {
|
|
|
39
39
|
const prompt = promptIdx !== -1 ? args.slice(promptIdx + 1).join(" ") : args.slice(2).join(" ") || null;
|
|
40
40
|
const { createAgent } = await import("../src/agent-create.js");
|
|
41
41
|
await createAgent(prompt);
|
|
42
|
+
} else if (args[0] === "download-macos") {
|
|
43
|
+
const { downloadMacOSApp } = await import("../src/download-macos.js");
|
|
44
|
+
await downloadMacOSApp();
|
|
42
45
|
} else {
|
|
43
46
|
const mode = parseMode();
|
|
44
47
|
if (mode) {
|
|
@@ -91,11 +91,16 @@ class GateService: ObservableObject {
|
|
|
91
91
|
|
|
92
92
|
@Published var status: Status = .stopped
|
|
93
93
|
@Published var logs: [String] = []
|
|
94
|
+
@Published var processLogs: [String] = []
|
|
94
95
|
@Published var terminalPreviews: [TerminalPreview] = []
|
|
95
96
|
@Published var userName: String? = nil
|
|
96
97
|
@Published var permissionMode: PermissionMode
|
|
97
98
|
@Published var hasCompletedSetup: Bool
|
|
98
99
|
@Published var systemPermissionStatuses: [SystemPermissionStatus] = []
|
|
100
|
+
@Published var availableUpdate: String? = nil
|
|
101
|
+
@Published var isUpdating = false
|
|
102
|
+
@Published var isCheckingForUpdate = false
|
|
103
|
+
@Published var updateCheckResult: String? = nil
|
|
99
104
|
|
|
100
105
|
private var hasAutoStarted = false
|
|
101
106
|
private var process: Process?
|
|
@@ -120,7 +125,9 @@ class GateService: ObservableObject {
|
|
|
120
125
|
object: nil,
|
|
121
126
|
queue: .main
|
|
122
127
|
) { [weak self] _ in
|
|
123
|
-
|
|
128
|
+
Task { @MainActor in
|
|
129
|
+
self?.refreshSystemPermissions()
|
|
130
|
+
}
|
|
124
131
|
}
|
|
125
132
|
}
|
|
126
133
|
|
|
@@ -301,6 +308,112 @@ class GateService: ObservableObject {
|
|
|
301
308
|
if hasAPIKey {
|
|
302
309
|
start()
|
|
303
310
|
}
|
|
311
|
+
checkForUpdate(silent: true)
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
func checkForUpdate(silent: Bool = false) {
|
|
315
|
+
let currentVersion = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "0.0.0"
|
|
316
|
+
isCheckingForUpdate = true
|
|
317
|
+
updateCheckResult = nil
|
|
318
|
+
|
|
319
|
+
Task.detached {
|
|
320
|
+
let start = ContinuousClock.now
|
|
321
|
+
var found = false
|
|
322
|
+
|
|
323
|
+
defer {
|
|
324
|
+
Task { @MainActor in
|
|
325
|
+
self.isCheckingForUpdate = false
|
|
326
|
+
if !found && !silent {
|
|
327
|
+
self.updateCheckResult = "You're on the latest version."
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
guard let url = URL(string: "https://registry.npmjs.org/poke-gate/latest") else { return }
|
|
333
|
+
do {
|
|
334
|
+
let (data, _) = try await URLSession.shared.data(from: url)
|
|
335
|
+
guard let json = try JSONSerialization.jsonObject(with: data) as? [String: Any],
|
|
336
|
+
let latestVersion = json["version"] as? String else { return }
|
|
337
|
+
|
|
338
|
+
let elapsed = ContinuousClock.now - start
|
|
339
|
+
if elapsed < .seconds(1) {
|
|
340
|
+
try await Task.sleep(for: .seconds(1) - elapsed)
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
if Self.isNewer(latestVersion, than: currentVersion) {
|
|
344
|
+
found = true
|
|
345
|
+
await MainActor.run {
|
|
346
|
+
self.availableUpdate = latestVersion
|
|
347
|
+
self.appendLog("Update available: v\(latestVersion) (current: v\(currentVersion))")
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
} catch {}
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
private nonisolated static func isNewer(_ remote: String, than local: String) -> Bool {
|
|
355
|
+
let r = remote.split(separator: ".").compactMap { Int($0) }
|
|
356
|
+
let l = local.split(separator: ".").compactMap { Int($0) }
|
|
357
|
+
for i in 0..<max(r.count, l.count) {
|
|
358
|
+
let rv = i < r.count ? r[i] : 0
|
|
359
|
+
let lv = i < l.count ? l[i] : 0
|
|
360
|
+
if rv > lv { return true }
|
|
361
|
+
if rv < lv { return false }
|
|
362
|
+
}
|
|
363
|
+
return false
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
func performUpdate() {
|
|
367
|
+
guard let version = availableUpdate else { return }
|
|
368
|
+
isUpdating = true
|
|
369
|
+
appendLog("Updating to v\(version)...")
|
|
370
|
+
|
|
371
|
+
stop()
|
|
372
|
+
|
|
373
|
+
let fullPath = shellPath()
|
|
374
|
+
let npxBin = findNpx()
|
|
375
|
+
|
|
376
|
+
let proc = Process()
|
|
377
|
+
let pipe = Pipe()
|
|
378
|
+
proc.executableURL = URL(fileURLWithPath: "/bin/zsh")
|
|
379
|
+
proc.arguments = ["-c", "\(npxBin) -y poke-gate@\(version) download-macos"]
|
|
380
|
+
proc.environment = ProcessInfo.processInfo.environment.merging(
|
|
381
|
+
["PATH": fullPath],
|
|
382
|
+
uniquingKeysWith: { _, new in new }
|
|
383
|
+
)
|
|
384
|
+
proc.standardOutput = pipe
|
|
385
|
+
proc.standardError = pipe
|
|
386
|
+
proc.currentDirectoryURL = FileManager.default.homeDirectoryForCurrentUser
|
|
387
|
+
|
|
388
|
+
let handle = pipe.fileHandleForReading
|
|
389
|
+
handle.readabilityHandler = { [weak self] fh in
|
|
390
|
+
let data = fh.availableData
|
|
391
|
+
guard !data.isEmpty, let line = String(data: data, encoding: .utf8) else { return }
|
|
392
|
+
DispatchQueue.main.async {
|
|
393
|
+
for l in line.components(separatedBy: .newlines) where !l.isEmpty {
|
|
394
|
+
self?.appendLog(l)
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
proc.terminationHandler = { [weak self] proc in
|
|
400
|
+
DispatchQueue.main.async {
|
|
401
|
+
self?.isUpdating = false
|
|
402
|
+
if proc.terminationStatus == 0 {
|
|
403
|
+
self?.appendLog("Update complete. The app will relaunch.")
|
|
404
|
+
self?.availableUpdate = nil
|
|
405
|
+
} else {
|
|
406
|
+
self?.appendLog("Update failed (exit \(proc.terminationStatus)).")
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
do {
|
|
412
|
+
try proc.run()
|
|
413
|
+
} catch {
|
|
414
|
+
isUpdating = false
|
|
415
|
+
appendLog("Failed to start update: \(error.localizedDescription)")
|
|
416
|
+
}
|
|
304
417
|
}
|
|
305
418
|
|
|
306
419
|
func start() {
|
|
@@ -489,11 +602,21 @@ class GateService: ObservableObject {
|
|
|
489
602
|
outputPipe?.fileHandleForReading.readabilityHandler = nil
|
|
490
603
|
process = nil
|
|
491
604
|
outputPipe = nil
|
|
605
|
+
killOrphanedProcesses()
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
private func killOrphanedProcesses() {
|
|
609
|
+
let task = Process()
|
|
610
|
+
task.executableURL = URL(fileURLWithPath: "/usr/bin/pkill")
|
|
611
|
+
task.arguments = ["-f", "poke-gate"]
|
|
612
|
+
try? task.run()
|
|
613
|
+
task.waitUntilExit()
|
|
492
614
|
}
|
|
493
615
|
|
|
494
616
|
private func handleOutput(_ raw: String) {
|
|
495
617
|
for line in raw.components(separatedBy: .newlines) where !line.isEmpty {
|
|
496
618
|
appendLog(line)
|
|
619
|
+
appendProcessLog(line)
|
|
497
620
|
parseTerminalPreviewLine(line)
|
|
498
621
|
|
|
499
622
|
if line.contains("Tunnel connected") || line.contains("Ready") {
|
|
@@ -510,6 +633,15 @@ class GateService: ObservableObject {
|
|
|
510
633
|
}
|
|
511
634
|
}
|
|
512
635
|
|
|
636
|
+
private func appendProcessLog(_ line: String) {
|
|
637
|
+
let stripped = stripToolTimestamp(from: line)
|
|
638
|
+
guard !stripped.isEmpty else { return }
|
|
639
|
+
processLogs.append(stripped)
|
|
640
|
+
if processLogs.count > maxLogs {
|
|
641
|
+
processLogs.removeFirst(processLogs.count - maxLogs)
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
|
|
513
645
|
private func handleTermination(exitCode: Int32) {
|
|
514
646
|
appendLog("Process exited with code \(exitCode)")
|
|
515
647
|
stopHealthCheck()
|
|
@@ -111,6 +111,9 @@ struct PopoverContent: View {
|
|
|
111
111
|
SetupView(service: service)
|
|
112
112
|
} else {
|
|
113
113
|
VStack(spacing: 10) {
|
|
114
|
+
if service.availableUpdate != nil {
|
|
115
|
+
updateBanner
|
|
116
|
+
}
|
|
114
117
|
statusSection
|
|
115
118
|
recentActivitySection
|
|
116
119
|
accessModeSection
|
|
@@ -128,6 +131,37 @@ struct PopoverContent: View {
|
|
|
128
131
|
}
|
|
129
132
|
}
|
|
130
133
|
|
|
134
|
+
private var updateBanner: some View {
|
|
135
|
+
HStack(spacing: 8) {
|
|
136
|
+
Image(systemName: "arrow.down.circle.fill")
|
|
137
|
+
.foregroundStyle(.blue)
|
|
138
|
+
|
|
139
|
+
VStack(alignment: .leading, spacing: 1) {
|
|
140
|
+
Text("Update available: v\(service.availableUpdate ?? "")")
|
|
141
|
+
.font(.caption)
|
|
142
|
+
.fontWeight(.semibold)
|
|
143
|
+
Text("A new version of Poke Gate is ready.")
|
|
144
|
+
.font(.caption2)
|
|
145
|
+
.foregroundStyle(.secondary)
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
Spacer()
|
|
149
|
+
|
|
150
|
+
if service.isUpdating {
|
|
151
|
+
ProgressView()
|
|
152
|
+
.controlSize(.small)
|
|
153
|
+
} else {
|
|
154
|
+
Button("Update") {
|
|
155
|
+
service.performUpdate()
|
|
156
|
+
}
|
|
157
|
+
.controlSize(.small)
|
|
158
|
+
.buttonStyle(.borderedProminent)
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
.padding(10)
|
|
162
|
+
.macPanelStyle(.neutral, cornerRadius: 10)
|
|
163
|
+
}
|
|
164
|
+
|
|
131
165
|
private var statusSection: some View {
|
|
132
166
|
VStack(alignment: .leading, spacing: 6) {
|
|
133
167
|
HStack(spacing: 8) {
|
|
@@ -189,9 +223,9 @@ struct PopoverContent: View {
|
|
|
189
223
|
.truncationMode(.tail)
|
|
190
224
|
}
|
|
191
225
|
}
|
|
192
|
-
} else if !service.
|
|
193
|
-
ForEach(Array(service.
|
|
194
|
-
Text(
|
|
226
|
+
} else if !service.processLogs.isEmpty {
|
|
227
|
+
ForEach(Array(service.processLogs.suffix(4).enumerated()), id: \.offset) { _, entry in
|
|
228
|
+
Text(entry)
|
|
195
229
|
.font(.system(size: 9, design: .monospaced))
|
|
196
230
|
.foregroundStyle(.tertiary)
|
|
197
231
|
.lineLimit(1)
|
|
@@ -303,8 +337,10 @@ struct PopoverContent: View {
|
|
|
303
337
|
.macPanelStyle(.neutral, cornerRadius: 12)
|
|
304
338
|
}
|
|
305
339
|
|
|
340
|
+
@State private var refreshRotation: Double = 0
|
|
341
|
+
|
|
306
342
|
private var footerSection: some View {
|
|
307
|
-
HStack {
|
|
343
|
+
HStack(spacing: 6) {
|
|
308
344
|
Button {
|
|
309
345
|
NSApp.activate(ignoringOtherApps: true)
|
|
310
346
|
openWindow(id: "about")
|
|
@@ -315,6 +351,28 @@ struct PopoverContent: View {
|
|
|
315
351
|
}
|
|
316
352
|
.buttonStyle(.plain)
|
|
317
353
|
|
|
354
|
+
Button {
|
|
355
|
+
service.checkForUpdate()
|
|
356
|
+
} label: {
|
|
357
|
+
Image(systemName: "arrow.triangle.2.circlepath")
|
|
358
|
+
.font(.system(size: 9))
|
|
359
|
+
.foregroundStyle(MacVisualStyle.sectionTitleColor)
|
|
360
|
+
.rotationEffect(.degrees(refreshRotation))
|
|
361
|
+
}
|
|
362
|
+
.buttonStyle(.plain)
|
|
363
|
+
.disabled(service.isCheckingForUpdate)
|
|
364
|
+
.onChange(of: service.isCheckingForUpdate) { _, checking in
|
|
365
|
+
if checking {
|
|
366
|
+
withAnimation(.linear(duration: 1).repeatForever(autoreverses: false)) {
|
|
367
|
+
refreshRotation = 360
|
|
368
|
+
}
|
|
369
|
+
} else {
|
|
370
|
+
withAnimation(.default) {
|
|
371
|
+
refreshRotation = 0
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
|
|
318
376
|
Spacer()
|
|
319
377
|
|
|
320
378
|
Text("Not affiliated with Poke")
|
|
@@ -322,6 +380,18 @@ struct PopoverContent: View {
|
|
|
322
380
|
.foregroundStyle(MacVisualStyle.sectionTitleColor.opacity(0.7))
|
|
323
381
|
}
|
|
324
382
|
.padding(.horizontal, 6)
|
|
383
|
+
.onChange(of: service.updateCheckResult) { _, result in
|
|
384
|
+
guard let message = result else { return }
|
|
385
|
+
service.updateCheckResult = nil
|
|
386
|
+
DispatchQueue.main.async {
|
|
387
|
+
let alert = NSAlert()
|
|
388
|
+
alert.messageText = "Version Check"
|
|
389
|
+
alert.informativeText = message
|
|
390
|
+
alert.alertStyle = .informational
|
|
391
|
+
alert.addButton(withTitle: "OK")
|
|
392
|
+
alert.runModal()
|
|
393
|
+
}
|
|
394
|
+
}
|
|
325
395
|
}
|
|
326
396
|
|
|
327
397
|
private func sectionTitle(_ text: String) -> some View {
|
|
@@ -286,7 +286,7 @@
|
|
|
286
286
|
"@executable_path/../Frameworks",
|
|
287
287
|
);
|
|
288
288
|
MACOSX_DEPLOYMENT_TARGET = 15.0;
|
|
289
|
-
MARKETING_VERSION = 0.2.
|
|
289
|
+
MARKETING_VERSION = 0.2.2;
|
|
290
290
|
PRODUCT_BUNDLE_IDENTIFIER = "dev.fka.Poke-macOS-Gate";
|
|
291
291
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
|
292
292
|
REGISTER_APP_GROUPS = YES;
|
|
@@ -322,7 +322,7 @@
|
|
|
322
322
|
"@executable_path/../Frameworks",
|
|
323
323
|
);
|
|
324
324
|
MACOSX_DEPLOYMENT_TARGET = 15.0;
|
|
325
|
-
MARKETING_VERSION = 0.2.
|
|
325
|
+
MARKETING_VERSION = 0.2.2;
|
|
326
326
|
PRODUCT_BUNDLE_IDENTIFIER = "dev.fka.Poke-macOS-Gate";
|
|
327
327
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
|
328
328
|
REGISTER_APP_GROUPS = YES;
|
package/docs/cli.md
CHANGED
|
@@ -82,6 +82,22 @@ npx poke-gate agent create --prompt "send me a daily git commit summary across a
|
|
|
82
82
|
npx poke-gate agent create --prompt "track Spotify listening and log my music taste"
|
|
83
83
|
```
|
|
84
84
|
|
|
85
|
+
## Download the macOS app
|
|
86
|
+
|
|
87
|
+
```bash
|
|
88
|
+
npx poke-gate download-macos
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
Downloads and installs the Poke macOS Gate app from GitHub Releases. Matches the version of the npm package being run.
|
|
92
|
+
|
|
93
|
+
This command:
|
|
94
|
+
1. Downloads the DMG from the matching GitHub release
|
|
95
|
+
2. Mounts the DMG, copies the app to `/Applications`
|
|
96
|
+
3. Clears the quarantine flag (`xattr -cr`)
|
|
97
|
+
4. Launches the app
|
|
98
|
+
|
|
99
|
+
The macOS app also checks for updates automatically on startup and shows a banner when a new version is available.
|
|
100
|
+
|
|
85
101
|
## Install an agent
|
|
86
102
|
|
|
87
103
|
```bash
|
package/docs/security.md
CHANGED
|
@@ -10,7 +10,7 @@ Poke Gate supports three access modes that control what tools your agent can use
|
|
|
10
10
|
|
|
11
11
|
### Full (default)
|
|
12
12
|
|
|
13
|
-
All tools are available
|
|
13
|
+
All tools are available with no approval required. The agent can run commands, write files, and take screenshots directly.
|
|
14
14
|
|
|
15
15
|
### Limited
|
|
16
16
|
|
|
@@ -48,13 +48,15 @@ POKE_GATE_PERMISSION_MODE=sandbox npx poke-gate
|
|
|
48
48
|
|
|
49
49
|
## Tool approval flow
|
|
50
50
|
|
|
51
|
-
In **
|
|
51
|
+
In **limited** and **sandbox** modes, risky tools (`run_command`, `write_file`, `take_screenshot`) use an HMAC-signed approval flow:
|
|
52
52
|
|
|
53
53
|
1. The agent calls the tool — Poke Gate returns `AWAITING_APPROVAL` with a signed token
|
|
54
54
|
2. The agent asks you in chat to approve
|
|
55
55
|
3. You approve — the agent re-calls the tool with the approval token
|
|
56
56
|
4. Optionally, you can `remember_in_session` (same command) or `remember_all_risky` (all risky tools for the session)
|
|
57
57
|
|
|
58
|
+
In **full** mode, all tools execute directly without approval.
|
|
59
|
+
|
|
58
60
|
## What protects you
|
|
59
61
|
|
|
60
62
|
- **Authentication** — only your Poke agent (authenticated via Poke OAuth) can reach the tunnel
|
package/package.json
CHANGED
package/src/app.js
CHANGED
|
@@ -2,10 +2,23 @@ import { startMcpServer, enableLogging, getPermissionMode } from "./mcp-server.j
|
|
|
2
2
|
import { startTunnel } from "./tunnel.js";
|
|
3
3
|
import { startAgentScheduler, stopAgentScheduler } from "./agents.js";
|
|
4
4
|
import { Poke, isLoggedIn, login, getToken } from "poke";
|
|
5
|
+
import { execSync } from "node:child_process";
|
|
5
6
|
|
|
6
7
|
const verbose = process.argv.includes("--verbose") || process.argv.includes("-v");
|
|
7
8
|
enableLogging(verbose);
|
|
8
9
|
|
|
10
|
+
function killExistingInstances() {
|
|
11
|
+
const myPid = process.pid;
|
|
12
|
+
try {
|
|
13
|
+
const out = execSync("pgrep -f 'poke-gate'", { encoding: "utf-8" }).trim();
|
|
14
|
+
const pids = out.split("\n").map(Number).filter((p) => p && p !== myPid);
|
|
15
|
+
for (const pid of pids) {
|
|
16
|
+
try { process.kill(pid, "SIGTERM"); } catch {}
|
|
17
|
+
}
|
|
18
|
+
if (pids.length > 0) log(`Killed ${pids.length} existing poke-gate process(es).`);
|
|
19
|
+
} catch {}
|
|
20
|
+
}
|
|
21
|
+
|
|
9
22
|
function log(msg) {
|
|
10
23
|
const ts = new Date().toISOString().slice(11, 19);
|
|
11
24
|
console.log(`[${ts}] ${msg}`);
|
|
@@ -102,6 +115,7 @@ function scheduleReconnect(mcpUrl, token) {
|
|
|
102
115
|
}
|
|
103
116
|
|
|
104
117
|
async function main() {
|
|
118
|
+
killExistingInstances();
|
|
105
119
|
log("poke-gate starting...");
|
|
106
120
|
log(`Access mode: ${getPermissionMode()}`);
|
|
107
121
|
|
|
@@ -132,8 +146,8 @@ function buildAccessModeMessage(mode) {
|
|
|
132
146
|
default:
|
|
133
147
|
return (
|
|
134
148
|
"Access mode: Full. " +
|
|
135
|
-
"You can run any shell command, read
|
|
136
|
-
"
|
|
149
|
+
"You can run any shell command, read files, list directories, take screenshots, and check system info — no approval needed. " +
|
|
150
|
+
"Only destructive actions (deleting files, rm, write_file) require a one-time approval; after that, everything is auto-approved for the session."
|
|
137
151
|
);
|
|
138
152
|
}
|
|
139
153
|
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import https from "node:https";
|
|
2
|
+
import { createWriteStream, unlinkSync, readFileSync } from "node:fs";
|
|
3
|
+
import { execSync } from "node:child_process";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { join, dirname } from "node:path";
|
|
6
|
+
import { fileURLToPath } from "node:url";
|
|
7
|
+
|
|
8
|
+
const APP_NAME = "Poke macOS Gate";
|
|
9
|
+
const DMG_ASSET = "Poke.macOS.Gate.dmg";
|
|
10
|
+
const INSTALL_PATH = `/Applications/${APP_NAME}.app`;
|
|
11
|
+
const REPO = "f/poke-gate";
|
|
12
|
+
|
|
13
|
+
function getPackageVersion() {
|
|
14
|
+
const pkgPath = join(dirname(fileURLToPath(import.meta.url)), "..", "package.json");
|
|
15
|
+
const pkg = JSON.parse(readFileSync(pkgPath, "utf-8"));
|
|
16
|
+
return pkg.version;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function httpGet(url) {
|
|
20
|
+
return new Promise((resolve, reject) => {
|
|
21
|
+
https.get(url, { headers: { "User-Agent": "poke-gate" } }, (res) => {
|
|
22
|
+
if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
|
|
23
|
+
return httpGet(res.headers.location).then(resolve, reject);
|
|
24
|
+
}
|
|
25
|
+
resolve(res);
|
|
26
|
+
}).on("error", reject);
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function downloadFile(url, dest, version) {
|
|
31
|
+
return new Promise((resolve, reject) => {
|
|
32
|
+
const follow = (followUrl) => {
|
|
33
|
+
https.get(followUrl, { headers: { "User-Agent": "poke-gate" } }, (res) => {
|
|
34
|
+
if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
|
|
35
|
+
return follow(res.headers.location);
|
|
36
|
+
}
|
|
37
|
+
if (res.statusCode !== 200) {
|
|
38
|
+
reject(new Error(`Download failed: HTTP ${res.statusCode}`));
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const total = parseInt(res.headers["content-length"], 10) || 0;
|
|
43
|
+
let downloaded = 0;
|
|
44
|
+
const file = createWriteStream(dest);
|
|
45
|
+
|
|
46
|
+
res.on("data", (chunk) => {
|
|
47
|
+
downloaded += chunk.length;
|
|
48
|
+
if (total > 0) {
|
|
49
|
+
const pct = Math.round((downloaded / total) * 100);
|
|
50
|
+
const dlMB = (downloaded / 1024 / 1024).toFixed(1);
|
|
51
|
+
const totalMB = (total / 1024 / 1024).toFixed(1);
|
|
52
|
+
process.stdout.write(`\rDownloading ${APP_NAME} v${version}... ${pct}% (${dlMB}/${totalMB} MB)`);
|
|
53
|
+
}
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
res.pipe(file);
|
|
57
|
+
|
|
58
|
+
file.on("finish", () => {
|
|
59
|
+
file.close(() => {
|
|
60
|
+
console.log("");
|
|
61
|
+
resolve();
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
file.on("error", (err) => {
|
|
66
|
+
file.close();
|
|
67
|
+
reject(err);
|
|
68
|
+
});
|
|
69
|
+
}).on("error", reject);
|
|
70
|
+
};
|
|
71
|
+
follow(url);
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function run(cmd) {
|
|
76
|
+
return execSync(cmd, { encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] }).trim();
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export async function downloadMacOSApp() {
|
|
80
|
+
const version = getPackageVersion();
|
|
81
|
+
const tag = `v${version}`;
|
|
82
|
+
const dmgUrl = `https://github.com/${REPO}/releases/download/${tag}/${DMG_ASSET}`;
|
|
83
|
+
|
|
84
|
+
console.log(`Poke Gate macOS installer (${tag})`);
|
|
85
|
+
console.log("");
|
|
86
|
+
|
|
87
|
+
const res = await httpGet(`https://api.github.com/repos/${REPO}/releases/tags/${tag}`);
|
|
88
|
+
const chunks = [];
|
|
89
|
+
for await (const chunk of res) chunks.push(chunk);
|
|
90
|
+
const release = JSON.parse(Buffer.concat(chunks).toString());
|
|
91
|
+
|
|
92
|
+
if (!release.assets || !release.assets.find((a) => a.name === DMG_ASSET)) {
|
|
93
|
+
console.error(`No DMG found for ${tag}. The release may not have finished building yet.`);
|
|
94
|
+
process.exit(1);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const dmgPath = join(tmpdir(), `poke-gate-${version}.dmg`);
|
|
98
|
+
|
|
99
|
+
try {
|
|
100
|
+
await downloadFile(dmgUrl, dmgPath, version);
|
|
101
|
+
|
|
102
|
+
console.log("Mounting DMG...");
|
|
103
|
+
const mountOutput = run(`hdiutil attach "${dmgPath}" -nobrowse`);
|
|
104
|
+
const mountMatch = mountOutput.match(/\/Volumes\/.+/);
|
|
105
|
+
if (!mountMatch) {
|
|
106
|
+
throw new Error("Failed to detect mount point from hdiutil output.");
|
|
107
|
+
}
|
|
108
|
+
const mountPoint = mountMatch[0].trim();
|
|
109
|
+
const appSource = `${mountPoint}/${APP_NAME}.app`;
|
|
110
|
+
|
|
111
|
+
console.log("Stopping running instances...");
|
|
112
|
+
try { run(`pkill -f "${APP_NAME}"`); } catch {}
|
|
113
|
+
await new Promise((r) => setTimeout(r, 1000));
|
|
114
|
+
|
|
115
|
+
console.log(`Installing to ${INSTALL_PATH}...`);
|
|
116
|
+
try { run(`rm -rf "${INSTALL_PATH}"`); } catch {}
|
|
117
|
+
run(`cp -R "${appSource}" "${INSTALL_PATH}"`);
|
|
118
|
+
|
|
119
|
+
console.log("Clearing quarantine...");
|
|
120
|
+
run(`xattr -cr "${INSTALL_PATH}"`);
|
|
121
|
+
|
|
122
|
+
console.log("Unmounting DMG...");
|
|
123
|
+
try { run(`hdiutil detach "${mountPoint}" -quiet`); } catch {}
|
|
124
|
+
|
|
125
|
+
console.log("");
|
|
126
|
+
console.log(`Poke macOS Gate v${version} installed successfully.`);
|
|
127
|
+
console.log("");
|
|
128
|
+
console.log("Launching...");
|
|
129
|
+
try { run(`open "${INSTALL_PATH}"`); } catch {}
|
|
130
|
+
} finally {
|
|
131
|
+
try { unlinkSync(dmgPath); } catch {}
|
|
132
|
+
}
|
|
133
|
+
}
|
package/src/mcp-server.js
CHANGED
|
@@ -22,6 +22,15 @@ const runCommandLoopState = new Map();
|
|
|
22
22
|
|
|
23
23
|
const SAFE_TOOL_NAMES = new Set(["read_file", "read_image", "list_directory", "system_info", "network_speed"]);
|
|
24
24
|
|
|
25
|
+
const DESTRUCTIVE_COMMAND_PATTERNS = [
|
|
26
|
+
/\brm\b/i,
|
|
27
|
+
/\brmdir\b/i,
|
|
28
|
+
/\bunlink\b/i,
|
|
29
|
+
/\bmkfs\b/i,
|
|
30
|
+
/\bdiskutil\s+erase/i,
|
|
31
|
+
/>\s*\//,
|
|
32
|
+
];
|
|
33
|
+
|
|
25
34
|
const LIMITED_RUN_COMMANDS = new Set([
|
|
26
35
|
"curl", "yt-dlp", "youtube-dl",
|
|
27
36
|
"ls", "pwd", "cat", "grep", "find", "head", "tail", "wc", "sed", "awk",
|
|
@@ -270,6 +279,15 @@ function hasDangerousPattern(commandText) {
|
|
|
270
279
|
return DANGEROUS_COMMAND_PATTERNS.some((pattern) => pattern.test(commandText));
|
|
271
280
|
}
|
|
272
281
|
|
|
282
|
+
function isDestructiveInFullMode(name, cleanArgs) {
|
|
283
|
+
if (name === "write_file") return true;
|
|
284
|
+
if (name === "run_command") {
|
|
285
|
+
const cmd = typeof cleanArgs.command === "string" ? cleanArgs.command : "";
|
|
286
|
+
return DESTRUCTIVE_COMMAND_PATTERNS.some((p) => p.test(cmd));
|
|
287
|
+
}
|
|
288
|
+
return false;
|
|
289
|
+
}
|
|
290
|
+
|
|
273
291
|
function validateRunCommandAgainstAllowlist(commandText, allowlist) {
|
|
274
292
|
if (typeof commandText !== "string" || commandText.trim().length === 0) {
|
|
275
293
|
return "Command is empty.";
|
|
@@ -565,7 +583,11 @@ function handleToolCall(name, args, context = {}) {
|
|
|
565
583
|
return blocked;
|
|
566
584
|
}
|
|
567
585
|
|
|
568
|
-
|
|
586
|
+
const needsApproval = PERMISSION_MODE === "full"
|
|
587
|
+
? isDestructiveInFullMode(name, cleanArgs)
|
|
588
|
+
: permissionService.isRisky(name);
|
|
589
|
+
|
|
590
|
+
if (needsApproval) {
|
|
569
591
|
const commandText = typeof cleanArgs.command === "string" ? cleanArgs.command : "";
|
|
570
592
|
const alreadyAllowed = sessionAutoApproveAllRisky.has(sessionId) ||
|
|
571
593
|
(commandText && permissionService.isAllowedBySessionPattern(sessionId, commandText));
|
|
@@ -581,12 +603,15 @@ function handleToolCall(name, args, context = {}) {
|
|
|
581
603
|
return buildApprovalResponse(name, cleanArgs, approval);
|
|
582
604
|
}
|
|
583
605
|
|
|
584
|
-
if (
|
|
585
|
-
permissionService.allowPatternForSession(sessionId, commandText);
|
|
586
|
-
}
|
|
587
|
-
|
|
588
|
-
if (args.remember_all_risky === true) {
|
|
606
|
+
if (PERMISSION_MODE === "full") {
|
|
589
607
|
sessionAutoApproveAllRisky.add(sessionId);
|
|
608
|
+
} else {
|
|
609
|
+
if (name === "run_command" && args.remember_in_session === true && commandText) {
|
|
610
|
+
permissionService.allowPatternForSession(sessionId, commandText);
|
|
611
|
+
}
|
|
612
|
+
if (args.remember_all_risky === true) {
|
|
613
|
+
sessionAutoApproveAllRisky.add(sessionId);
|
|
614
|
+
}
|
|
590
615
|
}
|
|
591
616
|
}
|
|
592
617
|
}
|