Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
71 commits
Select commit Hold shift + click to select a range
7818178
Sudoers: drop passwordless disablesleep 1; Start asks for the adminis…
krishhgg Oct 1, 2026
8d57dad
Prompt, install: bound the dialog wait and report its pid; put the ol…
krishhgg Oct 2, 2026
5330c28
Merge remote-tracking branch 'origin/main' into fix/sudoers-no-passwo…
krishhgg Oct 2, 2026
0087d0a
Install: quit the app before the sudoers rule; never write disablesle…
krishhgg Oct 2, 2026
71253d7
Install: ask for the password before quitting the app
krishhgg Oct 2, 2026
a2ffdd4
Prompt: turn sleep off only while the start that asked is pending
krishhgg Oct 2, 2026
b5f6de9
Merge origin/main into fix/sudoers-no-passwordless-sleep-off
krishhgg Oct 2, 2026
b180529
Prompt: lock the pending-start marker while the root command runs
krishhgg Oct 2, 2026
aced667
Prompt: refuse late answers and old backstops, release voided prompts
krishhgg Oct 2, 2026
178dde8
Merge origin/main into fix/sudoers-no-passwordless-sleep-off
krishhgg Oct 2, 2026
f88e93b
Tests: seed SleepDisabled 1 in reconcile tests from main
krishhgg Oct 2, 2026
241ea92
Install: run sudo's tools and the sudoers write by full path
krishhgg Oct 2, 2026
afc4948
Uninstall: give sudo test and rm by full path
krishhgg Oct 2, 2026
b01a97b
Tests: hold reconcile at its pmset read in main's reconcile race test
krishhgg Oct 2, 2026
4ea445b
Merge origin/main into fix/sudoers-no-passwordless-sleep-off
krishhgg Oct 2, 2026
887bc71
Prompt: wait for osascript with ProcessExit, not waitUntilExit
krishhgg Oct 2, 2026
4c7bdfa
Merge origin/main (0c2e791) into fix/sudoers-no-passwordless-sleep-off
krishhgg Oct 3, 2026
1b36a53
Start: refuse without the passwordless restore, before any dialog
krishhgg Oct 3, 2026
2ed716d
Marker: delete pending-start only while its path names the locked file
krishhgg Oct 3, 2026
6e1450a
Backstop: exit 1 on a stuck marker before every early success
krishhgg Oct 3, 2026
e1e56b0
Install: write the rule after older backstop runs, match runs exactly
krishhgg Oct 3, 2026
04e188e
Docs: name every marker deleter that cannot compare a written identity
krishhgg Oct 3, 2026
f72ba11
Docs: say what the passwordless restore check cannot see
krishhgg Oct 3, 2026
2ccab01
Merge origin/main (24a26ff) into fix/sudoers-no-passwordless-sleep-off
krishhgg Oct 3, 2026
a1f9f82
Tests: resume an offset-dated session the way this branch resumes one
krishhgg Oct 3, 2026
926d639
Start: prove the passwordless restore by running it
krishhgg Oct 3, 2026
ca56ec0
Merge origin/main (5e833d9) into fix/sudoers-no-passwordless-sleep-off
krishhgg Oct 3, 2026
12c8e40
Merge origin/main (af9c4f6) into fix/sudoers-no-passwordless-sleep-off
krishhgg Oct 3, 2026
3b1d726
Merge main 64886e9 (#22 still-running privileged commands) into the s…
krishhgg Oct 7, 2026
497cf15
Merge main c45e8fb (#28 sealed backstop, installer supervisor) into t…
krishhgg Oct 7, 2026
23d625f
Prove the passwordless restore in the root command, not before the di…
krishhgg Oct 7, 2026
edfb2d7
BackstopVersionTests: check the script sealed in the bundle
krishhgg Oct 7, 2026
6c02da5
Merge main aed25a5 (#49 lid close leaves meeting apps running) into t…
krishhgg Oct 7, 2026
fa281c1
LidActionsTests: seed SleepDisabled 1 for the session the launch reco…
krishhgg Oct 7, 2026
8f8693e
Merge main 781b596 (#33 release pipeline) into the sudoers branch
krishhgg Oct 7, 2026
bf4d6e2
Root command: compare the deadline again after the restore check
krishhgg Oct 7, 2026
2f6b11f
Root command: same recheck in the AppleScript copy; fake-clock tests
krishhgg Oct 7, 2026
8d3fe57
Tests: name the root command's fake clock RootCommandClock
krishhgg Oct 7, 2026
2b8028c
Install: resume frozen processes with the staged build, not the insta…
krishhgg Oct 7, 2026
70f5fac
Merge main (bfc9a57, PR #50) into fix/sudoers-no-passwordless-sleep-off
krishhgg Oct 7, 2026
42e42e5
Root command: read the setting as root and prove the restore on its o…
krishhgg Oct 8, 2026
576c215
Root command: ask sudo about the restore before any write, write once
krishhgg Oct 8, 2026
7dcf51f
Greptile rule: call the root command's sudo answers a check, not a run
krishhgg Oct 8, 2026
c5456f8
Root command: refuse sudo.conf, unaccepted Defaults and other sudo ve…
krishhgg Oct 8, 2026
bd7db43
Root command: record the write in a root-owned receipt; settle unfini…
krishhgg Oct 8, 2026
7e3ddc8
Receipt: lock it around every command and settlement, claim it per st…
krishhgg Oct 8, 2026
15c2fc3
Merge main (b5f7cf0, PR #43) into fix/sudoers-no-passwordless-sleep-off
krishhgg Oct 8, 2026
e741a7f
Docs and #43's tests: describe the round 24 receipt, seed resumed ses…
krishhgg Oct 8, 2026
93fb3dc
Sleep-off receipt: journal the decision before the claim goes back, h…
krishhgg Oct 8, 2026
d933b68
Round 28: uninstall checks the shared receipt first, only the user ca…
krishhgg Oct 9, 2026
468db03
Round 30: uninstall stops on any doubt about the shared receipt, root…
krishhgg Oct 9, 2026
1468950
Round 30: shape_of no longer writes uninstall's problems array; test …
krishhgg Oct 9, 2026
7e233e8
Round 30: the settlement read test expects its own fixture's paths
krishhgg Oct 9, 2026
29949a6
Round 30: recover on a full disk, finish an interrupted uninstall, pu…
krishhgg Oct 9, 2026
f643d49
Round 30: two fixtures follow the readers' new interface; cp copies n…
krishhgg Oct 9, 2026
44cc006
Round 32: hold the standard folder's lock through uninstall, keep the…
krishhgg Oct 9, 2026
efc0afb
Round 34: keep another folder's recovery agent, hold the receipt lock…
krishhgg Oct 9, 2026
b79d93b
Round 36: tests read no real access control list; the receipt is lock…
krishhgg Oct 10, 2026
717224f
Round 36: the scripts check the receipt only under its lock
krishhgg Oct 10, 2026
e189602
Round 36: one lock for every load and unload of the agent's label
krishhgg Oct 10, 2026
5272c34
Round 36: load the agent again after any stop once it is out
krishhgg Oct 10, 2026
fb8ea98
Scripts: say whether a bounded call's output was read whole (R35-6)
krishhgg Oct 10, 2026
c471a25
Uninstall keeps the rule another account may use; sessions carry an id
krishhgg Oct 10, 2026
3d99532
Round 36 R35-5: backstop and uninstall read access lists through the …
krishhgg Oct 10, 2026
bafecbe
Round 36: lost-status matrix through all three readers; slow script m…
krishhgg Oct 10, 2026
706385a
Round 36 docs: lists through --access-lists, lock before checks, sess…
krishhgg Oct 10, 2026
95c05af
Merge main 48bda50 (stable and nightly release channels, #51) into PR…
krishhgg Oct 10, 2026
b7594cd
Round 39: checks that cannot be made decide nothing; uninstall withou…
krishhgg Oct 10, 2026
e79edb2
Round 39 docs: unmade checks, local-only uninstall, legacy grant, C2 …
krishhgg Oct 10, 2026
73764a8
Round 39 tests: no real log read; system calls only on hosted CI
krishhgg Oct 11, 2026
ff7e2b9
Round 39 tests: the account check's fake id has no quote in its path
krishhgg Oct 11, 2026
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
32 changes: 19 additions & 13 deletions .greptile/config.json

Large diffs are not rendered by default.

267 changes: 258 additions & 9 deletions .greptile/rules.md

Large diffs are not rendered by default.

321 changes: 302 additions & 19 deletions README.md

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions Resources/Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@
<string>NSApplication</string>
<key>InsomniaResumeFrozenVersion</key>
<integer>1</integer>
<key>InsomniaAccessListsVersion</key>
<integer>1</integer>
<key>NSHumanReadableCopyright</key>
<string>Copyright © 2026 Krish Garg. MIT License.</string>
</dict>
Expand Down
703 changes: 697 additions & 6 deletions SECURITY.md

Large diffs are not rendered by default.

120 changes: 120 additions & 0 deletions Sources/Insomnia/Core/AccessListsCommand.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
import Darwin
import Foundation

/// `Insomnia --access-lists <seconds> <path>...`
///
/// One-shot mode for backstop.sh and uninstall.sh: each path's own
/// extended access control list, read the way the app reads the receipt's
/// (`SleepOffReceipts.accessList`), every entry, right and flag through
/// acl(3). The two scripts used to read `ls -le`, which never prints
/// synchronize, prints delete_child and most inheritance flags only for a
/// folder, skips an entry whose rights or flags it cannot read, and stops
/// at an entry it cannot step past; no reading of its text recovers what
/// it leaves out. Both scripts run as the user, so this mode runs as the
/// user too and needs nothing the user does not already have. install.sh
/// and the root command (AdministratorPrompt) still read `ls -le`: the
/// root command must not run a binary the user can replace.
///
/// `<seconds>` is how long this process may live, as for
/// `ResumeFrozenCommand` (1 to 300), armed before any list is read. Each
/// path must be absolute. Nothing is read from standard input. Prints one
/// line per path, in order:
///
/// none no entry at all
/// installed exactly the one entry install.sh adds to this
/// user's receipt (`AccessEntry.installed` for the
/// uid this process runs as)
/// denies entries, none of which allows anything
/// allows an entry that allows something, and not `installed`
/// unreadable <n> lstat(2) or acl_get_link_np(3) failed with errno n
/// incomplete an entry, or one of its flags, could not be read
///
/// Exits 0 when every list was read whole (the first four words), 1 when
/// any was not. A missing or malformed `<seconds>`, no path, or a path that
/// is not absolute prints the single line `usage` (details on stderr),
/// reads nothing, and exits 64. A caller takes any other answer, or none,
/// as lists it does not know.
///
/// The app bundle declares this interface as `InsomniaAccessListsVersion`
/// (`version`) in its Info.plist. The scripts run the binary only when the
/// bundle declares the version they speak, so they never start an older
/// build, which would open the menu bar app instead.
enum AccessListsCommand {
static let flag = "--access-lists"
/// `InsomniaAccessListsVersion` in Resources/Info.plist, and
/// ACCESS_LISTS_VERSION in backstop.sh and uninstall.sh.
static let version = 1

typealias Output = ResumeFrozenCommand.Output

enum Word: Equatable, Sendable {
case none, installed, denies, allows, incomplete
case unreadable(Int32)

var text: String {
switch self {
case .none: "none"
case .installed: "installed"
case .denies: "denies"
case .allows: "allows"
case .incomplete: "incomplete"
case let .unreadable(err): "unreadable \(err)"
}
}

/// The list was read whole.
var whole: Bool {
switch self {
case .none, .installed, .denies, .allows: true
case .incomplete, .unreadable: false
}
}
}

/// nil when `arguments` (the command line without the executable) do
/// not ask for this mode. `endAfter` gets the lifetime once the
/// arguments are valid, before any list is read (`endProcess` in the
/// binary). `read` gives a path's list (`native` in the binary).
static func run(
_ arguments: [String],
read: (String) -> AccessList = native,
user: uid_t = getuid(),
endAfter: (UInt32) -> Void
) -> Output? {
guard arguments.first == flag else { return nil }
guard arguments.count >= 3, let seconds = ResumeFrozenCommand.lifetime(arguments[1]) else { return usage() }
let paths = arguments.dropFirst(2)
guard paths.allSatisfy({ $0.hasPrefix("/") }) else { return usage() }
endAfter(seconds)
let words = paths.map { word(read($0), user: user) }
return Output(lines: words.map(\.text), status: words.allSatisfy(\.whole) ? 0 : 1)
}

private static func usage() -> Output {
FileHandle.standardError.write(Data("usage: Insomnia \(flag) <seconds 1-\(ResumeFrozenCommand.maxLifetimeSeconds)> <absolute path>...\n".utf8))
return Output(lines: ["usage"], status: ResumeFrozenCommand.usageStatus)
}

/// `path`'s own list, never a link's target's. acl_get_link_np(3)
/// answers ENOENT both for a path with no list and for no path at all,
/// so lstat(2) tells the two apart first.
static func native(_ path: String) -> AccessList {
var info = stat()
guard lstat(path, &info) == 0 else { return .unreadable(errno) }
return SleepOffReceipts.accessList(path)
}

/// The word for `list`, for `user`'s receipt.
static func word(_ list: AccessList, user: uid_t) -> Word {
switch list {
case let .unreadable(err):
return .unreadable(err)
case .incomplete:
return .incomplete
case let .entries(entries):
if entries.isEmpty { return .none }
if SleepOffReceipts.receiptAccessProblem(entries, user: user) == nil { return .installed }
return entries.contains(where: \.allows) ? .allows : .denies
}
}
}
57 changes: 57 additions & 0 deletions Sources/Insomnia/Core/BackstopVersion.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import Foundation

/// The recovery contract the agent's backstop.sh implements, read from
/// its `# insomnia-backstop-version: N` line.
///
/// Start reads it before the password dialog, under the recovery lock.
/// Version 2 is the first backstop.sh that deletes the pending-start marker
/// under its lock before it restores sleep. An older one restores sleep
/// after a crash but leaves the marker, so a dialog still open could turn
/// sleep off again with nothing journaled. Version 3 is the first that
/// settles a start the journal still records (`sleepOffAttempt`) from its
/// receipt. Version 4 is the first that reads the receipt under its lock,
/// as the 82-byte line with the predecessor nonce, and the start's
/// `expires`, and gives the start's claim back (SleepOffReceipts). An older
/// one would misread that receipt, or leave the entry, so the session of a
/// start that never finished would stay on disk and no later Start could
/// run until the app settled it. Version 5 is the first that publishes a
/// settlement's decision in the journal (`sleepOffAttempt.settled`) before
/// it gives the claim back, and finishes a settled record it finds. An
/// older one would give the claim back first, so a crash between the two
/// would leave a record later starts' receipt lines could be misread
/// against, and it would refuse a settled record as malformed and restore
/// nothing else. With any of them in place no dialog is
/// shown at all. The script read is the copy sealed in the bundle the
/// agent runs (LaunchdBackstop.scriptPath), so this refuses a bundle whose
/// copy is missing, unreadable or older than this build expects.
enum BackstopVersion {
static let required = 5
static let linePrefix = "# insomnia-backstop-version: "

/// The number on the script's first version line; nil when there is
/// no such line or it does not hold a number.
static func declared(in text: String) -> Int? {
guard let line = text.split(separator: "\n").first(where: { $0.hasPrefix(linePrefix) }) else { return nil }
return Int(line.dropFirst(linePrefix.count).trimmingCharacters(in: .whitespaces))
}

/// Throws, with the reason and the fix, unless the script at `url`
/// declares at least `required`.
static func check(scriptAt url: URL) throws {
let text: String
do {
text = try String(contentsOf: url, encoding: .utf8)
} catch {
throw BackstopError(message: "could not read backstop.sh at \(url.path) (\(error.localizedDescription)); run scripts/install.sh again")
}
guard let version = declared(in: text), version >= required else {
throw BackstopError(message: "the installed backstop.sh at \(url.path) is older than this build and cannot cancel a password dialog left open by a crash; run scripts/install.sh again")
}
}
}

extension LaunchdBackstop {
func checkVoidsPrompts() throws {
try BackstopVersion.check(scriptAt: URL(fileURLWithPath: scriptPath))
}
}
Loading
Loading