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

## [Unreleased]

**#173 — parent-chain diagnostics polish (follow-up bundle from the #169 verify).**

- **Changed** — the `--print-tcc-path` parent-chain walk now makes every early stop visible: hop-cap and cycle stops append a synthetic marker hop (`(chain truncated after 15 hops)` / `(cycle detected)`, carrying the pid the walk stopped at) instead of ending silently; `ps` rows with an empty `comm` keep their pid→ppid linkage as `(unknown)` instead of severing the chain one hop early; `ps` runs with `-ww` so long bundle paths are never width-clamped; non-UTF-8 `ps` output and non-zero exits now surface as `(parent chain unavailable: …)` reasons (with stderr's first line attached) instead of a silently empty chain. The NOTE wording no longer equates the parent chain with macOS's responsible process (it is an approximation), and Claude Desktop users are routed to the sqlite3 TCC query (this shell-invoked chain can never show the Desktop MCP context). 9 new tests including real-subprocess fixture tests for the decode/exit failure paths (`LiveParentChainSourceTests`).

**#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.
Expand Down
61 changes: 50 additions & 11 deletions Sources/CheICalMCP/EventKit/ParentChainSource.swift
Original file line number Diff line number Diff line change
Expand Up @@ -46,19 +46,25 @@ enum ParentChainWalker {
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,
guard columns.count >= 2,
let pid = Int32(columns[0]),
let ppid = Int32(columns[1])
else { continue }
table[pid] = ProcessEntry(ppid: ppid, command: String(columns[2]))
// Empty comm (#173): keep the row with a visible placeholder — dropping it
// would sever the ppid linkage and end the walk one hop early.
let command = columns.count == 3 ? String(columns[2]) : "(unknown)"
table[pid] = ProcessEntry(ppid: ppid, command: command)
}
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).
/// the walk — its ppid is unknowable). Cycle and hop-cap stops append a synthetic
/// marker hop (#173) — a silently-ended chain reads as complete, which for a
/// diagnostic that exists to show the real context is misinformation. The marker's
/// pid is the pid the walk stopped at, so a triager can resume by hand.
static func walk(
table: [Int32: ProcessEntry],
from startPid: Int32,
Expand All @@ -68,7 +74,20 @@ enum ParentChainWalker {
var hops: [ChainHop] = []
var seen: Set<Int32> = []
var pid = startPid
while hops.count < maxHops, pid > 0, !seen.contains(pid) {
while pid > 0 {
if seen.contains(pid) {
hops.append(ChainHop(pid: pid, command: "(cycle detected)"))
break
}
// The terminal sentinel (pid 1, launchd) is exempt from the cap: it ends the
// walk unconditionally one line below, so admitting it costs at most one
// entry — while marking an already-rooted chain "truncated" would be the
// mirror image of the misinformation this marker exists to eliminate
// (#173 verify LOW-1, DA probe-confirmed).
if hops.count >= maxHops, pid != 1 {
hops.append(ChainHop(pid: pid, command: "(chain truncated after \(maxHops) hops)"))
break
}
seen.insert(pid)
guard let entry = table[pid] else {
hops.append(ChainHop(pid: pid, command: "(unknown)"))
Expand Down Expand Up @@ -107,13 +126,21 @@ enum ParentChainFormatter {
}
}
lines.append("")
// Wording precision (#173): the responsible process is assigned at spawn time and
// is NOT guaranteed to sit on the getppid chain — describe the chain as an
// approximation. And since this flag is shell-invoked, the chain can never show
// the Claude Desktop MCP context; route Desktop diagnosis to the sqlite3 query
// printed earlier in the same output.
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),
(the execution context this query ran under, approximated by the parent chain
above), 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 / Terminal),
run this command from within that host's environment.
For Claude Desktop, this shell-invoked chain cannot show its MCP context —
inspect the TCC database directly instead (sqlite3 command above).
""")
return lines.joined(separator: "\n")
}
Expand Down Expand Up @@ -141,7 +168,10 @@ struct LiveParentChainSource: ParentChainSource {

let process = Process()
process.executableURL = URL(fileURLWithPath: psPath)
process.arguments = ["-A", "-o", "pid=,ppid=,comm="]
// `-ww` (#173): lift the output-width clamp so long bundle paths are never
// truncated by ps itself (BSD ps applies the clamp even without a tty in
// some configurations; the flag is a no-op when already unclamped).
process.arguments = ["-Aww", "-o", "pid=,ppid=,comm="]

let result: SubprocessRunResult
do {
Expand All @@ -153,10 +183,19 @@ struct LiveParentChainSource: ParentChainSource {
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)")
// Attach stderr's first line (#173) — it is the actionable part of a ps
// failure. Rendering escapes it (ParentChainFormatter), so raw bytes are safe.
let stderrFirstLine = String(data: result.stderrData, encoding: .utf8)?
.split(separator: "\n").first.map(String.init) ?? ""
let suffix = stderrFirstLine.isEmpty ? "" : ": \(stderrFirstLine)"
return ParentChainResult(hops: [], failureReason: "ps exited with status \(result.exitStatus)\(suffix)")
}

let output = String(data: result.stdoutData, encoding: .utf8) ?? ""
// Non-UTF-8 output (#173): report it — a silent empty table would render as a
// "successful" one-hop chain (mirrors ProcessInventorySource's decode handling).
guard let output = String(data: result.stdoutData, encoding: .utf8) else {
return ParentChainResult(hops: [], failureReason: "ps output not UTF-8")
}
let table = ParentChainWalker.parseProcessTable(output)
return ParentChainResult(
hops: ParentChainWalker.walk(table: table, from: startPid),
Expand Down
56 changes: 56 additions & 0 deletions Tests/CheICalMCPTests/LiveParentChainSourceTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import XCTest
@testable import CheICalMCP

/// Behavior tests for `LiveParentChainSource`'s failure paths (#173, logic F3/F4).
/// The `psPath` init parameter accepts any executable, so each test points it at a
/// small fixture script — real subprocess behavior, no mocks.
final class LiveParentChainSourceTests: XCTestCase {

private var fixtures: [URL] = []

override func tearDown() {
for url in fixtures { try? FileManager.default.removeItem(at: url) }
fixtures = []
super.tearDown()
}

/// Write an executable shell script and return its path.
private func makeScript(_ body: String) throws -> String {
let url = FileManager.default.temporaryDirectory
.appendingPathComponent("fake-ps-\(UUID().uuidString).sh")
try ("#!/bin/sh\n" + body + "\n").write(to: url, atomically: true, encoding: .utf8)
try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: url.path)
fixtures.append(url)
return url.path
}

func testNonUTF8Output_reportsFailureReasonInsteadOfEmptyChain() throws {
// printf emits an invalid UTF-8 byte sequence (lone 0xFF continuation bytes).
let script = try makeScript(#"printf ' 500 400 /bin/\377\377zsh\n'"#)
let result = LiveParentChainSource(psPath: script).captureChain(from: 500)
XCTAssertTrue(result.hops.isEmpty)
XCTAssertEqual(result.failureReason, "ps output not UTF-8",
"silent empty-table success would misreport a decode failure as a clean run")
}

func testNonZeroExit_reportsStatusAndStderrFirstLine() throws {
let script = try makeScript("echo 'ps: illegal option' >&2; exit 64")
let result = LiveParentChainSource(psPath: script).captureChain(from: 500)
XCTAssertTrue(result.hops.isEmpty)
let reason = try XCTUnwrap(result.failureReason)
XCTAssertTrue(reason.contains("64"), "exit status must be visible — got: \(reason)")
XCTAssertTrue(reason.contains("ps: illegal option"),
"stderr first line is the actionable part of a ps failure — got: \(reason)")
}

func testHappyPath_stillWalksNormally() throws {
let script = try makeScript("""
printf ' 500 400 /bin/zsh\\n'
printf ' 400 1 /sbin/launchd-ish\\n'
printf ' 1 0 /sbin/launchd\\n'
""")
let result = LiveParentChainSource(psPath: script).captureChain(from: 500)
XCTAssertNil(result.failureReason)
XCTAssertEqual(result.hops.map(\.pid), [500, 400, 1])
}
}
24 changes: 24 additions & 0 deletions Tests/CheICalMCPTests/ParentChainFormatterTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,30 @@ final class ParentChainFormatterTests: XCTestCase {
XCTAssertTrue(s.contains("\\r"), "CR renders visibly so forged lines can't split")
}

// MARK: - NOTE wording precision (#173, DA-2/DA-3)

func testNote_usesApproximationWording_notResponsibleProcessClaim() {
// macOS's responsible process is not guaranteed to sit on the getppid chain —
// the NOTE must not flatten "parent chain" into "responsible process" (#168's
// own nuance). It describes the chain as an approximation instead.
let s = ParentChainFormatter.executionContextSection(
selfPid: 500, selfPath: selfPath,
result: ParentChainResult(hops: [.init(pid: 1, command: "/sbin/launchd")], failureReason: nil))
XCTAssertFalse(s.contains("the responsible process in this chain"))
XCTAssertTrue(s.contains("approximated by the parent chain"))
}

func testNote_pointsClaudeDesktopUsersAtTccDbQuery() {
// This command is shell-invoked, so the printed chain can never show the Claude
// Desktop MCP context (DA-2) — the NOTE must route Desktop diagnosis to the
// sqlite3 TCC query printed earlier in the same output.
let s = ParentChainFormatter.executionContextSection(
selfPid: 500, selfPath: selfPath,
result: ParentChainResult(hops: [], failureReason: nil))
XCTAssertTrue(s.contains("Claude Desktop"))
XCTAssertTrue(s.contains("TCC database"))
}

func testEmptyChainWithoutFailure_stillRendersSelfAndWarning() {
// ps succeeded but the table somehow lacked our ppid — degenerate but legal.
let s = ParentChainFormatter.executionContextSection(
Expand Down
76 changes: 72 additions & 4 deletions Tests/CheICalMCPTests/ParentChainWalkTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -60,13 +60,16 @@ final class ParentChainWalkTests: XCTestCase {
])
}

func testWalk_cycle_terminatesWithoutRepeat() {
func testWalk_cycle_terminatesWithVisibleMarker() {
// #173: cycles must stop AND say so — a silently-ended chain reads as complete,
// which for a diagnostic that exists to show the real context is misinformation.
let table: [Int32: ParentChainWalker.ProcessEntry] = [
500: .init(ppid: 400, command: "/a"),
400: .init(ppid: 500, command: "/b"),
]
let chain = ParentChainWalker.walk(table: table, from: 500)
XCTAssertEqual(chain.map(\.pid), [500, 400])
XCTAssertEqual(chain.map(\.pid), [500, 400, 500])
XCTAssertEqual(chain.last?.command, "(cycle detected)")
}

func testWalk_orphanPpid_emitsUnknownHopAndStops() {
Expand All @@ -85,22 +88,87 @@ final class ParentChainWalkTests: XCTestCase {
XCTAssertEqual(chain, [ParentChainWalker.ChainHop(pid: 500, command: "(unknown)")])
}

func testWalk_hopCap_boundsOversizedChain() {
func testWalk_hopCap_boundsOversizedChainWithVisibleMarker() {
// 20-deep linear chain 500 → 501 → … ; cap at default 15 hops. The default must
// exceed a real Claude Code session's depth — an observed chain (swift → zsh →
// claude bg×2 → claude → login shell → login → Ghostty → launchd) already spends
// exactly 10 hops, so 10 would truncate the host on any deeper nesting (tmux etc.).
// #173: truncation must be visible — 15 real hops + 1 marker entry at the cut point.
var table: [Int32: ParentChainWalker.ProcessEntry] = [:]
for i in Int32(500)..<Int32(520) {
table[i] = .init(ppid: i + 1, command: "/p\(i)")
}
let chain = ParentChainWalker.walk(table: table, from: 500)
XCTAssertEqual(chain.count, 15)
XCTAssertEqual(chain.count, 16)
XCTAssertEqual(chain.first?.pid, 500)
XCTAssertEqual(chain.last?.pid, 515, "marker carries the pid the walk stopped at")
XCTAssertEqual(chain.last?.command, "(chain truncated after 15 hops)")
}

func testWalk_chainExactlyCapPlusLaunchd_showsLaunchdNotTruncationMarker() {
// #173 verify LOW-1 (DA probe-confirmed): a chain whose (maxHops+1)-th node IS
// launchd has reached its root — marking it "truncated" is the mirror image of
// the silent-truncation misinformation this issue exists to eliminate. The cap
// exempts the terminal sentinel (pid 1), which unconditionally ends the walk.
var table: [Int32: ParentChainWalker.ProcessEntry] = [:]
for i in Int32(500)..<Int32(514) {
table[i] = .init(ppid: i + 1, command: "/p\(i)")
}
table[514] = .init(ppid: 1, command: "/p514")
table[1] = .init(ppid: 0, command: "/sbin/launchd")
let chain = ParentChainWalker.walk(table: table, from: 500)
XCTAssertEqual(chain.count, 16)
XCTAssertEqual(chain.last, ParentChainWalker.ChainHop(pid: 1, command: "/sbin/launchd"))
XCTAssertFalse(chain.contains { $0.command.hasPrefix("(chain truncated") })
}

func testWalk_chainEndingWithinCap_hasNoTruncationMarker() {
// Complete chains stay marker-free — the marker only appears when information
// was actually cut, otherwise it would train users to ignore it.
let table: [Int32: ParentChainWalker.ProcessEntry] = [
500: .init(ppid: 1, command: "/bin/zsh"),
1: .init(ppid: 0, command: "/sbin/launchd"),
]
let chain = ParentChainWalker.walk(table: table, from: 500)
XCTAssertEqual(chain.count, 2)
XCTAssertFalse(chain.contains { $0.command.hasPrefix("(chain truncated") || $0.command == "(cycle detected)" })
}

func testWalk_nonPositiveStartPid_returnsEmpty() {
XCTAssertTrue(ParentChainWalker.walk(table: [:], from: 0).isEmpty)
XCTAssertTrue(ParentChainWalker.walk(table: [:], from: -1).isEmpty)
}

func testWalk_ppidZeroBeforeLaunchd_stopsCleanly() {
// #173 (logic F5 gap): a chain whose entry points at ppid 0 without passing
// through pid 1 must stop without marker (pid 0 is the legitimate root sentinel).
let table: [Int32: ParentChainWalker.ProcessEntry] = [
500: .init(ppid: 0, command: "/odd/root")
]
let chain = ParentChainWalker.walk(table: table, from: 500)
XCTAssertEqual(chain, [ParentChainWalker.ChainHop(pid: 500, command: "/odd/root")])
}

func testWalk_startAtLaunchd_returnsSingleHop() {
// #173 (logic F5 gap): degenerate but legal start point.
let table: [Int32: ParentChainWalker.ProcessEntry] = [
1: .init(ppid: 0, command: "/sbin/launchd")
]
let chain = ParentChainWalker.walk(table: table, from: 1)
XCTAssertEqual(chain, [ParentChainWalker.ChainHop(pid: 1, command: "/sbin/launchd")])
}

// MARK: - empty-comm rows (#173, logic F2)

func testParse_emptyCommRow_isKeptWithUnknownCommand() {
// A pid/ppid pair whose comm column is empty must keep its ppid linkage —
// dropping the whole row severs the chain one hop early at "(unknown)".
let output = """
500 400
400 1 /bin/zsh
"""
let table = ParentChainWalker.parseProcessTable(output)
XCTAssertEqual(table[500], ParentChainWalker.ProcessEntry(ppid: 400, command: "(unknown)"))
XCTAssertEqual(table[400]?.command, "/bin/zsh")
}
}
Loading