diff --git a/Sources/DevScope/App/AutomationComposition.swift b/Sources/DevScope/App/AutomationComposition.swift index 7e98880..32de0a2 100644 --- a/Sources/DevScope/App/AutomationComposition.swift +++ b/Sources/DevScope/App/AutomationComposition.swift @@ -89,14 +89,41 @@ enum DevScopeComposition { launchAgentsRoot: launchAgentsRoot, transactionRoot: transactionRoot ) + let cronUsername = currentUsername(for: currentUID) + let crontabProbe = CachedCrontabMutationProbe( + currentUsername: cronUsername, + run: { command in + try await runner.run(command) + } + ) let manager = AutomationManager( fileSystem: fileSystem, executor: executor, capabilityContext: { record in - try authority.context(for: record) + let context = try authority.context(for: record) + guard record.sourceKind == .crontab else { return context } + return AutomationCapabilityContext( + currentUID: context.currentUID, + canonicalPathIsApproved: context.canonicalPathIsApproved, + sourceOwnerUID: context.sourceOwnerUID, + isSymlink: context.isSymlink, + isManaged: context.isManaged, + implementedCapabilities: context.implementedCapabilities, + mutableSourceVerified: crontabProbe.cachedFailClosed() + ) }, destinationContext: { record, destination in - try authority.context(for: record, destination: destination) + let context = try authority.context(for: record, destination: destination) + guard record.sourceKind == .crontab else { return context } + return AutomationCapabilityContext( + currentUID: context.currentUID, + canonicalPathIsApproved: context.canonicalPathIsApproved, + sourceOwnerUID: context.sourceOwnerUID, + isSymlink: context.isSymlink, + isManaged: context.isManaged, + implementedCapabilities: context.implementedCapabilities, + mutableSourceVerified: crontabProbe.cachedFailClosed() + ) }, recoverableSource: { record in try await recoverableSources.capture(record) @@ -117,7 +144,10 @@ enum DevScopeComposition { inventoryService: inventoryService, manager: manager, capabilityDecisionProvider: AutomationAuthorityCapabilityDecisionProvider( - authority: authority + authority: authority, + crontabMutationVerified: { + await crontabProbe.verified() + } ), destinationProvider: AutomationManagementDestinationProvider( transactionRoot: transactionRoot diff --git a/Sources/DevScope/Infrastructure/AutomationManagementInfrastructure.swift b/Sources/DevScope/Infrastructure/AutomationManagementInfrastructure.swift index e185624..c525c4f 100644 --- a/Sources/DevScope/Infrastructure/AutomationManagementInfrastructure.swift +++ b/Sources/DevScope/Infrastructure/AutomationManagementInfrastructure.swift @@ -79,6 +79,36 @@ struct AutomationExecutorRouter: AutomationMutationApplying { } } +final class CachedCrontabMutationProbe: @unchecked Sendable { + private let currentUsername: String + private let run: @Sendable (AutomationCommand) async throws -> AutomationCommandResult + private let lock = NSLock() + private var cached: Bool? + + init( + currentUsername: String, + run: @escaping @Sendable (AutomationCommand) async throws -> AutomationCommandResult + ) { + self.currentUsername = currentUsername + self.run = run + } + + func verified() async -> Bool { + if let cached = lock.withLock({ cached }) { return cached } + let value = await CrontabMutationProbe.verifyWriteReadback( + currentUsername: currentUsername, + run: run + ) + lock.withLock { cached = value } + return value + } + + /// Fail closed until an async probe has completed successfully. + func cachedFailClosed() -> Bool { + lock.withLock { cached ?? false } + } +} + actor AutomationRecoverableSourceProvider { private let runner: any AutomationCommandRunning private let fileSystem: any AutomationFileSystem @@ -417,14 +447,36 @@ struct AutomationAuthorityCapabilityDecisionProvider: AutomationCapabilityDecisionProviding, Sendable { let authority: AutomationAuthorityContextBuilder + let crontabMutationVerified: @Sendable () async -> Bool + + init( + authority: AutomationAuthorityContextBuilder, + crontabMutationVerified: @escaping @Sendable () async -> Bool = { true } + ) { + self.authority = authority + self.crontabMutationVerified = crontabMutationVerified + } func decisions( for records: [AutomationRecord] ) async -> [AutomationRecord.ID: AutomationCapabilityDecision] { - await Task.detached(priority: .utility) { + let needsCronProbe = records.contains { $0.sourceKind == .crontab && $0.ownership == .user } + let cronWritable = needsCronProbe ? await crontabMutationVerified() : true + return await Task.detached(priority: .utility) { Dictionary(uniqueKeysWithValues: records.map { record in do { - let context = try authority.context(for: record) + var context = try authority.context(for: record) + if record.sourceKind == .crontab { + context = AutomationCapabilityContext( + currentUID: context.currentUID, + canonicalPathIsApproved: context.canonicalPathIsApproved, + sourceOwnerUID: context.sourceOwnerUID, + isSymlink: context.isSymlink, + isManaged: context.isManaged, + implementedCapabilities: context.implementedCapabilities, + mutableSourceVerified: cronWritable + ) + } return (record.id, AutomationCapabilityPolicy.decision(for: record, context: context)) } catch { return ( diff --git a/Sources/DevScopeCore/AutomationCapabilityPolicy.swift b/Sources/DevScopeCore/AutomationCapabilityPolicy.swift index 3c92ebb..416dc72 100644 --- a/Sources/DevScopeCore/AutomationCapabilityPolicy.swift +++ b/Sources/DevScopeCore/AutomationCapabilityPolicy.swift @@ -46,6 +46,9 @@ public struct AutomationCapabilityContext: Equatable, Sendable { public let isSymlink: Bool public let isManaged: Bool public let implementedCapabilities: Set + /// When false for crontab, mutation stays gated until write/readback is verified + /// (see `docs/release/0.1.0/SECURITY_STATUS.md` residual automation risk). + public let mutableSourceVerified: Bool public init( currentUID: uid_t, @@ -53,7 +56,8 @@ public struct AutomationCapabilityContext: Equatable, Sendable { sourceOwnerUID: uid_t?, isSymlink: Bool, isManaged: Bool, - implementedCapabilities: Set = [] + implementedCapabilities: Set = [], + mutableSourceVerified: Bool = true ) { self.currentUID = currentUID self.canonicalPathIsApproved = canonicalPathIsApproved @@ -61,6 +65,7 @@ public struct AutomationCapabilityContext: Equatable, Sendable { self.isSymlink = isSymlink self.isManaged = isManaged self.implementedCapabilities = implementedCapabilities + self.mutableSourceVerified = mutableSourceVerified } } @@ -120,6 +125,15 @@ public enum AutomationCapabilityPolicy { context: context ) } + if record.sourceKind == .crontab, !context.mutableSourceVerified { + let readRunCapabilities: Set = [ + .exportRecord, .startNow, .stopCurrentRun, + ] + return AutomationCapabilityDecision( + capabilities: readRunCapabilities.intersection(context.implementedCapabilities), + reason: "Current-user crontab write/readback could not be verified on this Mac." + ) + } let capabilities = Set(AutomationCapability.allCases) .intersection(context.implementedCapabilities) diff --git a/Sources/DevScopeCore/CronAutomationSource.swift b/Sources/DevScopeCore/CronAutomationSource.swift index 30b164a..1c75bed 100644 --- a/Sources/DevScopeCore/CronAutomationSource.swift +++ b/Sources/DevScopeCore/CronAutomationSource.swift @@ -444,7 +444,7 @@ public struct CronAutomationSource: AutomationSource { ) } - private static func isNoCrontabDiagnostic( + static func isNoCrontabDiagnostic( _ data: Data, currentUsername: String ) -> Bool { @@ -459,3 +459,82 @@ public struct CronAutomationSource: AutomationSource { || diagnostic == expected + "\r\n" } } + +/// Idempotent write/readback probe for current-user crontab mutation support. +/// Aligns capability advertising with `SECURITY_STATUS` residual automation risk. +public enum CrontabMutationProbe { + public static func verifyWriteReadback( + currentUsername: String, + run: (AutomationCommand) async throws -> AutomationCommandResult + ) async -> Bool { + do { + let listed = try await run(AutomationCommand( + executable: "/usr/bin/crontab", + arguments: ["-l"], + environment: ["LC_ALL": "C"] + )) + + let hadNoCrontab: Bool + let document: Data + if listed.status == 0 { + hadNoCrontab = false + document = listed.standardOutput + } else if listed.status == 1, + listed.standardOutput.isEmpty, + CronAutomationSource.isNoCrontabDiagnostic( + listed.standardError, + currentUsername: currentUsername + ) + { + hadNoCrontab = true + document = Data() + } else { + return false + } + + let staged = FileManager.default.temporaryDirectory + .appendingPathComponent("devscope-crontab-probe-\(UUID().uuidString)") + defer { try? FileManager.default.removeItem(at: staged) } + try document.write(to: staged, options: .atomic) + + let installed = try await run(AutomationCommand( + executable: "/usr/bin/crontab", + arguments: [staged.path], + environment: ["LC_ALL": "C"] + )) + guard installed.status == 0 else { return false } + + if hadNoCrontab { + // Restore absence: empty install may create a crontab document. + let removed = try await run(AutomationCommand( + executable: "/usr/bin/crontab", + arguments: ["-r"], + environment: ["LC_ALL": "C"] + )) + if removed.status == 0 { return true } + let again = try await run(AutomationCommand( + executable: "/usr/bin/crontab", + arguments: ["-l"], + environment: ["LC_ALL": "C"] + )) + return again.status == 1 + && again.standardOutput.isEmpty + && CronAutomationSource.isNoCrontabDiagnostic( + again.standardError, + currentUsername: currentUsername + ) + } + + let verified = try await run(AutomationCommand( + executable: "/usr/bin/crontab", + arguments: ["-l"], + environment: ["LC_ALL": "C"] + )) + guard verified.status == 0 else { return false } + return CronDocumentChecksum.checksum(verified.standardOutput) + == CronDocumentChecksum.checksum(document) + } catch { + return false + } + } +} diff --git a/Sources/DevScopeCore/ProcessActionPolicy.swift b/Sources/DevScopeCore/ProcessActionPolicy.swift index e0aee97..c5093a3 100644 --- a/Sources/DevScopeCore/ProcessActionPolicy.swift +++ b/Sources/DevScopeCore/ProcessActionPolicy.swift @@ -19,6 +19,16 @@ public enum ProcessActionPolicy { private static let protectedExecutables: Set = [ "kernel_task", "launchd", "loginwindow", "WindowServer", "runningboardd", "securityd", "tccd", "opendirectoryd", "powerd", + // Session-critical GUI / preference / audio agents (basename match). + "Finder", "Dock", "SystemUIServer", "cfprefsd", "distnoted", + "UserEventAgent", "coreaudiod", + ] + + /// Path prefixes for session-critical Apple infrastructure. Basename-only matching + /// misses renamed or nested CoreServices / libexec helpers. + private static let protectedPathPrefixes = [ + "/System/Library/CoreServices/", + "/usr/libexec/", ] public static func decision( @@ -32,9 +42,16 @@ public enum ProcessActionPolicy { if process.pid == 0 || process.pid == 1 || process.executableName == "launchd" { return .protected(reason: "macOS launch infrastructure is protected") } - if protectedExecutables.contains(process.executableName) { + if protectedExecutables.contains(process.executableName) + || isProtectedSystemPath(process.executable) + { return .protected(reason: "Critical macOS system infrastructure is protected") } return .allowed } + + private static func isProtectedSystemPath(_ executable: String) -> Bool { + let path = URL(fileURLWithPath: executable).standardizedFileURL.path + return protectedPathPrefixes.contains { path.hasPrefix($0) } + } } diff --git a/Sources/DevScopeCore/ProcessScanner.swift b/Sources/DevScopeCore/ProcessScanner.swift index 467e35c..79e2d50 100644 --- a/Sources/DevScopeCore/ProcessScanner.swift +++ b/Sources/DevScopeCore/ProcessScanner.swift @@ -184,14 +184,50 @@ public enum ProcessScanner { } public static func parseLsofCurrentDirectories(_ output: String) -> [Int32: String] { + parseLsofCurrentDirectories(Data(output.utf8)) + } + + /// Parses `lsof -F` field output. Prefer NUL-terminated fields (`-F …0`): a CWD path + /// may legally contain newlines on APFS, which would otherwise inject fake `p`/`n` records. + public static func parseLsofCurrentDirectories(_ output: Data) -> [Int32: String] { var currentPID: Int32? var result: [Int32: String] = [:] + let usesNUL = output.contains(0) + let separator: UInt8 = usesNUL ? 0 : UInt8(ascii: "\n") + let rawFields: [Data.SubSequence] = output.split( + separator: separator, + omittingEmptySubsequences: !usesNUL + ) - for line in output.split(separator: "\n", omittingEmptySubsequences: true) { - if line.hasPrefix("p") { - currentPID = Int32(line.dropFirst()) - } else if line.hasPrefix("n"), let currentPID { - result[currentPID] = String(line.dropFirst()) + for rawField in rawFields { + var field = Data(rawField) + while field.first == UInt8(ascii: "\n") || field.first == UInt8(ascii: "\r") { + field.removeFirst() + } + guard let type = field.first else { continue } + let value = field.dropFirst() + switch type { + case UInt8(ascii: "p"): + if let text = String(data: Data(value), encoding: .utf8), + let pid = Int32(text) + { + currentPID = pid + } else { + currentPID = nil + } + case UInt8(ascii: "n"): + if let currentPID, + let path = String(data: Data(value), encoding: .utf8), + !path.isEmpty + { + // Legacy NL mode cannot represent newline-bearing paths safely; drop them. + if !usesNUL, path.contains(where: \.isNewline) { + continue + } + result[currentPID] = path + } + default: + continue } } @@ -372,7 +408,8 @@ public final class SystemProcessScanner: @unchecked Sendable, ProcessProviding { do { result = try commandRunner.run( executableURL: URL(fileURLWithPath: "/usr/sbin/lsof"), - arguments: ["-a", "-d", "cwd", "-n", "-w", "-F", "pcn"] + // `-F pcn0`: NUL field terminators so CWD paths may contain newlines safely. + arguments: ["-a", "-d", "cwd", "-n", "-w", "-F", "pcn0"] ) } catch { return [:] @@ -382,9 +419,7 @@ public final class SystemProcessScanner: @unchecked Sendable, ProcessProviding { return [:] } - return ProcessScanner.parseLsofCurrentDirectories( - String(decoding: result.standardOutput, as: UTF8.self) - ) + return ProcessScanner.parseLsofCurrentDirectories(result.standardOutput) } } diff --git a/Tests/DevScopeCoreTests/AutomationCapabilityPolicyTests.swift b/Tests/DevScopeCoreTests/AutomationCapabilityPolicyTests.swift index ad78df7..72cc2e6 100644 --- a/Tests/DevScopeCoreTests/AutomationCapabilityPolicyTests.swift +++ b/Tests/DevScopeCoreTests/AutomationCapabilityPolicyTests.swift @@ -166,6 +166,30 @@ final class AutomationCapabilityPolicyTests: XCTestCase { ) } + func testCronWithoutVerifiedWriteReadbackKeepsMutationUnavailable() { + let cronRecord = automationRecord( + copying: Fixtures.userAgent, + kind: .cron, + sourceKind: .crontab, + sourceURL: nil + ) + let decision = AutomationCapabilityPolicy.decision( + for: cronRecord, + context: .fixture( + currentUID: 501, + canonicalPathIsApproved: true, + ownerUID: 501, + mutableSourceVerified: false + ) + ) + + XCTAssertEqual(decision.capabilities, [.exportRecord, .startNow, .stopCurrentRun]) + XCTAssertEqual( + decision.reason, + "Current-user crontab write/readback could not be verified on this Mac." + ) + } + func testExportAvailabilityIsGatedConsistentlyAndSurvivesPartialMutationAdapters() { let protected = AutomationCapabilityPolicy.decision( for: copyRecord(Fixtures.userAgent, ownership: .managed), diff --git a/Tests/DevScopeCoreTests/AutomationTestFixtures.swift b/Tests/DevScopeCoreTests/AutomationTestFixtures.swift index 50fcd6d..fe62faf 100644 --- a/Tests/DevScopeCoreTests/AutomationTestFixtures.swift +++ b/Tests/DevScopeCoreTests/AutomationTestFixtures.swift @@ -850,7 +850,8 @@ extension AutomationCapabilityContext { ownerUID: uid_t?, isSymlink: Bool = false, isManaged: Bool = false, - implementedCapabilities: Set = Set(AutomationCapability.allCases) + implementedCapabilities: Set = Set(AutomationCapability.allCases), + mutableSourceVerified: Bool = true ) -> Self { Self( currentUID: currentUID, @@ -858,7 +859,8 @@ extension AutomationCapabilityContext { sourceOwnerUID: ownerUID, isSymlink: isSymlink, isManaged: isManaged, - implementedCapabilities: implementedCapabilities + implementedCapabilities: implementedCapabilities, + mutableSourceVerified: mutableSourceVerified ) } } diff --git a/Tests/DevScopeCoreTests/BoundedSystemCommandRunnerTests.swift b/Tests/DevScopeCoreTests/BoundedSystemCommandRunnerTests.swift index 274d272..f64c9ff 100644 --- a/Tests/DevScopeCoreTests/BoundedSystemCommandRunnerTests.swift +++ b/Tests/DevScopeCoreTests/BoundedSystemCommandRunnerTests.swift @@ -25,6 +25,10 @@ final class BoundedSystemCommandRunnerTests: XCTestCase { XCTAssertEqual(snapshot.map(\.pid), [42]) XCTAssertEqual(snapshot.first?.currentDirectory, "/var/tmp") XCTAssertEqual(runner.requests.map(\.executablePath), ["/bin/ps", "/usr/sbin/lsof"]) + XCTAssertEqual( + runner.requests.last?.arguments, + ["-a", "-d", "cwd", "-n", "-w", "-F", "pcn0"] + ) } func testGPUMetricProviderRoutesIORegThroughTheBoundedRunner() throws { diff --git a/Tests/DevScopeCoreTests/CronAutomationSourceTests.swift b/Tests/DevScopeCoreTests/CronAutomationSourceTests.swift index 3c9061a..d2f7806 100644 --- a/Tests/DevScopeCoreTests/CronAutomationSourceTests.swift +++ b/Tests/DevScopeCoreTests/CronAutomationSourceTests.swift @@ -348,4 +348,64 @@ final class CronAutomationSourceTests: XCTestCase { XCTAssertTrue(snapshot.records.isEmpty) } } + + func testMutationProbeAcceptsIdempotentRewrite() async { + let document = Data("0 * * * * /bin/true\n".utf8) + let runner = ScriptedAutomationCommandRunner(results: [ + AutomationCommandResult(status: 0, standardOutput: document, standardError: Data()), + AutomationCommandResult(status: 0, standardOutput: Data(), standardError: Data()), + AutomationCommandResult(status: 0, standardOutput: document, standardError: Data()), + ]) + + let verified = await CrontabMutationProbe.verifyWriteReadback( + currentUsername: "ExampleUser", + run: { try await runner.run($0) } + ) + + XCTAssertTrue(verified) + XCTAssertEqual(runner.invocations.count, 3) + XCTAssertEqual(runner.invocations[0].arguments, ["-l"]) + XCTAssertEqual(runner.invocations[1].arguments.count, 1) + XCTAssertTrue(runner.invocations[1].arguments[0].contains("devscope-crontab-probe-")) + XCTAssertEqual(runner.invocations[2].arguments, ["-l"]) + } + + func testMutationProbeFailsClosedWhenInstallIsRejected() async { + let document = Data("0 * * * * /bin/true\n".utf8) + let runner = ScriptedAutomationCommandRunner(results: [ + AutomationCommandResult(status: 0, standardOutput: document, standardError: Data()), + AutomationCommandResult( + status: 1, + standardOutput: Data(), + standardError: Data("crontab: temporary failure\n".utf8) + ), + ]) + + let verified = await CrontabMutationProbe.verifyWriteReadback( + currentUsername: "ExampleUser", + run: { try await runner.run($0) } + ) + + XCTAssertFalse(verified) + } +} + +private final class ScriptedAutomationCommandRunner: AutomationCommandRunning, @unchecked Sendable { + private let lock = NSLock() + private var remaining: [AutomationCommandResult] + private(set) var invocations: [AutomationCommand] = [] + + init(results: [AutomationCommandResult]) { + remaining = results + } + + func run(_ command: AutomationCommand) async throws -> AutomationCommandResult { + lock.withLock { + invocations.append(command) + guard !remaining.isEmpty else { + return AutomationCommandResult(status: 1, standardOutput: Data(), standardError: Data()) + } + return remaining.removeFirst() + } + } } diff --git a/Tests/DevScopeCoreTests/ProcessActionPolicyTests.swift b/Tests/DevScopeCoreTests/ProcessActionPolicyTests.swift index 5aa6195..e28c6f7 100644 --- a/Tests/DevScopeCoreTests/ProcessActionPolicyTests.swift +++ b/Tests/DevScopeCoreTests/ProcessActionPolicyTests.swift @@ -67,6 +67,39 @@ final class ProcessActionPolicyTests: XCTestCase { ) } + func testProtectsSessionCriticalFinderAndDock() { + for executable in [ + "/System/Library/CoreServices/Finder.app/Contents/MacOS/Finder", + "/System/Library/CoreServices/Dock.app/Contents/MacOS/Dock", + "/System/Library/CoreServices/SystemUIServer.app/Contents/MacOS/SystemUIServer", + "/usr/sbin/cfprefsd", + "/usr/sbin/distnoted", + "/usr/libexec/UserEventAgent", + "/usr/sbin/coreaudiod", + ] { + let item = classified(pid: 4200, executable: executable, kind: .other) + let decision = ProcessActionPolicy.decision(for: item, currentProcessID: 9000) + XCTAssertFalse(decision.isAllowed, executable) + XCTAssertEqual(decision.reason, "Critical macOS system infrastructure is protected") + } + } + + func testProtectsCoreServicesAndLibexecPathsEvenWithUnknownBasenames() { + let coreServices = classified( + pid: 4200, + executable: "/System/Library/CoreServices/SomeHelper", + kind: .other + ) + let libexec = classified( + pid: 4201, + executable: "/usr/libexec/custom_session_helper", + kind: .other + ) + + XCTAssertFalse(ProcessActionPolicy.decision(for: coreServices, currentProcessID: 9000).isAllowed) + XCTAssertFalse(ProcessActionPolicy.decision(for: libexec, currentProcessID: 9000).isAllowed) + } + private func classified(pid: Int32, executable: String, kind: DevRuntimeKind) -> ClassifiedDevProcess { diff --git a/Tests/DevScopeCoreTests/SystemProcessScannerTests.swift b/Tests/DevScopeCoreTests/SystemProcessScannerTests.swift index de9866b..9d6d176 100644 --- a/Tests/DevScopeCoreTests/SystemProcessScannerTests.swift +++ b/Tests/DevScopeCoreTests/SystemProcessScannerTests.swift @@ -316,6 +316,31 @@ final class SystemProcessScannerTests: XCTestCase { XCTAssertEqual(directories[12176], "/Users/example/dev/sample-service") } + func testParsesNULTerminatedLsofCurrentDirectoriesWithEmbeddedNewlines() { + var output = Data() + for field in [ + "p12175", + "czsh", + "\nfcwd", + "n/Users/example/with\nnewline/project", + "\np12176", + "cnode", + "\nfcwd", + "n/Users/example/safe", + "\n", + ] { + output.append(contentsOf: Array(field.utf8)) + if field != "\n" { + output.append(0) + } + } + + let directories = ProcessScanner.parseLsofCurrentDirectories(output) + + XCTAssertEqual(directories[12175], "/Users/example/with\nnewline/project") + XCTAssertEqual(directories[12176], "/Users/example/safe") + } + func testMergesNativeExecutablePathWhenPSExecutableIsTruncated() { let birthToken = ProcessBirthToken(seconds: 1_000, microseconds: 321) let psProcess = DevProcess(