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 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 prefer the command line over the macOS app:
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. Risky actions (commands, file writes, screenshots) require chat approval. |
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
- self?.refreshSystemPermissions()
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.logs.isEmpty {
193
- ForEach(Array(service.logs.suffix(4).enumerated()), id: \.offset) { _, log in
194
- Text(log)
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.0;
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.0;
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. Risky actions (`run_command`, `write_file`, `take_screenshot`) require **chat approval** the agent must ask you in chat before executing, and you approve with a signed token.
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 **full** mode, risky tools (`run_command`, `write_file`, `take_screenshot`) use an HMAC-signed approval flow:
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "poke-gate",
3
- "version": "0.2.1",
3
+ "version": "0.3.0",
4
4
  "description": "Expose your machine to your Poke AI assistant via MCP tunnel",
5
5
  "type": "module",
6
6
  "bin": {
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 and write files, list directories, take screenshots, and check system info. " +
136
- "Risky actions (commands, file writes, screenshots) require user approval in chat before execution."
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
- if (permissionService.isRisky(name)) {
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 (name === "run_command" && args.remember_in_session === true && commandText) {
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
  }