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
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

**#169 — `--print-tcc-path` now prints its execution context (parent process chain) + a context-dependence warning.**

- **Added** — the `--print-tcc-path` diagnostic output ends with a new "Execution context (parent process chain)" section: the binary's own pid/path marked `(this binary)`, then every ancestor up to launchd (pid 1), captured via a single `ps -A -o pid=,ppid=,comm=` snapshot walked in-memory (cycle-guarded, hop-capped at 15 — a real Claude Code session chain already spends 10 hops). A `NOTE:` warning follows, stating that the EventKit authorization status shown above reflects the CURRENT execution context (the responsible process), not an absolute property of the binary (#168) — to diagnose a specific host, run the command from within that host's environment. `ps` failure/timeout degrades to a visible `(parent chain unavailable: <reason>)` line; the rest of the output is unaffected.
- **Internals** — new `ParentChainSource` seam (`LiveParentChainSource` via `SubprocessRunner`, 500 ms budget) + pure `ParentChainWalker` (parse/walk) + `ParentChainFormatter` (display, per the #117 extraction precedent); `--help` text mentions the new section. 14 new unit tests (`ParentChainWalkTests`, `ParentChainFormatterTests`).

## [1.14.2] - 2026-07-07

**#168 — `troubleshoot-tcc` diagnostic now covers the host-app (responsible-process) TCC layer, not just the binary's own grant.**
Expand Down
166 changes: 166 additions & 0 deletions Sources/CheICalMCP/EventKit/ParentChainSource.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
import Foundation

/// Test seam for capturing the parent process chain shown by `--print-tcc-path` (#169).
///
/// Why this exists: `EKEventStore.authorizationStatus(for:)` reflects the authorization of
/// the current attribution context (the responsible process — Terminal.app, Claude Code's
/// versioned binary, VS Code, …), not an absolute property of this binary (#168). The
/// diagnostic output must therefore show *which* context the query ran under, or users
/// misread the status as universal. Per CLAUDE.md "Test Seam Convention": narrow
/// `<Domain>Source` protocol, Live impl default-wired, fake injected in tests.
protocol ParentChainSource: Sendable {
/// Capture the chain from `startPid` up toward launchd. Never throws — failure returns
/// an empty chain plus a reason so the caller can surface it without hiding the rest
/// of the diagnostic output.
func captureChain(from startPid: Int32) -> ParentChainResult
}

/// Either the walked chain, or the reason it could not be captured.
struct ParentChainResult: Sendable, Equatable {
let hops: [ParentChainWalker.ChainHop]
let failureReason: String?
}

/// Pure parse + walk logic, separated from the `ps` subprocess so the adversarial table
/// shapes (cycle, orphan ppid, oversized chain) are unit-testable without spawning anything.
enum ParentChainWalker {
/// One row of the `ps -A -o pid=,ppid=,comm=` table.
struct ProcessEntry: Sendable, Equatable {
let ppid: Int32
let command: String
}

/// One hop of the walked chain, ready for display.
struct ChainHop: Sendable, Equatable {
let pid: Int32
let command: String
}

/// Parse `ps -A -o pid=,ppid=,comm=` output into a pid → entry table. Lines that don't
/// start with two integer columns are skipped (headers, truncation artifacts). The
/// command column keeps embedded spaces (`.app` bundle paths).
static func parseProcessTable(_ psOutput: String) -> [Int32: ProcessEntry] {
var table: [Int32: ProcessEntry] = [:]
for line in psOutput.split(separator: "\n") {
let trimmed = line.trimmingCharacters(in: .whitespaces)
guard !trimmed.isEmpty else { continue }
// Split into at most 3 columns: pid, ppid, command-with-possible-spaces.
let columns = trimmed.split(separator: " ", maxSplits: 2, omittingEmptySubsequences: true)
guard columns.count == 3,
let pid = Int32(columns[0]),
let ppid = Int32(columns[1])
else { continue }
table[pid] = ProcessEntry(ppid: ppid, command: String(columns[2]))
}
return table
}

/// Walk from `startPid` toward launchd (pid 1). Termination is guaranteed by three
/// guards: a seen-set (kills cycles), a hop cap (kills oversized/corrupt chains), and
/// the unknown-pid stop (a pid missing from the table renders as `(unknown)` and ends
/// the walk — its ppid is unknowable).
static func walk(
table: [Int32: ProcessEntry],
from startPid: Int32,
maxHops: Int = 15
) -> [ChainHop] {
guard startPid > 0 else { return [] }
var hops: [ChainHop] = []
var seen: Set<Int32> = []
var pid = startPid
while hops.count < maxHops, pid > 0, !seen.contains(pid) {
seen.insert(pid)
guard let entry = table[pid] else {
hops.append(ChainHop(pid: pid, command: "(unknown)"))
break
}
hops.append(ChainHop(pid: pid, command: entry.command))
if pid == 1 { break } // launchd — chain root reached
pid = entry.ppid
}
return hops
}
}

/// Display layer for the `--print-tcc-path` execution-context block. Extracted from
/// `main.swift` per the #117 precedent (TCCStatusFormatter) so the output shape —
/// including the context-dependence warning that must survive capture failures — is
/// unit-testable without spawning the binary.
enum ParentChainFormatter {
static func executionContextSection(
selfPid: Int32,
selfPath: String,
result: ParentChainResult
) -> String {
// Every interpolated field below is ancestor- or framework-controlled (`ps` comm
// can carry ESC/C0/C1 in a hostile process path) and this string reaches an
// interactive terminal — route through the same escaper as the EventKit stderr
// paths (CWE-150/117 discipline, #37/#73/#150).
let escape = EventKitErrorSanitizer.escapeForStderr
var lines: [String] = ["Execution context (parent process chain):"]
lines.append(" \(selfPid) \(escape(selfPath)) (this binary)")
if let reason = result.failureReason {
lines.append(" (parent chain unavailable: \(escape(reason)))")
} else {
for hop in result.hops {
lines.append(" \(hop.pid) \(escape(hop.command))")
}
}
lines.append("")
lines.append("""
NOTE: the authorization status above reflects the CURRENT execution context
(the responsible process in this chain), not an absolute property of this
binary. Two different binaries under the same host see the same status; the
same binary under different hosts can see different statuses (#168).
To diagnose a specific host (Claude Code / Claude Desktop / Terminal),
run this command from within that host's environment.
""")
return lines.joined(separator: "\n")
}
}

/// Production implementation: one `ps -A` snapshot, then an in-memory walk.
///
/// Why subprocess and not `sysctl` / `libproc`: same reasoning as
/// `LiveProcessInventorySource` (#122) — `ps` is universally available, its output is
/// stable across macOS versions, and one `-A` snapshot avoids per-hop process spawns.
/// Timeout via `SubprocessRunner` mirrors the drift detector's 500ms budget (#126).
struct LiveParentChainSource: ParentChainSource {
let psPath: String
let timeoutMilliseconds: Int

init(psPath: String = "/bin/ps", timeoutMilliseconds: Int = 500) {
self.psPath = psPath
self.timeoutMilliseconds = timeoutMilliseconds
}

func captureChain(from startPid: Int32) -> ParentChainResult {
guard FileManager.default.isExecutableFile(atPath: psPath) else {
return ParentChainResult(hops: [], failureReason: "ps not at \(psPath)")
}

let process = Process()
process.executableURL = URL(fileURLWithPath: psPath)
process.arguments = ["-A", "-o", "pid=,ppid=,comm="]

let result: SubprocessRunResult
do {
result = try SubprocessRunner.run(process: process, timeoutMilliseconds: timeoutMilliseconds)
} catch {
return ParentChainResult(hops: [], failureReason: "ps spawn failed: \(error.localizedDescription)")
}
if result.timedOut {
return ParentChainResult(hops: [], failureReason: "ps timed out after \(timeoutMilliseconds)ms")
}
guard result.exitStatus == 0 else {
return ParentChainResult(hops: [], failureReason: "ps exited with status \(result.exitStatus)")
}

let output = String(data: result.stdoutData, encoding: .utf8) ?? ""
let table = ParentChainWalker.parseProcessTable(output)
return ParentChainResult(
hops: ParentChainWalker.walk(table: table, from: startPid),
failureReason: nil
)
}
}
8 changes: 5 additions & 3 deletions Sources/CheICalMCP/Version.swift
Original file line number Diff line number Diff line change
Expand Up @@ -41,9 +41,11 @@ enum AppVersion {
Run this once from Terminal before using with launchd
or other non-interactive environments.
--print-tcc-path Print binary's runtime path, bundle ID, current TCC
authorization status, and ready-to-paste tccutil/sqlite3
commands. Diagnostic helper for .mcpb-installed users
who need to locate the extracted binary for --setup.
authorization status, execution context (parent process
chain — the status is context-dependent), and ready-to-
paste tccutil/sqlite3 commands. Diagnostic helper for
.mcpb-installed users who need to locate the extracted
binary for --setup.
--self-update Check GitHub Releases for a newer binary, download
and install it at the current binary's path. Use
when an existing install needs to upgrade — wrapper
Expand Down
12 changes: 12 additions & 0 deletions Sources/CheICalMCP/main.swift
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,18 @@ if CommandLine.arguments.contains("--print-tcc-path") {
Additional signing info:
codesign -dv "\(absolute)"
""")

// #169: the EventKit status above is context-dependent (attribution follows the
// responsible process, not the binary — #168). Show the parent chain so users can
// see WHICH context this query ran under, plus the warning that stops them from
// reading a Terminal-context status as a Claude-Desktop verdict. Capture failure
// degrades to a visible "(parent chain unavailable: …)" line — never silent.
let chainResult = LiveParentChainSource().captureChain(from: getppid())
print("")
print(ParentChainFormatter.executionContextSection(
selfPid: ProcessInfo.processInfo.processIdentifier,
selfPath: absolute,
result: chainResult))
exit(0)
}

Expand Down
80 changes: 80 additions & 0 deletions Tests/CheICalMCPTests/ParentChainFormatterTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import XCTest
@testable import CheICalMCP

/// Coverage for `ParentChainFormatter.executionContextSection` (#169) — the display layer
/// of the `--print-tcc-path` execution-context block. Extracted from `main.swift` per the
/// #117 precedent (TCCStatusFormatter) so output shape is unit-testable without spawning
/// the binary. The context-dependence warning is the load-bearing line: it must survive
/// every variant (normal chain, capture failure), because it is what stops users from
/// reading a Terminal-context status as a Claude-Desktop verdict (#168).
final class ParentChainFormatterTests: XCTestCase {

private let selfPath = "/Users/u/bin/CheICalMCP"

func testNormalChain_rendersSelfMarkerAndAllHops() {
let result = ParentChainResult(
hops: [
.init(pid: 400, command: "/bin/zsh"),
.init(pid: 1, command: "/sbin/launchd"),
],
failureReason: nil
)
let s = ParentChainFormatter.executionContextSection(selfPid: 500, selfPath: selfPath, result: result)
XCTAssertTrue(s.contains("Execution context (parent process chain):"))
XCTAssertTrue(s.contains("500 \(selfPath) (this binary)"))
XCTAssertTrue(s.contains("400 /bin/zsh"))
XCTAssertTrue(s.contains("1 /sbin/launchd"))
}

func testFailure_rendersUnavailableReasonInsteadOfHops() {
let result = ParentChainResult(hops: [], failureReason: "ps timed out after 500ms")
let s = ParentChainFormatter.executionContextSection(selfPid: 500, selfPath: selfPath, result: result)
XCTAssertTrue(s.contains("(parent chain unavailable: ps timed out after 500ms)"))
XCTAssertTrue(s.contains("(this binary)"), "self line comes from local state, not ps — must render even when ps fails")
}

func testWarningLine_presentInBothVariants() {
let ok = ParentChainFormatter.executionContextSection(
selfPid: 500, selfPath: selfPath,
result: ParentChainResult(hops: [.init(pid: 1, command: "/sbin/launchd")], failureReason: nil))
let failed = ParentChainFormatter.executionContextSection(
selfPid: 500, selfPath: selfPath,
result: ParentChainResult(hops: [], failureReason: "ps not at /bin/ps"))
for s in [ok, failed] {
XCTAssertTrue(s.contains("NOTE: the authorization status above reflects the CURRENT execution context"))
XCTAssertTrue(s.contains("run this command from within that host's environment"))
}
}

// MARK: - Control-char sanitization (verify #169 finding: ps `comm` is
// ancestor-controlled; raw ESC reaching an interactive terminal = CWE-150.
// Same escapeForStderr discipline as the EventKit stderr paths, #37/#73/#150.)

func testHopCommandWithEscapeSequence_isNeutralized() {
let result = ParentChainResult(
hops: [.init(pid: 400, command: "/tmp/\u{1B}[2J\u{1B}[H.app/x")],
failureReason: nil
)
let s = ParentChainFormatter.executionContextSection(selfPid: 500, selfPath: selfPath, result: result)
XCTAssertFalse(s.unicodeScalars.contains { $0.value == 0x1B }, "raw ESC must never reach stdout")
XCTAssertTrue(s.contains("\\x1b[2J"), "control chars render as visible escapes, not terminal effects")
}

func testSelfPathAndFailureReasonWithControlChars_areNeutralized() {
let s = ParentChainFormatter.executionContextSection(
selfPid: 500, selfPath: "/Users/u/\u{1B}]52;c;evil\u{07}/CheICalMCP",
result: ParentChainResult(hops: [], failureReason: "ps died\u{0D}FAKE: all good"))
XCTAssertFalse(s.unicodeScalars.contains { $0.value == 0x1B || $0.value == 0x07 || $0.value == 0x0D },
"ESC/BEL/CR must be escaped in every interpolated field")
XCTAssertTrue(s.contains("\\r"), "CR renders visibly so forged lines can't split")
}

func testEmptyChainWithoutFailure_stillRendersSelfAndWarning() {
// ps succeeded but the table somehow lacked our ppid — degenerate but legal.
let s = ParentChainFormatter.executionContextSection(
selfPid: 500, selfPath: selfPath,
result: ParentChainResult(hops: [], failureReason: nil))
XCTAssertTrue(s.contains("(this binary)"))
XCTAssertTrue(s.contains("NOTE: the authorization status above reflects the CURRENT execution context"))
}
}
Loading
Loading