macOS system health toolkit with 5 audit modules (packages, environment, security, cleanup, resources) and cleanup executor. Fixed bugs from review: - Replace GNU timeout with perl alarm (macOS compatible) - Remove Linux-only ps --sort flag, use portable sort - Add JSON escaping to all audit scripts - Remove redundant classify_size branch Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
144 lines
6.0 KiB
Bash
Executable File
144 lines
6.0 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
# audit_resources.sh — Monitor CPU, memory, disk, battery on macOS
|
|
# Output: JSON lines to stdout, one per finding
|
|
set -euo pipefail
|
|
|
|
json_escape() { printf '%s' "$1" | sed 's/\\/\\\\/g; s/"/\\"/g; s/\t/\\t/g' | tr '\n' ' '; }
|
|
|
|
json_finding() {
|
|
local severity="$1" finding action details
|
|
finding=$(json_escape "$2")
|
|
action=$(json_escape "$3")
|
|
details=$(json_escape "$4")
|
|
printf '{"module":"resources","severity":"%s","finding":"%s","action":"%s","details":"%s"}\n' \
|
|
"$severity" "$finding" "$action" "$details"
|
|
}
|
|
|
|
# Process deny-list (never suggest termination)
|
|
DENY_LIST="kernel_task|launchd|WindowServer|loginwindow|mds|mds_stores|opendirectoryd|coreaudiod|SystemUIServer|Finder|Dock"
|
|
|
|
# --- CPU ---
|
|
load_avg=$(sysctl -n vm.loadavg 2>/dev/null | tr -d '{}' | awk '{print $1}')
|
|
cpu_count=$(sysctl -n hw.ncpu 2>/dev/null || echo 1)
|
|
|
|
if command -v python3 &>/dev/null; then
|
|
cpu_severity=$(python3 -c "
|
|
load=$load_avg; cores=$cpu_count
|
|
if load > cores * 2: print('critical')
|
|
elif load > cores: print('warning')
|
|
else: print('info')
|
|
" 2>/dev/null || echo "info")
|
|
else
|
|
cpu_severity="info"
|
|
fi
|
|
|
|
json_finding "$cpu_severity" "Load average: ${load_avg} (${cpu_count} cores)" "review" ""
|
|
|
|
# Top CPU-consuming processes (excluding deny-list from action suggestions)
|
|
top_cpu=$(ps aux | sort -nrk 3 | head -5 || echo "")
|
|
if [ -n "$top_cpu" ]; then
|
|
while IFS= read -r line; do
|
|
proc_name=$(echo "$line" | awk '{print $11}' | xargs basename 2>/dev/null || echo "unknown")
|
|
cpu_pct=$(echo "$line" | awk '{print $3}')
|
|
mem_pct=$(echo "$line" | awk '{print $4}')
|
|
pid=$(echo "$line" | awk '{print $2}')
|
|
|
|
# Skip low-usage processes
|
|
is_high=$(python3 -c "print('yes' if float('${cpu_pct}') > 10 else 'no')" 2>/dev/null || echo "no")
|
|
if [ "$is_high" = "yes" ]; then
|
|
if echo "$proc_name" | grep -qE "$DENY_LIST"; then
|
|
json_finding "info" "High CPU: ${proc_name} (${cpu_pct}% CPU, PID ${pid})" "none" "System process - do not terminate"
|
|
else
|
|
json_finding "warning" "High CPU: ${proc_name} (${cpu_pct}% CPU, PID ${pid})" "quit" "User process - can be terminated"
|
|
fi
|
|
fi
|
|
done <<< "$top_cpu"
|
|
fi
|
|
|
|
# --- Memory ---
|
|
vm_stat_output=$(vm_stat 2>/dev/null || echo "")
|
|
if [ -n "$vm_stat_output" ]; then
|
|
page_size=$(vm_stat | head -1 | grep -o '[0-9]*' || echo 16384)
|
|
|
|
free_pages=$(echo "$vm_stat_output" | grep "Pages free" | awk '{print $3}' | tr -d '.')
|
|
active_pages=$(echo "$vm_stat_output" | grep "Pages active" | awk '{print $3}' | tr -d '.')
|
|
inactive_pages=$(echo "$vm_stat_output" | grep "Pages inactive" | awk '{print $3}' | tr -d '.')
|
|
wired_pages=$(echo "$vm_stat_output" | grep "Pages wired" | awk '{print $4}' | tr -d '.')
|
|
compressed_pages=$(echo "$vm_stat_output" | grep "Pages stored in compressor" | awk '{print $5}' | tr -d '.' || echo 0)
|
|
|
|
if command -v python3 &>/dev/null; then
|
|
mem_summary=$(python3 -c "
|
|
ps=${page_size}
|
|
free_mb = int(${free_pages:-0}) * ps / 1048576
|
|
active_mb = int(${active_pages:-0}) * ps / 1048576
|
|
inactive_mb = int(${inactive_pages:-0}) * ps / 1048576
|
|
wired_mb = int(${wired_pages:-0}) * ps / 1048576
|
|
compressed_mb = int(${compressed_pages:-0}) * ps / 1048576
|
|
used_mb = active_mb + wired_mb + compressed_mb
|
|
total_mb = free_mb + active_mb + inactive_mb + wired_mb + compressed_mb
|
|
pct = used_mb / total_mb * 100 if total_mb > 0 else 0
|
|
print(f'{used_mb:.0f}MB used / {total_mb:.0f}MB total ({pct:.0f}%)')
|
|
if pct > 90: print('SEVERITY:critical')
|
|
elif pct > 75: print('SEVERITY:warning')
|
|
else: print('SEVERITY:info')
|
|
" 2>/dev/null || echo "unknown")
|
|
mem_severity=$(echo "$mem_summary" | grep "SEVERITY:" | cut -d: -f2)
|
|
mem_info=$(echo "$mem_summary" | head -1)
|
|
json_finding "${mem_severity:-info}" "Memory: ${mem_info}" "review" ""
|
|
fi
|
|
fi
|
|
|
|
# --- Swap ---
|
|
swap_info=$(sysctl vm.swapusage 2>/dev/null || echo "")
|
|
if [ -n "$swap_info" ]; then
|
|
swap_used=$(echo "$swap_info" | grep -o "used = [0-9.]*M" | awk '{print $3}' | tr -d 'M')
|
|
if [ -n "$swap_used" ] && command -v python3 &>/dev/null; then
|
|
swap_severity=$(python3 -c "
|
|
s=float('${swap_used}')
|
|
if s > 4096: print('warning')
|
|
elif s > 1024: print('info')
|
|
else: print('info')
|
|
" 2>/dev/null || echo "info")
|
|
json_finding "$swap_severity" "Swap: ${swap_used}MB used" "review" "$swap_info"
|
|
fi
|
|
fi
|
|
|
|
# --- Disk usage ---
|
|
disk_info=$(df -h / 2>/dev/null | tail -1)
|
|
if [ -n "$disk_info" ]; then
|
|
disk_used_pct=$(echo "$disk_info" | awk '{print $5}' | tr -d '%')
|
|
disk_avail=$(echo "$disk_info" | awk '{print $4}')
|
|
disk_total=$(echo "$disk_info" | awk '{print $2}')
|
|
|
|
if [ "$disk_used_pct" -gt 90 ] 2>/dev/null; then
|
|
json_finding "critical" "Disk ${disk_used_pct}% full (${disk_avail} free of ${disk_total})" "cleanup" "Consider running cleanup module"
|
|
elif [ "$disk_used_pct" -gt 75 ] 2>/dev/null; then
|
|
json_finding "warning" "Disk ${disk_used_pct}% full (${disk_avail} free of ${disk_total})" "monitor" ""
|
|
else
|
|
json_finding "info" "Disk ${disk_used_pct}% full (${disk_avail} free of ${disk_total})" "none" ""
|
|
fi
|
|
fi
|
|
|
|
# --- Large directories (top 5 in home) ---
|
|
if command -v du &>/dev/null; then
|
|
large_dirs=$(du -sh "$HOME"/*/ "$HOME"/.[!.]* 2>/dev/null | sort -hr | head -5 || echo "")
|
|
if [ -n "$large_dirs" ]; then
|
|
dir_summary=$(echo "$large_dirs" | awk '{printf "%s (%s), ", $2, $1}' | sed 's/, $//')
|
|
json_finding "info" "Largest directories in home" "review" "$dir_summary"
|
|
fi
|
|
fi
|
|
|
|
# --- Battery (laptops only) ---
|
|
battery_info=$(system_profiler SPPowerDataType 2>/dev/null || echo "")
|
|
if echo "$battery_info" | grep -q "Cycle Count"; then
|
|
cycle_count=$(echo "$battery_info" | grep "Cycle Count" | awk '{print $NF}')
|
|
condition=$(echo "$battery_info" | grep "Condition" | awk '{print $NF}')
|
|
max_capacity=$(echo "$battery_info" | grep "Maximum Capacity" | awk '{print $NF}' || echo "unknown")
|
|
|
|
if [ "$condition" = "Normal" ]; then
|
|
json_finding "info" "Battery: ${condition}, ${cycle_count} cycles, ${max_capacity} capacity" "none" ""
|
|
else
|
|
json_finding "warning" "Battery: ${condition}, ${cycle_count} cycles, ${max_capacity} capacity" "review" "Battery may need service"
|
|
fi
|
|
fi
|