From 37573a46bfc521e56b91729410b8bae59ea1ee3e Mon Sep 17 00:00:00 2001 From: ilya the frog <56070863+ilya-the-frog@users.noreply.github.com> Date: Fri, 23 Oct 2026 15:54:49 +0300 Subject: [PATCH 1/6] Attribute the recovery background item to Insomnia --- Resources/Info.plist | 2 + Sources/Insomnia/Core/BackstopCommand.swift | 24 ++++++++++++ Sources/Insomnia/Core/LaunchdBackstop.swift | 12 +++++- Sources/Insomnia/main.swift | 4 ++ .../InsomniaTests/LaunchdBackstopTests.swift | 35 +++++++++++++++-- Tests/InsomniaTests/PackagingTests.swift | 27 +++++++++++-- Tests/InsomniaTests/RecoveryScriptTests.swift | 39 ++++++++++++++++++- docs/releasing.md | 1 + scripts/install.sh | 27 +++++++++---- 9 files changed, 154 insertions(+), 17 deletions(-) create mode 100644 Sources/Insomnia/Core/BackstopCommand.swift diff --git a/Resources/Info.plist b/Resources/Info.plist index 3f212a64..3a5661c6 100644 --- a/Resources/Info.plist +++ b/Resources/Info.plist @@ -36,6 +36,8 @@ NSApplication InsomniaResumeFrozenVersion 1 + InsomniaBackstopVersion + 1 NSHumanReadableCopyright Copyright © 2026 Krish Garg. MIT License. diff --git a/Sources/Insomnia/Core/BackstopCommand.swift b/Sources/Insomnia/Core/BackstopCommand.swift new file mode 100644 index 00000000..b20de7b0 --- /dev/null +++ b/Sources/Insomnia/Core/BackstopCommand.swift @@ -0,0 +1,24 @@ +import Darwin +import Foundation + +/// Launchd starts our signed executable so Login Items can attribute the +/// recovery agent to Insomnia, then we replace it with the verified script. +enum BackstopCommand { + static let version = 1 + + static func run(_ arguments: [String], bundle: URL = Bundle.main.bundleURL) -> Int32? { + guard arguments.first == "--backstop" else { return nil } + guard arguments.count == 2, !arguments[1].isEmpty, bundle.pathExtension == "app" else { + fputs("usage: Insomnia --backstop (from Insomnia.app)\n", stderr) + return 64 + } + let command = ["/bin/sh", "-c", LaunchdBackstop.agentProgram, "sh", arguments[1], bundle.path] + var argv = command.map { strdup($0) } + [nil] + defer { for argument in argv { free(argument) } } + return argv.withUnsafeMutableBufferPointer { + execv("/bin/sh", $0.baseAddress!) + perror("Insomnia backstop: exec /bin/sh") + return 1 + } + } +} diff --git a/Sources/Insomnia/Core/LaunchdBackstop.swift b/Sources/Insomnia/Core/LaunchdBackstop.swift index b3dcd605..7012b71f 100644 --- a/Sources/Insomnia/Core/LaunchdBackstop.swift +++ b/Sources/Insomnia/Core/LaunchdBackstop.swift @@ -49,7 +49,8 @@ struct LaunchdBackstop: BackstopScheduling { static let pollInterval = 60 static let commandTimeout: TimeInterval = 15 - /// What launchd runs: `/bin/sh -c sh `. + /// What the headless `Insomnia --backstop ` mode runs: + /// `/bin/sh -c sh `. /// The program verifies the bundle ($2) against the requirement ($1) with /// codesign and execs the sealed backstop.sh only when that passes; the /// resource seal covers the script, so an edited copy fails here. On @@ -101,6 +102,12 @@ struct LaunchdBackstop: BackstopScheduling { guard FileManager.default.fileExists(atPath: scriptPath) else { throw BackstopError(message: "backstop.sh is not sealed in the app bundle at \(scriptPath); run scripts/install.sh") } + let infoURL = bundle.appendingPathComponent("Contents/Info.plist") + guard let data = try? Data(contentsOf: infoURL), + let info = try? PropertyListSerialization.propertyList(from: data, format: nil) as? [String: Any], + info["InsomniaBackstopVersion"] as? Int == BackstopCommand.version else { + throw BackstopError(message: "\(bundle.path) does not support Insomnia --backstop; run scripts/install.sh") + } // Before trusting a loaded agent: the bundle it would verify must // pass that verification now. A sealed script edited after signing // leaves the plist current and the job loaded, but the agent refuses @@ -142,7 +149,8 @@ struct LaunchdBackstop: BackstopScheduling { static func plistDictionary(label: String, target: BackstopTarget) -> [String: Any] { [ "Label": label, - "ProgramArguments": ["/bin/sh", "-c", agentProgram, "sh", target.requirement, target.bundle.path], + "AssociatedBundleIdentifiers": [Paths.bundleIdentifier], + "ProgramArguments": [target.bundle.appendingPathComponent("Contents/MacOS/Insomnia").path, "--backstop", target.requirement], "RunAtLoad": true, "StartInterval": pollInterval, ] diff --git a/Sources/Insomnia/main.swift b/Sources/Insomnia/main.swift index ec2613bf..48c620f5 100644 --- a/Sources/Insomnia/main.swift +++ b/Sources/Insomnia/main.swift @@ -1,5 +1,9 @@ import Foundation +if let status = BackstopCommand.run(Array(CommandLine.arguments.dropFirst())) { + exit(status) +} + // Entry point. A one-shot command-line mode is answered here, before AppKit // or SwiftUI start, so backstop.sh can borrow the app's kernel-level // identity check (see ResumeFrozenCommand), which ends itself when its diff --git a/Tests/InsomniaTests/LaunchdBackstopTests.swift b/Tests/InsomniaTests/LaunchdBackstopTests.swift index 0f3235cf..34e77208 100644 --- a/Tests/InsomniaTests/LaunchdBackstopTests.swift +++ b/Tests/InsomniaTests/LaunchdBackstopTests.swift @@ -56,6 +56,9 @@ final class LaunchdBackstopTests: XCTestCase { let script = home.paths.backstopScript try FileManager.default.createDirectory(at: script.deletingLastPathComponent(), withIntermediateDirectories: true) try Data("#!/bin/bash\n".utf8).write(to: script) + let info: [String: Any] = ["InsomniaBackstopVersion": BackstopCommand.version] + try PropertyListSerialization.data(fromPropertyList: info, format: .xml, options: 0) + .write(to: home.paths.appBundle.appendingPathComponent("Contents/Info.plist")) } let calls = calls, loaded = loaded, loadedJob = loadedJob, loadedInterval = loadedInterval, bootstrapFails = bootstrapFails, bootoutFails = bootoutFails let onBootout = onBootout, onBootstrapped = onBootstrapped, trustedPlistAtBootstrap = trustedPlistAtBootstrap @@ -198,14 +201,15 @@ final class LaunchdBackstopTests: XCTestCase { } private var expectedArguments: [String] { - ["/bin/sh", "-c", LaunchdBackstop.agentProgram, "sh", Self.requirement, home.paths.appBundle.path] + [home.paths.appBundle.appendingPathComponent("Contents/MacOS/Insomnia").path, "--backstop", Self.requirement] } func testPlistIsAPersistentPollingAgentThatVerifiesTheBundleFirst() { let target = BackstopTarget(bundle: URL(fileURLWithPath: "/x/Insomnia.app", isDirectory: true), requirement: "cdhash H\"ab\"") let d = LaunchdBackstop.plistDictionary(label: "com.insomnia.backstop", target: target) XCTAssertEqual(d["Label"] as? String, "com.insomnia.backstop") - XCTAssertEqual(d["ProgramArguments"] as? [String], ["/bin/sh", "-c", LaunchdBackstop.agentProgram, "sh", "cdhash H\"ab\"", "/x/Insomnia.app"]) + XCTAssertEqual(d["ProgramArguments"] as? [String], ["/x/Insomnia.app/Contents/MacOS/Insomnia", "--backstop", "cdhash H\"ab\""]) + XCTAssertEqual(d["AssociatedBundleIdentifiers"] as? [String], [Paths.bundleIdentifier]) XCTAssertEqual(d["RunAtLoad"] as? Bool, true) XCTAssertEqual(d["StartInterval"] as? Int, 60) XCTAssertNil(d["StartCalendarInterval"], "a per-deadline trigger would need a reload per extension") @@ -279,6 +283,31 @@ final class LaunchdBackstopTests: XCTestCase { XCTAssertTrue(loaded.value) } + func testVerifiedShellAgentIsMigratedToTheAppExecutable() async throws { + let b = try makeBackstop() + let arguments = ["/bin/sh", "-c", LaunchdBackstop.agentProgram, "sh", Self.requirement, home.paths.appBundle.path] + try writePlist(["Label": "com.insomnia.backstop", "ProgramArguments": arguments, "RunAtLoad": true, "StartInterval": 60]) + loaded.value = true + loadedJob.value = arguments + loadedInterval.value = 60 + try await b.arm() + XCTAssertEqual(calls.value.map { $0[1] }, ["bootout", "bootstrap"]) + XCTAssertEqual(try plistOnDisk()["ProgramArguments"] as? [String], expectedArguments) + XCTAssertEqual(try plistOnDisk()["AssociatedBundleIdentifiers"] as? [String], [Paths.bundleIdentifier]) + } + + func testDevelopmentBuildCannotArmTheNativeModeAgainstAnOlderInstalledBundle() async throws { + let b = try makeBackstop() + try FileManager.default.removeItem(at: home.paths.appBundle.appendingPathComponent("Contents/Info.plist")) + do { + try await b.arm() + XCTFail("an older installed binary would start the app instead of recovery") + } catch { + XCTAssertTrue(error.localizedDescription.contains("does not support Insomnia --backstop")) + } + XCTAssertTrue(calls.value.isEmpty) + } + /// After an upgrade the bundle's requirement changes (a new ad-hoc /// cdhash). The plist pinning the previous build would make the agent /// refuse the new bundle, so the first arm() of the new build reloads it. @@ -403,7 +432,7 @@ final class LaunchdBackstopTests: XCTestCase { let b = try makeBackstop() try await b.arm() let trusted = try Data(contentsOf: home.paths.backstopPlist) - loadedJob.value = ["/bin/sh", "-c", LaunchdBackstop.agentProgram, "sh", "cdhash H\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\"", home.paths.appBundle.path] + loadedJob.value = [home.paths.appBundle.appendingPathComponent("Contents/MacOS/Insomnia").path, "--backstop", "cdhash H\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\""] calls.value = [] try await b.arm() diff --git a/Tests/InsomniaTests/PackagingTests.swift b/Tests/InsomniaTests/PackagingTests.swift index 95bb3648..9b6f92a8 100644 --- a/Tests/InsomniaTests/PackagingTests.swift +++ b/Tests/InsomniaTests/PackagingTests.swift @@ -118,14 +118,15 @@ final class PackagingTests: XCTestCase { /// A throwaway bundle laid out the way install.sh lays out Insomnia.app /// (Mach-O main executable, Info.plist, backstop.sh under /// Contents/Resources), ad-hoc signed afterwards so the resource seal - /// covers the script. The executable is a copy of /usr/bin/true. Nothing + /// covers the script. The executable is a copy of the built Insomnia. Nothing /// here touches an installed app, a LaunchAgent or a keychain. private func makeSignedBundle(named name: String = "Insomnia") throws -> URL { let fm = FileManager.default let bundle = scratch.appendingPathComponent("\(name).app", isDirectory: true) try fm.createDirectory(at: bundle.appendingPathComponent("Contents/MacOS"), withIntermediateDirectories: true) try fm.createDirectory(at: bundle.appendingPathComponent("Contents/Resources"), withIntermediateDirectories: true) - try fm.copyItem(atPath: "/usr/bin/true", toPath: bundle.appendingPathComponent("Contents/MacOS/\(name)").path) + let binary = Bundle(for: Self.self).bundleURL.deletingLastPathComponent().appendingPathComponent("Insomnia") + try fm.copyItem(at: binary, to: bundle.appendingPathComponent("Contents/MacOS/\(name)")) let info: [String: Any] = [ "CFBundleExecutable": name, "CFBundleIdentifier": "com.kgarg.insomnia.packaging-test", "CFBundlePackageType": "APPL", "CFBundleShortVersionString": "0.0.1", @@ -143,7 +144,27 @@ final class PackagingTests: XCTestCase { /// the scratch tree so the refusal log lands there. private func runAgent(requirement: String, bundle: URL, home: URL) throws -> (status: Int32, output: String) { try FileManager.default.createDirectory(at: home, withIntermediateDirectories: true) - return try run("/bin/sh", ["-c", LaunchdBackstop.agentProgram, "sh", requirement, bundle.path], environment: ["HOME": home.path, "PATH": "/usr/bin:/bin"]) + return try run(bundle.appendingPathComponent("Contents/MacOS/\(bundle.deletingPathExtension().lastPathComponent)").path, + ["--backstop", requirement], environment: ["HOME": home.path, "PATH": "/usr/bin:/bin"]) + } + + func testBackstopModeRejectsMalformedArgumentsWithoutStartingTheApp() throws { + let bundle = try makeSignedBundle() + let executable = bundle.appendingPathComponent("Contents/MacOS/Insomnia").path + for arguments in [["--backstop"], ["--backstop", ""], ["--backstop", "x", "extra"]] { + let result = try run(executable, arguments) + XCTAssertEqual(result.status, 64, result.output) + XCTAssertTrue(result.output.contains("usage: Insomnia --backstop"), result.output) + } + XCTAssertNil(BackstopCommand.run(["--resume-frozen", "30"])) + XCTAssertEqual(BackstopCommand.run(["--backstop", "x"], bundle: scratch), 64) + } + + func testBundleDeclaresTheNativeBackstopInterface() throws { + let data = try Data(contentsOf: resources.appendingPathComponent("Info.plist")) + let plist = try XCTUnwrap(PropertyListSerialization.propertyList(from: data, format: nil) as? [String: Any]) + XCTAssertEqual(plist["InsomniaBackstopVersion"] as? Int, BackstopCommand.version) + XCTAssertEqual(plist["CFBundleIdentifier"] as? String, Paths.bundleIdentifier) } private func run(_ exe: String, _ args: [String], environment: [String: String]? = nil, currentDirectory: URL? = nil) throws -> (status: Int32, output: String) { diff --git a/Tests/InsomniaTests/RecoveryScriptTests.swift b/Tests/InsomniaTests/RecoveryScriptTests.swift index 54f7e159..e4e66454 100644 --- a/Tests/InsomniaTests/RecoveryScriptTests.swift +++ b/Tests/InsomniaTests/RecoveryScriptTests.swift @@ -4501,6 +4501,23 @@ final class RecoveryScriptTests: XCTestCase { XCTAssertTrue(r.stdout.contains("Uninstall:"), r.stdout) } + func testInstallKeepsTheShellLauncherForABundleWithoutTheBackstopMode() throws { + try fx.prepareInstall() + let prebuilt = try fx.writePrebuiltApp() + let info = prebuilt.appendingPathComponent("Contents/Info.plist") + let text = try String(contentsOf: info, encoding: .utf8) + try text.replacingOccurrences(of: "InsomniaBackstopVersion1", with: "") + .write(to: info, atomically: true, encoding: .utf8) + fx.setMode("launchctl", "loaded") + + let result = try fx.run(fx.installRedirected, ["--allow-unverified-origin", "--app", prebuilt.path], extraEnvironment: ["USER": "tester"]) + + XCTAssertEqual(result.status, 0, result.stderr + result.stdout) + let plist = try fx.plistOnDisk() + XCTAssertEqual(plist["ProgramArguments"] as? [String], ["/bin/sh", "-c", LaunchdBackstop.agentProgram, "sh", fx.requirement, fx.app.path]) + XCTAssertNil(plist["AssociatedBundleIdentifiers"], "a system shell cannot be attributed to our signer") + } + /// The checks run on a private copy, and that copy is what gets staged /// and pinned. A bundle swapped at the --app path while the password /// prompt waits (the fake sudo does it during visudo) is never copied in. @@ -5001,6 +5018,24 @@ final class RecoveryScriptTests: XCTestCase { XCTAssertTrue(pinsCurrent.stdout.contains("removed the bundle an interrupted run had set aside"), pinsCurrent.stdout) } + func testInstallRepairsAnInterruptedSwapUsingTheLegacyShellPlist() throws { + try fx.prepareInstall() + let previous = fx.appsDir.appendingPathComponent(".Insomnia.app.previous", isDirectory: true) + try fx.writeBundle(at: previous, marker: "previous") + try fx.writeBundle(at: fx.app, marker: "interrupted") + let plist: [String: Any] = [ + "Label": "com.insomnia.backstop", + "ProgramArguments": ["/bin/sh", "-c", LaunchdBackstop.agentProgram, "sh", fx.requirement, fx.app.path], + "RunAtLoad": true, "StartInterval": 60, + ] + try PropertyListSerialization.data(fromPropertyList: plist, format: .xml, options: 0).write(to: fx.plist) + try fx.rejectSignature(of: fx.app) + let result = try fx.run(fx.installRedirected, extraEnvironment: ["USER": "tester"]) + XCTAssertEqual(result.status, 1, result.stderr + result.stdout) + XCTAssertEqual(try fx.installedBinaryFirstLine(), "previous") + XCTAssertTrue(fx.calls().contains("codesign --verify --strict -R=\(fx.requirement) \(previous.path)")) + } + /// The interrupted run may have left its own job loaded (killed after /// its bootstrap, or its unload failed), and that job pins the build at /// $APP. The rerun first ends the stale session (its recovery step), and @@ -6420,7 +6455,8 @@ private final class ScriptFixture { try """ - CFBundleIdentifiercom.kgarg.insomnia + CFBundleIdentifiercom.kgarg.insomnia + InsomniaBackstopVersion1 """.write(to: resources.appendingPathComponent("Info.plist"), atomically: true, encoding: .utf8) try "icns".write(to: resources.appendingPathComponent("AppIcon.icns"), atomically: true, encoding: .utf8) let binroot = root.appendingPathComponent("binroot", isDirectory: true) @@ -6577,6 +6613,7 @@ private final class ScriptFixture { CFBundleIdentifier\(bundleID) CFBundlePackageTypeAPPL CFBundleShortVersionString\(version) + InsomniaBackstopVersion1 """.write(to: app.appendingPathComponent("Contents/Info.plist"), atomically: true, encoding: .utf8) if machO { diff --git a/docs/releasing.md b/docs/releasing.md index 51ef0f8d..2a17559f 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -222,6 +222,7 @@ below cover what only a real Mac shows. ### Sessions and recovery +- Login Items & Extensions lists the recovery agent as Insomnia with its app icon after an upgrade from the shell-based agent and after login; `launchctl print gui/$(id -u)/com.insomnia.backstop` starts `Insomnia --backstop` and records exit code 0 without opening the menu bar app. Ad-hoc builds may still say "Item from unidentified developer". Do not reset the background task database to clear a cached shell entry. - Normal end, deadline expiry, and repeated Quit restore live power state - Sleep guard read back: on a Mac where another process sets SleepDisabled back to 0 about 2 s after `pmset -a disablesleep 1` (for example a root LaunchDaemon that loops `pmset -a disablesleep 0`), Start fails after about 5 s with "SleepDisabled read 0 again" and no session runs; on a Mac where the bit holds, the session starts about 5 s after Start and `pmset -g` reads SleepDisabled 1 - Force-quit followed by launchd deadline recovery and retry after failure diff --git a/scripts/install.sh b/scripts/install.sh index 48ba9ea4..cca6a5f1 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -617,7 +617,11 @@ done # it is not known which of the two it pins. plist_pins_previous() { local pinned rc - pinned="$("$PLUTIL" -extract ProgramArguments.4 raw -o - "$PLIST" 2>/dev/null || true)" + if [[ "$("$PLUTIL" -extract ProgramArguments.1 raw -o - "$PLIST" 2>/dev/null || true)" == --backstop ]]; then + pinned="$("$PLUTIL" -extract ProgramArguments.2 raw -o - "$PLIST" 2>/dev/null || true)" + else + pinned="$("$PLUTIL" -extract ProgramArguments.4 raw -o - "$PLIST" 2>/dev/null || true)" + fi [[ -n "$pinned" ]] || return 1 rc=0 bounded "$CODESIGN" --verify --strict "-R=$pinned" "$APP" || rc=$? @@ -824,7 +828,9 @@ fi # 6. LaunchAgent: verifies the bundle and runs its sealed backstop at load # and every 60 s. The backstop enforces the saved deadline itself and is # a no-op while the session on disk is valid. The plist's ProgramArguments -# are `/bin/sh -c "$AGENT_PROGRAM" sh "$REQUIREMENT" "$APP"`: the program +# start `Insomnia --backstop "$REQUIREMENT"` without the UI, so Login +# Items associates the agent with the app instead of a system shell. +# Older bundles without that mode keep the shell launcher. The program # runs `codesign --verify --strict -R=` on the bundle and # execs Contents/Resources/backstop.sh only when that passes; otherwise it # logs one line to $LOG_DIR/insomnia.log and exits 1 without running @@ -853,6 +859,11 @@ before="$(loaded_state)" # shellcheck disable=SC2016 # the $1/$2/$HOME/$r below are for the agent's shell, not this one AGENT_PROGRAM='r="$(/usr/bin/codesign --verify --strict "-R=$1" "$2" 2>&1)" && exec /bin/bash "$2/Contents/Resources/backstop.sh"; mkdir -p "$HOME/Library/Logs/Insomnia"; printf "%s [error] backstop agent: %s does not satisfy the pinned code requirement; backstop.sh not run. Reinstall Insomnia (scripts/install.sh). codesign: %s\n" "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$2" "$(printf %s "$r" | tr "\n" " ")" >> "$HOME/Library/Logs/Insomnia/insomnia.log"; exit 1' +AGENT_ARGUMENTS=(/bin/sh -c "$AGENT_PROGRAM" sh "$REQUIREMENT" "$APP") +NATIVE_BACKSTOP="$("$PLUTIL" -extract InsomniaBackstopVersion raw -o - "$NEW_APP/Contents/Info.plist" 2>/dev/null || true)" +if [[ "$NATIVE_BACKSTOP" == 1 ]]; then + AGENT_ARGUMENTS=("$APP/Contents/MacOS/Insomnia" --backstop "$REQUIREMENT") +fi xml_escape() { sed -e 's/&/\&/g' -e 's//\>/g'; } cat > "$CANDIDATE" < @@ -861,14 +872,14 @@ cat > "$CANDIDATE" < Label $LABEL +$(if [[ "$NATIVE_BACKSTOP" == 1 ]]; then + printf '\tAssociatedBundleIdentifiers\n\tcom.kgarg.insomnia\n' +fi) ProgramArguments - /bin/sh - -c - $(printf '%s' "$AGENT_PROGRAM" | xml_escape) - sh - $(printf '%s' "$REQUIREMENT" | xml_escape) - $(printf '%s' "$APP" | xml_escape) +$(for argument in "${AGENT_ARGUMENTS[@]}"; do + printf '\t\t%s\n' "$(printf '%s' "$argument" | xml_escape)" +done) RunAtLoad From 88bdf644817fda742dee84ff288f5c27d507210f Mon Sep 17 00:00:00 2001 From: ilya the frog <56070863+ilya-the-frog@users.noreply.github.com> Date: Fri, 23 Oct 2026 16:27:28 +0300 Subject: [PATCH 2/6] Enforce the pinned code identity before launching native recovery --- Resources/Info.plist | 4 +- SECURITY.md | 6 ++ Sources/Insomnia/Core/BackstopCommand.swift | 4 +- Sources/Insomnia/Core/LaunchdBackstop.swift | 54 ++++++++++---- Sources/Insomnia/System/CodeRequirement.swift | 20 +++++ Sources/Insomnia/main.swift | 7 +- .../InsomniaTests/LaunchdBackstopTests.swift | 73 ++++++++++++++----- Tests/InsomniaTests/PackagingTests.swift | 19 ++++- Tests/InsomniaTests/RecoveryScriptTests.swift | 15 ++-- docs/install.md | 13 +++- docs/releasing.md | 2 + docs/spec.md | 13 ++++ scripts/install.sh | 36 +++++++-- 13 files changed, 209 insertions(+), 57 deletions(-) diff --git a/Resources/Info.plist b/Resources/Info.plist index 3a5661c6..e1d5614a 100644 --- a/Resources/Info.plist +++ b/Resources/Info.plist @@ -36,8 +36,8 @@ NSApplication InsomniaResumeFrozenVersion 1 - InsomniaBackstopVersion - 1 + InsomniaBackstopCommand + --backstop NSHumanReadableCopyright Copyright © 2026 Krish Garg. MIT License. diff --git a/SECURITY.md b/SECURITY.md index 3bcbf397..f7313282 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -49,6 +49,12 @@ In releases built by the Release workflow and in builds of the current source, the recovery LaunchAgent runs only the `backstop.sh` sealed inside the signed app bundle, after `codesign --verify --strict` passes against the code requirement pinned in its plist (the build's cdhash for an ad-hoc signature). +For current ad-hoc builds, launchd first enforces a `SpawnConstraint` with +those cdhashes before starting `Insomnia --backstop`. A different binary, +even one re-signed ad-hoc that skips the script verifier, is rejected before +its entry point. This uses macOS launch-constraint enforcement, with System +Integrity Protection enabled. Older bundles and requirements that cannot +be represented entirely as cdhashes keep the external system-shell verifier. An edited bundle or script is refused and logged. The app pins the code it is running (its own designated requirement, after confirming that the bundle on disk is still that code and passes the agent's check), so a bundle edited or diff --git a/Sources/Insomnia/Core/BackstopCommand.swift b/Sources/Insomnia/Core/BackstopCommand.swift index b20de7b0..05059ede 100644 --- a/Sources/Insomnia/Core/BackstopCommand.swift +++ b/Sources/Insomnia/Core/BackstopCommand.swift @@ -4,10 +4,10 @@ import Foundation /// Launchd starts our signed executable so Login Items can attribute the /// recovery agent to Insomnia, then we replace it with the verified script. enum BackstopCommand { - static let version = 1 + static let argument = "--backstop" static func run(_ arguments: [String], bundle: URL = Bundle.main.bundleURL) -> Int32? { - guard arguments.first == "--backstop" else { return nil } + guard arguments.first == argument else { return nil } guard arguments.count == 2, !arguments[1].isEmpty, bundle.pathExtension == "app" else { fputs("usage: Insomnia --backstop (from Insomnia.app)\n", stderr) return 64 diff --git a/Sources/Insomnia/Core/LaunchdBackstop.swift b/Sources/Insomnia/Core/LaunchdBackstop.swift index 7012b71f..baa97812 100644 --- a/Sources/Insomnia/Core/LaunchdBackstop.swift +++ b/Sources/Insomnia/Core/LaunchdBackstop.swift @@ -10,6 +10,9 @@ import Foundation /// signature against the requirement pinned in the plist and only then /// execs the script, so a backstop.sh edited on disk is never run by /// launchd. Nothing executable lives in a writable support directory. +/// Before launching the native entry point, launchd enforces SpawnConstraint +/// against the pinned cdhashes. A replacement binary cannot bypass the check +/// by declining to run agentProgram. Older bundles use the external shell. /// /// What the app pins is the requirement of the code it is itself running /// (CodeRequirement.pin), after checking that the bundle on disk still is @@ -34,6 +37,7 @@ struct BackstopTarget: Sendable, Equatable { /// Designated requirement of the bundle's signature, in requirement /// language, as `codesign -d -r-` prints it (see CodeRequirement). let requirement: String + var nativeExecutable: URL? = nil var script: URL { Paths.backstopScript(inBundle: bundle) } } @@ -102,12 +106,6 @@ struct LaunchdBackstop: BackstopScheduling { guard FileManager.default.fileExists(atPath: scriptPath) else { throw BackstopError(message: "backstop.sh is not sealed in the app bundle at \(scriptPath); run scripts/install.sh") } - let infoURL = bundle.appendingPathComponent("Contents/Info.plist") - guard let data = try? Data(contentsOf: infoURL), - let info = try? PropertyListSerialization.propertyList(from: data, format: nil) as? [String: Any], - info["InsomniaBackstopVersion"] as? Int == BackstopCommand.version else { - throw BackstopError(message: "\(bundle.path) does not support Insomnia --backstop; run scripts/install.sh") - } // Before trusting a loaded agent: the bundle it would verify must // pass that verification now. A sealed script edited after signing // leaves the plist current and the job loaded, but the agent refuses @@ -118,10 +116,13 @@ struct LaunchdBackstop: BackstopScheduling { } catch { throw BackstopError(message: "the recovery agent cannot pin \(bundle.path): \(error.localizedDescription). Reinstall with scripts/install.sh") } - let desired = Self.plistDictionary(label: label, target: BackstopTarget(bundle: bundle, requirement: requirement)) + let desired = Self.plistDictionary(label: label, target: BackstopTarget( + bundle: bundle, requirement: requirement, nativeExecutable: Self.nativeExecutable(in: bundle) + )) // Armed when the plist the next login loads is this build's and the // loaded job is the one it describes: the same command line, started - // every `pollInterval` seconds. A loaded label alone may be another + // every `pollInterval` seconds, with the native spawn constraint + // present. A loaded label alone may be another // build's job, pinning a bundle or requirement this one does not // satisfy (install.sh can leave one loaded when it stops between its // bootstrap and publishing the plist), or a job loaded from a plist @@ -144,16 +145,30 @@ struct LaunchdBackstop: BackstopScheduling { // MARK: Plist + static func nativeExecutable(in bundle: URL) -> URL? { + guard let data = try? Data(contentsOf: bundle.appendingPathComponent("Contents/Info.plist")), + let info = try? PropertyListSerialization.propertyList(from: data, format: nil) as? [String: Any], + info["InsomniaBackstopCommand"] as? String == BackstopCommand.argument, + let name = info["CFBundleExecutable"] as? String, + !name.isEmpty, name != ".", name != "..", !name.contains("/") else { return nil } + return bundle.appendingPathComponent("Contents/MacOS").appendingPathComponent(name) + } + /// Pure builder, testable without launchd. Must produce exactly what /// install.sh writes, or every arm() reloads the agent. static func plistDictionary(label: String, target: BackstopTarget) -> [String: Any] { - [ + var plist: [String: Any] = [ "Label": label, - "AssociatedBundleIdentifiers": [Paths.bundleIdentifier], - "ProgramArguments": [target.bundle.appendingPathComponent("Contents/MacOS/Insomnia").path, "--backstop", target.requirement], + "ProgramArguments": ["/bin/sh", "-c", agentProgram, "sh", target.requirement, target.bundle.path], "RunAtLoad": true, "StartInterval": pollInterval, ] + if let executable = target.nativeExecutable, let hashes = CodeRequirement.launchHashes(in: target.requirement) { + plist["ProgramArguments"] = [executable.path, BackstopCommand.argument, target.requirement] + plist["SpawnConstraint"] = ["cdhash": ["$in": hashes]] + plist["AssociatedBundleIdentifiers"] = [Paths.bundleIdentifier] + } + return plist } func plistOnDiskMatches(_ desired: [String: Any]) -> Bool { @@ -233,15 +248,19 @@ struct LaunchdBackstop: BackstopScheduling { /// StartInterval in seconds; nil when the job has none, so launchd /// never starts it again by itself. var runInterval: Int? + /// Older native agents ran the same command without enforcing its pin. + var hasSpawnConstraint: Bool - init(arguments: [String], runInterval: Int?) { + init(arguments: [String], runInterval: Int?, hasSpawnConstraint: Bool = false) { self.arguments = arguments self.runInterval = runInterval + self.hasSpawnConstraint = hasSpawnConstraint } /// The job a plist from `plistDictionary` loads as. init(plist: [String: Any]) { - self.init(arguments: plist["ProgramArguments"] as? [String] ?? [], runInterval: plist["StartInterval"] as? Int) + self.init(arguments: plist["ProgramArguments"] as? [String] ?? [], runInterval: plist["StartInterval"] as? Int, + hasSpawnConstraint: plist["SpawnConstraint"] != nil) } } @@ -258,12 +277,14 @@ struct LaunchdBackstop: BackstopScheduling { /// seconds` is there only for a job with a StartInterval. nil when the /// arguments block is missing, repeated, unclosed or holds a line this /// does not know; a run interval in another form reads as none. Either - /// way the job does not match a plist, and arm() reloads it. + /// way the job does not match a plist, and arm() reloads it. The top-level + /// properties line reports whether the native spawn constraint is loaded. static func loadedJob(fromPrint output: String) -> LoadedJob? { let intervalPrefix = "\trun interval = ", intervalSuffix = " seconds" var arguments: [String]? var inArguments = false var runInterval: Int? + var hasSpawnConstraint = false for line in output.split(separator: "\n", omittingEmptySubsequences: false) { if inArguments { if line == "\t}" { @@ -279,10 +300,13 @@ struct LaunchdBackstop: BackstopScheduling { inArguments = true } else if line.hasPrefix(intervalPrefix), line.hasSuffix(intervalSuffix) { runInterval = Int(line.dropFirst(intervalPrefix.count).dropLast(intervalSuffix.count)) + } else if line.hasPrefix("\tproperties = ") { + hasSpawnConstraint = line.dropFirst("\tproperties = ".count) + .components(separatedBy: " | ").contains("has spawn constraint") } } guard let arguments, !inArguments else { return nil } - return LoadedJob(arguments: arguments, runInterval: runInterval) + return LoadedJob(arguments: arguments, runInterval: runInterval, hasSpawnConstraint: hasSpawnConstraint) } /// bootout by service target (ignored if not loaded: the trusted path may diff --git a/Sources/Insomnia/System/CodeRequirement.swift b/Sources/Insomnia/System/CodeRequirement.swift index 2574b16d..713c6a46 100644 --- a/Sources/Insomnia/System/CodeRequirement.swift +++ b/Sources/Insomnia/System/CodeRequirement.swift @@ -16,6 +16,26 @@ import Security /// which install.sh reads; the two must agree for the app to recognise the /// plist install.sh wrote (PackagingTests checks this on a scratch bundle). enum CodeRequirement { + /// Only a requirement made entirely of cdhash clauses can be enforced + /// before executing ad-hoc code. Keep the external shell verifier for + /// any other requirement instead of dropping a clause we do not know. + static func launchHashes(in requirement: String) -> [Data]? { + var hashes: [Data] = [] + for clause in requirement.components(separatedBy: " or ") { + guard clause.hasPrefix("cdhash H\""), clause.hasSuffix("\"") else { return nil } + let hex = Array(clause.dropFirst(9).dropLast().utf8) + guard hex.count == 40, hex.allSatisfy({ "0123456789abcdefABCDEF".utf8.contains($0) }) else { return nil } + var hash = Data() + for offset in stride(from: 0, to: hex.count, by: 2) { + let pair = String(decoding: hex[offset..<(offset + 2)], as: UTF8.self) + guard let byte = UInt8(pair, radix: 16) else { return nil } + hash.append(byte) + } + hashes.append(hash) + } + return hashes + } + struct ReadError: LocalizedError { let path: String let step: String diff --git a/Sources/Insomnia/main.swift b/Sources/Insomnia/main.swift index 48c620f5..38a28cf3 100644 --- a/Sources/Insomnia/main.swift +++ b/Sources/Insomnia/main.swift @@ -1,13 +1,12 @@ import Foundation +// Answer both recovery commands before starting AppKit or SwiftUI. The +// backstop mode execs the verified script; resume-frozen performs the +// kernel-level process identity check with its own lifetime limit. if let status = BackstopCommand.run(Array(CommandLine.arguments.dropFirst())) { exit(status) } -// Entry point. A one-shot command-line mode is answered here, before AppKit -// or SwiftUI start, so backstop.sh can borrow the app's kernel-level -// identity check (see ResumeFrozenCommand), which ends itself when its -// lifetime is up. Anything else starts the menu bar app. if let output = ResumeFrozenCommand.run(Array(CommandLine.arguments.dropFirst()), endAfter: ResumeFrozenCommand.endProcess) { for line in output.lines { print(line) } exit(output.status) diff --git a/Tests/InsomniaTests/LaunchdBackstopTests.swift b/Tests/InsomniaTests/LaunchdBackstopTests.swift index 34e77208..7d781ccf 100644 --- a/Tests/InsomniaTests/LaunchdBackstopTests.swift +++ b/Tests/InsomniaTests/LaunchdBackstopTests.swift @@ -19,6 +19,7 @@ final class LaunchdBackstopTests: XCTestCase { let loadedJob = Locked<[String]>([]) /// Its StartInterval, which `launchctl print` lists as `run interval`. let loadedInterval = Locked(nil) + let loadedConstraint = Locked(false) let bootstrapFails = Locked(false) /// bootout returns an error and leaves the job loaded, as launchd does /// for a job that is mid-transition. @@ -37,6 +38,7 @@ final class LaunchdBackstopTests: XCTestCase { loaded.value = false loadedJob.value = [] loadedInterval.value = nil + loadedConstraint.value = false bootstrapFails.value = false bootoutFails.value = false onBootout.value = nil @@ -56,11 +58,11 @@ final class LaunchdBackstopTests: XCTestCase { let script = home.paths.backstopScript try FileManager.default.createDirectory(at: script.deletingLastPathComponent(), withIntermediateDirectories: true) try Data("#!/bin/bash\n".utf8).write(to: script) - let info: [String: Any] = ["InsomniaBackstopVersion": BackstopCommand.version] + let info: [String: Any] = ["InsomniaBackstopCommand": BackstopCommand.argument, "CFBundleExecutable": "Insomnia"] try PropertyListSerialization.data(fromPropertyList: info, format: .xml, options: 0) .write(to: home.paths.appBundle.appendingPathComponent("Contents/Info.plist")) } - let calls = calls, loaded = loaded, loadedJob = loadedJob, loadedInterval = loadedInterval, bootstrapFails = bootstrapFails, bootoutFails = bootoutFails + let calls = calls, loaded = loaded, loadedJob = loadedJob, loadedInterval = loadedInterval, loadedConstraint = loadedConstraint, bootstrapFails = bootstrapFails, bootoutFails = bootoutFails let onBootout = onBootout, onBootstrapped = onBootstrapped, trustedPlistAtBootstrap = trustedPlistAtBootstrap let trusted = home.paths.backstopPlist let label = "com.insomnia.backstop" @@ -92,7 +94,7 @@ final class LaunchdBackstopTests: XCTestCase { switch args.first { case "print": guard loaded.value else { return ShellResult(status: 113, stdout: "", stderr: "Could not find service") } - return ShellResult(status: 0, stdout: Self.printOutput(arguments: loadedJob.value, runInterval: loadedInterval.value), stderr: "") + return ShellResult(status: 0, stdout: Self.printOutput(arguments: loadedJob.value, runInterval: loadedInterval.value, hasSpawnConstraint: loadedConstraint.value), stderr: "") case "bootstrap": trustedPlistAtBootstrap.value.append(try? Data(contentsOf: trusted)) guard args.count == 3, launchdAccepts(args[2]) else { @@ -106,6 +108,7 @@ final class LaunchdBackstopTests: XCTestCase { .flatMap { try? PropertyListSerialization.propertyList(from: $0, format: nil) as? [String: Any] } loadedJob.value = plist?["ProgramArguments"] as? [String] ?? [] loadedInterval.value = plist?["StartInterval"] as? Int + loadedConstraint.value = plist?["SpawnConstraint"] != nil if let hook = onBootstrapped.value { onBootstrapped.value = nil; hook() } return ShellResult(status: 0, stdout: "", stderr: "") case "bootout": @@ -131,7 +134,7 @@ final class LaunchdBackstopTests: XCTestCase { /// launchctl prints (checked 2026-10-02): top-level keys indented by one /// tab, each argument on its own line indented by two, and `run /// interval` only for a job with a StartInterval. - static func printOutput(arguments: [String], runInterval: Int?) -> String { + static func printOutput(arguments: [String], runInterval: Int?, hasSpawnConstraint: Bool = false) -> String { """ gui/501/com.insomnia.backstop = { \tactive count = 0 @@ -150,7 +153,7 @@ final class LaunchdBackstopTests: XCTestCase { \tdomain = gui/501 [100022] \tcpumon = default \(runInterval.map { "\trun interval = \($0) seconds\n" } ?? "") - \tproperties = runatload | inferred program | managed LWCR | has LWCR + \tproperties = runatload | inferred program | managed LWCR | has LWCR\(hasSpawnConstraint ? " | has spawn constraint" : "") } """ @@ -205,17 +208,46 @@ final class LaunchdBackstopTests: XCTestCase { } func testPlistIsAPersistentPollingAgentThatVerifiesTheBundleFirst() { - let target = BackstopTarget(bundle: URL(fileURLWithPath: "/x/Insomnia.app", isDirectory: true), requirement: "cdhash H\"ab\"") + let target = BackstopTarget(bundle: URL(fileURLWithPath: "/x/Insomnia.app", isDirectory: true), requirement: Self.requirement, nativeExecutable: URL(fileURLWithPath: "/x/Insomnia.app/Contents/MacOS/Insomnia")) let d = LaunchdBackstop.plistDictionary(label: "com.insomnia.backstop", target: target) XCTAssertEqual(d["Label"] as? String, "com.insomnia.backstop") - XCTAssertEqual(d["ProgramArguments"] as? [String], ["/x/Insomnia.app/Contents/MacOS/Insomnia", "--backstop", "cdhash H\"ab\""]) + XCTAssertEqual(d["ProgramArguments"] as? [String], ["/x/Insomnia.app/Contents/MacOS/Insomnia", "--backstop", Self.requirement]) XCTAssertEqual(d["AssociatedBundleIdentifiers"] as? [String], [Paths.bundleIdentifier]) + let constraint = d["SpawnConstraint"] as? [String: [String: [Data]]] + XCTAssertEqual(constraint?["cdhash"]?["$in"], CodeRequirement.launchHashes(in: Self.requirement)) XCTAssertEqual(d["RunAtLoad"] as? Bool, true) XCTAssertEqual(d["StartInterval"] as? Int, 60) XCTAssertNil(d["StartCalendarInterval"], "a per-deadline trigger would need a reload per extension") XCTAssertEqual(target.script.path, "/x/Insomnia.app/Contents/Resources/backstop.sh") } + func testSpawnHashesRequireEveryClauseToBeAnExactCdHash() { + let other = "cdhash H\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\"" + XCTAssertEqual(CodeRequirement.launchHashes(in: Self.requirement + " or " + other)?.count, 2) + XCTAssertEqual(CodeRequirement.launchHashes(in: other), [Data(repeating: 0xaa, count: 20)]) + for invalid in ["", "cdhash H\"ab\"", "cdhash H\"+1aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\"", + Self.requirement + " or always", Self.requirement + " and anchor apple", + "identifier com.kgarg.insomnia and anchor apple generic"] { + XCTAssertNil(CodeRequirement.launchHashes(in: invalid), invalid) + } + } + + func testUnknownRequirementsKeepTheExternalVerifier() { + let target = BackstopTarget(bundle: home.paths.appBundle, requirement: "identifier com.kgarg.insomnia", + nativeExecutable: home.paths.appBundle.appendingPathComponent("Contents/MacOS/Insomnia")) + let plist = LaunchdBackstop.plistDictionary(label: "com.insomnia.backstop", target: target) + XCTAssertEqual((plist["ProgramArguments"] as? [String])?.first, "/bin/sh") + XCTAssertNil(plist["SpawnConstraint"]) + } + + func testNativeExecutableComesFromTheBundleMetadata() throws { + _ = try makeBackstop() + let info: [String: Any] = ["InsomniaBackstopCommand": BackstopCommand.argument, "CFBundleExecutable": "Custom helper"] + try PropertyListSerialization.data(fromPropertyList: info, format: .xml, options: 0) + .write(to: home.paths.appBundle.appendingPathComponent("Contents/Info.plist")) + XCTAssertEqual(LaunchdBackstop.nativeExecutable(in: home.paths.appBundle)?.lastPathComponent, "Custom helper") + } + /// The program itself: verify the bundle ($2) against the requirement /// ($1), exec the sealed script only then, and never run anything else. /// PackagingTests runs it against a real signed bundle; this pins the @@ -296,16 +328,23 @@ final class LaunchdBackstopTests: XCTestCase { XCTAssertEqual(try plistOnDisk()["AssociatedBundleIdentifiers"] as? [String], [Paths.bundleIdentifier]) } - func testDevelopmentBuildCannotArmTheNativeModeAgainstAnOlderInstalledBundle() async throws { + func testDevelopmentBuildKeepsTheExternalVerifierForAnOlderInstalledBundle() async throws { let b = try makeBackstop() try FileManager.default.removeItem(at: home.paths.appBundle.appendingPathComponent("Contents/Info.plist")) - do { - try await b.arm() - XCTFail("an older installed binary would start the app instead of recovery") - } catch { - XCTAssertTrue(error.localizedDescription.contains("does not support Insomnia --backstop")) - } - XCTAssertTrue(calls.value.isEmpty) + try await b.arm() + XCTAssertEqual(try plistOnDisk()["ProgramArguments"] as? [String], + ["/bin/sh", "-c", LaunchdBackstop.agentProgram, "sh", Self.requirement, home.paths.appBundle.path]) + XCTAssertNil(try plistOnDisk()["SpawnConstraint"]) + } + + func testLoadedNativeJobWithoutTheConstraintIsReloaded() async throws { + let b = try makeBackstop() + try await b.arm() + loadedConstraint.value = false + calls.value = [] + try await b.arm() + XCTAssertEqual(calls.value.map { $0[1] }, ["print", "bootout", "bootstrap"]) + XCTAssertTrue(loadedConstraint.value) } /// After an upgrade the bundle's requirement changes (a new ad-hoc @@ -455,8 +494,8 @@ final class LaunchdBackstopTests: XCTestCase { /// has no StartInterval. func testLoadedJobIsReadFromLaunchctlsLayout() { XCTAssertEqual( - LaunchdBackstop.loadedJob(fromPrint: Self.printOutput(arguments: expectedArguments, runInterval: 60)), - LaunchdBackstop.LoadedJob(arguments: expectedArguments, runInterval: 60) + LaunchdBackstop.loadedJob(fromPrint: Self.printOutput(arguments: expectedArguments, runInterval: 60, hasSpawnConstraint: true)), + LaunchdBackstop.LoadedJob(arguments: expectedArguments, runInterval: 60, hasSpawnConstraint: true) ) let captured = """ gui/501/com.insomnia.backstop = { diff --git a/Tests/InsomniaTests/PackagingTests.swift b/Tests/InsomniaTests/PackagingTests.swift index 9b6f92a8..a85433a6 100644 --- a/Tests/InsomniaTests/PackagingTests.swift +++ b/Tests/InsomniaTests/PackagingTests.swift @@ -126,6 +126,7 @@ final class PackagingTests: XCTestCase { try fm.createDirectory(at: bundle.appendingPathComponent("Contents/MacOS"), withIntermediateDirectories: true) try fm.createDirectory(at: bundle.appendingPathComponent("Contents/Resources"), withIntermediateDirectories: true) let binary = Bundle(for: Self.self).bundleURL.deletingLastPathComponent().appendingPathComponent("Insomnia") + _ = try XCTUnwrap(fm.isExecutableFile(atPath: binary.path) ? binary : nil, "Build the Insomnia product before running packaging tests; expected \(binary.path)") try fm.copyItem(at: binary, to: bundle.appendingPathComponent("Contents/MacOS/\(name)")) let info: [String: Any] = [ "CFBundleExecutable": name, "CFBundleIdentifier": "com.kgarg.insomnia.packaging-test", @@ -163,10 +164,26 @@ final class PackagingTests: XCTestCase { func testBundleDeclaresTheNativeBackstopInterface() throws { let data = try Data(contentsOf: resources.appendingPathComponent("Info.plist")) let plist = try XCTUnwrap(PropertyListSerialization.propertyList(from: data, format: nil) as? [String: Any]) - XCTAssertEqual(plist["InsomniaBackstopVersion"] as? Int, BackstopCommand.version) + XCTAssertEqual(plist["InsomniaBackstopCommand"] as? String, BackstopCommand.argument) XCTAssertEqual(plist["CFBundleIdentifier"] as? String, Paths.bundleIdentifier) } + func testNativeSpawnConstraintPinsTheActualExecutableHash() throws { + let bundle = try makeSignedBundle() + let requirement = try CodeRequirement.designated(ofCodeAt: bundle) + let executable = bundle.appendingPathComponent("Contents/MacOS/Insomnia") + let target = BackstopTarget(bundle: bundle, requirement: requirement, nativeExecutable: executable) + let plist = LaunchdBackstop.plistDictionary(label: "com.insomnia.backstop", target: target) + let constraint = try XCTUnwrap(plist["SpawnConstraint"] as? [String: [String: [Data]]]) + let hashes = try XCTUnwrap(constraint["cdhash"]?["$in"]) + XCTAssertFalse(hashes.isEmpty) + let signature = try run("/usr/bin/codesign", ["-dvvv", executable.path]) + XCTAssertEqual(signature.status, 0, signature.output) + for hash in hashes { + XCTAssertTrue(signature.output.contains("CDHash=" + hash.map { String(format: "%02x", $0) }.joined()), signature.output) + } + } + private func run(_ exe: String, _ args: [String], environment: [String: String]? = nil, currentDirectory: URL? = nil) throws -> (status: Int32, output: String) { let p = Process() p.executableURL = URL(fileURLWithPath: exe) diff --git a/Tests/InsomniaTests/RecoveryScriptTests.swift b/Tests/InsomniaTests/RecoveryScriptTests.swift index e4e66454..818f6730 100644 --- a/Tests/InsomniaTests/RecoveryScriptTests.swift +++ b/Tests/InsomniaTests/RecoveryScriptTests.swift @@ -4426,7 +4426,7 @@ final class RecoveryScriptTests: XCTestCase { XCTAssertEqual(plist["StartInterval"] as? Int, 60) // The plist must be exactly what the app builds for the same bundle // and requirement, or the app reloads the agent at every start. - let expected = LaunchdBackstop.plistDictionary(label: "com.insomnia.backstop", target: BackstopTarget(bundle: fx.app, requirement: fx.requirement)) + let expected = LaunchdBackstop.plistDictionary(label: "com.insomnia.backstop", target: BackstopTarget(bundle: fx.app, requirement: fx.requirement, nativeExecutable: fx.app.appendingPathComponent("Contents/MacOS/Insomnia"))) XCTAssertTrue(NSDictionary(dictionary: plist).isEqual(to: expected), "install.sh wrote \(plist), the app builds \(expected)") XCTAssertTrue(calls.contains { $0.hasPrefix("codesign --verify --strict -R=\(fx.requirement) \(fx.appsDir.path)/.Insomnia.app.staging.") && $0.hasSuffix("/Insomnia.app") }, "the installer runs the agent's own check once, on the staged bundle: \(calls)") XCTAssertEqual(try fx.installedBinaryFirstLine(), "#!/bin/bash", "the new bundle is installed") @@ -4496,7 +4496,7 @@ final class RecoveryScriptTests: XCTestCase { XCTAssertTrue(fx.exists(fx.installedBackstop)) XCTAssertTrue(fx.exists(prebuilt), "the source bundle is copied, not moved") let plist = try fx.plistOnDisk() - let expected = LaunchdBackstop.plistDictionary(label: "com.insomnia.backstop", target: BackstopTarget(bundle: fx.app, requirement: fx.requirement)) + let expected = LaunchdBackstop.plistDictionary(label: "com.insomnia.backstop", target: BackstopTarget(bundle: fx.app, requirement: fx.requirement, nativeExecutable: fx.app.appendingPathComponent("Contents/MacOS/Insomnia"))) XCTAssertTrue(NSDictionary(dictionary: plist).isEqual(to: expected), "same agent as a source install: \(plist)") XCTAssertTrue(r.stdout.contains("Uninstall:"), r.stdout) } @@ -4506,7 +4506,7 @@ final class RecoveryScriptTests: XCTestCase { let prebuilt = try fx.writePrebuiltApp() let info = prebuilt.appendingPathComponent("Contents/Info.plist") let text = try String(contentsOf: info, encoding: .utf8) - try text.replacingOccurrences(of: "InsomniaBackstopVersion1", with: "") + try text.replacingOccurrences(of: "InsomniaBackstopCommand--backstop", with: "") .write(to: info, atomically: true, encoding: .utf8) fx.setMode("launchctl", "loaded") @@ -4700,7 +4700,7 @@ final class RecoveryScriptTests: XCTestCase { XCTAssertEqual(installed.status, 0, "the ditto copy at the installed path verifies: \(installed.output)") XCTAssertNoThrow(try CodeRequirement.verify(codeAt: fx.app, satisfies: requirement)) let plist = try fx.plistOnDisk() - let expected = LaunchdBackstop.plistDictionary(label: "com.insomnia.backstop", target: BackstopTarget(bundle: fx.app, requirement: requirement)) + let expected = LaunchdBackstop.plistDictionary(label: "com.insomnia.backstop", target: BackstopTarget(bundle: fx.app, requirement: requirement, nativeExecutable: fx.app.appendingPathComponent("Contents/MacOS/Insomnia"))) XCTAssertTrue(NSDictionary(dictionary: plist).isEqual(to: expected), "the agent pins the real requirement: \(plist)") XCTAssertTrue(fx.calls().contains { $0.hasPrefix("launchctl bootstrap") }, "\(fx.calls())") @@ -6456,7 +6456,8 @@ private final class ScriptFixture { CFBundleIdentifiercom.kgarg.insomnia - InsomniaBackstopVersion1 + CFBundleExecutableInsomnia + InsomniaBackstopCommand--backstop """.write(to: resources.appendingPathComponent("Info.plist"), atomically: true, encoding: .utf8) try "icns".write(to: resources.appendingPathComponent("AppIcon.icns"), atomically: true, encoding: .utf8) let binroot = root.appendingPathComponent("binroot", isDirectory: true) @@ -6613,7 +6614,7 @@ private final class ScriptFixture { CFBundleIdentifier\(bundleID) CFBundlePackageTypeAPPL CFBundleShortVersionString\(version) - InsomniaBackstopVersion1 + InsomniaBackstopCommand--backstop """.write(to: app.appendingPathComponent("Contents/Info.plist"), atomically: true, encoding: .utf8) if machO { @@ -7197,7 +7198,7 @@ private final class ScriptFixture { /// The agent plist an install writes for the bundle at $APP, pinning /// the fake codesign's requirement. func writeAgentPlist() throws { - let dict = LaunchdBackstop.plistDictionary(label: "com.insomnia.backstop", target: BackstopTarget(bundle: app, requirement: requirement)) + let dict = LaunchdBackstop.plistDictionary(label: "com.insomnia.backstop", target: BackstopTarget(bundle: app, requirement: requirement, nativeExecutable: app.appendingPathComponent("Contents/MacOS/Insomnia"))) let data = try PropertyListSerialization.data(fromPropertyList: dict, format: .xml, options: 0) try data.write(to: plist) } diff --git a/docs/install.md b/docs/install.md index cca173e1..a0bfe931 100644 --- a/docs/install.md +++ b/docs/install.md @@ -189,9 +189,16 @@ login Keychain, not the configuration file. The recovery agent runs at login and every 60 seconds. Its command line pins the installed bundle's code requirement (for an ad-hoc build, the cdhash of that build) and runs `codesign --verify --strict` against it before executing -the `backstop.sh` sealed inside the bundle. An edited bundle or script fails -that check: the agent writes one line to `insomnia.log` and runs nothing until -you reinstall. So no other account can edit it, the installer removes group +the `backstop.sh` sealed inside the bundle. Current ad-hoc builds start the +app's headless recovery command, with a `SpawnConstraint` that launchd checks +against the pinned cdhashes before the binary executes. Restoring an old +binary or replacing it with one that skips verification is rejected by +launchd, with a code-signing diagnostic in the system log. This enforcement +requires System Integrity Protection to be enabled. An edited sealed script +is refused by the subsequent full-bundle check and logged in `insomnia.log`. +Older bundles and other code-requirement formats keep the external shell +verifier. Reinstall to restore the matching bundle/agent pair. +So no other account can edit it, the installer removes group and other write permission and every ACL from the bundle it installs. The signature covers neither, so the bundle still verifies, and extended attributes such as a download's quarantine flag are kept. No executable is diff --git a/docs/releasing.md b/docs/releasing.md index 2a17559f..8a3c523c 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -223,6 +223,8 @@ below cover what only a real Mac shows. ### Sessions and recovery - Login Items & Extensions lists the recovery agent as Insomnia with its app icon after an upgrade from the shell-based agent and after login; `launchctl print gui/$(id -u)/com.insomnia.backstop` starts `Insomnia --backstop` and records exit code 0 without opening the menu bar app. Ad-hoc builds may still say "Item from unidentified developer". Do not reset the background task database to clear a cached shell entry. +- With SIP enabled, test the native plist's `SpawnConstraint` on a disposable agent: the pinned ad-hoc executable runs, but a different ad-hoc executable that only writes a marker (and performs no verification) must leave no marker and report `OS_REASON_CODESIGNING`. The same replacement without the constraint must run as a control. Restore an older Insomnia binary under the native plist and confirm that no GUI starts. Keep the full-bundle verification tests for edited scripts too. +- Measure idle recovery launch cost against the external shell with the same temporary clean journal; record warm-run timings, since the native entry point loads the app's linked frameworks every minute. - Normal end, deadline expiry, and repeated Quit restore live power state - Sleep guard read back: on a Mac where another process sets SleepDisabled back to 0 about 2 s after `pmset -a disablesleep 1` (for example a root LaunchDaemon that loops `pmset -a disablesleep 0`), Start fails after about 5 s with "SleepDisabled read 0 again" and no session runs; on a Mac where the bit holds, the session starts about 5 s after Start and `pmset -g` reads SleepDisabled 1 - Force-quit followed by launchd deadline recovery and retry after failure diff --git a/docs/spec.md b/docs/spec.md index 55402a81..d81573fa 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -59,6 +59,19 @@ closed bag. Its design goals are to: ## Core model +The recovery agent starts the app's headless `--backstop` command only when +the bundle advertises that command in `InsomniaBackstopCommand` and its +pinned requirement consists entirely of cdhash clauses. `SpawnConstraint` +lets launchd enforce those hashes before any app code executes; the command +then runs the existing full-bundle verifier before the sealed script. Both +the installer and `arm()` use the external shell verifier for older bundles +or other requirement formats, including a `swift run` build targeting an +older installed app. The executable name comes from `CFBundleExecutable`. +The native bundle path gives Background Task Management the app identity; +`AssociatedBundleIdentifiers` also names the app, but is not proof of +attribution for ad-hoc signatures with no Team ID. Appearance still needs +the real-Mac check, and no Developer ID identity is implied. + ``` Session { startedAt: Date diff --git a/scripts/install.sh b/scripts/install.sh index cca6a5f1..5f5fae65 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -55,6 +55,7 @@ LAUNCHCTL=/bin/launchctl SUDO=/usr/bin/sudo PLUTIL=/usr/bin/plutil CODESIGN=/usr/bin/codesign +BASE64=/usr/bin/base64 DITTO=/usr/bin/ditto CHMOD=/bin/chmod SYSCTL=/usr/sbin/sysctl @@ -860,10 +861,32 @@ before="$(loaded_state)" # shellcheck disable=SC2016 # the $1/$2/$HOME/$r below are for the agent's shell, not this one AGENT_PROGRAM='r="$(/usr/bin/codesign --verify --strict "-R=$1" "$2" 2>&1)" && exec /bin/bash "$2/Contents/Resources/backstop.sh"; mkdir -p "$HOME/Library/Logs/Insomnia"; printf "%s [error] backstop agent: %s does not satisfy the pinned code requirement; backstop.sh not run. Reinstall Insomnia (scripts/install.sh). codesign: %s\n" "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$2" "$(printf %s "$r" | tr "\n" " ")" >> "$HOME/Library/Logs/Insomnia/insomnia.log"; exit 1' AGENT_ARGUMENTS=(/bin/sh -c "$AGENT_PROGRAM" sh "$REQUIREMENT" "$APP") -NATIVE_BACKSTOP="$("$PLUTIL" -extract InsomniaBackstopVersion raw -o - "$NEW_APP/Contents/Info.plist" 2>/dev/null || true)" -if [[ "$NATIVE_BACKSTOP" == 1 ]]; then - AGENT_ARGUMENTS=("$APP/Contents/MacOS/Insomnia" --backstop "$REQUIREMENT") -fi +# Only complete cdhash requirements can become a pre-execution constraint. +# Unknown clauses keep the external verifier, as in LaunchdBackstop.swift. +launch_hashes() { + local rest="$REQUIREMENT" clause hash encoded i + while :; do + clause="${rest%% or *}" + [[ "$clause" =~ ^cdhash\ H\"([[:xdigit:]]{40})\"$ ]] || return 1 + hash="${BASH_REMATCH[1]}" + encoded="$(for (( i = 0; i < 40; i += 2 )); do printf '%b' "\\x${hash:i:2}"; done | "$BASE64")" || return 1 + [[ -n "$encoded" ]] || return 1 + printf '\t\t\t%s\n' "$encoded" + [[ "$rest" == *" or "* ]] || break + rest="${rest#* or }" + done +} +LAUNCH_HASHES="$(launch_hashes)" || LAUNCH_HASHES="" +NATIVE_BACKSTOP="$("$PLUTIL" -extract InsomniaBackstopCommand raw -o - "$NEW_APP/Contents/Info.plist" 2>/dev/null || true)" +EXECUTABLE_NAME="$("$PLUTIL" -extract CFBundleExecutable raw -o - "$NEW_APP/Contents/Info.plist" 2>/dev/null || true)" +AGENT_EXECUTABLE="" +case "$EXECUTABLE_NAME" in + ""|.|..|*/*) ;; + *) if [[ "$NATIVE_BACKSTOP" == --backstop && -n "$LAUNCH_HASHES" ]]; then + AGENT_EXECUTABLE="$APP/Contents/MacOS/$EXECUTABLE_NAME" + AGENT_ARGUMENTS=("$AGENT_EXECUTABLE" --backstop "$REQUIREMENT") + fi ;; +esac xml_escape() { sed -e 's/&/\&/g' -e 's//\>/g'; } cat > "$CANDIDATE" < @@ -872,8 +895,9 @@ cat > "$CANDIDATE" < Label $LABEL -$(if [[ "$NATIVE_BACKSTOP" == 1 ]]; then - printf '\tAssociatedBundleIdentifiers\n\tcom.kgarg.insomnia\n' +$(if [[ -n "$AGENT_EXECUTABLE" ]]; then + printf '\tAssociatedBundleIdentifiers\n\t%s\n' "$BUNDLE_ID" + printf '\tSpawnConstraint\n\tcdhash%s\n%s\n\t\n' "\$in" "$LAUNCH_HASHES" fi) ProgramArguments From 52c95a2b7e6b16834618bb1a003d0826ca73f140 Mon Sep 17 00:00:00 2001 From: ilya the frog <56070863+ilya-the-frog@users.noreply.github.com> Date: Fri, 23 Oct 2026 16:59:46 +0300 Subject: [PATCH 3/6] Verify loaded native jobs against captured output and gate launch on SIP --- SECURITY.md | 9 +- Sources/Insomnia/Core/LaunchdBackstop.swift | 18 ++- .../InsomniaTests/LaunchdBackstopTests.swift | 141 +++++++++++++++++- Tests/InsomniaTests/RecoveryScriptTests.swift | 24 +++ docs/install.md | 6 +- docs/releasing.md | 2 + docs/spec.md | 6 +- scripts/install.sh | 4 +- 8 files changed, 201 insertions(+), 9 deletions(-) diff --git a/SECURITY.md b/SECURITY.md index f7313282..104f1fbc 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -53,8 +53,13 @@ For current ad-hoc builds, launchd first enforces a `SpawnConstraint` with those cdhashes before starting `Insomnia --backstop`. A different binary, even one re-signed ad-hoc that skips the script verifier, is rejected before its entry point. This uses macOS launch-constraint enforcement, with System -Integrity Protection enabled. Older bundles and requirements that cannot -be represented entirely as cdhashes keep the external system-shell verifier. +Integrity Protection enabled. The installer and every `arm()` require an +affirmative `csrutil status` result before selecting native execution; +disabled, custom, unreadable or timed-out status uses the external +system-shell verifier, as do older bundles and requirements that cannot +be represented entirely as cdhashes. After changing SIP, a previously +registered native job depends on the new OS enforcement state until the +app next arms it; changing SIP does not itself rewrite the stored plist. An edited bundle or script is refused and logged. The app pins the code it is running (its own designated requirement, after confirming that the bundle on disk is still that code and passes the agent's check), so a bundle edited or diff --git a/Sources/Insomnia/Core/LaunchdBackstop.swift b/Sources/Insomnia/Core/LaunchdBackstop.swift index baa97812..4b9939f2 100644 --- a/Sources/Insomnia/Core/LaunchdBackstop.swift +++ b/Sources/Insomnia/Core/LaunchdBackstop.swift @@ -49,6 +49,7 @@ struct LaunchdBackstop: BackstopScheduling { typealias BundlePinner = @Sendable (_ bundle: URL) throws -> String static let launchctl = "/bin/launchctl" + static let csrutil = "/usr/bin/csrutil" /// Seconds between backstop.sh runs while loaded. install.sh writes the same value. static let pollInterval = 60 static let commandTimeout: TimeInterval = 15 @@ -116,8 +117,23 @@ struct LaunchdBackstop: BackstopScheduling { } catch { throw BackstopError(message: "the recovery agent cannot pin \(bundle.path): \(error.localizedDescription). Reinstall with scripts/install.sh") } + var executable = Self.nativeExecutable(in: bundle) + if executable != nil, CodeRequirement.launchHashes(in: requirement) != nil { + // Recheck each arm: launch constraints rely on SIP enforcement. + do { + let status = try await run(Self.csrutil, ["status"]) + let output = status.stdout.hasSuffix("\n") ? String(status.stdout.dropLast()) : status.stdout + if !status.succeeded || !status.stderr.isEmpty || output != "System Integrity Protection status: enabled." { + executable = nil + } + } catch is CancellationError { + throw CancellationError() + } catch { + executable = nil + } + } let desired = Self.plistDictionary(label: label, target: BackstopTarget( - bundle: bundle, requirement: requirement, nativeExecutable: Self.nativeExecutable(in: bundle) + bundle: bundle, requirement: requirement, nativeExecutable: executable )) // Armed when the plist the next login loads is this build's and the // loaded job is the one it describes: the same command line, started diff --git a/Tests/InsomniaTests/LaunchdBackstopTests.swift b/Tests/InsomniaTests/LaunchdBackstopTests.swift index 7d781ccf..b1d1b1c0 100644 --- a/Tests/InsomniaTests/LaunchdBackstopTests.swift +++ b/Tests/InsomniaTests/LaunchdBackstopTests.swift @@ -10,8 +10,12 @@ final class LaunchdBackstopTests: XCTestCase { /// fake reader below hands it out for the fixture bundle. static let requirement = "cdhash H\"0f1e2d3c4b5a69788796a5b4c3d2e1f00f1e2d3c\"" var home: TempHome! - /// (exe, args) of every command the backstop ran. + /// (exe, args) of every launchctl command the backstop ran. let calls = Locked<[[String]]>([]) + let integrityStatus = Locked(ShellResult(status: 0, stdout: "System Integrity Protection status: enabled.\n", stderr: "")) + let integrityReadFails = Locked(false) + let integrityReadCancelled = Locked(false) + let integrityReads = Locked(0) /// Whether `launchctl print` reports the job loaded. let loaded = Locked(false) /// The ProgramArguments of the plist the loaded job was bootstrapped @@ -20,6 +24,7 @@ final class LaunchdBackstopTests: XCTestCase { /// Its StartInterval, which `launchctl print` lists as `run interval`. let loadedInterval = Locked(nil) let loadedConstraint = Locked(false) + let printedJob = Locked(nil) let bootstrapFails = Locked(false) /// bootout returns an error and leaves the job loaded, as launchd does /// for a job that is mid-transition. @@ -35,10 +40,15 @@ final class LaunchdBackstopTests: XCTestCase { override func setUp() { home = TempHome() calls.value = [] + integrityStatus.value = ShellResult(status: 0, stdout: "System Integrity Protection status: enabled.\n", stderr: "") + integrityReadFails.value = false + integrityReadCancelled.value = false + integrityReads.value = 0 loaded.value = false loadedJob.value = [] loadedInterval.value = nil loadedConstraint.value = false + printedJob.value = nil bootstrapFails.value = false bootoutFails.value = false onBootout.value = nil @@ -63,7 +73,9 @@ final class LaunchdBackstopTests: XCTestCase { .write(to: home.paths.appBundle.appendingPathComponent("Contents/Info.plist")) } let calls = calls, loaded = loaded, loadedJob = loadedJob, loadedInterval = loadedInterval, loadedConstraint = loadedConstraint, bootstrapFails = bootstrapFails, bootoutFails = bootoutFails + let integrityStatus = integrityStatus, integrityReadFails = integrityReadFails, integrityReadCancelled = integrityReadCancelled, integrityReads = integrityReads let onBootout = onBootout, onBootstrapped = onBootstrapped, trustedPlistAtBootstrap = trustedPlistAtBootstrap + let printedJob = printedJob let trusted = home.paths.backstopPlist let label = "com.insomnia.backstop" // What launchd itself enforces on a path argument (proven against a @@ -90,11 +102,18 @@ final class LaunchdBackstopTests: XCTestCase { return requirement } return LaunchdBackstop(paths: home.paths, bundle: bundle, pin: pin, uid: 501) { exe, args in + if exe == LaunchdBackstop.csrutil { + integrityReads.value += 1 + XCTAssertEqual(args, ["status"]) + if integrityReadCancelled.value { throw CancellationError() } + if integrityReadFails.value { throw BackstopError(message: "status unavailable") } + return integrityStatus.value + } calls.value.append([exe] + args) switch args.first { case "print": guard loaded.value else { return ShellResult(status: 113, stdout: "", stderr: "Could not find service") } - return ShellResult(status: 0, stdout: Self.printOutput(arguments: loadedJob.value, runInterval: loadedInterval.value, hasSpawnConstraint: loadedConstraint.value), stderr: "") + return ShellResult(status: 0, stdout: printedJob.value ?? Self.printOutput(arguments: loadedJob.value, runInterval: loadedInterval.value, hasSpawnConstraint: loadedConstraint.value), stderr: "") case "bootstrap": trustedPlistAtBootstrap.value.append(try? Data(contentsOf: trusted)) guard args.count == 3, launchdAccepts(args[2]) else { @@ -130,6 +149,72 @@ final class LaunchdBackstopTests: XCTestCase { } } + /// Captured from a disposable native job using the generated constraint. + /// Paths, identifiers, hashes and numeric IDs are replaced; layout and + /// properties are launchctl's actual output, not inferred from the plist. + static let capturedNative = """ + gui/502/com.insomnia.spawn-probe.fixture = { + \tactive count = 0 + \tpath = /private/tmp/insomnia-spawn-probe-fixture/com.insomnia.spawn-probe.fixture.plist + \ttype = LaunchAgent + \tstate = not running + + \tprogram = /private/tmp/insomnia-spawn-probe-fixture/Insomnia Probe.app/Contents/MacOS/Insomnia + \targuments = { + \t\t/private/tmp/insomnia-spawn-probe-fixture/Insomnia Probe.app/Contents/MacOS/Insomnia + \t\t--backstop + \t\tcdhash H"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + \t} + + \tinherited environment = { + \t\tSSH_AUTH_SOCK => /private/tmp/com.apple.launchd.fixture/Listeners + \t} + + \tdefault environment = { + \t\tPATH => /usr/bin:/bin:/usr/sbin:/sbin + \t} + + \tenvironment = { + \t\tOSLogRateLimit => 64 + \t\tXPC_SERVICE_NAME => com.insomnia.spawn-probe.fixture + \t} + + \tdomain = gui/502 [100000] + \tasid = 100000 + \tminimum runtime = 10 + \texit timeout = 5 + \truns = 1 + \tlast exit code = 0 + + \tresource coalition = { + \t\tID = 100 + \t\ttype = resource + \t\tstate = active + \t\tactive count = 1 + \t\tname = com.insomnia.spawn-probe.fixture + \t} + + \tjetsam coalition = { + \t\tID = 101 + \t\ttype = jetsam + \t\tstate = active + \t\tactive count = 1 + \t\tname = com.insomnia.spawn-probe.fixture + \t} + + \tspawn type = daemon (3) + \tjetsam priority = 40 + \tjetsam memory limit (active) = (unlimited) + \tjetsam memory limit (inactive) = (unlimited) + \tjetsamproperties category = daemon + \tjetsam thread limit = 32 + \tcpumon = default + \trun interval = 60 seconds + + \tproperties = runatload | inferred program | has spawn constraint + } + """ + /// `launchctl print gui/501/