diff --git a/Sources/DevScope/Stores/ProcessStore.swift b/Sources/DevScope/Stores/ProcessStore.swift index 646a44c..e2c7fb1 100644 --- a/Sources/DevScope/Stores/ProcessStore.swift +++ b/Sources/DevScope/Stores/ProcessStore.swift @@ -286,7 +286,12 @@ final class ProcessStore: ObservableObject { } func familySummary(for process: DevProcess) -> ProcessFamilySummary { - ProcessPresentation.familySummary(for: process, in: processes) + let live = ProcessPresentation.liveScopedTreeInputs( + processes: processes, + classifiedProcesses: classifiedProcesses, + liveProcessIDs: liveProcessIDs + ) + return ProcessPresentation.familySummary(for: process, in: live.processes) } func isProcessLive(pid: Int32) -> Bool { @@ -412,10 +417,15 @@ final class ProcessStore: ObservableObject { func terminateTree(root item: ClassifiedDevProcess) { guard allowSignal(item) else { return } do { - let targets = try killer.terminateTree( - root: item, + let live = ProcessPresentation.liveScopedTreeInputs( processes: processes, classifiedProcesses: classifiedProcesses, + liveProcessIDs: liveProcessIDs + ) + let targets = try killer.terminateTree( + root: item, + processes: live.processes, + classifiedProcesses: live.classifiedProcesses, currentProcessID: Int32(ProcessInfo.processInfo.processIdentifier) ) statusMessage = "Sent TERM to \(targets.count) processes" @@ -434,10 +444,15 @@ final class ProcessStore: ObservableObject { func forceTerminateTree(root item: ClassifiedDevProcess) { guard allowSignal(item) else { return } do { - let targets = try killer.forceTerminateTree( - root: item, + let live = ProcessPresentation.liveScopedTreeInputs( processes: processes, classifiedProcesses: classifiedProcesses, + liveProcessIDs: liveProcessIDs + ) + let targets = try killer.forceTerminateTree( + root: item, + processes: live.processes, + classifiedProcesses: live.classifiedProcesses, currentProcessID: Int32(ProcessInfo.processInfo.processIdentifier) ) statusMessage = "Sent KILL to \(targets.count) processes" diff --git a/Sources/DevScopeCore/AutomationCapabilityPolicy.swift b/Sources/DevScopeCore/AutomationCapabilityPolicy.swift index ea0854b..3c92ebb 100644 --- a/Sources/DevScopeCore/AutomationCapabilityPolicy.swift +++ b/Sources/DevScopeCore/AutomationCapabilityPolicy.swift @@ -16,9 +16,17 @@ public enum AutomationPathAuthorization { ? destination : destination.deletingLastPathComponent().standardizedFileURL return !metadataIsSymbolicLink - && destination.path.hasPrefix(root.path + "/") + && isPath(destination.path, underApprovedRoot: root.path) && verifiedMetadataURL.path == expectedMetadataURL.path } + + /// Fail closed for filesystem root: `"/"` + `"/"` must not become `"//"`. + static func isPath(_ destinationPath: String, underApprovedRoot rootPath: String) -> Bool { + if rootPath == "/" { + return destinationPath.hasPrefix("/") && destinationPath != "/" + } + return destinationPath.hasPrefix(rootPath + "/") + } } public struct AutomationCapabilityDecision: Equatable, Sendable { diff --git a/Sources/DevScopeCore/ProcessPresentation.swift b/Sources/DevScopeCore/ProcessPresentation.swift index 3f40d22..df1e766 100644 --- a/Sources/DevScopeCore/ProcessPresentation.swift +++ b/Sources/DevScopeCore/ProcessPresentation.swift @@ -293,6 +293,19 @@ public enum ProcessPresentation { ) } + /// Snapshot stabilizers keep exited PIDs briefly for UI continuity. Tree signals and + /// family counts must use live rows only, or ProcessKiller fails closed on ghosts. + public static func liveScopedTreeInputs( + processes: [DevProcess], + classifiedProcesses: [ClassifiedDevProcess], + liveProcessIDs: Set + ) -> (processes: [DevProcess], classifiedProcesses: [ClassifiedDevProcess]) { + ( + processes.filter { liveProcessIDs.contains($0.pid) }, + classifiedProcesses.filter { liveProcessIDs.contains($0.process.pid) } + ) + } + public static func searchableText(for item: ClassifiedDevProcess) -> String { [ item.classification.displayName, diff --git a/Tests/DevScopeCoreTests/AutomationCapabilityPolicyTests.swift b/Tests/DevScopeCoreTests/AutomationCapabilityPolicyTests.swift index ebc3ad1..ad78df7 100644 --- a/Tests/DevScopeCoreTests/AutomationCapabilityPolicyTests.swift +++ b/Tests/DevScopeCoreTests/AutomationCapabilityPolicyTests.swift @@ -29,6 +29,28 @@ final class AutomationCapabilityPolicyTests: XCTestCase { )) } + func testFilesystemRootApprovedPathDoesNotUseDoubleSlashPrefix() { + let root = URL(fileURLWithPath: "/") + let destination = URL(fileURLWithPath: "/Users/test/Library/LaunchAgents/com.example.plist") + + XCTAssertTrue(AutomationPathAuthorization.isApprovedDestination( + destination, + approvedRoot: root, + destinationExists: true, + verifiedMetadataURL: destination, + metadataIsSymbolicLink: false + )) + XCTAssertFalse(AutomationPathAuthorization.isApprovedDestination( + root, + approvedRoot: root, + destinationExists: true, + verifiedMetadataURL: root, + metadataIsSymbolicLink: false + )) + XCTAssertTrue(AutomationPathAuthorization.isPath("/tmp/a", underApprovedRoot: "/")) + XCTAssertFalse(AutomationPathAuthorization.isPath("/", underApprovedRoot: "/")) + } + func testCurrentUserLaunchAgentReceivesFullCapabilities() { let decision = AutomationCapabilityPolicy.decision( for: Fixtures.userAgent, diff --git a/Tests/DevScopeCoreTests/ProcessPresentationTests.swift b/Tests/DevScopeCoreTests/ProcessPresentationTests.swift index ce629bf..c98ccf5 100644 --- a/Tests/DevScopeCoreTests/ProcessPresentationTests.swift +++ b/Tests/DevScopeCoreTests/ProcessPresentationTests.swift @@ -576,6 +576,54 @@ final class ProcessPresentationTests: XCTestCase { XCTAssertEqual(family.descendantCount, 2) } + func testLiveScopedTreeInputsDropGracePeriodGhosts() { + let liveRoot = DevProcess( + pid: 10, + parentPID: 1, + executable: "npm", + command: "npm run dev", + birthToken: ProcessBirthToken(seconds: 100, microseconds: 0) + ) + let liveChild = DevProcess( + pid: 11, + parentPID: 10, + executable: "node", + command: "node next", + birthToken: ProcessBirthToken(seconds: 101, microseconds: 0) + ) + let ghostChild = DevProcess( + pid: 12, + parentPID: 10, + executable: "node", + command: "node worker", + birthToken: ProcessBirthToken(seconds: 102, microseconds: 0) + ) + let processes = [liveRoot, liveChild, ghostChild] + let classified = processes.map { process in + ClassifiedDevProcess( + process: process, + classification: DevProcessClassification( + kind: .javascript, + displayName: process.executableName, + projectHint: nil, + tags: [] + ) + ) + } + + let live = ProcessPresentation.liveScopedTreeInputs( + processes: processes, + classifiedProcesses: classified, + liveProcessIDs: [10, 11] + ) + let family = ProcessPresentation.familySummary(for: liveRoot, in: live.processes) + + XCTAssertEqual(live.processes.map(\.pid), [10, 11]) + XCTAssertEqual(live.classifiedProcesses.map(\.process.pid), [10, 11]) + XCTAssertEqual(family.childCount, 1) + XCTAssertEqual(family.descendantCount, 1) + } + func testBuildsDashboardStatsForVisibleProcesses() { let totalItems = [ classified(