Skip to content

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

Merged
actuallymentor merged 18 commits intoactuallymentor:mainfrom
base47:root-owned-executables
Feb 25, 2026
Merged

Fix security issues, simplify app setup/updates, fix calibrate and some minor issues#446
actuallymentor merged 18 commits intoactuallymentor:mainfrom
base47:root-owned-executables

Conversation

@base47
Copy link
Copy Markdown

@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
Copy Markdown
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
Copy Markdown
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
Copy Markdown
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
Copy Markdown
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
Copy Markdown
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.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Done

$battery_binary maintain stop

# Disable charge blocker if enabled
$battery_binary adapter on
Copy link
Copy Markdown
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
Copy Markdown
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.

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

Ah this is dope, I didn't know that util. I see the default for -m is already 755 but I like it explicitly in here for clarity indeed


touch $logfile
sudo chown $calling_user $logfile
sudo chmod 755 $logfile
Copy link
Copy Markdown
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.

setup.sh Outdated

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
Copy Markdown
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
Copy Markdown
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.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

done

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
Copy Markdown
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.

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

True that, nice add

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

base47 commented Feb 15, 2026

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

@actuallymentor
Copy link
Copy Markdown
Owner

@base47 I will try to make time this week to review in detail, in the meantime I'll ask the robot to do a first pass.

A question at first glance: will we fuck over any other pieces of software by root owning the bin directory? It is not only our directory but a shared one.

Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

This PR targets the security vulnerability described in #443 by tightening ownership/permissions for privileged executables and adding a passwordless update_silent path for the GUI, while also fixing several CLI behaviors (charge loop, calibrate flow, adapter semantics) and improving GUI shell-exec robustness.

Changes:

  • Harden installation/update scripts and sudoers rules to reduce local privilege-escalation risk and enable GUI “silent” updates.
  • Refactor GUI shell execution helpers to preserve stdout/stderr, avoid treating stderr as fatal, and make timeouts explicit.
  • Fix/adjust CLI actions (charge, adapter, calibrate, --help/--version) and improve logging.

Reviewed changes

Copilot reviewed 6 out of 6 changed files in this pull request and generated 7 comments.

Show a summary per file
File Description
update.sh Uses a mktemp temp dir and installs the updated battery script as root.
setup.sh Installs smc/battery as root-owned and applies permissions/ownership changes + visudo setup.
battery.sh Adds update_silent, hardcodes executable paths/URLs, fixes charge loop, updates calibrate flow, and tightens permission repair logic.
app/modules/battery.js Reworks exec helpers, simplifies install/update decision logic, and improves online detection.
app/modules/helpers.js Improves GUI log formatting by using util.format and preserving message structure.
app/modules/interface.js Adjusts startup logging output formatting.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

update.sh Outdated
set -eu
sudo install -d -m 755 -o root -g wheel "$binfolder"
sudo install -m 755 -o root -g wheel "$updatefolder/battery.sh" "$binfolder/battery"
sudo chown root:wheel "$binfolder/smc"
Copy link

Copilot AI Feb 18, 2026

Choose a reason for hiding this comment

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

sudo chown root:wheel "$binfolder/smc" will cause the update to fail (because set -e is enabled) on installations where /usr/local/bin/smc is missing or temporarily unavailable. Consider guarding this with an existence check and/or installing/updating smc as part of the update flow so battery update remains reliable.

Suggested change
sudo chown root:wheel "$binfolder/smc"
if [ -e "$binfolder/smc" ]; then sudo chown root:wheel "$binfolder/smc"; fi

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

When $binfolder/smc is missing the battery setup is broken. It would be misleading for update script to succeed like everything is fine. When Terminal users see the error, they are expected to run battery reinstall. GUI app will trigger reinstall on its startup. We can adopt suggested change, but I don't see a reason for that.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

the line changing smc ownership is not needed anymore and removed

setup.sh Outdated
Comment on lines 45 to 67
@@ -33,44 +66,41 @@ unzip -qq $batteryfolder/repo.zip -d $batteryfolder
cp -r $batteryfolder/$in_zip_folder_name/* $batteryfolder
rm $batteryfolder/repo.zip
Copy link

Copilot AI Feb 18, 2026

Choose a reason for hiding this comment

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

tempfolder=/Users/$calling_user/.battery-tmp is a predictable, user-writable location, but later the script runs sudo install ... "$batteryfolder/..." from inside that tree. This creates a local privilege-escalation opportunity via symlink/TOCTOU replacement of the downloaded artifacts before the sudo install steps. Use a mktemp -d temp directory with restrictive permissions (and ideally root-owned for the portion used as the sudo install source) and clean it up via trap.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

An unlikely scenario, but I agree. Will add mktemp -d to setup.sh

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

done

setup.sh Outdated
Comment on lines +69 to +70
echo "[ 3 ] Make sure $binfolder exists and owned by root"
sudo install -d -m 755 -o root -g wheel "$binfolder"
Copy link

Copilot AI Feb 18, 2026

Choose a reason for hiding this comment

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

sudo install -d -m 755 -o root -g wheel "$binfolder" will modify the ownership of the existing /usr/local/bin directory. On Intel/Homebrew setups that rely on user-owned /usr/local, this can break package installs/updates and other tooling. If the goal is to prevent replacement of battery executables, consider installing into a dedicated root-owned subdirectory (or only enforcing root ownership on the specific battery files + a dedicated parent dir) rather than taking ownership of the entire /usr/local/bin.

Suggested change
echo "[ 3 ] Make sure $binfolder exists and owned by root"
sudo install -d -m 755 -o root -g wheel "$binfolder"
echo "[ 3 ] Make sure $binfolder exists"
sudo install -d -m 755 "$binfolder"

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Homebrew lives somewhere in /opt on Apple silicon macs and this app supports silicon macs only. But regarding some other 3pp software we don't even know exists, the Copilot's comment is true.
That means we are homeless now. Additionally, the change would require a modification of PATH variable for any possible shell used by users so the battery script can be launched without a full path.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Battery executables were relocated to /usr/local/co.palokaj.battery in order to avoid conflicts with other apps. /usr/local is SIP protected by system which makes it the best choice (see this Discussion with Copilot for details).
The name for co.palokaj.battery folder was chosen the same as bundle id of the battery.app. It's purpose is to be unique to avoid conflicts with other software. Also, it has to indicate that it's a battery.app's folder, so when a user sees it in their PATH variable they don't freak out.
The executables are installed right inside /usr/local/co.palokaj.battery. There are no 'bin' or 'sbin' subfolders because they are unlikely to ever be needed.
Also, /usr/local/bin/battery and /usr/local/bin/smc are now symlinks, mostly for compatibility with current version of GUI app. The names are already claimed and having them does no harm.

Please, let me know if you prefer a different name for co.palokaj.battery folder.

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

Name is fine for now. It is also an identity that links to my apple certificate that I use for signing. It's not user facing so it's fine

Comment on lines +7 to +9
const path_fix = 'PATH=/opt/homebrew/bin:/opt/homebrew/sbin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin'
const battery = `${ path_fix } battery`

Copy link

Copilot AI Feb 18, 2026

Choose a reason for hiding this comment

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

The GUI still runs battery via PATH (const battery = ${ path_fix } battery``) while path_fix includes user-writable locations like `/opt/homebrew/bin`. This reintroduces PATH-based spoofing risk (a malicious `battery` earlier in PATH could be executed and then prompt for admin credentials / run privileged subcommands). Prefer invoking the absolute path (`/usr/local/bin/battery`) everywhere, and avoid inheriting `process.env.PATH` in `shell_options` for these calls.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Makes sense.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Now we are using a full executable path and minimal PATH. path_fix is not needed and removed.

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

Nice approach

Comment on lines +14 to +23
// exec_async_no_timeout(), exec_async() and exec_sudo_async() are async helper functions
// which run bash-shell commands.
//
// They provide a unified output contract:
// Fulfilled result: { stdout: string, stderr: string }
// Rejected result: 'Error' object having the following extra properties:
// - 'cmd': shell command string
// - 'code': shell exit code or 'ETIMEDOUT'
// - 'output': { stdout: string, stderr: string }
//
Copy link

Copilot AI Feb 18, 2026

Choose a reason for hiding this comment

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

The comment/doc for the shell helpers says rejected results are Error objects with extra cmd and code properties, but the implementation only attaches output (and the timeout error sets code but not cmd). Either populate err.cmd consistently (and ensure err.code is always set for exec errors) or adjust the documented contract so downstream callers/logging can rely on it.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Good catch. cmd and code are included in error objects by other means, that's what I see in logs. But I don't add them to timeout errors. Also, if the process is terminated by a signal there will be no code.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

done

Comment on lines 632 to +633
fi
curl -sS https://raw.githubusercontent.com/actuallymentor/battery/main/setup.sh | bash
curl -sS "$github_url_setup_sh" | bash
Copy link

Copilot AI Feb 18, 2026

Choose a reason for hiding this comment

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

The curl -sS "$github_url_setup_sh" | bash pipeline downloads and executes a remote shell script from GitHub without any integrity or authenticity verification. If the GitHub repository or the network path is compromised, an attacker can serve a modified setup.sh that runs arbitrary code (including invoking sudo) under the installing user's session. Replace this pattern by downloading to a file and verifying a pinned commit/tag or checksum before execution, or by executing only locally distributed installer scripts instead of live curl|bash.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

The described problem is not related to this PR and deserves a new one. But only if we decide to consider compromise of the GitHub repository or the HTTPS channel a threat in our security model.
If the actuallymentor's GitHub account doesn't change owner and users don't have state actors targeting them, everybody should be fine.

Comment on lines +648 to 651
if ! is_latest_version_installed; then
curl -sS "$github_url_update_sh" | bash
echo "✅ battery background script was updated to the latest version."
else
Copy link

Copilot AI Feb 18, 2026

Choose a reason for hiding this comment

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

The update_silent action executes curl -sS "$github_url_update_sh" | bash as root via a passwordless sudoers rule, which downloads and runs an unpinned script from GitHub with full root privileges and no integrity verification. Compromise of the upstream repository or the HTTPS channel would immediately yield arbitrary root code execution on every client that invokes battery update_silent (including the GUI’s automatic background updates). Mitigate by avoiding curl|bash, pinning to immutable versions (e.g. commit SHA) and verifying checksums/signatures before executing any downloaded update logic, or by shipping and updating from locally stored, signed artifacts instead of live scripts.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Author

@base47 base47 Feb 18, 2026

Choose a reason for hiding this comment

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

Signatures would resolve all listed threats (even though those threats are unlikely), I like the idea, but again, not in this PR.

@base47
Copy link
Copy Markdown
Author

base47 commented Feb 18, 2026

will we fuck over any other pieces of software by root owning the bin directory

No, it's owned by root according to the OS design. We are making sure it's owned by root because that's were 3pp software likes to install it's executables. If some broken 3pp installer changes /usr/local/bin ownership for any reason, all our protection measures become pointless. Anyway, there can be many user accounts in the system, changing the ownership of shared /usr/local/bin to any specific user would not be fair :-) Note, that macOS protects /usr/local and /usr ownership, even root can't change it. Unfortunately, it's not the case for /usr/local/bin (at least on macOS Sequoia).

We should be this paranoid because we add sudoers config file to the system; this implies huge responsibility.


UPDATE: My statement above about owned by root according to the OS design is not correct. I think in my system /usr/local/bin was created by battery installer, that is why it is owned by root. I can't find any evidence that this folder must be root owned according to macOS design.

@base47
Copy link
Copy Markdown
Author

base47 commented Feb 23, 2026

@actuallymentor

I addressed Copilot's comments and did the full retest on Sequoia 15.7.3 and the latest Tahoe 26.3.

The PR is ready. I tested it using my test branch which is rebased on top of this PR and modifies update URLs for GUI and background executables.

Below I'm sharing my test list which describes in details the expected user experience in possible scenarios when updating to a newer version. The only potential inconvenience I know about is described in tests 4 and 5.

1. Installed: battery.sh 1.3.2 only, update is available
   Steps: Run 'battery update' in Terminal
   Expected result:
        'battery update' triggers full reinstall using setup.sh script. New version
        of background tools installed, 'battery maintain' process restarted.

2. Installed: GUI 1.3.2, battery.sh 1.3.2, update is available
   Steps: Launch GUI app 1.3.2.
   Expected result:
        GUI starts the update of background executables, sees new version but does nothing.
        GUI silently ignores the update, old version of background executables remains untouched.
        Everything works as before.

3. Installed: GUI 1.3.2, battery.sh 1.3.2, update is available
   Steps: Run 'battery update' in Terminal while GUI app 1.3.2 is running.
   Expected result:
        Background executables are updated to the latest version. Old GUI app is not disrupted,
        indicates no errors. Everything works as before. 'battery maintain' process restarted
        using a new version of background executables.

4. Installed: GUI 1.3.2, NEW battery.sh (updated from Terminal)
   Steps: Launch GUI app 1.3.2.
   Expected result on the older Sequoia 15.7.3:
        GUI starts and runs normally with a newer version of background executables. All commands
        work. When old GUI tries to update the background executables to even newer version -
        'battery update silent' action silently succeeds doing nothing.
   Expected result on the latest Tahoe 26.3:
        Same as on Sequoia, but each time the GUI app starts it asks for user password and executes
        'battery visudo' which does nothing since the config is already up to date. This happens
        because password-less sudo does not work for symlinks on Tahoe like it does on Sequoia.
        This can't be fixed, because adding link paths to visudo config would introduce the same
        security issue we are fighting against in this PR. We can't assume that we own
        /usr/local/bin and some other installer will not make it user-owned.
        Since visudo is incomplete according to v1.3.2, the GUI does not even try to run
        'battery update silent'.

5. Installed: GUI 1.3.2 or nothing AND PR is merged to the main branch, and bettery.sh version tag
              is OR not incremented in the main branch.
   Steps: Do a fresh install. Run the GUI 1.3.2 when there are no battery.sh installed OR execute
          curl -sS https://raw.githubusercontent.com/actuallymentor/battery/main/setup.sh | bash
   Expected result:
        Since we are updating from the main branch, the merged background executables from this PR
        are installed and since they are backward compatible with GUI 1.3.2 everything works as
        descripbed in the previous case #4 (where GUI 1.3.2 works with NEW battery.sh)

6. Installed: NEW GUI, battery.sh 1.3.2, update is available
   Steps: Launch the new version of GUI app.
   Expected result:
        GUI app triggers a full reinstall and continues working normally with a new version
        of background executables.

7. Installed: NEW GUI, no background executables
   Steps: Launch the new version of GUI app.
   Expected result:
        GUI app installs missing background executables and works as usual.

8. Installed: NEW GUI, NEW battery.sh, even newer update is available
   Steps: Launch GUI app.
   Expected result:
        GUI app performs silent update of background executables to the latest version and works
        as usual.

9. Installed: NEW battery.sh, even newer update is available
   Steps: Run 'battery update' in Terminal
   Expected result:
        new version of background background executables is installed. 'battery maintain' process
        restarted using a new version.

@actuallymentor
Copy link
Copy Markdown
Owner

Love this in depth and thoughtful work @base47! I'm happy to ship this. This requires the PR merged and a local build of the GUI on my part. I'm not sure what timezone you are in, but I'd like to make sure to do that while you are awake just on the off chance that an edge-case breaks things.

Is there a day/time this week that would be a good target for me to merge, build, and upload?

Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 6 out of 6 changed files in this pull request and generated 5 comments.

Comments suppressed due to low confidence (3)

update.sh:8

  • The comment says the script is running as root, but this script explicitly supports non-root execution paths (e.g. GUI-launched updates and the reinstall trigger). Consider rewording to avoid implying EUID==0 is guaranteed so future maintainers don’t accidentally add root-only assumptions above the EUID checks.
# This script is running as root:
#   - Reset PATH to safe defaults at the very beginning of the script.
#   - Never include user-owned directories in PATH.
PATH=/usr/bin:/bin:/usr/sbin:/sbin

app/modules/battery.js:137

  • Spelling typo in comment: “batery” -> “battery”.
        // 'batery maintain' creates a child process, so when the command exits exec_async does not return.
        // That's why here we use a timeout and wait for some time.

app/modules/helpers.js:3

  • This file consistently uses single quotes and omits semicolons, but the new util require uses double quotes and a semicolon. If ESLint/style rules enforce the existing convention, this will fail linting. Align the new import line with the surrounding style.
const util = require("util");

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +54 to +67
const workers = [ exec_async_no_timeout( command ) ]
if ( timeout_in_ms > 0 ) {
workers.push(
wait(timeout_in_ms).then( () => {
const error = new Error( `${ command } timed out` )
error.code = 'ETIMEDOUT'
error.cmd = command
error.output = { stdout: '', stderr: '' }
throw error;
})
);
}

return Promise.race( workers )
Copy link

Copilot AI Feb 23, 2026

Choose a reason for hiding this comment

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

exec_async() implements timeouts via Promise.race, but it does not terminate the underlying subprocess when the timeout wins. For commands like the online checks (curl), this can leave long-running/hung processes behind even though the caller has moved on. Prefer using child_process.exec’s built-in timeout/killSignal options or enforce timeouts at the command level (e.g. curl --max-time/--connect-timeout) so the process is actually stopped.

Suggested change
const workers = [ exec_async_no_timeout( command ) ]
if ( timeout_in_ms > 0 ) {
workers.push(
wait(timeout_in_ms).then( () => {
const error = new Error( `${ command } timed out` )
error.code = 'ETIMEDOUT'
error.cmd = command
error.output = { stdout: '', stderr: '' }
throw error;
})
);
}
return Promise.race( workers )
// If no timeout is requested, use the existing helper unchanged
if ( timeout_in_ms <= 0 ) {
return exec_async_no_timeout( command )
}
// Use child_process.exec's built-in timeout/killSignal so the subprocess
// is actually terminated when the timeout elapses.
return new Promise( ( resolve, reject ) => {
log( `Executing ${ command } with timeout ${ timeout_in_ms }ms` )
const options = {
...shell_options,
timeout: timeout_in_ms,
// Use SIGTERM so the child is terminated on timeout
killSignal: 'SIGTERM'
}
exec( command, options, ( error, stdout, stderr ) => {
const output = { stdout: stdout ?? '', stderr: stderr ?? '' }
if ( error ) {
// If this was caused by a timeout, normalize the code to ETIMEDOUT
if ( error.killed && error.signal === options.killSignal ) {
error.code = 'ETIMEDOUT'
} else {
error.code ??= ( error.signal ? 'SIGNAL' : 'UNKNOWN' )
}
error.cmd ??= command
error.output = output
return reject( error )
} else {
return resolve( output )
}
} )
} )

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

The behavior we have is exactly the one we need for 'battery maintain'.
Regarding hanging network requests, I consider it not a big deal. Also, the PR does not make the situation here worse comparing to what we already have in the latest version 1.3.2.

battery.sh Outdated
Comment on lines 699 to 705
version_before="$($battery_binary version)"

if ! check_installation_integrity; then
echo -e "‼️ The battery installation seems to be broken. Forcing reinstall...\n"
$battery_binary reinstall silent
version_before="0" # Force restart maintenance process
else
Copy link

Copilot AI Feb 23, 2026

Choose a reason for hiding this comment

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

In the update action, version_before is computed by executing $battery_binary version before check_installation_integrity runs. If the installation is partially broken (e.g. missing or non-executable battery binary), this call can fail/noise before you’ve decided to force reinstall. Consider moving the version_before call to after the integrity check (and only when $battery_binary is known to be runnable), or guard it so the reinstall path remains clean and reliable.

Suggested change
version_before="$($battery_binary version)"
if ! check_installation_integrity; then
echo -e "‼️ The battery installation seems to be broken. Forcing reinstall...\n"
$battery_binary reinstall silent
version_before="0" # Force restart maintenance process
else
if ! check_installation_integrity; then
echo -e "‼️ The battery installation seems to be broken. Forcing reinstall...\n"
$battery_binary reinstall silent
version_before="0" # Force restart maintenance process
else
# Installation looks healthy; safe to query current version
version_before="$($battery_binary version)"

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

will fix.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

done

Comment on lines +59 to 66
echo "[ 3 ] Writing script to $binfolder/battery"
sudo install -d -m 755 -o root -g wheel "$binfolder"
sudo install -m 755 -o root -g wheel "$updatefolder/battery.sh" "$binfolder/battery"

# Remove tempfiles
cd
rm -rf $tempfolder
echo "[ 3 ] Removed temporary folder"
echo "[ 4 ] Remove temporary folder"
rm -rf "$tempfolder";

echo -e "\n🎉 Battery tool updated.\n"
Copy link

Copilot AI Feb 23, 2026

Choose a reason for hiding this comment

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

The update flow doesn’t check the result of the sudo install ... steps. If install fails (e.g. permissions, disk full), the script will still print the success message and exit 0, leaving a partial update. Consider checking the exit status of the install commands (or enabling set -e / explicit || exit with a clear error message) so failures are surfaced.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Author

@base47 base47 Feb 23, 2026

Choose a reason for hiding this comment

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

It would make sense to check for this kind of errors if we had a way to recover from them. So, the only problem we have is we can tell user that everything is fine when it's actually not. It's a consistent behavior throughout the whole bash code base.

Comment on lines 73 to 78
rm -rf $batteryfolder
mkdir -p $batteryfolder
curl -sSL -o $batteryfolder/repo.zip "https://github.com/actuallymentor/battery/archive/refs/heads/$update_branch.zip"
unzip -qq $batteryfolder/repo.zip -d $batteryfolder
cp -r $batteryfolder/$in_zip_folder_name/* $batteryfolder
rm $batteryfolder/repo.zip
Copy link

Copilot AI Feb 23, 2026

Choose a reason for hiding this comment

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

The download/unzip/copy steps aren’t checked for failure (no set -e and no explicit exit-status checks). If curl/unzip fails, the script can continue and still print a successful install message, potentially leaving a broken partial installation. Consider enabling strict error handling (e.g. set -euo pipefail) or adding explicit checks with a clear failure message before proceeding to install steps.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Already answered regarding this kind of issues above. In practice, setup.sh is the most reliable piece of code here. A single pill for all the problems.

Comment on lines +491 to +529
function ensure_owner() {
local owner="$1" group="$2" path="$3"
[[ -e $path ]] || { return 1; }
local cur_owner=$(stat -f '%Su' "$path")
local cur_group=$(stat -f '%Sg' "$path")
if [[ $cur_owner != "$owner" || $cur_group != "$group" ]]; then
sudo chown "${owner}:${group}" "$path"
fi
}

function ensure_owner_mode() {
local owner="$1" group="$2" mode="$3" path="$4"
ensure_owner "$owner" "$group" "$path" || return
local cur_mode=$(stat -f '%Lp' "$path")
if [[ $cur_mode != "${mode#0}" ]]; then
sudo chmod "$mode" "$path"
fi
}

# Use the following function to apply any setup related fixes which require root permissions.
# This function is executed by 'update_silent' action with EUID==0.
function fixup_installation_owner_mode() {
local username=$1

ensure_owner_mode root wheel 755 "$visudo_folder"
ensure_owner_mode root wheel 440 "$visudo_file"

ensure_owner_mode root wheel 755 "$binfolder"
ensure_owner_mode root wheel 755 "$battery_binary"
ensure_owner_mode root wheel 755 "$smc_binary"

ensure_owner_mode $username staff 755 "$(dirname "$daemon_path")"
ensure_owner_mode $username staff 644 "$daemon_path"

ensure_owner_mode $username staff 755 "$configfolder"
ensure_owner_mode $username staff 644 "$pidfile"
ensure_owner_mode $username staff 644 "$logfile"
ensure_owner_mode $username staff 644 "$maintain_percentage_tracker_file"
ensure_owner_mode $username staff 644 "$maintain_voltage_tracker_file"
Copy link

Copilot AI Feb 23, 2026

Choose a reason for hiding this comment

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

The combination of ensure_owner / ensure_owner_mode and fixup_installation_owner_mode performs chown/chmod as root on paths under $configfolder and $daemon_path, which reside in user-writable directories (e.g. $HOME/.battery, $HOME/Library/LaunchAgents). Because these helpers unconditionally follow symlinks (stat/chown/chmod on $path without checking for symlinks), an unprivileged local user can point these paths (or files like $logfile or $pidfile) at arbitrary system locations (e.g. /etc/sudoers or other root-owned files) and have them re-owned and world-readable during a passwordless battery update_silent, leading to full local privilege escalation. To mitigate, avoid performing privileged ownership/permission fixes on user-controlled paths without first ensuring they are not symlinks and live under a root-owned directory, or move this metadata into a root-owned location that users cannot replace with symlinks.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

these helpers unconditionally follow symlinks

Oh, this is a good one if true. Must be fixed. Looking into it.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

@actuallymentor The attack works. When executed without any options (at least on Sequoia), chown/chmod change the ownership/mode of the link target, not the link itself. This behavior is platform dependent. It's crazy. Now I have to review every chown/chmod usage in the project and pass explicit options to avoid default behavior. It can take some time, I think, should be ready by tomorrow if full retest is needed.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

done

@base47
Copy link
Copy Markdown
Author

base47 commented Feb 23, 2026

@actuallymentor

Is there a day/time this week that would be a good target for me to merge, build, and upload?

Anyday, Western Europe

@actuallymentor
Copy link
Copy Markdown
Owner

I'll do the merge tomorrow and tag you

@base47
Copy link
Copy Markdown
Author

base47 commented Feb 24, 2026

@actuallymentor Ok, but let me add a small commit addressing the Cpilot's comment #446 (comment) . I will try to allocate some time this evening.

@base47
Copy link
Copy Markdown
Author

base47 commented Feb 25, 2026

@actuallymentor I added 2 trivial commits. The main change is to use -h option with chown and chmod, preventing them from following symlinks. All tests passed on both Sequoia and Tahoe. I approve the PR for merging.

@actuallymentor actuallymentor merged commit d3bbb18 into actuallymentor:main Feb 25, 2026
@actuallymentor
Copy link
Copy Markdown
Owner

@base47 merged, encountered a CLI error curl: (56) Failure writing output to destination, passed 277 returned 0. I fixed that one and will build the GUI now.

I tested the local GUI with the updated battery (including my fix) and the update went well

@actuallymentor
Copy link
Copy Markdown
Owner

Release published here. Let's keep an eye out for issues.

Thanks for the work!

@base47
Copy link
Copy Markdown
Author

base47 commented Feb 25, 2026

@actuallymentor Sequoia updated GUI to 1.4 and battery.sh to 1.3.4
Everything is fine so far

@base47
Copy link
Copy Markdown
Author

base47 commented Feb 25, 2026

@actuallymentor I forgot to update README with the new paths and the battery help output. It’s not a big deal, since people prefer creating issues anyway.

panoskava pushed a commit to panoskava/battery that referenced this pull request Mar 17, 2026
Fix security issues, simplify app setup/updates, fix calibrate and some minor issues
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.

3 participants