diff --git a/setup.sh b/setup.sh
index b66c99517..ae87d0761 100755
--- a/setup.sh
+++ b/setup.sh
@@ -85,6 +85,30 @@ if [[ -f "$_SHARED_CONSTANTS" ]]; then
fi
unset _SHARED_CONSTANTS
+# Escape a string for safe embedding in XML (plist heredocs).
+# Prevents XML injection if paths contain &, <, >, ", or ' characters.
+_xml_escape() {
+ local str="$1"
+ str="${str//&/&}"
+ str="${str//<}"
+ str="${str//>/>}"
+ str="${str//\"/"}"
+ str="${str//\'/'}"
+ printf '%s' "$str"
+ return 0
+}
+
+# Escape a string for safe embedding in crontab entries.
+# Wraps value in single quotes (prevents $(…), backtick, and variable expansion
+# by cron's /bin/sh). Embedded single quotes are escaped via the '\'' idiom.
+_cron_escape() {
+ local str="$1"
+ # Replace each ' with '\'' (end quote, escaped quote, start quote)
+ str="${str//\'/\'\\\'\'}"
+ printf "'%s'" "$str"
+ return 0
+}
+
# Check if a launchd agent is loaded (SIGPIPE-safe for pipefail, t1265)
_launchd_has_agent() {
local label="$1"
@@ -839,7 +863,7 @@ main() {
_pulse_installed=true
fi
fi
- if [[ "$_pulse_installed" == "false" ]] && crontab -l 2>/dev/null | grep -qF "pulse-wrapper" 2>/dev/null; then
+ if [[ "$_pulse_installed" == "false" ]] && crontab -l 2>/dev/null | grep -qF "pulse-wrapper"; then
_pulse_installed=true
fi
@@ -856,17 +880,26 @@ main() {
# Unload old plist if upgrading
if _launchd_has_agent "$pulse_label"; then
- launchctl unload "$pulse_plist" 2>/dev/null || true
+ launchctl unload "$pulse_plist" || true
pkill -f 'Supervisor Pulse' 2>/dev/null || true
fi
# Also clean up old label if present
local old_plist="$HOME/Library/LaunchAgents/com.aidevops.supervisor-pulse.plist"
if [[ -f "$old_plist" ]]; then
- launchctl unload "$old_plist" 2>/dev/null || true
+ launchctl unload "$old_plist" || true
rm -f "$old_plist"
fi
+ # XML-escape paths for safe plist embedding (prevents injection
+ # if $HOME or paths contain &, <, > characters)
+ local _xml_wrapper_script _xml_home _xml_opencode_bin _xml_aidevops_dir _xml_path
+ _xml_wrapper_script=$(_xml_escape "$wrapper_script")
+ _xml_home=$(_xml_escape "$HOME")
+ _xml_opencode_bin=$(_xml_escape "$opencode_bin")
+ _xml_aidevops_dir=$(_xml_escape "$_aidevops_dir")
+ _xml_path=$(_xml_escape "$PATH")
+
# Write the plist (always regenerated to pick up config changes)
cat >"$pulse_plist" <
@@ -878,24 +911,24 @@ main() {
ProgramArguments
/bin/bash
- ${wrapper_script}
+ ${_xml_wrapper_script}
StartInterval
120
StandardOutPath
- ${HOME}/.aidevops/logs/pulse-wrapper.log
+ ${_xml_home}/.aidevops/logs/pulse-wrapper.log
StandardErrorPath
- ${HOME}/.aidevops/logs/pulse-wrapper.log
+ ${_xml_home}/.aidevops/logs/pulse-wrapper.log
EnvironmentVariables
PATH
- ${PATH}
+ ${_xml_path}
HOME
- ${HOME}
+ ${_xml_home}
OPENCODE_BIN
- ${opencode_bin}
+ ${_xml_opencode_bin}
PULSE_DIR
- ${_aidevops_dir}
+ ${_xml_aidevops_dir}
PULSE_STALE_THRESHOLD
1800
@@ -907,7 +940,7 @@ main() {
PLIST
- if launchctl load "$pulse_plist" 2>/dev/null; then
+ if launchctl load "$pulse_plist"; then
if [[ "$_pulse_installed" == "true" ]]; then
print_info "Supervisor pulse updated (launchd config regenerated)"
else
@@ -919,10 +952,16 @@ PLIST
else
# Linux: use cron entry with wrapper
# Remove old-style cron entries (direct opencode invocation)
+ # Shell-escape all interpolated paths to prevent command injection
+ # via $(…) or backticks if paths contain shell metacharacters
+ local _cron_opencode_bin _cron_aidevops_dir _cron_wrapper_script
+ _cron_opencode_bin=$(_cron_escape "$opencode_bin")
+ _cron_aidevops_dir=$(_cron_escape "$_aidevops_dir")
+ _cron_wrapper_script=$(_cron_escape "$wrapper_script")
(
crontab -l 2>/dev/null | grep -v 'aidevops: supervisor-pulse'
- echo "*/2 * * * * OPENCODE_BIN=${opencode_bin} PULSE_DIR=${_aidevops_dir} /bin/bash ${wrapper_script} >> $HOME/.aidevops/logs/pulse-wrapper.log 2>&1 # aidevops: supervisor-pulse"
- ) | crontab - 2>/dev/null || true
+ echo "*/2 * * * * PATH=\"/usr/local/bin:/usr/bin:/bin\" OPENCODE_BIN=${_cron_opencode_bin} PULSE_DIR=${_cron_aidevops_dir} /bin/bash ${_cron_wrapper_script} >> \"\$HOME/.aidevops/logs/pulse-wrapper.log\" 2>&1 # aidevops: supervisor-pulse"
+ ) | crontab - || true
if crontab -l 2>/dev/null | grep -qF "aidevops: supervisor-pulse"; then
print_info "Supervisor pulse enabled (cron, every 2 min). Disable: crontab -e and remove the supervisor-pulse line"
else
@@ -934,14 +973,14 @@ PLIST
if [[ "$(uname -s)" == "Darwin" ]]; then
local pulse_plist="$HOME/Library/LaunchAgents/${pulse_label}.plist"
if _launchd_has_agent "$pulse_label"; then
- launchctl unload "$pulse_plist" 2>/dev/null || true
+ launchctl unload "$pulse_plist" || true
rm -f "$pulse_plist"
pkill -f 'Supervisor Pulse' 2>/dev/null || true
print_info "Supervisor pulse disabled (launchd agent removed per config)"
fi
else
- if crontab -l 2>/dev/null | grep -qF "pulse-wrapper" 2>/dev/null; then
- crontab -l 2>/dev/null | grep -v 'aidevops: supervisor-pulse' | crontab - 2>/dev/null || true
+ if crontab -l 2>/dev/null | grep -qF "pulse-wrapper"; then
+ crontab -l 2>/dev/null | grep -v 'aidevops: supervisor-pulse' | crontab - || true
print_info "Supervisor pulse disabled (cron entry removed per config)"
fi
fi
@@ -994,6 +1033,13 @@ PLIST
launchctl unload "$guard_plist" || true
fi
+ # XML-escape paths for safe plist embedding (prevents injection
+ # if $HOME or paths contain &, <, > characters)
+ local _xml_guard_script _xml_guard_home _xml_guard_path
+ _xml_guard_script=$(_xml_escape "$guard_script")
+ _xml_guard_home=$(_xml_escape "$HOME")
+ _xml_guard_path=$(_xml_escape "$PATH")
+
cat >"$guard_plist" <
@@ -1003,21 +1049,22 @@ PLIST
${guard_label}
ProgramArguments
- ${guard_script}
+ /bin/bash
+ ${_xml_guard_script}
kill-runaways
StartInterval
30
StandardOutPath
- ${HOME}/.aidevops/logs/process-guard.log
+ ${_xml_guard_home}/.aidevops/logs/process-guard.log
StandardErrorPath
- ${HOME}/.aidevops/logs/process-guard.log
+ ${_xml_guard_home}/.aidevops/logs/process-guard.log
EnvironmentVariables
PATH
- ${PATH}
+ ${_xml_guard_path}
HOME
- ${HOME}
+ ${_xml_guard_home}
SHELLCHECK_RSS_LIMIT_KB
524288
SHELLCHECK_RUNTIME_LIMIT
@@ -1043,11 +1090,14 @@ GUARD_PLIST
else
# Linux: cron entry (every minute — cron minimum granularity)
# Always regenerate to pick up config changes (matches macOS behavior)
+ # Shell-escape path to prevent command injection via metacharacters
+ local _cron_guard_script
+ _cron_guard_script=$(_cron_escape "$guard_script")
(
crontab -l 2>/dev/null | grep -v 'aidevops: process-guard'
- echo "* * * * * SHELLCHECK_RSS_LIMIT_KB=524288 SHELLCHECK_RUNTIME_LIMIT=120 CHILD_RSS_LIMIT_KB=8388608 CHILD_RUNTIME_LIMIT=7200 /bin/bash \"${guard_script}\" kill-runaways >> \"\$HOME/.aidevops/logs/process-guard.log\" 2>&1 # aidevops: process-guard"
- ) | crontab - 2>/dev/null || true
- if crontab -l 2>/dev/null | grep -qF "aidevops: process-guard" 2>/dev/null; then
+ echo "* * * * * PATH=\"/usr/local/bin:/usr/bin:/bin\" SHELLCHECK_RSS_LIMIT_KB=524288 SHELLCHECK_RUNTIME_LIMIT=120 CHILD_RSS_LIMIT_KB=8388608 CHILD_RUNTIME_LIMIT=7200 /bin/bash ${_cron_guard_script} kill-runaways >> \"\$HOME/.aidevops/logs/process-guard.log\" 2>&1 # aidevops: process-guard"
+ ) | crontab - || true
+ if crontab -l 2>/dev/null | grep -qF "aidevops: process-guard"; then
print_info "Process guard enabled (cron, every minute)"
else
print_warning "Failed to install process guard cron entry"