Skip to content

Fix security issues, simplify app setup/updates, fix calibrate and some minor issues#446

Open
base47 wants to merge 14 commits intoactuallymentor:mainfrom
base47:root-owned-executables
Open

Fix security issues, simplify app setup/updates, fix calibrate and some minor issues#446
base47 wants to merge 14 commits intoactuallymentor:mainfrom
base47:root-owned-executables

Conversation

@base47
Copy link

@base47 base47 commented Feb 14, 2026

Primary goal of this PR is to fix the #443 .

All changes in bash scripts and GUI (including updates from current version 1.3.2 using GUI and Terminal) were tested on Sequoia 15.7.3 and Tahoe 26.2 (which is the latest).

During the update GUI users will be asked to reinstall background executables. Terminal users can just run battery update as usual. In both cases user password will be required.


First of all let me start with assuring you that this PR is not vibe-coded. Every change I made have a reason, I'm aware of each modification and understand what they do. Now, let’s look at the changes from a bird’s-eye perspective

Security improvements

Battery limiter requires root previleges to set the values of SMC keys which control macbook charging. That means that maintainers have to pay a special attention to app security, specifically to privilage escalation attacks. This PR addresses the following attack scenarios:

  • Modification of battery executables: A user might run some third-party app they trust; this app never asks for a user password, but if it can modify executables that run as root (or legitimately ask for the user password), an attacker can get their code executed with root privileges. To address this problem, this PR makes the battery background executables owned by root and writable by root only. Only root can change file/folder ownership on Unix systems, so any third-party app running as the user loses the ability to modify the battery executables.
  • Replacement of battery executables: Essentially the same as modification. Even if the file itself is owned by root, if the parent directory is owned by the user, the user can remove the file and create a new one. To prevent this, this PR ensures that the /usr/local/bin directory is also owned by root. macOS protects grandparent directories by other means, but does not do the same for /usr/local/bin (at least on Sequoia).
  • PATH-based spoofing: By modifying the PATH variable, an attacker can get their own battery executed and trick a user into entering a password. The battery.sh script already resets the PATH variable to hardcoded safe defaults right after launch, diminishing the problem to almost non-existent. This PR additionally hardcodes the full paths to the battery executables in our scripts, which can be useful when we consider app maintenance in the long run.
  • GitHub URLs protection: Those are already hardcoded and safe. This PR just adds comments for maintainers about the importance of keeping URLs hardcoded. I almost made that mistake myself.

Setup/Update functionality in battery.sh

Since the battery background executables are not writable by the user, root privileges are required for updates. To keep updates silent on each startup of the GUI app, this PR adds a passwordless 'update_silent' action to the battery.sh script (via visudo config, same way as we already do for smc). This same action is also used by the 'battery update' action, so updates from the Terminal are also passwordless. Normally, with this PR, a user will have to enter a password twice: during installation of the background binaries and during uninstall.
Users no longer need to use the battery visudo action, even though I suspect few of them ever did. The update_silent action executes battery visudo after each update. Even if no updates are available, update_silent still invokes battery visudo and ensures that the ownership and permissions of the battery files are intact. The visudo action does not overwrite sudoers config on every invocation. It writes it only if it is missing or outdated, so it is OK to invoke it on each update.
In the past some users had an issues related to the ownership of battery config folder or battery daemon plist, because they invoked battery visudo or other actions with sudo. I did it many times myself. With this PR the visudo action uses system allocated cache folder to store its temp file and not touching config folder at all. Additionally, the PR adds some guards to make sure some actions are not executed with sudo by mistake.

Setup/Update functionality in app/modules/battery.js

This functionality is located in initialize_battery function. This PR simplifies the process to a simple condition:

if (not_installed) { install } 
else { try updating using passwordless 'sudo battery update_silent' }

The decision about whether to install or update is based on:

  • availability of executables
  • ownership of executables and their parrent folder
  • is passwordless update_silent enabled or not (absense of visudo config is assumed otherwise)

If any of the conditions fail, the GUI app triggers reinstallation of background components asking for user password.
Silent update is launched otherwise.
The time app checks for updates (when user sees the "Updating..." message in menubar) is significantly reduced with this PR comparing to current version 1.3.2, even though I'm not sure why.

exec_async() and other shell execution helpers in app/modules/battery.js

Existing exec_async related functionality makes it difficult to maintain the app and introduces some bugs:

  • If a shell command prints a warning to stderr, exec_async() considers it as an error. As a result, some users had "Error installing battery limiter: undefined" alert due to a syntax error in their '.bashrc'. The output to stderr is not an error indication, apps also using stderr comunicate warnings.
  • When an error occurs, both stderr and stdout are lost, even though the app tries to return all of them, only the error arrives. This makes logs less useful and complicates debugging.
  • Each and every exec_async() call the app makes has a hidden 2 second timeout. If the shell command does not return during 2 seconds the exec_async() indicates success with 'undefined' value.

With this PR:

  • The stderr output is no longer treated as an error.
  • Both stdout and stderr are available for exec_async() user after the command completes. Both stdout and stderr are/(can be) logged upon successful execution or when an error occurs.
  • The timeouts are enabled only explicitly. In our case, only "battery maintain" needs timeout, because when it succeeds the Electron does not indicate command completion until all child processes quit and file descriptors closed.

Other changes

  • Fix never-ending 'battery charge' action (BUG: terminal stuck when "battery charge 80" command is run. #439). A line updating loop condition variable were missing.
  • Fix online connectivity check in GUI app. The previous implementation let the first completed check determine the "online" status, so it could report offline even if one URL was reachable. With this PR, "online" is true if either of the two URLs is reachable, and false otherwise.
  • Add --help and --version actions (cli support --help #433). Because they are trivial to implement, and it’s sometimes annoying to run battery help with its long output and scroll back just to find out which version is installed.
  • Fix confusing on/off meaning in adapter action. Before the fix, battery adapter on would actually disable the adapter.
  • Add PID to battery.sh log messages. Because it's useful to see if log messages belong to the same process or different ones.
  • battery.sh: Check network reachability before the version check. Before the fix, battery update would say that a new version is available when the internet is offline (it would fail later, of course). Now it just says “☑️ No updates found”. Honestly, now I’m not sure about this one. It seems confusing either way.
  • Consistent behavior for charge/discharge actions. Both actions start charging/discharging and run a loop until the requested charge level is reached. Previously, battery charge killed the maintenance process, while battery discharge did not (otherwise maintenance process would kill itself with --force-discharge option). With this PR, both "charge" and "discharge" actions kill maintenance process, but only when they are called by user from Terminal. This prevents interference between the two charge-controlling tasks. These actions wouldn't work otherwise. Additionally, since they stop maintenance, both actions now restore it on completion, again only when run directly from the Terminal. This change is implemented using environment variable flag, so affected clients (if any) can easily adapt to the new behavior.
  • Fix calibrate action. It was severely broken and was trying to run calibration in a background process for no reason. With this PR, it runs in a Terminal window. Similar to the charge/discharge actions, it terminates the maintenance process and restores it upon completion. The opposite is true as well: if the user launches maintenance in the Terminal or by starting the GUI menubar app, the calibration process is terminated. We would need to implement some kind of confirmations one day. Tested it once and now coconutBattery reports that battery health increased by 2%. Maybe it was worth it.

@base47
Copy link
Author

base47 commented Feb 14, 2026

@actuallymentor ready for review

Comment on lines +26 to +27
const line = util.format(...messages) + "\n";
await fs.appendFile( `${ HOME }/.battery/gui.log`, line, 'utf8' )
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This change allows to have a nice output like:

Update details:  {
  stdout: '☑️  No updates found\n' +
    '☑️  The existing battery visudo file is what it should be for version v1.3.2\n',
  stderr: ''
}

not only in terminal but in gui.log as well. Version 1.3.2 logs stdout only. Btw, stdout and stderr are logged for errors as well, unless we decide to ignore resolved/rejected value.

async function set_initial_interface() {

log( "Starting tray app" )
log('\n===\n=== Starting tray app\n===\n')
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Makes the new app session more visible in the gui.log

local color=$1

# Check whether user can run color changes without password (required for backwards compatibility)
if sudo -n smc -k ACLC -r &>/dev/null; then
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This check is not needed anymore. The smc command just reads smc changing nothing

battery.sh Outdated
Comment on lines 673 to 677
# Temporarily support the 'silent' setting for backward compatibility with older UI versions.
if [[ "$setting" == "silent" ]]; then
sudo -n $battery_binary update_silent
exit 0
fi
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Old GUI version actually works if the new battery.sh is already installed, but it cannot be used to update from current version. It will install the new battery script using current battery executable, but the next time Old GUI starts user will get an error alert. I'm gonna need one more commit to prevent it.

$battery_binary maintain stop

# Disable charge blocker if enabled
$battery_binary adapter on
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not needed here, charging is restored below, don't do it twice.

sudo chmod 755 $binfolder/smc
sudo chmod +x $binfolder/smc
echo "[ 3 ] Make sure $binfolder exists and owned by root"
sudo install -d -m 755 -o root -g wheel "$binfolder"
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

install is just a system utility which does the same job as cp/chown/chmod commands we deleted above and below.


touch $logfile
sudo chown $calling_user $logfile
sudo chmod 755 $logfile
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Only executables and folders require 755. Others are good with just 644 which is read/write for owner and read for everyone else.


sudo chown $calling_user $binfolder/battery
# Fix permissions for 'create_daemon' action
echo "[ 7 ] Fix ownership and permissions for $(dirname "$launch_agent_plist")"
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A user once had a root ownership on ~/Library/LaunchAgents folder (#420 (comment)). Not sure how that could happen, but lets make installer capable of fixing it.

setup.sh Outdated
echo "[ 6 ] Setting up visudo declarations"
sudo $batteryfolder/battery.sh visudo $USER
echo "[ 8 ] Setup visudo configuration"
sudo $binfolder/battery visudo $calling_user
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

visudo does not need $calling_user argument anymore. Will remove.

mkdir -p $batteryfolder
curl -sS -o $batteryfolder/battery.sh https://raw.githubusercontent.com/actuallymentor/battery/main/battery.sh
echo -n "[ 1 ] Allocating temp folder: "
tempfolder="$(mktemp -d)"
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

mktemp is a standard system utility for temporary directory allocation, used extensively on macOS. The system owns the directory and will remove it at some point on startup if we don’t remove it ourselves.

@base47 base47 force-pushed the root-owned-executables branch from aac6db3 to acb0eac Compare February 15, 2026 19:49
@base47
Copy link
Author

base47 commented Feb 15, 2026

@actuallymentor You asked to tag you when PR is ready. Now it is.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant