#!/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