From ef991dd1b1be78edcab3e1009854a08109088268 Mon Sep 17 00:00:00 2001 From: che cheng Date: Fri, 10 Jul 2026 08:33:10 +0800 Subject: [PATCH 1/4] feat: add ParentChainSource seam + pure walk logic for --print-tcc-path execution context (#169) --- .../EventKit/ParentChainSource.swift | 129 ++++++++++++++++++ .../ParentChainWalkTests.swift | 103 ++++++++++++++ 2 files changed, 232 insertions(+) create mode 100644 Sources/CheICalMCP/EventKit/ParentChainSource.swift create mode 100644 Tests/CheICalMCPTests/ParentChainWalkTests.swift diff --git a/Sources/CheICalMCP/EventKit/ParentChainSource.swift b/Sources/CheICalMCP/EventKit/ParentChainSource.swift new file mode 100644 index 0000000..185bced --- /dev/null +++ b/Sources/CheICalMCP/EventKit/ParentChainSource.swift @@ -0,0 +1,129 @@ +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 +/// `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 = 10 + ) -> [ChainHop] { + guard startPid > 0 else { return [] } + var hops: [ChainHop] = [] + var seen: Set = [] + 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 + } +} + +/// 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 + ) + } +} diff --git a/Tests/CheICalMCPTests/ParentChainWalkTests.swift b/Tests/CheICalMCPTests/ParentChainWalkTests.swift new file mode 100644 index 0000000..b8b6e80 --- /dev/null +++ b/Tests/CheICalMCPTests/ParentChainWalkTests.swift @@ -0,0 +1,103 @@ +import XCTest +@testable import CheICalMCP + +/// Pure-unit coverage for `ParentChainWalker` (#169) — the `ps` table parser + parent-chain +/// walk behind the `--print-tcc-path` "Execution context" section. The walk must terminate +/// on every adversarial table shape (cycle, orphan ppid, oversized chain) because it runs +/// inside a diagnostic command users reach for precisely when their system is misbehaving. +final class ParentChainWalkTests: XCTestCase { + + // MARK: - parseProcessTable + + func testParse_normalLines_buildsTable() { + let output = """ + 1 0 /sbin/launchd + 500 400 /bin/zsh + 400 1 /System/Applications/Utilities/Terminal.app/Contents/MacOS/Terminal + """ + let table = ParentChainWalker.parseProcessTable(output) + XCTAssertEqual(table.count, 3) + XCTAssertEqual(table[500], ParentChainWalker.ProcessEntry(ppid: 400, command: "/bin/zsh")) + XCTAssertEqual(table[1]?.ppid, 0) + XCTAssertEqual(table[400]?.command, "/System/Applications/Utilities/Terminal.app/Contents/MacOS/Terminal") + } + + func testParse_pathWithSpaces_keepsFullCommand() { + let output = " 400 1 /Applications/My Helper.app/Contents/MacOS/My Helper" + let table = ParentChainWalker.parseProcessTable(output) + XCTAssertEqual(table[400]?.command, "/Applications/My Helper.app/Contents/MacOS/My Helper") + } + + func testParse_malformedLines_areSkipped() { + let output = """ + garbage line without numbers + 500 400 /bin/zsh + abc def /not/numeric + 42 + """ + let table = ParentChainWalker.parseProcessTable(output) + XCTAssertEqual(table.count, 1) + XCTAssertNotNil(table[500]) + } + + func testParse_emptyOutput_yieldsEmptyTable() { + XCTAssertTrue(ParentChainWalker.parseProcessTable("").isEmpty) + } + + // MARK: - walk + + func testWalk_normalChain_reachesLaunchdAndStops() { + let table: [Int32: ParentChainWalker.ProcessEntry] = [ + 500: .init(ppid: 400, command: "/bin/zsh"), + 400: .init(ppid: 1, command: "/System/Applications/Utilities/Terminal.app/Contents/MacOS/Terminal"), + 1: .init(ppid: 0, command: "/sbin/launchd"), + ] + let chain = ParentChainWalker.walk(table: table, from: 500) + XCTAssertEqual(chain, [ + ParentChainWalker.ChainHop(pid: 500, command: "/bin/zsh"), + ParentChainWalker.ChainHop(pid: 400, command: "/System/Applications/Utilities/Terminal.app/Contents/MacOS/Terminal"), + ParentChainWalker.ChainHop(pid: 1, command: "/sbin/launchd"), + ]) + } + + func testWalk_cycle_terminatesWithoutRepeat() { + 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]) + } + + func testWalk_orphanPpid_emitsUnknownHopAndStops() { + let table: [Int32: ParentChainWalker.ProcessEntry] = [ + 500: .init(ppid: 999, command: "/bin/zsh") + ] + let chain = ParentChainWalker.walk(table: table, from: 500) + XCTAssertEqual(chain, [ + ParentChainWalker.ChainHop(pid: 500, command: "/bin/zsh"), + ParentChainWalker.ChainHop(pid: 999, command: "(unknown)"), + ]) + } + + func testWalk_startPidMissingFromTable_emitsSingleUnknownHop() { + let chain = ParentChainWalker.walk(table: [:], from: 500) + XCTAssertEqual(chain, [ParentChainWalker.ChainHop(pid: 500, command: "(unknown)")]) + } + + func testWalk_hopCap_boundsOversizedChain() { + // 20-deep linear chain 500 → 501 → … ; cap at default 10 hops. + var table: [Int32: ParentChainWalker.ProcessEntry] = [:] + for i in Int32(500).. Date: Fri, 10 Jul 2026 08:37:55 +0800 Subject: [PATCH 2/4] feat: print execution context (parent chain) + context-dependence warning in --print-tcc-path (#169) --- .../EventKit/ParentChainSource.swift | 34 ++++++++++- Sources/CheICalMCP/Version.swift | 8 ++- Sources/CheICalMCP/main.swift | 12 ++++ .../ParentChainFormatterTests.swift | 57 +++++++++++++++++++ .../ParentChainWalkTests.swift | 7 ++- 5 files changed, 112 insertions(+), 6 deletions(-) create mode 100644 Tests/CheICalMCPTests/ParentChainFormatterTests.swift diff --git a/Sources/CheICalMCP/EventKit/ParentChainSource.swift b/Sources/CheICalMCP/EventKit/ParentChainSource.swift index 185bced..29fcaed 100644 --- a/Sources/CheICalMCP/EventKit/ParentChainSource.swift +++ b/Sources/CheICalMCP/EventKit/ParentChainSource.swift @@ -62,7 +62,7 @@ enum ParentChainWalker { static func walk( table: [Int32: ProcessEntry], from startPid: Int32, - maxHops: Int = 10 + maxHops: Int = 15 ) -> [ChainHop] { guard startPid > 0 else { return [] } var hops: [ChainHop] = [] @@ -82,6 +82,38 @@ enum ParentChainWalker { } } +/// 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 { + var lines: [String] = ["Execution context (parent process chain):"] + lines.append(" \(selfPid) \(selfPath) (this binary)") + if let reason = result.failureReason { + lines.append(" (parent chain unavailable: \(reason))") + } else { + for hop in result.hops { + lines.append(" \(hop.pid) \(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 diff --git a/Sources/CheICalMCP/Version.swift b/Sources/CheICalMCP/Version.swift index 6d8062f..3fd21a1 100644 --- a/Sources/CheICalMCP/Version.swift +++ b/Sources/CheICalMCP/Version.swift @@ -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 diff --git a/Sources/CheICalMCP/main.swift b/Sources/CheICalMCP/main.swift index 6d03641..af3cd35 100644 --- a/Sources/CheICalMCP/main.swift +++ b/Sources/CheICalMCP/main.swift @@ -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) } diff --git a/Tests/CheICalMCPTests/ParentChainFormatterTests.swift b/Tests/CheICalMCPTests/ParentChainFormatterTests.swift new file mode 100644 index 0000000..e750e00 --- /dev/null +++ b/Tests/CheICalMCPTests/ParentChainFormatterTests.swift @@ -0,0 +1,57 @@ +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")) + } + } + + 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")) + } +} diff --git a/Tests/CheICalMCPTests/ParentChainWalkTests.swift b/Tests/CheICalMCPTests/ParentChainWalkTests.swift index b8b6e80..0086c11 100644 --- a/Tests/CheICalMCPTests/ParentChainWalkTests.swift +++ b/Tests/CheICalMCPTests/ParentChainWalkTests.swift @@ -86,13 +86,16 @@ final class ParentChainWalkTests: XCTestCase { } func testWalk_hopCap_boundsOversizedChain() { - // 20-deep linear chain 500 → 501 → … ; cap at default 10 hops. + // 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.). var table: [Int32: ParentChainWalker.ProcessEntry] = [:] for i in Int32(500).. Date: Fri, 10 Jul 2026 08:44:10 +0800 Subject: [PATCH 3/4] docs: changelog entry for --print-tcc-path execution context section (#169) --- CHANGELOG.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 40ecd0b..d8acfe1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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: )` 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.** From 37e9e32a24b2d2401ac3237cfe43c53833944a70 Mon Sep 17 00:00:00 2001 From: che cheng Date: Fri, 10 Jul 2026 08:57:34 +0800 Subject: [PATCH 4/4] fix: sanitize parent-chain output fields through escapeForStderr (#169) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Verify finding (security lens + Codex cross-model, DA-confirmed real): ps comm is ancestor-controlled and reached stdout unescaped — CWE-150 terminal escape injection, bypassing the repo's own escapeForStderr discipline (#37/#73/#150). All three interpolated fields (hop.command, selfPath, failureReason) now route through the sanitizer. --- .../EventKit/ParentChainSource.swift | 11 ++++++--- .../ParentChainFormatterTests.swift | 23 +++++++++++++++++++ 2 files changed, 31 insertions(+), 3 deletions(-) diff --git a/Sources/CheICalMCP/EventKit/ParentChainSource.swift b/Sources/CheICalMCP/EventKit/ParentChainSource.swift index 29fcaed..a4967cd 100644 --- a/Sources/CheICalMCP/EventKit/ParentChainSource.swift +++ b/Sources/CheICalMCP/EventKit/ParentChainSource.swift @@ -92,13 +92,18 @@ enum ParentChainFormatter { 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) \(selfPath) (this binary)") + lines.append(" \(selfPid) \(escape(selfPath)) (this binary)") if let reason = result.failureReason { - lines.append(" (parent chain unavailable: \(reason))") + lines.append(" (parent chain unavailable: \(escape(reason)))") } else { for hop in result.hops { - lines.append(" \(hop.pid) \(hop.command)") + lines.append(" \(hop.pid) \(escape(hop.command))") } } lines.append("") diff --git a/Tests/CheICalMCPTests/ParentChainFormatterTests.swift b/Tests/CheICalMCPTests/ParentChainFormatterTests.swift index e750e00..4121e07 100644 --- a/Tests/CheICalMCPTests/ParentChainFormatterTests.swift +++ b/Tests/CheICalMCPTests/ParentChainFormatterTests.swift @@ -46,6 +46,29 @@ final class ParentChainFormatterTests: XCTestCase { } } + // 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(