Use GitHub private vulnerability reporting:
https://github.com/ArangoGutierrez/coffee-bar/security/advisories/new
That route is enabled and is the one to prefer: it keeps the report private and attaches it to this repository.
If you cannot use it, email the maintainer directly:
arangogutierrez@gmail.com — Carlos Eduardo Arango Gutierrez
coffee-bar is a personal project. It is not an NVIDIA product, and a report sent to a work address will not reach the maintainer any faster.
Do not open a public issue, a discussion, or a pull request for a security problem. A public report tells everyone at once, including the people you do not want to tell.
Include, if you have them: the affected version or commit, the macOS version and
build, the hardware model, the steps to reproduce, and what an attacker gets.
CONTRIBUTING.md shows the commands that print the first three.
Expect an acknowledgement within 7 days. There is no bounty programme.
Please give the maintainer 90 days to ship a fix before you disclose publicly. If the flaw is already exploited in the wild, say so — that shortens the clock.
| Version | Supported |
|---|---|
main |
Yes |
| v0.3.x | Yes |
v0.3.0 is tagged, and the Homebrew tap installs from that tag. main and the
v0.3.x line both get fixes. Earlier lines do not.
scripts/build-app.sh derives the version from git describe --tags. A build
from a tagged tree reports the tag plus the commits since it, such as
0.1.0-3-g1258578. A release tarball carries no .git, so the Homebrew formula
passes the version in through COFFEE_BAR_VERSION instead. The 0.0.0-dev
fallback now appears only when neither source is available. Report the commit as
well as the version.
These are the properties a security report should be measured against. Each one is a commitment, not a description of current luck.
coffee-bar tracks which agent sessions are running and what state each one is
in. It does not read, parse, store, or display the contents of an agent
conversation. transcript_path and ~/.claude/projects/**/*.jsonl hold
proprietary source code and secrets, and coffee-bar stays out of them.
This is a privacy commitment and it binds future work. Token accounting (M7) does not relax it: that design uses the OTLP metrics stream, which carries counters and attributes and no message content at all. The transcript-parsing route to token counts is rejected by design.
If you find code that reads transcript content, that is a vulnerability under this policy. Report it.
No telemetry, no crash reporting, no analytics. One outbound request exists
in the shipped code and it is the update check: a GET of a static JSON file on
this project's own site, which answers "which version is current" and nothing
else.
It tells you. It does not update itself. Nothing is downloaded but that
file, no bundle is replaced, and no installer runs. The whole outcome of a check
is a sentence — in the panel, and in the Preferences window beside the interval. That is why the Sparkle appcast this
section used to hold on record was narrowed away rather than built: brew install coffee-bar puts the app inside the Homebrew prefix, so an app that
replaced its own bundle would desynchronise Homebrew's manifest and the next
brew upgrade would fight it — and an updater framework would be the first
third-party code ever linked into this binary, sitting in the update path, which
is the highest-trust path in the application.
What the request carries, in full. coffee-bar adds no identifier of any
kind: no install ID, no machine ID, no user name, no host name, no custom
User-Agent, no cookie and no body. The address is
https://arangogutierrez.github.io/coffee-bar/latest.json with no query string
and no fragment, the method is GET, and the session is ephemeral, refuses
cookies, keeps no cache and queues nothing for later.
That leaves the headers macOS puts on every request an application makes, which coffee-bar does not choose and does not modify. They are listed here rather than left for you to find with a proxy. Measured on this machine by pointing the shipped session configuration at a loopback listener and printing what arrived:
GET /coffee-bar/latest.json HTTP/1.1
Host: …
Cache-Control: no-cache
Accept: */*
User-Agent: <application>/<version> CFNetwork/3860.600.21 Darwin/25.5.0
Accept-Language: en-US,en;q=0.9
Accept-Encoding: gzip, deflate
Connection: keep-alive
Two of those say something about the machine and neither identifies an install.
The User-Agent names the application, its version and the OS build; every copy
of the same build on the same OS sends the same string. Accept-Language
carries the language you have set, which is the only line here that is about
you rather than about the software, and it is stated because "in full" has to
mean in full — the first draft of this section listed the User-Agent alone and
was corrected by the measurement above rather than by reading the code.
Why Accept-Language is disclosed rather than removed, so that nobody
"fixes" it by weakening the guard. Every header in that block is supplied by
the operating system for every URLSession request any application makes.
coffee-bar sets none of them, and none is what this policy means by an
identifier: an identifier is something the APP adds that tells one install from
another, which is what the no-identifier promise above rules out.
Removing Accept-Language would mean setting a header, and setting a header
needs URLRequest — the request-construction API that
noLinkedTargetCanReachTheNetworkByAddress deliberately keeps banned even in
the one file entitled to reach the network. That ban is exactly what makes
"there is no request object here on which a header could be set" a structural
fact rather than a promise somebody has to keep. Trading that structure away to
drop a locale string would be a bad bargain, and a rule that permits one header
is a rule that has to adjudicate every future one. If you are reading this
because you want to strip the header: the answer is no, and this paragraph is
the reason.
How often, and how to see it. At most once a day, and only when coffee-bar
starts. There is no timer: a coffee-bar sitting in the menu bar all week makes
one request, not seven, because the interval is enforced against a stamp in your
preferences rather than by anything this process holds. The panel says what the
last check concluded and when it ran, and carries a Check now button that makes
one on demand; the Preferences window says the same and states the interval
beside it. The "no hidden durations" rule in docs/ROADMAP.md is why both the
period and the timestamp are on the surface rather than only in this file.
If you find a second outbound request, or an identifier on this one, that is a vulnerability under this policy. Report it.
It does open one socket, and it is not a network socket. Ingest listens on a
unix domain socket at ~/Library/Application Support/coffee-bar/ingest.sock,
mode 0600, so Claude Code hooks can post session events. A unix domain socket
lives in the filesystem namespace: it has no address, no port and no route off
this machine, and the file permissions are what stop another user reaching it.
IngestListener also calls Darwin.connect against that same local path,
once, to tell a stale socket file from one a live instance is still serving —
without that probe a second app instance would delete the running instance's
socket and kill ingest silently.
Four facts back the network claim, all checkable from a clone:
- One file reaches the network, and it is named.
UpdateChecker.swiftis the only file inSources/allowed to nameURLSession, and it names none of the other address-shaped APIs. The guard that holds this isnoLinkedTargetCanReachTheNetworkByAddressinTests/CoffeeBarUITests/AppLayerBoundary_test.swift: it bansURLSession,URLRequest,NSURL,CFNetwork,getaddrinfo,gethostbyname,NWConnection(host:,NWEndpoint.hostPort,AF_INET,sockaddr_in,inet_ptonandinet_addracross every target thecoffee-barbinary links, and relieves that one file ofURLSessionalone.URLRequeststaying banned there is what leaves no request object on which a header could be set, which is the structural half of the no-identifier promise above. - One host, pinned.
theOnlyEntitledFileReachesOnlyThePinnedHostreads everyhttps://address out of that same set of files and refuses one naming anywhere butarangogutierrez.github.io, andtheOneFileThatReachesTheNetworkSendsNoIdentifierreads the entitled file for the vocabulary of identity. Grep for the guards by name rather than for a line number: this section used to cite one, and the next edit above it made the citation point at unrelated code. - The listener is bound to the filesystem, not to a port.
IngestListener.swiftimportsNetworkand builds anNWListener, then setsparameters.requiredLocalEndpoint = .unix(path: path)before starting it, so it answers on a unix socket and never on an IP endpoint. The onlyconnect(inSources/is in that same file, onsockaddr_un— a filesystem path, never an IP address or a host name. Package.swiftdeclares no external package dependencies, so no third-party code is fetched or linked, and none of it can open a socket on coffee-bar's behalf. That is unchanged by the update check, which is why it was built out ofFoundationrather than out of an updater framework.
This section previously read "opens no socket … connect( returns zero hits".
That became false when ingest landed, and a security policy that fails its own
grep is worse than no policy. It then read "makes no network egress … no update
ping", and that became false when the update check landed. Both are recorded
rather than quietly overwritten, because this file promised that the day an
outbound request existed it would say so explicitly.
Any FURTHER outbound request is opt-in, off by default, and named here before the release that carries it. This paragraph grants no permission to add telemetry, and the update check is not a precedent for one: it sends nothing about you, which is the whole reason it was allowed.
TelemetryRecon is the one component whose name suggests otherwise. It reads
three local files — the Claude Code managed settings, the user settings, and the
Codex config — and looks for an OTLP endpoint that is already configured. It
inspects files. It never contacts the endpoint it finds.
coffee-bar observes. It never returns a decision, a permission decision, or a stop signal to an agent tool, and it never answers a prompt for you. A power utility that can block your tool calls is a supply-chain risk, so it does not have that power.
Two channels cross the ingest socket, and the difference between them is who asked. Until v0.3 there was one, and the promise above held by construction: the listener had a single response builder, it hard-coded a zero-length body, and coffee-bar had no way to say anything to anyone. A read route replaces that construction with a rule, so the rule is written down here.
The hook channel stays mute. Your agent tool runs the hook; you did not ask
for its output and you never see it. Claude Code executes hooks and can act on
what they print, and the curl in the documented hook command writes a response
body to its stdout — so a body on that reply would be coffee-bar talking into
your agent behind your back. Every answer POST /event gives carries a
zero-length body, on every code path: the served one, a payload that will not
decode, a request the framer cannot frame, a request over the size cap, and a
connection refused over the connection cap before its request line is even read.
That is measured on the wire rather than argued from the source, by
everyAnswerTheEventPathGivesCarriesNoBody and
aConnectionRefusedOverTheCapAlsoCarriesNoBody in
Tests/CoffeeBarIngestTests/IngestListener_test.swift.
The read channel answers only when asked. GET /status on the same socket
returns coffee-bar's own state as JSON: a schema version, the version string the
panel shows, the control position you chose, whether an assertion is held right
now, how many sessions are working, how many are waiting on you, one word for
hook health, and whether this process is answering on the socket. An agent can
poll it to find out what coffee-bar is doing. Nothing reaches an agent that did
not go and read it.
The command is
curl --fail-with-body --unix-socket ~/Library/Application\ Support/coffee-bar/ingest.sock \
-H 'Content-Length: 0' http://localhost/status
and the Content-Length header is required rather than polite: one framer
serves both channels and it declines any request that declares no length, so a
read is refused on the same terms a malformed post is.
Four commitments bound that channel, and each one is a property of the code rather than of how it is currently called:
- It is read-only. There is no write route and no control route. Every verb
but
GETdraws a refusal, and the refusal happens before the state is read. - It publishes counts, never sessions. The payload type has nowhere to put a
session identity, a working directory, a transcript path or any message text,
and hook health crosses it as a single word rather than as the list of events
you have left unwired.
theReadPayloadCarriesASchemaVersionAndNothingUnlistedpins the whole key set, so a field added later has to be read against the transcript commitment above before it can ship. - It adds no access control, and that is deliberate. There is no token and
no port. The socket is mode
0600inside a mode0700directory, and the filesystem is the boundary — the same boundary the hook channel has always had. A token would be a second mechanism to leak, and it would not narrow who can already reach the socket: as §4.1 of the design says, any process running as you can post to it, and any process running as you can read from it. - It opens nothing new. Same socket, same bind, no second endpoint. The commitment above is untouched: this channel is the unix socket, and the one outbound request named there is the update check and nothing else.
If a future release lets an agent change anything through this socket, that is a new commitment and it is written here first.
v0.1 holds PreventUserIdleSystemSleep, an unprivileged
IOPMAssertionCreateWithName call that any user process may make. v0.1 needed
no root at all: no privileged helper, no LaunchDaemon, no sudo prompt, and
no kernel extension.
Three of those four promises change in v0.2, and they are restated here rather
than quietly dropped. Lid-closed mode installs a LaunchDaemon —
com.coffeebar.probewatchdog — and it needs sudo. The kernel-extension
promise does not change: coffee-bar ships no kernel extension and will not.
The one that matters most is the one that does not change either. coffee-bar
never elevates its own privilege. The app process never becomes root, in any
version, and it never asks you for a password. It cannot elevate itself, and it
cannot ask you to elevate it. What it can do, since v0.3, is ask macOS to
install and run a separate job as root on your behalf, through SMAppService;
macOS runs that job only after you have enabled it by hand, and the sections
below set out what it may do and how each end authenticates the other.
The root path is still opt-in, and you are still the one who takes it. In
v0.2 that meant one thing: you type sudo coffee-bar-probe arm in your own
shell. It now means one of two, depending on how your copy is signed, and
neither of them happens without you.
Two sentences that were true in v0.2 change in v0.3, and they are restated
here rather than quietly dropped. They read: "The app shows no authorization
prompt, installs no privileged helper of its own, and has no route to becoming
root." and "It cannot arm lid-closed mode, cannot install that daemon, and
cannot revert one." On a Developer ID signed build the middle clause of the
first is now false, and the whole of the second is: the app asks macOS to
install com.coffeebar.probehelper, arming lid-closed mode is a button rather
than a command you type, and taking the helper back off reverts the hold before
it unregisters the job.
What did not change is the part that carries the security. There is still no
authorization prompt, and still no password, ever. A registration gets no modal:
macOS refuses the first attempt outright and files the request under
coffee-bar's name in System Settings › General › Login Items & Extensions, where
you have to find the switch and turn it on. Until you do, nothing of
coffee-bar's runs as root. The menu bar app itself still holds only the
unprivileged assertion, on every build: changing SleepDisabled is the root
job's doing, and the two are separate processes. The app still has no route to
becoming root itself — AuthorizationExecuteWithPrivileges and NSAppleScript "with administrator privileges" would each take your password inside
coffee-bar's own process, and theAppLayerNeverReachesForPrivilegeEscalation
refuses them, and eight more names, for every file in the app layer. What
changed is who collects the consent, not whether it is required.
The sudo route is not deprecated, and for many installs it is the only one.
A build that carries no team identifier registers nothing and is never offered
the button — that is every Homebrew install, which compiles on your own machine
by design, and anything built by scripts/build-app.sh. There the app prints
the command for you to run — the same posture it already takes with hook
configuration, where it prints the snippet and refuses to write
~/.claude/settings.json for you.
You can see exactly what it holds, at any time, with:
pmset -g assertions
The app's assertions are named coffee-bar is serving and coffee-bar is keeping the network up, plus coffee-bar is keeping the display awake when you
have opted in to the display hold. The capability probe's is named
coffee-bar probe baseline. The names are deliberately distinct so a stranded
assertion can be attributed to the code that stranded it.
M5 adds a root path so the Mac can stay awake with the lid closed. It ships as a
root CLI plus a launchd watchdog: you run sudo coffee-bar-probe arm, and
that installs the daemon com.coffeebar.probewatchdog, which supervises the
setting and puts it back. This policy is the authoritative bound on that path,
and a shipped binary that exceeds any clause below is a vulnerability under this
policy:
- There is a Mach service now, and every peer on it is pinned (#71). This
section used to say there was none, and that was true of M5. What replaced it
is not a weaker version of the same promise: the endpoint
com.coffeebar.probehelperaccepts a connection only from code that satisfiesanchor apple generic, the Developer ID marker OIDs, team85FN4Z37V8and bundle identifiercom.coffeebar.app— and the app demands the same four of the helper before it says a word to it. The next section is the whole of that argument. The CLI path is unchanged and still ships; a bundle that is not signed by that team registers nothing and is not offered the button. - Its verbs are a fixed list, and none of them takes an arbitrary string.
There is no "run this command" and no user-supplied path to execute.
The complete verb list is
run,arm,report,revert,watchdogandserve. The one argument that crosses the XPC channel is a TTL in seconds, and the helper clamps it on its own side againstJournalRecord.maxTTLSeconds— a peer that passed the code-signing pin is still not trusted to bound a root hold, because "signed by the right team" and "not currently compromised" are different claims. - The program the daemon executes is resolved inside the installer and is never
taken from an argument. Accepting one turned
sudo coffee-bar-probe arminto a one-line root persistence primitive, measured, and the shipped interface cannot express it at all. - Every state-mutating verb takes a TTL.
ProbeVerb.defaultTTLSecondsgives 8 hours when you name none, andJournalRecord.maxTTLSecondscaps it at 24 hours however much you ask for. A root process still holding a setting after whatever armed it has gone is the failure the watchdog exists to prevent. - That cap is counted in elapsed time, not on the clock you can set. The
journal records a monotonic since-boot stamp beside its wall-clock one, and
WatchdogDecision.decidemeasures the TTL against the first of the two. Both ends of that subtraction come frommach_continuous_time, which nothing in userspace can move and which keeps counting while a lidded machine naps. So putting the system clock back while a hold is live buys no extra hold: the daemon reports the step as a clock anomaly and ends the hold there. Until this shipped, a backward step landing inside a live window was invisible to every check the daemon made, and it extended the hold by its own size — far past the bound stated above, and without limit for a large enough step (#77). - The monotonic stamp means nothing across a reboot and never has to survive
one: a deadline measured on a clock that restarts at boot cannot outlive that
boot. What it does not do is make the reboot check itself clock-proof.
That check compares the journal's own timestamp against
kern.boottime, and both are wall-clock values —kern.boottimeis stored as realtime, so moving the clock backward moves it too while the journal's timestamp stays put. The comparison then gets harder to satisfy, which suppresses a revert rather than causing one. The cap above is bounded regardless; this remaining gap is tracked on its own and is not claimed as closed here. Adding the stamp also moved the journal'sschemaVersionon, so a journal an older build left behind is refused rather than judged against a reference it never recorded. - Supervision is TTL-only. There is no heartbeat channel, because there is
no channel at all. Nothing cuts a hold short when the work finishes early, so
the hold you ask for is what a machine you have walked away from will spend —
8 hours if you named no
--ttl. - The daemon uses the built-in battery floor of 15% and does not read your
batteryFloorPercentsetting. A root process reading an unprivileged user's preferences is a new data flow into a privileged process, and it deserves its own review before it exists rather than after. - What ends a hold, stated rather than implied (#74). On battery it is the
floor above. That check is guarded by
inputs.onBatteryand sits at rung 5 ofWatchdogDecision.decide, one rung ABOVE the TTL, so it ends a hold whatever the TTL says — the hazardous case, a closed machine in a bag, normally runs on battery and is bounded by charge rather than by time. With one exception, and it is the worst case this change makes worse. Rung 5 needs a battery PERCENTAGE, not merely "on battery": it readsif inputs.onBattery, let pct = inputs.batteryPercent. When the capacity keys are missing or report a maximum of zero,SystemPowerReaderyields a nil percentage, andunknownBatteryDoesNotTriggerFloorpins that input to holding rather than reverting — deliberately, because a floor cannot be enforced against a charge nobody can read. On that path the floor cannot fire and the TTL is the only bound left, so a machine whose battery reporting is broken now holds for the hold you chose rather than for thirty minutes. That is a real widening of the worst case, it is why the ceiling is 24 hours and not unbounded, and it is stated here rather than discovered. On AC it is the TTL, and nothing else in the ordinary course; a thermal abort and a reboot remain. This section used to say the model was scheduled to change, toward a hold that continued indefinitely on AC. It does not do that. An unbounded hold on a privileged process is a bound this document could not state, so the hold stays bounded and the bound became yours to choose: the Preferences window offers any length up toJournalRecord.maxTTLSeconds, which is 24 hours. - The length you choose never reaches this process. coffee-bar renders it
into the
--ttlof the command it prints and you run that command yourself, so the value arrives as an argument you typed rather than as a preferences file a root daemon went looking for. That is the same refusal the bullet above makes forbatteryFloorPercent, and the reason it costs nothing here is that arming is already a thing you do by hand. - The heartbeat rung cannot fire on the daemon path, by construction.
decide()reverts with.heartbeatLostwhen it is handed no heartbeat, which is right when a channel exists and has gone quiet. There is no channel here, soLidClosedSessionsubstitutes the current time — and that substitution can only ever make the rung PASS, which means it never ends a hold on this path. It is kept rather than deleted becausedecide()is not daemon-only and because the TTL is tested BEFORE it: no heartbeat, absent, substituted or forged, buys a second past the TTL. Raising the ceiling to 24 hours does not weaken that ordering — it is the same ladder, one rung longer.aForgedFutureHeartbeatCannotOutliveTheTTLpins the ordering andanACHoldRunsTheWholeConfiguredCapAndOnlyTheCapEndsItpins that the rung stays inert across the whole of that ceiling with no heartbeat anywhere. - A thermal or battery abort restores the setting you had, not a safe one.
The revert writes the journal's recorded
priorValue, which is deliberate: the daemon undoes what it did and never overrides a choice it did not make. The consequence is worth stating plainly, because §8.1 calls thermal pressure the real risk. If you had already setSleepDisabledyourself before arming, the abort hands that value back and the machine still refuses to sleep. The abort ends coffee-bar's hold; it is not a thermal safety cutout for the machine, and it is the one case where aborting does not reduce the risk. Clear your ownSleepDisabledif you want the machine to sleep on heat.
This section has said three different things, and the sequence is the record of a decision rather than an embarrassment. It first required a pinned helper, before anything was built. It then said no helper could exist, because the only bundle shipping was ad-hoc signed and there was no certificate to pin. It now says a helper exists and what authenticates it. The rule never moved. What moved was whether it could be satisfied.
The premise that unblocked it. The bundle that ships is Developer ID signed and notarised, so it carries both a team identifier and a full certificate chain. Measured 2026-08-10 against the installed v0.2.0 bundle:
$ codesign -dvvv /Applications/CoffeeBar.app
Authority=Developer ID Application: Carlos Eduardo Arango Gutierrez (85FN4Z37V8)
Authority=Developer ID Certification Authority
Authority=Apple Root CA
TeamIdentifier=85FN4Z37V8
$ codesign -v -R='anchor apple generic' <that app> -> rc=0 PASSES
The requirement both ends apply, PrivilegedHelperIdentity, is:
anchor apple generic
and identifier "<the peer's bundle ID>"
and certificate 1[field.1.2.840.113635.100.6.2.6]
and certificate leaf[field.1.2.840.113635.100.6.1.13]
and certificate leaf[subject.OU] = "85FN4Z37V8"
The helper demands it of com.coffeebar.app; the app demands it of
com.coffeebar.probehelper. Dropping any clause is a different attack, and
none of them is decoration:
- Without
anchor apple genericthe other clauses are worthless.identifierandsubject.OUare both fields the SIGNER chooses, so a local attacker self-signs a binary claiming this bundle ID and this team and walks in. Measured 2026-08-16 on an ad-hoc fixture,codesign -v -R='identifier "com.coffeebar.app"'returns rc=0; adding the team clause takes the same fixture to rc=3. - The two marker OIDs rule out the other chains that also reach an Apple root — a development certificate, or a Mac App Store leaf.
- Without
identifier, any binary this team ever signs is accepted. A team is an author, not a program.
Where the pin may be applied. In exactly one file.
PrivilegedHelperPeerGate.swift is the only place in this package that may name
NSXPCListener, NSXPCConnection, setCodeSigningRequirement or
machServiceName, and PrivilegedHelperClient.swift is the only place that may
name SMAppService. No file anywhere may name SMJobBless: it is the
deprecated path, and nothing here needs it.
noTargetOnThePrivilegedPathReachesForXPCOrSMAppService enforces the ban and
the two exemptions; theEntitledChannelFilePinsEveryPeerItOpens refuses a
connection that is created without a pin and refuses a requirement spelled out
inline, which is the shape somebody reaches for when the real one will not
compile and which reads exactly like a pin. Both connections are pinned
before they are resumed: a resumed connection dispatches messages that have
already arrived, so pinning afterwards closes a door the caller is through.
PrivilegedHelperIdentity_test.swift evaluates the requirement through
Security.framework rather than reading it, with /bin/ls as a positive control
for the harness and as the discriminating negative — a validly signed program
belonging to somebody else.
coffee-bar still elevates nothing on its own initiative, and this is the part
worth reading slowly. SMAppService.register() does not take a password, does
not run an interpreter as root, and does not install anything itself: it asks
macOS, which lists the request under the app's name in System Settings › General
› Login Items & Extensions and runs the job only once the user enables it there.
AuthorizationExecuteWithPrivileges (deprecated since 10.7) and
NSAppleScript "with administrator privileges" were both rejected for the
opposite property — each takes the user's credentials inside coffee-bar's own
process — and both remain in the app layer's denylist.
theAppLayerNeverReachesForPrivilegeEscalation still refuses setuid,
launchctl, AuthorizationRef and seven more names for every file including the
entitled one. What changed is who collects the consent, not whether it is
required.
Both paths ship, and the CLI is not deprecated. sudo coffee-bar-probe arm
installs com.coffeebar.probewatchdog into /Library/LaunchDaemons exactly as
before, and a user who armed it is unaffected by any of this. The registered
helper is a separate job with a separate label — sharing one would let a
registration boot out a watchdog supervising a live hold, leaving nothing to put
SleepDisabled back. A Homebrew install cannot use the helper at all: that
bundle is built with no SIGN_IDENTITY, names no team, and the app reads its own
signature and does not offer the button. For those users the command is not a
fallback, it is the feature.
What was observed, and when. On 2026-08-17 a signed Developer ID build of
this branch, team 85FN4Z37V8, registered the helper, had it enabled in System
Settings, and completed a live XPC round trip: each side set the other's
requirement identifier on the connection, both connections activated, and
SleepDisabled moved 0 → 1 with the helper still running afterwards. That
records one run, on one Mac, on one date. It is not a promise about the build in
front of you, and no test in this repository re-runs it — the run needed a
signed bundle, which is opt-in, and a registration approved by hand in System
Settings, so the suite cannot reproduce it.
Removal and the unsigned fallback were exercised on 2026-08-19, on the same
Mac and under the same limits as the run above. Removal was sampled at 5 Hz
across the click: SleepDisabled returned to 0 while the helper process was
still running, and the helper was gone one sample later. No sample showed the
hold still set with the helper already gone, which is the failure that ordering
exists to prevent. macOS kept the app's entry in Login Items and Extensions
afterwards, because the helper's plist ships inside the bundle, so removal
retires the registration rather than erasing the record of it.
The unsigned fallback was exercised the same day. A bundle presenting
TeamIdentifier=not set offered no arming control at all and named the sudo
route instead.
Both are one run, on one Mac, on one date, and no test in this repository re-runs either: one needs a signed bundle and a registration approved by hand, the other needs an unsigned one. What has been measured about the requirement itself is that it parses, that it rejects a correctly signed program from another team, and that the build stamps the helper with the identifier it pins.
The privileged side reads a journal file to know what to restore. That file is
an instruction to a root process, and it is treated as one. All four of these
hold in the shipped code, and they are recorded in docs/ROADMAP.md under
"M5 security precondition":
- Before it reads a single byte, the reader verifies the journal's whole path,
not only the final file. Two different bars apply, and
GuardedJournalReaderenforces both:- Every ancestor is owned by root and is neither group-writable nor
other-writable. An ancestor may be
0755, and that is deliberate rather than lax:/,/Libraryand/Library/Application Supportare all root-owned0755in production, so an owner-only rule applied to ancestors would refuse every journal on every Mac. - The journal's own directory is exactly
0700, and nothing looser passes. The journal file is exactly0600, and nothing looser passes. A0755directory is refused; so is a0640file, which is unwritable by anyone else and still leaks thearmedByprovenance to every account on the machine.
- Every ancestor is owned by root and is neither group-writable nor
other-writable. An ancestor may be
- The helper refuses to act on a journal it did not write, or one whose ownership or mode does not match expectation, and quarantines it instead.
- The privileged side creates the directory and the file with explicit modes
(
0700and0600). Neither is left to whichever process created the path first. - The
armedByprovenance in the journal — pid, binary path, uid — is advisory forensics, never authentication. In this threat model it is attacker-controlled data.
FileJournalStore creates every directory level it makes with mode 0700 and the
journal with mode 0600. The atomic replace behind each save carries the new
file's mode rather than the destination's, so a journal an earlier build left
0644 is repaired by the next write instead of keeping that mode forever.
Tests/CoffeeBarPowerTests/JournalStore_test.swift asserts all of this with
stat(2), after the create path and after the replace path.
One gap stays open on purpose, and the reader is what closes it. The store pins
the mode of a directory it creates; it does not chmod a directory that already
exists. An unprivileged process that repairs a path another user may control is
not a fix, and item 1 puts that check on the reader instead. So a journal
directory an earlier build left 0755 keeps that mode — and GuardedJournalReader
refuses it rather than assume the writer corrected it.
This document used to contradict itself here, and the shipped code follows the
stricter reading. Item 1 asked only that every component be root-owned and not
group- or other-writable, which a 0755 directory satisfies, while this
paragraph demanded that the helper refuse exactly that. Item 1 now states the
rule the code enforces, so the two agree. If item 1 or item 3 is not true of the
code you are reading, that is a vulnerability. Report it.
A journal that fails these checks is refused, and refusing it has a price you
should be able to find before you meet it. The privileged side discards the
journal's priorValue — untrusted data cannot be allowed to name the value a
root process restores — and forces SleepDisabled to false instead.
If you had genuinely set disablesleep yourself, you lose that setting.
That is the deliberate direction. Refusing to trust the file cannot mean leaving
the machine awake for ever, which is the exact failure the watchdog exists to
prevent, and an untrusted file cannot tell us which of the two you wanted. Until
now this cost was recorded only in code comments and in a line on standard error
that launchd captures, which is not somewhere a user looks.
The refused journal is quarantined rather than deleted. It still proves something was armed, and it is the only evidence of why a machine stopped sleeping, so it is renamed and left for you to find.
-
A Homebrew-installed bundle is ad-hoc signed, not Developer ID signed. The formula builds from source on your own machine, so the result carries no team identifier:
codesign -dv /opt/homebrew/opt/coffee-bar/CoffeeBar.app Signature=adhoc TeamIdentifier=not setThat is the tap's design, not a defect. Homebrew compiled the code locally, so the bundle never crossed a trust boundary and carries no quarantine flag.
-
A bundle built by
scripts/build-app.shis unsigned. Same reason. Gatekeeper quarantines such a copy handed to another Mac. -
The version carries a commit suffix, such as
0.1.0-3-g1258578. That isgit describeoutput for a tree ahead of the last tag. See above. -
coffee-bar keeps the Mac awake. That is the product.
-
A finding produced only by a scanner, with no reproduction. Send the steps.