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
36 changes: 33 additions & 3 deletions Sources/DevScope/App/AutomationComposition.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -117,7 +144,10 @@ enum DevScopeComposition {
inventoryService: inventoryService,
manager: manager,
capabilityDecisionProvider: AutomationAuthorityCapabilityDecisionProvider(
authority: authority
authority: authority,
crontabMutationVerified: {
await crontabProbe.verified()
}
),
destinationProvider: AutomationManagementDestinationProvider(
transactionRoot: transactionRoot
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 (
Expand Down
16 changes: 15 additions & 1 deletion Sources/DevScopeCore/AutomationCapabilityPolicy.swift
Original file line number Diff line number Diff line change
Expand Up @@ -46,21 +46,26 @@ public struct AutomationCapabilityContext: Equatable, Sendable {
public let isSymlink: Bool
public let isManaged: Bool
public let implementedCapabilities: Set<AutomationCapability>
/// 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,
canonicalPathIsApproved: Bool,
sourceOwnerUID: uid_t?,
isSymlink: Bool,
isManaged: Bool,
implementedCapabilities: Set<AutomationCapability> = []
implementedCapabilities: Set<AutomationCapability> = [],
mutableSourceVerified: Bool = true
) {
self.currentUID = currentUID
self.canonicalPathIsApproved = canonicalPathIsApproved
self.sourceOwnerUID = sourceOwnerUID
self.isSymlink = isSymlink
self.isManaged = isManaged
self.implementedCapabilities = implementedCapabilities
self.mutableSourceVerified = mutableSourceVerified
}
}

Expand Down Expand Up @@ -120,6 +125,15 @@ public enum AutomationCapabilityPolicy {
context: context
)
}
if record.sourceKind == .crontab, !context.mutableSourceVerified {
let readRunCapabilities: Set<AutomationCapability> = [
.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)
Expand Down
81 changes: 80 additions & 1 deletion Sources/DevScopeCore/CronAutomationSource.swift
Original file line number Diff line number Diff line change
Expand Up @@ -444,7 +444,7 @@ public struct CronAutomationSource: AutomationSource {
)
}

private static func isNoCrontabDiagnostic(
static func isNoCrontabDiagnostic(
_ data: Data,
currentUsername: String
) -> Bool {
Expand All @@ -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
}
}
}
19 changes: 18 additions & 1 deletion Sources/DevScopeCore/ProcessActionPolicy.swift
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,16 @@ public enum ProcessActionPolicy {
private static let protectedExecutables: Set<String> = [
"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(
Expand All @@ -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) }
}
}
53 changes: 44 additions & 9 deletions Sources/DevScopeCore/ProcessScanner.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}

Expand Down Expand Up @@ -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 [:]
Expand All @@ -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)
}
}

Expand Down
Loading