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
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
id: 01M5R5AD0RY5P85NSXXSKN98Q9
anchor: func resolution
created: 2026-10-24T23:53:14Z
norm: '1'
---

with no app above the client and the walk reaching launchd, only a team-signed program may be the caller, since an unsigned one or a bare path can't be told apart from anything else the user runs in the background; owner decision on #194
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
id: 01M5R5AD11Q3YCWA4SGPVZSVWR
anchor: func identify
created: 2026-10-24T23:53:14Z
norm: '1'
---

a caller's team counts only when it satisfies anchor apple generic with that leaf OU: any signer can write a team ID into a self-signed certificate, and approvals are keyed team:identifier
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@ anchor: '@file'
created: 2026-10-20T03:54:56Z
norm: '1'
sig: 27abcba5e975f16e
body_hash: 0332f4f8e9f85b02
raw_hash: 70b3ce929e7b393d
vouched: 2026-10-20T16:42:10Z
body_hash: 26df10437a933c7c
raw_hash: 54fe00bd522a68a6
vouched: 2026-10-24T23:53:17Z
lines: 1-218
---

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,9 @@ anchor: '@file'
created: 2026-10-20T16:41:34Z
norm: '1'
sig: 27abcba5e975f16e
body_hash: 0332f4f8e9f85b02
raw_hash: 70b3ce929e7b393d
body_hash: 26df10437a933c7c
raw_hash: 54fe00bd522a68a6
vouched: 2026-10-24T23:53:17Z
lines: 1-222
---

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
id: 01M5R5APQE5AZ2EGY66AMGNF9Z
anchor: '@file'
created: 2026-10-24T23:53:24Z
norm: '1'
sig: 27abcba5e975f16e
body_hash: 26df10437a933c7c
raw_hash: 54fe00bd522a68a6
lines: 1-267
---

resolution: with no app above the client and the walk reaching launchd, only a team-signed program may be the caller, since an unsigned one or a bare path can't be told apart from anything else run in the background; owner decision on #194
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
id: 01M5R5APQVAH9TB1J38JQHAV4Z
anchor: '@file'
created: 2026-10-24T23:53:24Z
norm: '1'
sig: 27abcba5e975f16e
body_hash: 26df10437a933c7c
raw_hash: 54fe00bd522a68a6
lines: 1-267
---

identify: a caller's team counts only when it satisfies anchor apple generic with that leaf OU; any signer can write a team ID into a self-signed certificate, and approvals are keyed team:identifier
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,9 @@ anchor: '@file'
created: 2026-10-20T14:14:23Z
norm: '1'
sig: 27abcba5e975f16e
body_hash: 09a9f5a325c7050c
raw_hash: b867b5dfff182a21
body_hash: 31046e4016729db1
raw_hash: 2a6aae4f9c4ecbd4
vouched: 2026-10-24T23:53:17Z
lines: 1-443
---

Expand Down
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,9 @@ Notable changes to Desktop Tools, newest first. Each release's GitHub release no
- The signed `desktop-tools-client` and `desktop-tools-mcp` check the host again before sending anything: a process at the socket path that isn't the same user's and signed by the same team gets no requests, and the call fails with `HOST_UNTRUSTED`. The Peekaboo-based client did this, and docs/embed.md still said so. ([#191](https://github.com/githubnext/desktop-tools/issues/191))
- The app and `desktop-tools-build --native2` ship Peekaboo's and AXorcist's MIT notices again, in `Licenses/`, since the Swift host contains code ported from them. docs/licenses.md describes native2. ([#192](https://github.com/githubnext/desktop-tools/issues/192))
- When `npx` runs `desktop-tools-mcp` against a Desktop Tools app of another version that has no MCP server of its own (before 0.17.0), every failed call names both versions and says to install the matching app, since updating the `npx` tag doesn't update the app. ([#179](https://github.com/githubnext/desktop-tools/issues/179))
### Changed

- An agent run as a background job, with no terminal or app above it, is asked about as the nearest program signed by a developer team that runs it, such as Claude Code, and the prompt says it's a program, not an app. When there is no such program, the refusal says so and why, instead of only that the caller couldn't be identified. A caller's team counts only when Apple issued its certificate. Setup docs note that background jobs don't load nvm. ([#194](https://github.com/githubnext/desktop-tools/issues/194))

## [0.17.1] - 2026-10-20

Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ You need macOS 15 or later. The MCP server ships inside the app.
}
```

An `npx` setup, `npx -y --package=github:githubnext/desktop-tools#v0.17.1 desktop-tools-mcp` (Node 22 or later), also works: it hands over to the app's server. One pinned before v0.17.0 is refused with `CLIENT_OUTDATED`, so update its tag. Updating the tag doesn't update the app, which holds the macOS permissions: install the app's release of the same version, since an app from before v0.17.0 has no server to hand over to, and a failed call then names both versions.
An `npx` setup, `npx -y --package=github:githubnext/desktop-tools#v0.17.1 desktop-tools-mcp` (Node 22 or later), also works: it hands over to the app's server. One pinned before v0.17.0 is refused with `CLIENT_OUTDATED`, so update its tag. Updating the tag doesn't update the app, which holds the macOS permissions: install the app's release of the same version, since an app from before v0.17.0 has no server to hand over to, and a failed call then names both versions. A background job, such as `claude --bg`, doesn't load nvm from your shell, so give `npx` by its absolute path there.

4. **Verify.** Ask the agent to call `desktop_apps`. It should list the running apps with an `inventory_completeness` of `complete`. If it says the socket is missing, open the app.

Expand Down
6 changes: 5 additions & 1 deletion docs/app.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,11 @@ Anything running as the user could use those grants, so the app asks before serv

The first time an agent calls, the app asks **Allow *name* to control this Mac?**, naming who signed it and where it runs from, with **Allow** and **Don't Allow**. Until you answer, and after Don't Allow, every request from that agent is refused with `CALLER_NOT_APPROVED`, listings and screen captures included; the agent is told to ask you, and is never left waiting. The answer is remembered in `callers.json` in the state folder (`~/.local/state/desktop-tools`), readable only by you, and the app asks about each agent once. Updating from 0.13.1 or earlier, each agent you use is asked about once.

The agent is the program that ran the MCP server, skipping `node`, `npx` and shells: Claude Code is `claude-code`, and a server run from a terminal is that terminal app. Signed programs are recognized by their signature, so an update keeps its approval; unsigned ones by their path. **Approved Agents** in the menu lists every agent with who signed it and its path: revoke an allowed one, allow one you refused or forget it so it's asked about again, or answer one still waiting.
The agent is the program that ran the MCP server, skipping `node`, `npx` and shells: Claude Code is `claude-code`, and a server run from a terminal is that terminal app. Signed programs are recognized by their signature, so an update keeps its approval; unsigned ones by their path. A team counts only when Apple issued the certificate to it.

An agent run as a background job, such as `claude --bg`, has no terminal or app above it: launchd adopted its processes. Its agent is then the nearest program above the MCP server that is signed by a developer team, such as Claude Code, and the prompt says it is a program, not an app: **Allow the program *name* to control this Mac?** Allowing it covers every agent that program runs, in a terminal or in the background, since both are the same signed program. When no app and no team-signed program runs the agent, nothing can be approved, so every request is refused with `CALLER_NOT_APPROVED` and no prompt, naming the processes it found; run the agent from a terminal app or a signed program instead.

A background job doesn't load your shell's startup files, so a version manager such as nvm doesn't put `node` on its `PATH`. Set the MCP server up with the app's `desktop-tools-mcp`, or give an `npx` or `node` setup by absolute path, such as the output of `which npx`; a bare `node` fails with `ENOENT: Executable not found in $PATH`. **Approved Agents** in the menu lists every agent with who signed it and its path: revoke an allowed one, allow one you refused or forget it so it's asked about again, or answer one still waiting.

The app's own prompts, menus and windows answer only to you. Agent input aimed at any Desktop Tools process, including the app, its client and another build of it, is refused with `SELF_TARGET_REFUSED`, and so is every agent input while one of its prompts or menus is open. Agents can still observe them.

Expand Down
8 changes: 6 additions & 2 deletions native/sources/app/app.swift
Original file line number Diff line number Diff line change
Expand Up @@ -303,13 +303,17 @@ private final class Host: NSObject, NSApplicationDelegate, NSMenuDelegate {
else { return }
waiting.removeAll { $0.key == caller.key }
waiting.append(caller)
// A command-line program, such as Claude Code, says so, so the user knows what they allow.
let isApp = CallerChain.appBundle(of: caller.path) != nil
Task {
let answer = await Confirmations.shared.ask(.init(
key: "caller \(caller.key)",
title: "Allow \(caller.name) to control this Mac?",
title: isApp
? "Allow \(caller.name) to control this Mac?"
: "Allow the program \(caller.name) to control this Mac?",
message: """
\(caller.origin)

\(isApp ? "" : "\nThis is a command-line program, not an app. Every agent it runs, in a terminal or in the background, could use Desktop Tools.\n")
It could see the screen, read the clipboard, and click, type and change apps and windows, with \
the permissions you gave Desktop Tools. You can change this under Approved Agents in the \
Desktop Tools menu.
Expand Down
13 changes: 13 additions & 0 deletions native2/Sources/DesktopCore/Callers.swift
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,19 @@ public enum CallerRefusal {
public static let unidentified =
"\(notApproved): Desktop Tools couldn't identify the program that ran its client, so nothing ran. Run the agent from a terminal or app the user can approve."

/// No app runs the agent and no program above it is signed with a team, as when a background job runs it from
/// an unsigned program: `ancestors` are the client's parents by executable name, up to launchd.
public static func detached(_ ancestors: [String]) -> String {
let chain = ancestors.isEmpty
? "its client's parent is launchd, with no app or program signed by a developer team above it"
: "its client's parent processes (\(ancestors.joined(separator: " > "))) reach launchd with no app and no "
+ "program signed by a developer team among them"
return "\(notApproved): Desktop Tools couldn't identify the program that ran its client, so nothing ran and "
+ "there is no prompt for the user to answer: \(chain), as when an agent runs as a background job from an unsigned program. Run the agent from "
+ "a terminal app such as Terminal or Ghostty, or from a signed program such as Claude Code, which Desktop "
+ "Tools can ask the user to approve."
}

public static let ownWindow =
"\(selfTarget): Desktop Tools' own menus, prompts and windows answer only to the user, so nothing was sent. Ask the user to act on them."

Expand Down
71 changes: 58 additions & 13 deletions native2/Sources/DesktopHost/Admission.swift
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,12 @@ public final class Callers: @unchecked Sendable {
/// at once and the app is asked about it once; the client never waits for the user.
func refusal(client: pid_t?) -> String? {
guard enforcing else { return nil }
guard let client, let caller = resolve(client: client)?.caller else { return CallerRefusal.unidentified }
let caller: Caller
switch client.map(resolution) ?? .unknown {
case let .caller(found, _): caller = found
case let .unsigned(names): return CallerRefusal.detached(names)
case .unknown: return CallerRefusal.unidentified
}
let (decision, ask): (Bool?, (@Sendable (Caller) -> Void)?) = lock.withLock {
let decision = decisions[caller.key]
return (decision, decision == nil && asked.insert(caller.key).inserted ? handler : nil)
Expand All @@ -57,25 +62,60 @@ public final class Callers: @unchecked Sendable {

/// The verified caller of the client `client` and its pid: the nearest ancestor that isn't a launcher or shell.
public func resolve(client: pid_t) -> (caller: Caller, pid: pid_t)? {
guard case let .caller(caller, pid) = resolution(client) else { return nil }
return (caller, pid)
}

enum Resolution {
case caller(Caller, pid_t)
/// The client's ancestors, by executable name from its parent up, reach launchd with no app and no
/// team-signed program among them, so there is nothing the user could approve.
case unsigned([String])
/// The ancestors couldn't be read, or changed while they were.
case unknown
}

/// The caller is the nearest ancestor that isn't a launcher or shell. When no app runs the agent, as when it runs
/// as a background job whose processes launchd adopted, it is the nearest such program signed with a team instead,
/// such as Claude Code, so the user can approve that program by its signature (#194).
func resolution(_ client: pid_t) -> Resolution {
var chain: [(pid: pid_t, path: String)] = []
var next = parentProcess(of: client)
while let process = next, chain.count < 32, let path = executablePath(process) {
var next = parentID(of: client)
var reachedLaunchd = false
while let process = next, chain.count < 32 {
if process <= 1 {
reachedLaunchd = process == 1
break
}
guard let path = executablePath(process) else { break }
chain.append((process, path))
next = parentProcess(of: process)
next = parentID(of: process)
}
guard let index = CallerChain.callerIndex(chain.map(\.path)) else { return nil }
let (process, path) = chain[index]
let paths = chain.map(\.path)
if paths.contains(where: { CallerChain.appBundle(of: $0) != nil }) || !reachedLaunchd {
guard let index = CallerChain.callerIndex(paths), let caller = identified(chain[index].pid, path: chain[index].path)
else { return .unknown }
return .caller(caller, chain[index].pid)
}
for (process, path) in chain where !CallerChain.launchers.contains(CallerChain.executableName(path)) {
guard let caller = identified(process, path: path) else { return .unknown }
if caller.team != nil { return .caller(caller, process) }
}
return .unsigned(paths.map(CallerChain.executableName))
}

/// `process`'s caller identity, read once per launch; nil when the pid was reused while its signature was read.
private func identified(_ process: pid_t, path: String) -> Caller? {
guard let start = Mac.generation(process) else { return nil }
let launch = "\(process) \(start)"
if let cached = lock.withLock({ resolved[launch] }) { return (cached, process) }
if let cached = lock.withLock({ resolved[launch] }) { return cached }
let caller = identify(process, path: path)
// The pid could have been reused while its signature was read.
guard Mac.generation(process) == start else { return nil }
lock.withLock {
if resolved.count >= 64 { resolved.removeAll() }
resolved[launch] = caller
}
return (caller, process)
return caller
}
}

Expand Down Expand Up @@ -167,7 +207,12 @@ private func identify(_ process: pid_t, path: String) -> Caller {
let values = information as? [String: Any]
{
identifier = values[kSecCodeInfoIdentifier as String] as? String
team = (values[kSecCodeInfoTeamIdentifier as String] as? String).flatMap { $0.isEmpty ? nil : $0 }
// A team counts only from a certificate Apple issued to it: any signer can write a team ID into a
// self-signed certificate, and approvals are keyed on it.
team = (values[kSecCodeInfoTeamIdentifier as String] as? String).flatMap {
!$0.isEmpty && $0.allSatisfy({ $0.isASCII && ($0.isLetter || $0.isNumber) })
&& satisfies(code, "anchor apple generic and certificate leaf[subject.OU] = \"\($0)\"") ? $0 : nil
}
if team != nil, let leaf = (values[kSecCodeInfoCertificates as String] as? [SecCertificate])?.first {
signer = organization(leaf)
} else if satisfies(code, "anchor apple") {
Expand Down Expand Up @@ -212,11 +257,11 @@ private func executablePath(_ process: pid_t) -> String? {
return path.isEmpty ? nil : path
}

func parentProcess(of process: pid_t) -> pid_t? {
/// The parent's pid as the kernel reports it, launchd's 1 included.
private func parentID(of process: pid_t) -> pid_t? {
var info = kinfo_proc()
var size = MemoryLayout<kinfo_proc>.stride
var mib = [CTL_KERN, KERN_PROC, KERN_PROC_PID, process]
guard sysctl(&mib, 4, &info, &size, nil, 0) == 0, size > 0 else { return nil }
let parent = info.kp_eproc.e_ppid
return parent > 1 ? parent : nil
return info.kp_eproc.e_ppid
}
8 changes: 6 additions & 2 deletions native2/Sources/desktop-tools-app/app.swift
Original file line number Diff line number Diff line change
Expand Up @@ -303,13 +303,17 @@ private final class Host: NSObject, NSApplicationDelegate, NSMenuDelegate {
else { return }
waiting.removeAll { $0.key == caller.key }
waiting.append(caller)
// A command-line program, such as Claude Code, says so, so the user knows what they allow.
let isApp = CallerChain.appBundle(of: caller.path) != nil
Task {
let answer = await Confirmations.shared.ask(.init(
key: "caller \(caller.key)",
title: "Allow \(caller.name) to control this Mac?",
title: isApp
? "Allow \(caller.name) to control this Mac?"
: "Allow the program \(caller.name) to control this Mac?",
message: """
\(caller.origin)

\(isApp ? "" : "\nThis is a command-line program, not an app. Every agent it runs, in a terminal or in the background, could use Desktop Tools.\n")
It could see the screen, read the clipboard, and click, type and change apps and windows, with \
the permissions you gave Desktop Tools. You can change this under Approved Agents in the \
Desktop Tools menu.
Expand Down
9 changes: 9 additions & 0 deletions native2/Tests/CoreTests/CallersTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -98,4 +98,13 @@ struct CallersTests {
#expect(!OwnUIGuard.isPromptOrMenu(layer: 1000, sharingState: 0, onScreen: true)) // agent cursor
#expect(!OwnUIGuard.isPromptOrMenu(layer: 8, sharingState: 1, onScreen: false))
}

@Test
func `a caller with no app or signed program above it is told why it can't be approved`() {
let message = CallerRefusal.detached(["node", "agent"])
#expect(message.hasPrefix("CALLER_NOT_APPROVED: "))
#expect(message.contains("(node > agent) reach launchd with no app and no program signed by a developer team"))
#expect(message.contains("there is no prompt for the user to answer"))
#expect(CallerRefusal.detached([]).contains("its client's parent is launchd, with no app or program signed by a developer team above it, as when"))
}
}
Loading