Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ let package = Package(
linkerSettings: [
.linkedFramework("ScreenCaptureKit"),
.linkedFramework("CoreGraphics"),
.linkedFramework("ApplicationServices"),
.linkedFramework("Network"),
]
),
Expand Down
23 changes: 14 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ flowchart LR
input["Input<br/>CGEvent mouse / keyboard"]
apps["Apps<br/>osascript / JXA"]
end
tcc{{"TCC grants, once, on the Mac's own screen<br/>Screen Recording · Accessibility · Automation<br/>bound to bundle id dev.openab.instance-mcp"}}
tcc{{"TCC grants, once, on the Mac's own screen<br/>Screen Recording · Accessibility · Full Disk Access<br/>bound to bundle id dev.openab.instance-mcp"}}
end

cli -- "HTTPS · MCP Streamable HTTP" --> s8444
Expand Down Expand Up @@ -174,10 +174,12 @@ Tagged releases publish a universal, Developer-ID-signed and Apple-notarized ins
3. Double-click the package. It auto-detects your Tailscale login/name, preserves or creates the
bearer token, installs the LaunchAgent, detects the Playwright upstream, and configures
`tailscale serve :8444`.
4. Once, enable Full Disk Access, Screen & System Audio Recording, and Accessibility for
**oab-instance-mcp** in System Settings → Privacy & Security. Future releases keep the same
Developer ID + bundle id, so these grants survive updates.
5. Use the menu bar item to copy the MCP URL and bearer token into OpenAB Connect/Remote.
4. On first launch, the **Set Up Mac Permissions** window opens once if anything is missing. Use
each row's Open Settings button, return to the wizard, then Test Again. Grant only what you need:
Full Disk Access, Screen & System Audio Recording, and/or Accessibility. Browser tools work
without any of them. Future releases keep the same Developer ID + bundle id, so grants survive.
5. The same wizard remains available from the menu bar as **Set Up Permissions…**; use the menu
item to copy the MCP URL and bearer token into OpenAB Connect/Remote.

The `.app.zip` beside the package is an advanced/manual artifact. After unzipping:

Expand Down Expand Up @@ -249,10 +251,13 @@ kiro-cli mcp add --name macmini-mcp --url https://<host>.<tailnet>.ts.net:8444/m
## Menu bar

With `--menu-bar` (deploy.sh sets it) the agent shows a status item: version, the public MCP URL
(click to copy), a masked bearer token line (click to copy the full token), ✓/✗ for Screen
Recording and Accessibility (click ✗ to open the pane), session / call counters with the last tool
call, Open Log, Restart, and Quit (which boots the launchd job out so KeepAlive does not bring it
back). The icon fills briefly on each tool call.
(click to copy), a masked bearer token line (click to copy the full token), live ✓/✗/? rows for
Screen Recording, Accessibility, and Full Disk Access, **Set Up Permissions…** (the same window
that auto-shows once on first launch when anything is missing), session / call counters with the
last tool call, Open Log, Restart, and Quit. The setup window opens the exact System Settings pane
for each permission and re-tests when the app becomes active or the user clicks Test Again; it
never polls screenshot or triggers permission prompts by itself. The icon fills briefly on each
tool call.

## Operate

Expand Down
114 changes: 114 additions & 0 deletions Sources/InstanceMCPCore/PermissionStatus.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
import ApplicationServices
import CoreGraphics
import Darwin
import Foundation

/// The three macOS TCC capabilities this daemon can use. Browser-only operation
/// needs none; each row in the setup window explains which tools it unlocks.
public enum PermissionKind: String, CaseIterable, Sendable {
case screenRecording
case accessibility
case fullDiskAccess
}

/// `unknown` is meaningful for FDA: macOS has no public preflight API, and a Mac
/// with none of our protected probe files gives us no honest measurement.
public enum PermissionState: Equatable, Sendable {
case granted
case denied
case unknown

public var isGranted: Bool { self == .granted }
}

public struct PermissionSnapshot: Equatable, Sendable {
public var screenRecording: PermissionState
public var accessibility: PermissionState
public var fullDiskAccess: PermissionState

public init(screenRecording: PermissionState, accessibility: PermissionState,
fullDiskAccess: PermissionState) {
self.screenRecording = screenRecording
self.accessibility = accessibility
self.fullDiskAccess = fullDiskAccess
}

public subscript(_ kind: PermissionKind) -> PermissionState {
switch kind {
case .screenRecording: return screenRecording
case .accessibility: return accessibility
case .fullDiskAccess: return fullDiskAccess
}
}

public var allGranted: Bool {
PermissionKind.allCases.allSatisfy { self[$0].isGranted }
}

public var grantedCount: Int {
PermissionKind.allCases.filter { self[$0].isGranted }.count
}
}

/// Testable probes for the shipping permission rules.
public enum PermissionProbe {
/// Snapshot using Apple's public preflight APIs plus an actual protected-file
/// open for Full Disk Access.
public static func current(homeDirectory: String = NSHomeDirectory()) -> PermissionSnapshot {
PermissionSnapshot(
screenRecording: CGPreflightScreenCaptureAccess() ? .granted : .denied,
accessibility: AXIsProcessTrusted() ? .granted : .denied,
fullDiskAccess: fullDiskAccess(homeDirectory: homeDirectory)
)
}

/// Paths protected by `kTCCServiceSystemPolicyAllFiles`. We do not read or
/// inspect any content: a successful `open` is immediately followed by
/// `close`. The user's own TCC.db exists on every normal desktop account;
/// Safari/Messages are fallbacks for unusual layouts.
public static func fullDiskAccessCandidatePaths(homeDirectory: String) -> [String] {
let home = URL(fileURLWithPath: homeDirectory, isDirectory: true)
return [
"Library/Application Support/com.apple.TCC/TCC.db",
"Library/Safari/History.db",
"Library/Messages/chat.db",
].map { home.appendingPathComponent($0).path }
}

public static func fullDiskAccess(homeDirectory: String = NSHomeDirectory()) -> PermissionState {
fullDiskAccess(
paths: fullDiskAccessCandidatePaths(homeDirectory: homeDirectory),
exists: { FileManager.default.fileExists(atPath: $0) },
openForRead: { path in
let fd = Darwin.open(path, O_RDONLY | O_CLOEXEC)
guard fd >= 0 else { return false }
Darwin.close(fd)
return true
}
)
}

/// Injection seam: tests cover granted, denied and no-probe-file without
/// depending on the CI runner's own TCC database.
public static func fullDiskAccess(
paths: [String],
exists: (String) -> Bool,
openForRead: (String) -> Bool
) -> PermissionState {
var found = false
for path in paths where exists(path) {
found = true
if openForRead(path) { return .granted }
}
return found ? .denied : .unknown
}
}

/// One-time auto-show policy. "Not Now" is respected across launches and
/// versions; the menu item remains available forever. A fully granted machine
/// never gets an onboarding window just because it upgraded to 0.6.3.
public enum PermissionSetupPolicy {
public static func shouldAutoShow(hasShown: Bool, snapshot: PermissionSnapshot) -> Bool {
!hasShown && !snapshot.allGranted
}
}
24 changes: 18 additions & 6 deletions Sources/InstanceMCPCore/Tools/SysInfoTool.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,8 @@ public struct SysInfoTool: Tool {
public let name = "sys_info"
public let description = """
Describe this Mac: hostname, macOS version, hardware, logged-in GUI user, displays, \
Tailscale addresses, and which TCC permissions (Screen Recording, Accessibility) the \
agent currently holds. Call this first to learn what the other tools can do here.
Tailscale addresses, and which TCC permissions (Screen Recording, Accessibility, Full Disk \
Access) the agent currently holds. Call this first to learn what the other tools can do here.
"""
public let inputSchema: JSONValue = ["type": "object", "properties": [:]]

Expand Down Expand Up @@ -42,9 +42,18 @@ public struct SysInfoTool: Tool {
]
}

// TCC. CGPreflightScreenCaptureAccess is the non-prompting check.
let screenRecording = CGPreflightScreenCaptureAccess()
let accessibility = AXIsProcessTrusted()
// TCC. Public non-prompting checks for screen/AX; FDA is an actual
// open+close of a protected database (no content read).
let permissions = PermissionProbe.current()
let screenRecording = permissions.screenRecording.isGranted
let accessibility = permissions.accessibility.isGranted
let fullDiskAccess = permissions.fullDiskAccess.isGranted
let fullDiskAccessState: String
switch permissions.fullDiskAccess {
case .granted: fullDiskAccessState = "granted"
case .denied: fullDiskAccessState = "denied"
case .unknown: fullDiskAccessState = "unknown"
}

let console = consoleUser()
let tail = tailnetAddresses()
Expand All @@ -62,6 +71,8 @@ public struct SysInfoTool: Tool {
"permissions": [
"screen_recording": .bool(screenRecording),
"accessibility": .bool(accessibility),
"full_disk_access": .bool(fullDiskAccess),
"full_disk_access_state": .string(fullDiskAccessState),
],
"uptime_secs": .number(pi.systemUptime.rounded()),
]
Expand All @@ -73,9 +84,10 @@ public struct SysInfoTool: Tool {
"\(Int(d["points"]?["width"]?.doubleValue ?? 0))×\(Int(d["points"]?["height"]?.doubleValue ?? 0))pt\(d["main"]?.boolValue == true ? " (main)" : "")"
}.joined(separator: ", "))
lines.append("tailscale: \(tail.isEmpty ? "none" : tail.joined(separator: ", "))")
lines.append("permissions: screen_recording=\(screenRecording) accessibility=\(accessibility)")
lines.append("permissions: screen_recording=\(screenRecording) accessibility=\(accessibility) full_disk_access=\(fullDiskAccessState)")
if !screenRecording { lines.append("→ screenshot will fail until Screen Recording is granted to oab-instance-mcp") }
if !accessibility { lines.append("→ mouse/key will fail until Accessibility is granted to oab-instance-mcp") }
if !fullDiskAccess { lines.append("→ protected Mail/Messages/Safari files will fail until Full Disk Access is granted to oab-instance-mcp") }
lines.append("agent \(agentVersion)")
return ToolResult(content: [.text(lines.joined(separator: "\n"))], structured: structured)
}
Expand Down
Loading
Loading