diff --git a/.lane/attic/native2/Sources/DesktopHost/Admission.swift/01M5R5AD0RY5P85NSXXSKN98Q9-with-no-app-above-the-client.md b/.lane/attic/native2/Sources/DesktopHost/Admission.swift/01M5R5AD0RY5P85NSXXSKN98Q9-with-no-app-above-the-client.md new file mode 100644 index 00000000..7e19beff --- /dev/null +++ b/.lane/attic/native2/Sources/DesktopHost/Admission.swift/01M5R5AD0RY5P85NSXXSKN98Q9-with-no-app-above-the-client.md @@ -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 diff --git a/.lane/attic/native2/Sources/DesktopHost/Admission.swift/01M5R5AD11Q3YCWA4SGPVZSVWR-a-caller-s-team-counts-only.md b/.lane/attic/native2/Sources/DesktopHost/Admission.swift/01M5R5AD11Q3YCWA4SGPVZSVWR-a-caller-s-team-counts-only.md new file mode 100644 index 00000000..436a5646 --- /dev/null +++ b/.lane/attic/native2/Sources/DesktopHost/Admission.swift/01M5R5AD11Q3YCWA4SGPVZSVWR-a-caller-s-team-counts-only.md @@ -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 diff --git a/.lane/memory/native2/Sources/DesktopHost/Admission.swift/01M5BQ5C1B40AGV6A4BCHRPSNJ-caller-refusals-come-back-as.md b/.lane/memory/native2/Sources/DesktopHost/Admission.swift/01M5BQ5C1B40AGV6A4BCHRPSNJ-caller-refusals-come-back-as.md index fc310d36..5de96944 100644 --- a/.lane/memory/native2/Sources/DesktopHost/Admission.swift/01M5BQ5C1B40AGV6A4BCHRPSNJ-caller-refusals-come-back-as.md +++ b/.lane/memory/native2/Sources/DesktopHost/Admission.swift/01M5BQ5C1B40AGV6A4BCHRPSNJ-caller-refusals-come-back-as.md @@ -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 --- diff --git a/.lane/memory/native2/Sources/DesktopHost/Admission.swift/01M5D313R0FKBNZHYZ2F6BVRNC-the-app-s-prompt-goes-up-asy.md b/.lane/memory/native2/Sources/DesktopHost/Admission.swift/01M5D313R0FKBNZHYZ2F6BVRNC-the-app-s-prompt-goes-up-asy.md index e3b0a298..7bbefef2 100644 --- a/.lane/memory/native2/Sources/DesktopHost/Admission.swift/01M5D313R0FKBNZHYZ2F6BVRNC-the-app-s-prompt-goes-up-asy.md +++ b/.lane/memory/native2/Sources/DesktopHost/Admission.swift/01M5D313R0FKBNZHYZ2F6BVRNC-the-app-s-prompt-goes-up-asy.md @@ -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 --- diff --git a/.lane/memory/native2/Sources/DesktopHost/Admission.swift/01M5R5APQE5AZ2EGY66AMGNF9Z-resolution-with-no-app-above.md b/.lane/memory/native2/Sources/DesktopHost/Admission.swift/01M5R5APQE5AZ2EGY66AMGNF9Z-resolution-with-no-app-above.md new file mode 100644 index 00000000..13dae9bc --- /dev/null +++ b/.lane/memory/native2/Sources/DesktopHost/Admission.swift/01M5R5APQE5AZ2EGY66AMGNF9Z-resolution-with-no-app-above.md @@ -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 diff --git a/.lane/memory/native2/Sources/DesktopHost/Admission.swift/01M5R5APQVAH9TB1J38JQHAV4Z-identify-a-caller-s-team-cou.md b/.lane/memory/native2/Sources/DesktopHost/Admission.swift/01M5R5APQVAH9TB1J38JQHAV4Z-identify-a-caller-s-team-cou.md new file mode 100644 index 00000000..b390fbff --- /dev/null +++ b/.lane/memory/native2/Sources/DesktopHost/Admission.swift/01M5R5APQVAH9TB1J38JQHAV4Z-identify-a-caller-s-team-cou.md @@ -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 diff --git a/.lane/memory/native2/Sources/desktop-tools-app/app.swift/01M5CTKMFFZQRP2ST6W4AZXCTC-a-copy-of-native-sources-app.md b/.lane/memory/native2/Sources/desktop-tools-app/app.swift/01M5CTKMFFZQRP2ST6W4AZXCTC-a-copy-of-native-sources-app.md index 65e80224..87d5f025 100644 --- a/.lane/memory/native2/Sources/desktop-tools-app/app.swift/01M5CTKMFFZQRP2ST6W4AZXCTC-a-copy-of-native-sources-app.md +++ b/.lane/memory/native2/Sources/desktop-tools-app/app.swift/01M5CTKMFFZQRP2ST6W4AZXCTC-a-copy-of-native-sources-app.md @@ -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 --- diff --git a/CHANGELOG.md b/CHANGELOG.md index 95268f40..80cbc32a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index cc45e99f..553a1567 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/docs/app.md b/docs/app.md index b27782c2..80ab95c1 100644 --- a/docs/app.md +++ b/docs/app.md @@ -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. diff --git a/native/sources/app/app.swift b/native/sources/app/app.swift index 51681bd8..be085aca 100644 --- a/native/sources/app/app.swift +++ b/native/sources/app/app.swift @@ -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. diff --git a/native2/Sources/DesktopCore/Callers.swift b/native2/Sources/DesktopCore/Callers.swift index 9737471a..ee3f25fa 100644 --- a/native2/Sources/DesktopCore/Callers.swift +++ b/native2/Sources/DesktopCore/Callers.swift @@ -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." diff --git a/native2/Sources/DesktopHost/Admission.swift b/native2/Sources/DesktopHost/Admission.swift index ec27bc91..b1f26a94 100644 --- a/native2/Sources/DesktopHost/Admission.swift +++ b/native2/Sources/DesktopHost/Admission.swift @@ -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) @@ -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 } } @@ -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") { @@ -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.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 } diff --git a/native2/Sources/desktop-tools-app/app.swift b/native2/Sources/desktop-tools-app/app.swift index 51681bd8..be085aca 100644 --- a/native2/Sources/desktop-tools-app/app.swift +++ b/native2/Sources/desktop-tools-app/app.swift @@ -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. diff --git a/native2/Tests/CoreTests/CallersTests.swift b/native2/Tests/CoreTests/CallersTests.swift index 36ef4560..1fca2e70 100644 --- a/native2/Tests/CoreTests/CallersTests.swift +++ b/native2/Tests/CoreTests/CallersTests.swift @@ -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")) + } }