From 8188676b53db5e8d96f1af5d02e24d7a4941ab0e Mon Sep 17 00:00:00 2001 From: Rafal Sikora <24563931+s1korrrr@users.noreply.github.com> Date: Wed, 7 Oct 2026 11:52:54 +0200 Subject: [PATCH 1/2] fix(processes): bound system I/O and speed saved-item matching Dispatch synchronous scanner work outside Swift cooperative workers and stop oversized command output promptly. Guard numeric boundaries, retain persisted identity hashes with faster encoding, and remove misleading per-process GPU attribution. Keep large sidebar counts readable and use the default release build backend. --- CHANGELOG.md | 19 +++ .../DevScope/App/AutomationComposition.swift | 8 +- .../Infrastructure/BlockingSystemWork.swift | 17 +++ .../SystemAutomationCommandRunner.swift | 16 ++- Sources/DevScope/Stores/ProcessStore.swift | 4 +- Sources/DevScope/Views/ContentView.swift | 4 +- .../DevScope/Views/ProcessDetailView.swift | 7 +- .../Views/ProcessMetricHistoryView.swift | 17 +-- Sources/DevScope/Views/SettingsView.swift | 18 +-- Sources/DevScopeCore/DevProcess.swift | 5 +- .../ProcessMetricHistoryStore.swift | 5 +- .../DevScopeCore/ProcessPresentation.swift | 22 +++- Sources/DevScopeCore/ProcessScanner.swift | 8 +- .../ProcessClassifierTests.swift | 12 ++ .../ProcessMetricHistoryStoreTests.swift | 2 +- .../ProcessPresentationTests.swift | 24 ++++ .../BlockingSystemWorkTests.swift | 23 ++++ .../SystemAutomationCommandRunnerTests.swift | 30 ++++- docs/verification/2026-10-07-audit.md | 110 ++++++++++++++++++ script/build_release_bundle.sh | 1 - 20 files changed, 299 insertions(+), 53 deletions(-) create mode 100644 Sources/DevScope/Infrastructure/BlockingSystemWork.swift create mode 100644 Tests/DevScopeTests/BlockingSystemWorkTests.swift create mode 100644 docs/verification/2026-10-07-audit.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 996122d..3b60f6b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,25 @@ All notable changes to DevScope are documented here. The format follows ### Changed +- Process inspectors show process CPU and memory; machine-wide GPU utilization + remains in the system activity dashboard. +- Favorites and watch matching use direct SHA-256 hex encoding and lexical + executable names, preserving existing saved keys without filesystem probes. +- Activity counts stay on one line when the process inventory exceeds 999 items. + +### Fixed + +- Blocking process scans run on dispatch workers in live refresh, access checks, + and automation verification instead of occupying Swift cooperative workers. +- Oversized automation-command output triggers process-group termination while + both streams are drained, rather than waiting for the execution deadline. +- Process metrics reject negative and non-finite values and unsafe RSS conversion; + elapsed-time parsing rejects totals that exceed `Int64`. +- Release builds use SwiftPM's default build system without the deprecated + forced-native option on Swift 6.4. + +### Documentation + - Recorded Rafal Sikora as copyright owner and RSI Tech as the public maintainer, with `rsitech.ai` and `info@rsitech.ai` as the project routes. - Updated release evidence to distinguish the installed Developer ID identity diff --git a/Sources/DevScope/App/AutomationComposition.swift b/Sources/DevScope/App/AutomationComposition.swift index 32de0a2..1ac4e45 100644 --- a/Sources/DevScope/App/AutomationComposition.swift +++ b/Sources/DevScope/App/AutomationComposition.swift @@ -67,7 +67,9 @@ enum DevScopeComposition { fileSystem: fileSystem, currentUID: currentUID, processSnapshot: { - try processScanner.snapshot(includeCurrentDirectories: false) + try await BlockingSystemWork.run { + try processScanner.snapshot(includeCurrentDirectories: false) + } } ), legacy: LegacyLoginItemAutomationExecutor( @@ -132,7 +134,9 @@ enum DevScopeComposition { await inventoryService.refreshAfterCurrent() }, refreshProcesses: { - try processScanner.snapshot(includeCurrentDirectories: false) + try await BlockingSystemWork.run { + try processScanner.snapshot(includeCurrentDirectories: false) + } }, backupDirectory: recoveryRoot, currentUID: currentUID diff --git a/Sources/DevScope/Infrastructure/BlockingSystemWork.swift b/Sources/DevScope/Infrastructure/BlockingSystemWork.swift new file mode 100644 index 0000000..7c7d7e1 --- /dev/null +++ b/Sources/DevScope/Infrastructure/BlockingSystemWork.swift @@ -0,0 +1,17 @@ +import Foundation + +/// Runs synchronous system I/O without occupying Swift's cooperative executor. +enum BlockingSystemWork { + static func run( + _ operation: @escaping @Sendable () throws -> Value + ) async throws -> Value { + try Task.checkCancellation() + let value = try await withCheckedThrowingContinuation { continuation in + DispatchQueue.global(qos: .utility).async { + continuation.resume(with: Result { try operation() }) + } + } + try Task.checkCancellation() + return value + } +} diff --git a/Sources/DevScope/Infrastructure/SystemAutomationCommandRunner.swift b/Sources/DevScope/Infrastructure/SystemAutomationCommandRunner.swift index 3da4895..6bb433d 100644 --- a/Sources/DevScope/Infrastructure/SystemAutomationCommandRunner.swift +++ b/Sources/DevScope/Infrastructure/SystemAutomationCommandRunner.swift @@ -61,7 +61,7 @@ private final class AutomationChildProcessController: @unchecked Sendable { guard escalationTask == nil else { return } terminate(group, signal: SIGTERM) escalationTask = Task.detached { - try? await Task.sleep(for: .seconds(2)) + try? await Task.sleep(for: .seconds(2)) guard !Task.isCancelled else { return } _ = Darwin.kill(-group, SIGKILL) } @@ -111,7 +111,8 @@ actor SystemAutomationCommandRunner: AutomationCommandRunning { try await Self.performBlocking { try Self.drain( spawned.standardOutput, - maximumCapturedBytes: self.maximumCapturedBytes + maximumCapturedBytes: self.maximumCapturedBytes, + onLimitExceeded: { controller.cancel() } ) } } @@ -119,7 +120,8 @@ actor SystemAutomationCommandRunner: AutomationCommandRunning { try await Self.performBlocking { try Self.drain( spawned.standardError, - maximumCapturedBytes: self.maximumCapturedBytes + maximumCapturedBytes: self.maximumCapturedBytes, + onLimitExceeded: { controller.cancel() } ) } } @@ -333,7 +335,8 @@ actor SystemAutomationCommandRunner: AutomationCommandRunning { nonisolated private static func drain( _ handle: FileHandle, - maximumCapturedBytes: Int + maximumCapturedBytes: Int, + onLimitExceeded: @Sendable () -> Void ) throws -> Data { defer { try? handle.close() } var captured = Data() @@ -343,7 +346,10 @@ actor SystemAutomationCommandRunner: AutomationCommandRunning { if available > 0 { captured.append(chunk.prefix(available)) } - if chunk.count > available { exceededLimit = true } + if chunk.count > available, !exceededLimit { + exceededLimit = true + onLimitExceeded() + } } if exceededLimit { throw SystemAutomationCommandError.outputLimitExceeded } return captured diff --git a/Sources/DevScope/Stores/ProcessStore.swift b/Sources/DevScope/Stores/ProcessStore.swift index e2c7fb1..4f2ecf2 100644 --- a/Sources/DevScope/Stores/ProcessStore.swift +++ b/Sources/DevScope/Stores/ProcessStore.swift @@ -173,11 +173,11 @@ final class ProcessStore: ObservableObject { if includeCurrentDirectories { await snapshotWorker.invalidateWorkspaceFacts() } - let scanResult = try await Task.detached(priority: .userInitiated) { + let scanResult = try await BlockingSystemWork.run { let processes = try scanner.snapshot(includeCurrentDirectories: includeCurrentDirectories) let gpuMetric = includeGPUMetric ? try? gpuMetricProvider.snapshot() : nil return (processes: processes, gpuMetric: gpuMetric) - }.value + } if includeGPUMetric { cachedGPUMetric = scanResult.gpuMetric lastGPUMetricRefresh = Date() diff --git a/Sources/DevScope/Views/ContentView.swift b/Sources/DevScope/Views/ContentView.swift index c9c6cee..aa14bba 100644 --- a/Sources/DevScope/Views/ContentView.swift +++ b/Sources/DevScope/Views/ContentView.swift @@ -1390,7 +1390,9 @@ private struct RuntimeRailItem: View { .font(.caption2.monospacedDigit().weight(.semibold)) .foregroundStyle(isSelected ? .primary : .tertiary) .contentTransition(.numericText()) - .frame(width: 22, alignment: .trailing) + .lineLimit(1) + .fixedSize(horizontal: true, vertical: false) + .frame(minWidth: 22, alignment: .trailing) } } .padding(.horizontal, isCollapsed ? 5 : 7) diff --git a/Sources/DevScope/Views/ProcessDetailView.swift b/Sources/DevScope/Views/ProcessDetailView.swift index 5191005..37ba4e3 100644 --- a/Sources/DevScope/Views/ProcessDetailView.swift +++ b/Sources/DevScope/Views/ProcessDetailView.swift @@ -75,7 +75,6 @@ struct ProcessDetailView: View { process.process.resourceUsage?.residentMemoryBytes)) DetailChip(label: "Mem Avg", value: ProcessMetricFormat.memory(averageMemory)) DetailChip(label: "Mem Peak", value: ProcessMetricFormat.memory(peakMemory)) - DetailChip(label: "GPU", value: ProcessMetricFormat.gpu(latestGPU)) DetailChip( label: "Running", value: process.process.resourceUsage?.elapsedTime ?? "-") DetailChip(label: "Samples", value: "\(metricHistory.count)") @@ -190,10 +189,6 @@ struct ProcessDetailView: View { .map(String.init) } - private var latestGPU: Double? { - metricHistory.last { $0.gpuPercent != nil }?.gpuPercent - } - private var averageCPU: Double? { average(metricHistory.map(\.cpuPercent)) } @@ -252,7 +247,7 @@ private struct EmptyProcessInspector: View { VStack(alignment: .leading, spacing: 8) { EmptyInspectorLine( symbolName: "gauge.with.dots.needle.67percent", title: "Resources", - detail: "CPU, memory, GPU sample, peaks, averages, and sample count") + detail: "Process CPU and memory, peaks, averages, and sample count") EmptyInspectorLine( symbolName: "point.3.connected.trianglepath.dotted", title: "Tree", detail: "Parent, children, and retained ended-process state") diff --git a/Sources/DevScope/Views/ProcessMetricHistoryView.swift b/Sources/DevScope/Views/ProcessMetricHistoryView.swift index 7935d10..9a763d1 100644 --- a/Sources/DevScope/Views/ProcessMetricHistoryView.swift +++ b/Sources/DevScope/Views/ProcessMetricHistoryView.swift @@ -21,9 +21,6 @@ struct ProcessMetricHistoryView: View { HStack(spacing: 12) { LegendSwatch(color: .cyan, label: "CPU") LegendSwatch(color: .purple, label: "Memory") - if hasGPUSamples { - LegendSwatch(color: .orange, label: "GPU") - } Spacer() Text(summaryText) .font(.caption.monospacedDigit()) @@ -52,11 +49,7 @@ struct ProcessMetricHistoryView: View { } return - "\(ProcessMetricFormat.cpu(latest.cpuPercent)) · \(ProcessMetricFormat.memory(latest.residentMemoryBytes)) · GPU \(ProcessMetricFormat.gpu(latest.gpuPercent))" - } - - private var hasGPUSamples: Bool { - samples.contains { $0.gpuPercent != nil } + "\(ProcessMetricFormat.cpu(latest.cpuPercent)) · \(ProcessMetricFormat.memory(latest.residentMemoryBytes))" } } @@ -88,14 +81,6 @@ private struct MetricChart: View { style: StrokeStyle(lineWidth: 2.2, lineCap: .round, lineJoin: .round) ) - let gpuValues = samples.compactMap(\.gpuPercent) - if gpuValues.count == samples.count { - context.stroke( - path(for: gpuValues, maxValue: 100, in: rect), - with: .color(.orange), - style: StrokeStyle(lineWidth: 2.2, lineCap: .round, lineJoin: .round) - ) - } } .background( .quaternary.opacity(0.24), in: RoundedRectangle(cornerRadius: 10, style: .continuous) diff --git a/Sources/DevScope/Views/SettingsView.swift b/Sources/DevScope/Views/SettingsView.swift index 409294e..e4a7180 100644 --- a/Sources/DevScope/Views/SettingsView.swift +++ b/Sources/DevScope/Views/SettingsView.swift @@ -469,22 +469,22 @@ struct SettingsView: View { let isSandboxed = ProcessAccessStatus.isSandboxed isCheckingAccess = true Task { @MainActor in - accessAssessment = await Task.detached(priority: .userInitiated) { - do { + do { + accessAssessment = try await BlockingSystemWork.run { let processes = try SystemProcessScanner().snapshot(includeCurrentDirectories: true) return ProcessAccessAssessment.assess( isSandboxed: isSandboxed, processes: processes, errorDescription: nil ) - } catch { - return ProcessAccessAssessment.assess( - isSandboxed: isSandboxed, - processes: nil, - errorDescription: error.localizedDescription - ) } - }.value + } catch { + accessAssessment = ProcessAccessAssessment.assess( + isSandboxed: isSandboxed, + processes: nil, + errorDescription: error.localizedDescription + ) + } isCheckingAccess = false } } diff --git a/Sources/DevScopeCore/DevProcess.swift b/Sources/DevScopeCore/DevProcess.swift index aebe982..4750e6d 100644 --- a/Sources/DevScopeCore/DevProcess.swift +++ b/Sources/DevScopeCore/DevProcess.swift @@ -51,7 +51,10 @@ public struct DevProcess: Identifiable, Equatable, Hashable, Sendable { } public var executableName: String { - URL(fileURLWithPath: executable).lastPathComponent + // This is a lexical process fact. Constructing a file URL probes the file + // system (and resolves relative paths) on every row/filter evaluation. + executable.split(separator: "/", omittingEmptySubsequences: true).last.map(String.init) + ?? executable } } diff --git a/Sources/DevScopeCore/ProcessMetricHistoryStore.swift b/Sources/DevScopeCore/ProcessMetricHistoryStore.swift index 97c0144..294272e 100644 --- a/Sources/DevScopeCore/ProcessMetricHistoryStore.swift +++ b/Sources/DevScopeCore/ProcessMetricHistoryStore.swift @@ -16,7 +16,7 @@ public struct ProcessMetricHistoryStore: Sendable { public mutating func record( processes: [DevProcess], - gpuMetric: DevGPUMetric?, + gpuMetric _: DevGPUMetric?, timestamp: Date ) { let activeProcessIDs = Set(processes.map(\.pid)) @@ -40,7 +40,8 @@ public struct ProcessMetricHistoryStore: Sendable { timestamp: timestamp, cpuPercent: usage.cpuPercent, residentMemoryBytes: usage.residentMemoryBytes, - gpuPercent: gpuMetric?.utilizationPercent + // IORegistry reports machine utilization, not usage by this process. + gpuPercent: nil ) ) let overflow = (entries[process.pid]?.samples.count ?? 0) - limit diff --git a/Sources/DevScopeCore/ProcessPresentation.swift b/Sources/DevScopeCore/ProcessPresentation.swift index df1e766..85a1997 100644 --- a/Sources/DevScopeCore/ProcessPresentation.swift +++ b/Sources/DevScopeCore/ProcessPresentation.swift @@ -376,13 +376,17 @@ public enum ProcessPresentation { } public static func identityKeys(for item: ClassifiedDevProcess) -> Set { + let primary = identityKey(for: item) + guard let directory = item.process.currentDirectory, !directory.isEmpty else { + return [primary] + } let fallback = hashedIdentityKey( executableName: item.process.executableName, currentDirectory: "", command: item.process.command ) - return [identityKey(for: item), fallback] + return [primary, fallback] } private static func hashedIdentityKey( @@ -398,9 +402,14 @@ public enum ProcessPresentation { private static func hashedIdentityKey(source: String) -> String { let digest = SHA256.hash(data: Data(source.utf8)) - .map { String(format: "%02x", $0) } - .joined() - return "v2:\(digest)" + let alphabet = Array("0123456789abcdef".utf8) + var encoded = Array("v2:".utf8) + encoded.reserveCapacity(67) + for byte in digest { + encoded.append(alphabet[Int(byte >> 4)]) + encoded.append(alphabet[Int(byte & 0x0f)]) + } + return String(decoding: encoded, as: UTF8.self) } public static func isSaved(_ item: ClassifiedDevProcess, in keys: Set) -> Bool { @@ -469,7 +478,10 @@ public enum ProcessPresentation { guard let hours, hours >= 0, hours < 24 else { return -1 } - return days * 86_400 + hours * 3_600 + minutes * 60 + seconds + let daySeconds = days * 86_400 + let timeSeconds = hours * 3_600 + minutes * 60 + seconds + guard daySeconds <= Int64.max - timeSeconds else { return -1 } + return daySeconds + timeSeconds } public static func redactedCommand(_ command: String) -> String { diff --git a/Sources/DevScopeCore/ProcessScanner.swift b/Sources/DevScopeCore/ProcessScanner.swift index 79e2d50..cdee45f 100644 --- a/Sources/DevScopeCore/ProcessScanner.swift +++ b/Sources/DevScopeCore/ProcessScanner.swift @@ -150,13 +150,19 @@ public enum ProcessScanner { let parts = trimmed.split(separator: " ", maxSplits: 6, omittingEmptySubsequences: true) guard parts.count >= 3, let pid = Int32(parts[0]), - let parentPID = Int32(parts[1]) else { + let parentPID = Int32(parts[1]), + pid > 0, parentPID >= 0 else { return nil } if parts.count >= 6, let cpuPercent = Double(parts[2]), let residentMemoryKilobytes = Int64(parts[3]) { + guard cpuPercent.isFinite, cpuPercent >= 0, + residentMemoryKilobytes >= 0, + residentMemoryKilobytes <= Int64.max / 1024 else { + return nil + } let elapsedTime = String(parts[4]) let executable = String(parts[5]) let command = parts.count == 7 ? String(parts[6]) : executable diff --git a/Tests/DevScopeCoreTests/ProcessClassifierTests.swift b/Tests/DevScopeCoreTests/ProcessClassifierTests.swift index 8263788..90493a0 100644 --- a/Tests/DevScopeCoreTests/ProcessClassifierTests.swift +++ b/Tests/DevScopeCoreTests/ProcessClassifierTests.swift @@ -128,6 +128,18 @@ final class ProcessClassifierTests: XCTestCase { XCTAssertEqual(process.resourceUsage?.elapsedTime, "01:02:03") } + func testRejectsUnsafePSResourceMetricsAndProcessIdentifiers() { + for cpu in ["nan", "inf", "-1"] { + XCTAssertNil(ProcessScanner.parsePSLine("4312 112 \(cpu) 64 00:10 node node server.js")) + } + for memory in ["-1", "9223372036854775807"] { + XCTAssertNil(ProcessScanner.parsePSLine("4312 112 1 \(memory) 00:10 node node server.js")) + } + XCTAssertNil(ProcessScanner.parsePSLine("-1 112 node node server.js")) + XCTAssertNil(ProcessScanner.parsePSLine("4312 -1 node node server.js")) + XCTAssertNotNil(ProcessScanner.parsePSLine("4312 112 200 64 00:10 node node server.js")) + } + func testClassifiesExpectedDevProcesses() throws { let samples: [(String, DevRuntimeKind)] = [ ("node ./node_modules/.bin/next dev --port 3014", .javascript), diff --git a/Tests/DevScopeCoreTests/ProcessMetricHistoryStoreTests.swift b/Tests/DevScopeCoreTests/ProcessMetricHistoryStoreTests.swift index c63a90d..9625727 100644 --- a/Tests/DevScopeCoreTests/ProcessMetricHistoryStoreTests.swift +++ b/Tests/DevScopeCoreTests/ProcessMetricHistoryStoreTests.swift @@ -16,7 +16,7 @@ final class ProcessMetricHistoryStoreTests: XCTestCase { XCTAssertEqual(sample.timestamp, timestamp) XCTAssertEqual(sample.cpuPercent, 12) XCTAssertEqual(sample.residentMemoryBytes, 256) - XCTAssertEqual(sample.gpuPercent, 34) + XCTAssertNil(sample.gpuPercent, "Machine GPU utilization must not be attributed to a process") } func testCapsEachProcessHistoryAtTheConfiguredLimit() { diff --git a/Tests/DevScopeCoreTests/ProcessPresentationTests.swift b/Tests/DevScopeCoreTests/ProcessPresentationTests.swift index c98ccf5..f093501 100644 --- a/Tests/DevScopeCoreTests/ProcessPresentationTests.swift +++ b/Tests/DevScopeCoreTests/ProcessPresentationTests.swift @@ -124,6 +124,28 @@ final class ProcessPresentationTests: XCTestCase { XCTAssertFalse(key.contains("/Users/example")) } + func testSavedIdentityEncodingPreservesExistingSHA256Keys() { + let item = ClassifiedDevProcess( + process: DevProcess(pid: 98, parentPID: 1, executable: "node", command: "node server.js"), + classification: DevProcessClassification( + kind: .javascript, displayName: "node", projectHint: nil, tags: [] + ) + ) + let key = "v2:61259ad0ca5a8ffcf8425a8a053d94b1b15f77eb6a092324fd537ffe1eef5e57" + XCTAssertEqual(ProcessPresentation.identityKey(for: item), key) + XCTAssertEqual(ProcessPresentation.identityKeys(for: item), [key]) + } + + func testExecutableNameIsLexicalForRelativeAbsoluteAndEmptyPaths() { + for (path, expected) in [ + ("node", "node"), ("./node", "node"), + ("/Applications/Example App.app/Contents/MacOS/Example App", "Example App"), + ("/usr/bin/node/", "node"), ("/", "/"), ("", ""), + ] { + XCTAssertEqual(DevProcess(pid: 98, parentPID: 1, executable: path, command: path).executableName, expected) + } + } + func testSanitizedSavedIdentityKeysMigrateLegacyValuesWithoutRetainingRawData() { let safeKey = "v2:" + String(repeating: "a", count: 64) let legacyKey = @@ -553,6 +575,8 @@ final class ProcessPresentationTests: XCTestCase { } func testParsesElapsedTimeForChronologicalTableSorting() { + XCTAssertEqual(ProcessPresentation.elapsedSeconds("106751991167300-23:59:59"), -1) + XCTAssertEqual(ProcessPresentation.elapsedSeconds("106751991167300-15:30:07"), Int64.max) XCTAssertEqual(ProcessPresentation.elapsedSeconds("00:42"), 42) XCTAssertEqual(ProcessPresentation.elapsedSeconds("02:03:04"), 7_384) XCTAssertEqual(ProcessPresentation.elapsedSeconds("9-00:00:00"), 777_600) diff --git a/Tests/DevScopeTests/BlockingSystemWorkTests.swift b/Tests/DevScopeTests/BlockingSystemWorkTests.swift new file mode 100644 index 0000000..f7b4bd1 --- /dev/null +++ b/Tests/DevScopeTests/BlockingSystemWorkTests.swift @@ -0,0 +1,23 @@ +import Foundation +import XCTest +@testable import DevScope + +final class BlockingSystemWorkTests: XCTestCase { + func testSystemWorkRunsOutsideSwiftTasksAndReturnsItsResult() async throws { + let result = try await BlockingSystemWork.run { + (withUnsafeCurrentTask { $0 == nil }, Thread.isMainThread, 42) + } + XCTAssertTrue(result.0) + XCTAssertFalse(result.1) + XCTAssertEqual(result.2, 42) + } + + func testPropagatesSystemErrors() async { + do { + _ = try await BlockingSystemWork.run { throw CocoaError(.fileReadNoPermission) } + XCTFail("Expected the system error") + } catch { + XCTAssertEqual((error as? CocoaError)?.code, .fileReadNoPermission) + } + } +} diff --git a/Tests/DevScopeTests/SystemAutomationCommandRunnerTests.swift b/Tests/DevScopeTests/SystemAutomationCommandRunnerTests.swift index 3d0351c..48dc070 100644 --- a/Tests/DevScopeTests/SystemAutomationCommandRunnerTests.swift +++ b/Tests/DevScopeTests/SystemAutomationCommandRunnerTests.swift @@ -43,7 +43,7 @@ final class SystemAutomationCommandRunnerTests: XCTestCase { )) XCTFail("Expected outputLimitExceeded") } catch SystemAutomationCommandError.outputLimitExceeded { - // Expected only after the output pipe reaches EOF. + // Output is drained after the oversized command is terminated. } catch { XCTFail("Expected outputLimitExceeded, received \(error)") } @@ -81,6 +81,34 @@ final class SystemAutomationCommandRunnerTests: XCTestCase { await assertProcessGone(processGroup.value) } + func testUnendingOutputIsStoppedAtTheCaptureLimitBeforeExecutionTimeout() async throws { + let processGroup = LockedValue(nil) + let runner = SystemAutomationCommandRunner( + hooks: .init(afterSpawnBeforeInstall: { processGroup.set($0) }), + maximumCapturedBytes: 64, + executionTimeout: .seconds(10) + ) + let fallback = cleanupFallback(processGroup) + defer { + fallback.cancel() + killGroupIfPresent(processGroup.value) + } + let started = ContinuousClock.now + do { + _ = try await runner.run(AutomationCommand( + executable: "/bin/sh", + arguments: ["-c", "trap '' TERM; while :; do printf '1234567890'; done"] + )) + XCTFail("Expected outputLimitExceeded") + } catch SystemAutomationCommandError.outputLimitExceeded { + // The capture limit, rather than the ten-second deadline, stopped the job. + } catch { + XCTFail("Expected outputLimitExceeded, received \(error)") + } + XCTAssertLessThan(ContinuousClock.now - started, .seconds(4)) + await assertProcessGone(processGroup.value) + } + func testAlreadyCancelledTaskDoesNotSpawnAChild() async throws { let gate = DispatchSemaphore(value: 0) let spawnCount = LockedValue(0) diff --git a/docs/verification/2026-10-07-audit.md b/docs/verification/2026-10-07-audit.md new file mode 100644 index 0000000..4bf3e22 --- /dev/null +++ b/docs/verification/2026-10-07-audit.md @@ -0,0 +1,110 @@ +# DevScope audit, 2026-10-07 + +This pass compares against public `main` at `c4a3d2d`. It covers source review, +regression tests, native interaction, local build policy and independent review. +It is evidence for the specified paths, not proof that every possible host, +permission, automation or on-device model state is error-free. + +## Review boundaries + +- Scanner: bounded ps/lsof/ioreg execution, native metadata caches, exact argument + evidence, process birth identity, directory carry-forward and normalization. +- Process actions: protected targets, stale/reused PIDs, live tree scope, + confirmation, partial signal outcomes and post-action observation. +- Automation: inventory refresh/coalescing, source health, capability/ownership + checks, command execution/cancellation, guarded filesystem I/O, recovery and + manager/executor composition. Mutating native smoke uses a disposable fixture. +- Presentation: process scopes, filters/sorting, saved state, resource charts, + settings/access, clipboard recovery and source warnings. +- Distribution: entitlements, notices, privacy manifest, exact bundle launch and + current architecture. Local installation does not establish notarization. + +The deterministic core still owns facts and action policy; optional Foundation +Models only enrich presentation. Foundation Models availability is checked and +the existing deterministic fallback remains available. + +## Findings and changes + +| Finding | Impact | Change / evidence | +| --- | --- | --- | +| Machine GPU values copied to every process history | Misleading process attribution | Per-process samples now leave GPU unavailable; process chip/chart omit it. Dashboard retains system GPU. | +| RSS multiplication and elapsed total can overflow | Invalid boundary input can trap | Guard finite/nonnegative metrics, conversion limit and total addition. Regression includes exact `Int64.max` elapsed time and overflowing total. | +| Endless oversized command continues until timeout | Unnecessary CPU and delayed failure | Overflow starts TERM/KILL process-group cleanup while pipes drain. Term-ignoring writer regression fails with outputLimitExceeded within four seconds rather than the ten-second deadline. | +| Synchronous scanner runs inside Swift tasks | Blocking waits occupy cooperative workers | Dispatch/continuation bridge used by process refresh, Settings access and automation process verification. Test asserts no current Swift task and no main thread inside the blocking closure. | +| Saved-item matching formats thousands of digest bytes and constructs file URLs | Main-thread work grows with process count | Direct lowercase hex encoding, no duplicate alias hash without CWD, lexical executable basename. Fixed hash vector preserves persisted key format. | +| Four-digit rail counts wrap inside a fixed 22-point width | Inventory total becomes hard to read | Keep the count on one line with intrinsic width and a 22-point minimum. | +| Release script forces SwiftPM's deprecated native backend | Swift 6.4 emits deprecation warnings | Use the toolchain's default backend for both build and binary lookup. | + +The performance reproduction used a native app snapshot containing over 1,300 +processes with one favorite and watched fixture. A sample of the main thread +showed category counting and saved-key matching dominated by hex formatting and +file URL construction. An optimized isolated benchmark of 6,500 SHA-256 key +encodings measured 1.171 seconds before and 0.009 seconds after, with identical +outputs. This isolates encoding cost; it is not an overall app speedup estimate. + +## Validation + +- `swift test --jobs 4 -Xswiftc -warnings-as-errors`: 685 XCTest cases passed, + 100 in the app test product and 585 in the core test product; zero failures. +- `git diff --check`: passed. +- `bash -n script/*.sh`: passed. +- `./script/check_open_source_readiness.sh`: passed on the final implementation. +- `swift build --jobs 4 -c release -Xswiftc -warnings-as-errors`: passed. +- `swift test --jobs 4 --filter ReleaseScriptTests -Xswiftc -warnings-as-errors`: + 63 release-contract tests passed after removing the deprecated build flag. +- Default-backend arm64 full-access bundle: built, signed and validated with + sandbox disabled and the Apple Events entitlement required. The final + presentation change is rebuilt before installation. +- Gitleaks source scan and full reachable public candidate history: no findings. + A scan of unrelated personal local refs found historical findings absent from + public `main`; no history or suppression changes are included. +- Independent read-only review: no confirmed merge blockers in scanner numeric + checks, I/O bridge, output cleanup, GPU attribution or saved-key optimization. + +## Native scenario matrix + +| Flow | Evidence / result | +| --- | --- | +| Build and exact development-bundle launch | Passed with bundle signature verification. | +| Live process listing and PID search | Passed with over 1,300 processes and isolated sleep fixture. | +| Selected-process chart | CPU/memory visible; no per-process GPU attribution. | +| Termination confirmation and cancel | Correct fixture PID, cancel retains live process. | +| Favorites / watch | Both changes reflected in fixture row, inspector and rail. | +| Copy / recovery | Command copy matches the fixture command; Restore last copy shows redacted-recovery confirmation. No repeated window timeout after optimization. | +| Actual TERM | Correct owned fixture PID confirmed; TERM sent and process exited with status 143. | +| Navigation | Applications, Processes, Hierarchy and Workflows display their expected grouping; search survives workspace switching and clear returns visible records. | +| Settings | General, Automations, Access and Support tabs render. Access refresh reports visible process and directory counts; notifications remain opt-in. | +| Automation fixture | Start Now transitions Idle to Running with a strong process link. Stop Current Run unloads the exact service and confirms no controlled process remains. Native launchctl/ps observations agree. | +| Automation editor | Typed fields match the fixture; Cancel retains the definition. | +| Final installation | Recorded in the PR completion evidence after protected checks and merge. | + +## Limitations and remaining risk + +- Some host LaunchAgent definitions are unreadable; the app reports partial + source health. They were not changed by this audit. +- Background Items diagnostics report an administrator-approval limitation on + the macOS 27 host. This does not prove background-item integration on other OS + versions; the app leaves the state explicit. +- macOS logged App Intents connection errors for the existing and development + bundles (`com.apple.linkd.autoShortcut`, Cocoa error 4097). The linkd service is + running; Shortcuts integration remains unverified on this host. +- A command descendant that deliberately escapes its process group while + retaining a pipe can outlive group cleanup. Review found no concrete production + trigger in the fixed system-tool call sites; general arbitrary-job isolation + is not claimed. +- Sandbox inspection, Apple Intelligence generation, notification delivery and + all accessibility/system appearance combinations require separate evidence. +- A local ad-hoc signed build is not a notarized public release. + +## Primary documentation checked + +- [Swift concurrency and cooperative cancellation](https://docs.swift.org/swift-book/LanguageGuide/Concurrency.html). +- [Apple Foundation Models availability and fallback](https://developer.apple.com/documentation/foundationmodels/generating-content-and-performing-tasks-with-foundation-models). +- [Apple LanguageModelSession](https://developer.apple.com/documentation/FoundationModels/LanguageModelSession). +- [Apple SwiftUI Table selection and sorting](https://developer.apple.com/documentation/swiftui/table). +- [POSIX spawn and process-group inheritance](https://pubs.opengroup.org/onlinepubs/007904975/functions/posix_spawn.html). +- [Swift 6.4 default Swift Build backend](https://www.swift.org/blog/swift-6.4-released/). + +Project standards checked: CONTRIBUTING.md, docs/ARCHITECTURE.md, +docs/RELEASING.md, PRIVACY.md, entitlements, privacy manifest and protected GitHub +checks. User-machine logs and screenshots are kept outside the public diff. diff --git a/script/build_release_bundle.sh b/script/build_release_bundle.sh index faa216e..df1f375 100755 --- a/script/build_release_bundle.sh +++ b/script/build_release_bundle.sh @@ -37,7 +37,6 @@ for architecture in $ARCHITECTURES; do BUILD_ARGS=( -c release --arch "$architecture" - --build-system native --scratch-path "$scratchPath" ) swift build "${BUILD_ARGS[@]}" >&2 From 5ca654f47e9ec0f4a5629f3e31b17325b6aef193 Mon Sep 17 00:00:00 2001 From: Rafal Sikora <24563931+s1korrrr@users.noreply.github.com> Date: Wed, 7 Oct 2026 12:04:43 +0200 Subject: [PATCH 2/2] fix(settings): show pending access checks accurately Keep the initial and refreshed Access assessment pending until a scan completes. Withhold requirement guidance and diagnostic copying while the result is unknown so an empty initial assessment cannot flash a false Blocked state. --- CHANGELOG.md | 2 ++ Sources/DevScope/Views/SettingsView.swift | 24 ++++++++++++++++++----- docs/verification/2026-10-07-audit.md | 1 + 3 files changed, 22 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3b60f6b..f76c8ef 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,8 @@ All notable changes to DevScope are documented here. The format follows - Favorites and watch matching use direct SHA-256 hex encoding and lexical executable names, preserving existing saved keys without filesystem probes. - Activity counts stay on one line when the process inventory exceeds 999 items. +- Access Settings show Checking during a scan, withholding stale requirement + guidance and diagnostics until the assessment finishes. ### Fixed diff --git a/Sources/DevScope/Views/SettingsView.swift b/Sources/DevScope/Views/SettingsView.swift index e4a7180..df7d5c3 100644 --- a/Sources/DevScope/Views/SettingsView.swift +++ b/Sources/DevScope/Views/SettingsView.swift @@ -38,6 +38,7 @@ struct SettingsView: View { errorDescription: nil ) @State private var isCheckingAccess = false + @State private var hasCheckedAccess = false var body: some View { TabView { @@ -140,7 +141,7 @@ struct SettingsView: View { Divider() - ForEach(accessAssessment.requirements, id: \.kind) { requirement in + ForEach(isAccessAssessmentPending ? [] : accessAssessment.requirements, id: \.kind) { requirement in AccessRequirementRow(requirement: requirement) { action in performAccessAction(action) } @@ -265,6 +266,7 @@ struct SettingsView: View { } .accessibilityLabel("Copy access diagnostics") .accessibilityHint("Copies DevScope access and sandbox diagnostics to the clipboard.") + .disabled(isAccessAssessmentPending) } } @@ -435,7 +437,11 @@ struct SettingsView: View { } private var accessSummary: ProcessAccessSummary { - ProcessAccessSummary(assessment: accessAssessment) + ProcessAccessSummary(assessment: accessAssessment, isChecking: isAccessAssessmentPending) + } + + private var isAccessAssessmentPending: Bool { + !hasCheckedAccess || isCheckingAccess } private var neededAccessActions: [ProcessAccessAction] { @@ -458,7 +464,8 @@ struct SettingsView: View { } private var shouldShowFullDiskAccessGuidance: Bool { - ProcessAccessStatus.isSandboxed || neededAccessActions.contains(.fullDiskAccess) + !isAccessAssessmentPending + && (ProcessAccessStatus.isSandboxed || neededAccessActions.contains(.fullDiskAccess)) } private func refreshAccessAssessment() { @@ -485,6 +492,7 @@ struct SettingsView: View { errorDescription: error.localizedDescription ) } + hasCheckedAccess = true isCheckingAccess = false } } @@ -608,8 +616,14 @@ private struct ProcessAccessSummary { let symbolName: String let tint: Color - init(assessment: ProcessAccessAssessment) { - if assessment.hasBlockedRequirement { + init(assessment: ProcessAccessAssessment, isChecking: Bool) { + if isChecking { + title = "Checking process access" + badge = "Checking" + message = "Inspecting current process metadata and working-directory access." + symbolName = "arrow.clockwise" + tint = .secondary + } else if assessment.hasBlockedRequirement { title = "Process access blocked" badge = "Blocked" message = diff --git a/docs/verification/2026-10-07-audit.md b/docs/verification/2026-10-07-audit.md index 4bf3e22..726b5cc 100644 --- a/docs/verification/2026-10-07-audit.md +++ b/docs/verification/2026-10-07-audit.md @@ -34,6 +34,7 @@ the existing deterministic fallback remains available. | Saved-item matching formats thousands of digest bytes and constructs file URLs | Main-thread work grows with process count | Direct lowercase hex encoding, no duplicate alias hash without CWD, lexical executable basename. Fixed hash vector preserves persisted key format. | | Four-digit rail counts wrap inside a fixed 22-point width | Inventory total becomes hard to read | Keep the count on one line with intrinsic width and a 22-point minimum. | | Release script forces SwiftPM's deprecated native backend | Swift 6.4 emits deprecation warnings | Use the toolchain's default backend for both build and binary lookup. | +| Access assessment starts with an empty blocked result while a scan runs | Temporary false permission warning | Show Checking and withhold requirement guidance and diagnostic copying until the scan finishes. | The performance reproduction used a native app snapshot containing over 1,300 processes with one favorite and watched fixture. A sample of the main thread