-
Notifications
You must be signed in to change notification settings - Fork 7
Expand file tree
/
Copy pathstepsecurity-dev-machine-guard.sh
More file actions
executable file
·3785 lines (3258 loc) · 144 KB
/
stepsecurity-dev-machine-guard.sh
File metadata and controls
executable file
·3785 lines (3258 loc) · 144 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
#!/bin/bash
#
# StepSecurity Dev Machine Guard
# https://github.com/step-security/dev-machine-guard
#
# Open-source tool to scan macOS developer environments for:
# - IDE installations and extensions
# - AI coding agents and CLI tools
# - MCP (Model Context Protocol) server configurations
# - Node.js packages (optional)
#
# Community mode: Outputs results locally (pretty/JSON/HTML). No backend required.
# Enterprise mode: Sends scan data to StepSecurity backend for centralized visibility.
#
# Usage: stepsecurity-dev-machine-guard.sh [COMMAND] [OPTIONS]
#
# Commands (enterprise only):
# install Install launchd for periodic scanning
# uninstall Remove launchd configuration
# send-telemetry Send scan data to StepSecurity backend
#
# Output formats (community mode):
# --pretty Pretty terminal output (default)
# --json JSON output to stdout
# --html FILE HTML report saved to FILE
#
# Options:
# --enable-npm-scan Enable Node.js package scanning
# --disable-npm-scan Disable Node.js package scanning
# --search-dirs DIR [DIR...] Search DIRs instead of $HOME (replaces default; repeatable)
# --verbose Show progress messages
# --color=WHEN auto | always | never (default: auto)
# -v, --version Show version
# -h, --help Show help
#
# Learn more: https://stepsecurity.io
#
set -euo pipefail
#==============================================================================
# SECTION 2: VERSION AND CLI DEFAULTS
#==============================================================================
AGENT_VERSION="1.8.2"
# Output configuration (set by CLI flags)
OUTPUT_FORMAT="pretty" # pretty | json | html
HTML_OUTPUT_FILE=""
COLOR_MODE="auto" # auto | always | never
QUIET=true # Suppress progress messages by default in community mode
ENABLE_NODE_PACKAGE_SCAN="auto" # auto | true | false
# Directories to search for projects and extensions (bash array)
# Default: user's home directory. Customize as needed, e.g.:
# SEARCH_DIRS=("\$HOME" "/Volumes/code") # home + encrypted partition
# SEARCH_DIRS=("/Volumes/code") # only encrypted partition
# SEARCH_DIRS=("\$HOME" "/Volumes/code" "/opt/work") # multiple locations
SEARCH_DIRS=("\$HOME")
_SEARCH_DIRS_SET=false
#==============================================================================
# STEPSECURITY ENTERPRISE CONFIGURATION
# Community users: leave these unchanged. They are only used in enterprise mode.
# Enterprise users: these values are set by the StepSecurity backend when
# generating the installation script from your dashboard.
# Learn more: https://docs.stepsecurity.io/developer-mdm/installation-script
#==============================================================================
CUSTOMER_ID="{{CUSTOMER_ID}}"
API_ENDPOINT="{{API_ENDPOINT}}"
API_KEY="{{API_KEY}}"
SCAN_FREQUENCY_HOURS="{{SCAN_FREQUENCY_HOURS}}"
# Feature flags
ENABLE_NODE_PACKAGE_SCAN_ENTERPRISE=true
# Tool availability checks
JQ_AVAILABLE=false
command -v jq &>/dev/null && JQ_AVAILABLE=true
PERL_AVAILABLE=false
command -v perl &>/dev/null && PERL_AVAILABLE=true
CURL_AVAILABLE=false
command -v curl &>/dev/null && CURL_AVAILABLE=true
# Log directory (for launchd output)
LOG_DIR="/var/log/stepsecurity"
# Set process priority to run in background (nice value 19 = lowest priority)
renice -n 19 $$ > /dev/null 2>&1
#==============================================================================
# MODE DETECTION
#==============================================================================
is_enterprise_mode() {
if [ -n "$API_KEY" ] && [[ "$API_KEY" != *"{{"* ]]; then
return 0
fi
return 1
}
#==============================================================================
# COLOR & OUTPUT UTILITIES
#==============================================================================
# ANSI color variables (set by setup_colors)
BOLD=""
DIM=""
PURPLE=""
GREEN=""
RED=""
CYAN=""
YELLOW=""
RESET=""
setup_colors() {
if [ "$COLOR_MODE" = "never" ]; then
return
fi
if [ "$COLOR_MODE" = "auto" ]; then
# Only use colors if stderr is a terminal
if [ ! -t 2 ]; then
return
fi
fi
BOLD='\033[1m'
DIM='\033[2m'
PURPLE='\033[0;35m'
GREEN='\033[0;32m'
RED='\033[0;31m'
CYAN='\033[0;36m'
YELLOW='\033[0;33m'
RESET='\033[0m'
}
# Print progress message to stderr (suppressed in quiet mode)
print_progress() {
if [ "$QUIET" = true ]; then
return
fi
printf "${DIM}[scanning]${RESET} %s\n" "$*" >&2
}
# Print error message to stderr (never suppressed)
print_error() {
printf "${RED}[error]${RESET} %s\n" "$*" >&2
}
# Count objects in a JSON array using awk (no jq dependency)
count_json_array_items() {
local json_array="$1"
if [ "$json_array" = "[]" ]; then
echo "0"
return
fi
# Count by matching opening braces at the top level
echo "$json_array" | awk '{
depth=0; count=0
for(i=1;i<=length($0);i++){
c=substr($0,i,1)
if(c=="{") { if(depth==0) count++; depth++ }
else if(c=="}") depth--
}
print count
}'
}
# Aliases for enterprise mode compatibility
print_info() {
print_progress "$@"
}
print_success() {
print_progress "$@"
}
#==============================================================================
# CORE UTILITIES
#==============================================================================
# Maximum size limits
MAX_LOG_SIZE_BYTES=$((10 * 1024 * 1024)) # 10MB limit
MAX_PACKAGE_OUTPUT_SIZE_BYTES=$((50 * 1024 * 1024)) # 50MB limit for package manager output
MAX_NODE_PROJECTS_SIZE_BYTES=$((500 * 1024 * 1024)) # 500MB limit for node projects data (stay under server warning threshold)
# Simple JSON string escape - only handles quotes, backslashes, and control chars
json_string_escape() {
local string="$1"
# Escape backslashes and quotes
string="${string//\\/\\\\}"
string="${string//\"/\\\"}"
# Remove control characters
string="$(echo "$string" | tr -d '\000-\037')"
echo "$string"
}
# Escape HTML special characters to prevent injection
html_escape() {
local string="$1"
string="${string//&/&}"
string="${string//</<}"
string="${string//>/>}"
string="${string//\"/"}"
echo "$string"
}
# Get current time in milliseconds (macOS compatible)
# macOS date doesn't support %N, so we use seconds * 1000
get_timestamp_ms() {
echo "$(($(date +%s) * 1000))"
}
# Safely read a file with size checking to prevent memory allocation errors
# Parameters: $1 = file path, $2 = max size in bytes (optional, defaults to 50MB)
# Returns: file contents (truncated if needed) or error message
safe_read_file() {
local file_path="$1"
local max_size="${2:-$MAX_PACKAGE_OUTPUT_SIZE_BYTES}"
if [ ! -f "$file_path" ]; then
echo ""
return 1
fi
# Get file size using stat (macOS compatible)
local file_size=$(stat -f%z "$file_path" 2>/dev/null || echo "0")
# Check if file is too large
if [ "$file_size" -gt "$max_size" ]; then
print_error "Output file exceeds maximum size (${file_size} bytes > ${max_size} bytes), truncating"
# Read only the last N bytes to get the most recent output
tail -c "$max_size" "$file_path" 2>/dev/null || echo ""
return 2
fi
# File is within limits, read it normally
cat "$file_path" 2>/dev/null || echo ""
return 0
}
# Base64 encode function (using built-in base64 command)
# DEPRECATED: This function causes xrealloc errors with large strings due to bash variable expansion
# Use: base64 < file | tr -d '\n' instead of base64_encode "$(cat file)"
base64_encode() {
echo -n "$1" | base64
}
#==============================================================================
# INSTANCE LOCKING
#==============================================================================
# Lock file location (depends on user privileges)
get_lock_file_path() {
if [ "$(id -u)" -eq 0 ]; then
# Running as root - use system location
echo "/var/run/stepsecurity-agent.lock"
else
# Running as regular user - use user home
local user_lock_dir="$HOME/.stepsecurity"
mkdir -p "$user_lock_dir" 2>/dev/null
echo "$user_lock_dir/agent.lock"
fi
}
# Acquire exclusive lock to prevent multiple instances
acquire_lock() {
local lock_file=$(get_lock_file_path)
# Check if lock file exists
if [ -f "$lock_file" ]; then
local existing_pid=$(cat "$lock_file" 2>/dev/null)
# Validate if the process is still running
if [ -n "$existing_pid" ] && kill -0 "$existing_pid" 2>/dev/null; then
# Check if it's actually our script (not just any process with that PID)
local process_name=$(ps -p "$existing_pid" -o comm= 2>/dev/null)
if echo "$process_name" | grep -q "bash"; then
print_error "Another instance is already running (PID: $existing_pid)"
print_progress "If this is incorrect, remove the lock file: $lock_file"
return 1
else
# PID exists but not our script - remove stale lock
print_progress "Removing stale lock file (PID $existing_pid is not this script)"
rm -f "$lock_file"
fi
else
# Process doesn't exist - remove stale lock
print_progress "Removing stale lock file (process $existing_pid not running)"
rm -f "$lock_file"
fi
fi
# Create lock file with current PID
echo $$ > "$lock_file"
# Verify we successfully wrote our PID
local written_pid=$(cat "$lock_file" 2>/dev/null)
if [ "$written_pid" != "$$" ]; then
print_error "Failed to acquire lock (race condition detected)"
return 1
fi
print_progress "Lock acquired (PID: $$)"
return 0
}
# Release the lock file
release_lock() {
local lock_file=$(get_lock_file_path)
if [ -f "$lock_file" ]; then
local lock_pid=$(cat "$lock_file" 2>/dev/null)
# Only remove if it's our lock
if [ "$lock_pid" = "$$" ]; then
rm -f "$lock_file"
print_progress "Lock released (PID: $$)"
fi
fi
}
# Cleanup handler for script exit
cleanup_on_exit() {
release_lock
}
# Register cleanup handler
trap cleanup_on_exit EXIT INT TERM QUIT HUP
#==============================================================================
# DEVICE IDENTITY
#==============================================================================
get_device_id() {
# Use hardware serial number as device ID
local serial=$(get_serial_number)
echo "$serial"
}
get_serial_number() {
# Get hardware serial number from IOKit
local serial=$(ioreg -l | grep IOPlatformSerialNumber | awk '{print $4}' | tr -d '"' 2>/dev/null)
# Fallback if ioreg fails
if [ -z "$serial" ]; then
serial=$(system_profiler SPHardwareDataType | awk '/Serial/ {print $4}' 2>/dev/null)
fi
# If still empty, use "unknown"
if [ -z "$serial" ]; then
serial="unknown"
fi
echo "$serial"
}
get_os_version() {
# Get macOS version (e.g., "14.1.1" or "26.0.1" for macOS Sequoia)
local os_version=$(sw_vers -productVersion 2>/dev/null)
if [ -z "$os_version" ]; then
os_version="unknown"
fi
echo "$os_version"
}
get_developer_identity() {
local username=$1
# List of environment variables to check (in order of preference)
local env_vars=("USER_EMAIL" "DEVELOPER_EMAIL" "STEPSEC_DEVELOPER_EMAIL")
# Try each environment variable in order
for var_name in "${env_vars[@]}"; do
local var_value="${!var_name:-}"
if [ -n "$var_value" ]; then
echo "$var_value"
return
fi
done
# Fallback to username only
echo "${username}"
}
#==============================================================================
# USER DIRECTORY DETECTION
#==============================================================================
# Get the currently logged-in user and their home directory
get_logged_in_user_info() {
# Get the logged-in user from console
local logged_in_user=$(stat -f%Su /dev/console 2>/dev/null)
# Check if no user is logged in or if it's a system user
if [ -z "$logged_in_user" ] || [ "$logged_in_user" = "root" ] || [ "$logged_in_user" = "_windowserver" ]; then
echo "" # No user logged in
echo "" # No home directory
return 1
fi
# Get home directory using dscl
local user_home=$(dscl . -read /Users/$logged_in_user NFSHomeDirectory 2>/dev/null | awk '{ print $2 }')
# Verify the home directory exists
if [ -z "$user_home" ] || [ ! -d "$user_home" ]; then
print_error "Home directory not found for user: $logged_in_user"
echo "$logged_in_user" # Return username
echo "" # No home directory
return 1
fi
echo "$logged_in_user"
echo "$user_home"
return 0
}
# Execute command in the context of the logged-in user if running as root
# Usage: run_as_logged_in_user <username> <command> [args...]
# The command output can be redirected by the caller (redirection happens in parent shell)
run_as_logged_in_user() {
local logged_in_user=$1
shift
local command="$*"
if [ "$(id -u)" -eq 0 ] && [ -n "$logged_in_user" ]; then
# Running as root - execute as the logged-in user
# Use login shell (-l) to source profile files and get complete PATH
# Get user's configured login shell
local user_shell=$(dscl . -read /Users/$logged_in_user UserShell 2>/dev/null | awk '{print $2}')
# Fallback to /bin/bash if shell detection fails
if [ -z "$user_shell" ] || [ ! -x "$user_shell" ]; then
user_shell="/bin/bash"
fi
# Execute as logged-in user with their login shell
# The -l flag makes it a login shell, which sources profile files (.bash_profile, .zprofile, etc.)
# This ensures the user's PATH includes developer tools (npm, pnpm, yarn, etc.)
sudo -H -u "$logged_in_user" "$user_shell" -l -c "$command"
else
# Not running as root - execute directly
bash -c "$command"
fi
}
# Run a command with a timeout (macOS doesn't have GNU timeout)
# Usage: run_with_timeout <seconds> <command>
# Returns: command exit code, or 124 if timed out
run_with_timeout() {
local timeout_seconds=$1
shift
local command="$*"
# Run command in background
bash -c "$command" &
local cmd_pid=$!
# Wait for command or timeout
local count=0
while kill -0 "$cmd_pid" 2>/dev/null; do
if [ "$count" -ge "$timeout_seconds" ]; then
kill -9 "$cmd_pid" 2>/dev/null
wait "$cmd_pid" 2>/dev/null
return 124
fi
sleep 1
count=$((count + 1))
done
wait "$cmd_pid"
return $?
}
# Execute command in the context of the logged-in user with a timeout
# Usage: run_as_logged_in_user_with_timeout <seconds> <username> <command> [args...]
run_as_logged_in_user_with_timeout() {
local timeout_seconds=$1
shift
local logged_in_user=$1
shift
local command="$*"
if [ "$(id -u)" -eq 0 ] && [ -n "$logged_in_user" ]; then
local user_shell=$(dscl . -read /Users/$logged_in_user UserShell 2>/dev/null | awk '{print $2}')
if [ -z "$user_shell" ] || [ ! -x "$user_shell" ]; then
user_shell="/bin/bash"
fi
# Run as user in background with timeout
sudo -H -u "$logged_in_user" "$user_shell" -l -c "$command" &
local cmd_pid=$!
local count=0
while kill -0 "$cmd_pid" 2>/dev/null; do
if [ "$count" -ge "$timeout_seconds" ]; then
kill -9 "$cmd_pid" 2>/dev/null
wait "$cmd_pid" 2>/dev/null
return 124
fi
sleep 1
count=$((count + 1))
done
wait "$cmd_pid"
return $?
else
run_with_timeout "$timeout_seconds" "$command"
return $?
fi
}
get_user_directory() {
local user_info=$(get_logged_in_user_info)
local logged_in_user=$(echo "$user_info" | sed -n '1p')
local user_home=$(echo "$user_info" | sed -n '2p')
if [ -z "$logged_in_user" ] || [ -z "$user_home" ]; then
# No user logged in
print_progress "No user currently logged in to console"
echo "" # Return empty string
return 1
fi
print_progress "Detected logged-in user: $logged_in_user"
print_progress " Home directory: $user_home"
# Return only the logged-in user's home directory
echo "$user_home"
return 0
}
resolve_search_directories() {
local user_home="$1"
local resolved_dirs=()
for dir in "${SEARCH_DIRS[@]}"; do
# Resolve $HOME to the actual user home directory
local resolved="${dir/\$HOME/$user_home}"
if [ -d "$resolved" ]; then
resolved_dirs+=("$resolved")
else
print_progress "Warning: Search directory not found, skipping: $resolved"
fi
done
if [ ${#resolved_dirs[@]} -eq 0 ]; then
print_progress "Warning: No valid search directories found, falling back to: $user_home"
echo "$user_home"
return
fi
printf '%s\n' "${resolved_dirs[@]}"
}
#==============================================================================
# LAUNCHD MANAGEMENT
#==============================================================================
get_script_path() {
# Get the absolute path of this script
local script_path="$0"
# If it's a relative path, convert to absolute
if [[ ! "$script_path" = /* ]]; then
script_path="$(cd "$(dirname "$script_path")" && pwd)/$(basename "$script_path")"
fi
# Resolve symlinks
if command -v readlink &> /dev/null; then
if [[ "$OSTYPE" == "darwin"* ]]; then
# macOS doesn't have readlink -f, use a different approach
while [ -L "$script_path" ]; do
local target=$(readlink "$script_path")
if [[ "$target" = /* ]]; then
script_path="$target"
else
script_path="$(cd "$(dirname "$script_path")" && pwd)/$(basename "$target")"
fi
done
else
script_path=$(readlink -f "$script_path" 2>/dev/null || echo "$script_path")
fi
fi
echo "$script_path"
}
is_launchd_configured() {
local current_user="$1"
local plist_path
if [ "$(id -u)" -eq 0 ]; then
# Root - check LaunchDaemons
plist_path="/Library/LaunchDaemons/com.stepsecurity.agent.plist"
else
# Regular user - check LaunchAgents
plist_path="$HOME/Library/LaunchAgents/com.stepsecurity.agent.plist"
fi
# Check if plist file exists
if [ ! -f "$plist_path" ]; then
return 1
fi
# Check if it's loaded in launchd
if launchctl list | grep -q "com.stepsecurity.agent"; then
return 0
else
return 1
fi
}
configure_launchd() {
local script_path=$(get_script_path)
local plist_path
local interval_seconds=$((SCAN_FREQUENCY_HOURS * 3600))
print_progress "Configuring launchd for periodic execution..."
print_progress " Script: ${script_path}"
print_progress " Interval: Every ${SCAN_FREQUENCY_HOURS} hours (${interval_seconds} seconds)"
if [ "$(id -u)" -eq 0 ]; then
# Running as root - use LaunchDaemon
plist_path="/Library/LaunchDaemons/com.stepsecurity.agent.plist"
print_progress " Type: LaunchDaemon (system-wide)"
# Create log directory
mkdir -p "$LOG_DIR"
chmod 755 "$LOG_DIR"
# Create the plist file
cat > "$plist_path" <<EOF
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>com.stepsecurity.agent</string>
<key>ProgramArguments</key>
<array>
<string>/bin/bash</string>
<string>${script_path}</string>
<string>send-telemetry</string>
</array>
<key>StartInterval</key>
<integer>${interval_seconds}</integer>
<key>RunAtLoad</key>
<false/>
<key>StandardOutPath</key>
<string>${LOG_DIR}/agent.log</string>
<key>StandardErrorPath</key>
<string>${LOG_DIR}/agent.error.log</string>
</dict>
</plist>
EOF
chmod 644 "$plist_path"
# Load the plist
if launchctl load "$plist_path" 2>/dev/null; then
print_progress "launchd configuration completed successfully"
print_progress " Plist: ${plist_path}"
print_progress " Logs: ${LOG_DIR}/agent.log"
print_progress " Errors: ${LOG_DIR}/agent.error.log"
return 0
else
print_error "Failed to load launchd configuration"
return 1
fi
else
# Running as regular user - use LaunchAgent
plist_path="$HOME/Library/LaunchAgents/com.stepsecurity.agent.plist"
print_progress " Type: LaunchAgent (user-specific)"
# Create LaunchAgents directory if it doesn't exist
mkdir -p "$HOME/Library/LaunchAgents"
# Create log directory in user's home
local user_log_dir="$HOME/.stepsecurity"
mkdir -p "$user_log_dir"
# Create the plist file
cat > "$plist_path" <<EOF
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>com.stepsecurity.agent</string>
<key>ProgramArguments</key>
<array>
<string>/bin/bash</string>
<string>${script_path}</string>
<string>send-telemetry</string>
</array>
<key>StartInterval</key>
<integer>${interval_seconds}</integer>
<key>RunAtLoad</key>
<false/>
<key>StandardOutPath</key>
<string>${user_log_dir}/agent.log</string>
<key>StandardErrorPath</key>
<string>${user_log_dir}/agent.error.log</string>
</dict>
</plist>
EOF
# Load the plist
if launchctl load "$plist_path" 2>/dev/null; then
print_progress "launchd configuration completed successfully"
print_progress " Plist: ${plist_path}"
print_progress " Logs: ${user_log_dir}/agent.log"
print_progress " Errors: ${user_log_dir}/agent.error.log"
return 0
else
print_error "Failed to load launchd configuration"
return 1
fi
fi
}
uninstall_launchd() {
local plist_path
if [ "$(id -u)" -eq 0 ]; then
plist_path="/Library/LaunchDaemons/com.stepsecurity.agent.plist"
print_progress "Removing LaunchDaemon configuration..."
else
plist_path="$HOME/Library/LaunchAgents/com.stepsecurity.agent.plist"
print_progress "Removing LaunchAgent configuration..."
fi
# Unload the plist if it's loaded
if launchctl list | grep -q "com.stepsecurity.agent"; then
if launchctl unload "$plist_path" 2>/dev/null; then
print_progress "Unloaded launchd agent"
else
print_error "Failed to unload launchd agent"
fi
fi
# Remove the plist file
if [ -f "$plist_path" ]; then
rm "$plist_path"
print_progress "Removed plist file: ${plist_path}"
else
print_progress "Plist file not found: ${plist_path}"
fi
print_progress "launchd configuration removed successfully"
print_progress "The agent will no longer run automatically"
}
#==============================================================================
# IDE DETECTION
#==============================================================================
detect_ide_installations() {
local logged_in_user=$1
print_progress "Detecting IDE and AI desktop app installations..."
local ide_installations=""
local first=true
# Define IDEs/apps to detect: format is "app_name|ide_type|vendor|app_path|binary_path_for_version|version_command"
local apps=(
"Visual Studio Code|vscode|Microsoft|/Applications/Visual Studio Code.app|Contents/Resources/app/bin/code|--version"
"Cursor|cursor|Cursor|/Applications/Cursor.app|Contents/Resources/app/bin/cursor|--version"
"Windsurf|windsurf|Codeium|/Applications/Windsurf.app|Contents/MacOS/Windsurf|--version"
"Antigravity|antigravity|Google|/Applications/Antigravity.app|Contents/MacOS/Antigravity|--version"
"Zed|zed|Zed|/Applications/Zed.app|Contents/MacOS/zed||"
"Claude|claude_desktop|Anthropic|/Applications/Claude.app|||"
"Microsoft Copilot|microsoft_copilot_desktop|Microsoft|/Applications/Copilot.app|||"
)
for app_def in "${apps[@]}"; do
IFS='|' read -r app_name ide_type vendor app_path binary_path version_command <<< "$app_def"
if [ -d "$app_path" ]; then
local version="unknown"
# Try to get version from binary if specified
if [ -n "$binary_path" ] && [ -x "$app_path/$binary_path" ] && [ -n "$version_command" ]; then
version=$(run_as_logged_in_user_with_timeout 10 "$logged_in_user" "\"$app_path/$binary_path\" $version_command 2>/dev/null | head -1" || echo "unknown")
fi
# Fallback: try to get version from Info.plist
if [ "$version" = "unknown" ]; then
local plist_file="$app_path/Contents/Info.plist"
if [ -f "$plist_file" ]; then
version=$(/usr/libexec/PlistBuddy -c "Print :CFBundleShortVersionString" "$plist_file" 2>/dev/null || echo "unknown")
fi
fi
# Clean up version string
version=$(echo "$version" | tr -d '\n\r' | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')
[ -z "$version" ] && version="unknown"
print_progress " Found: ${app_name} (${vendor}) v${version} at ${app_path}"
[ "$first" = false ] && ide_installations="${ide_installations},"
first=false
# Escape strings for JSON
local escaped_ide_type=$(json_string_escape "$ide_type")
local escaped_version=$(json_string_escape "$version")
local escaped_install_path=$(json_string_escape "$app_path")
local escaped_vendor=$(json_string_escape "$vendor")
ide_installations="${ide_installations}{\"ide_type\":\"${escaped_ide_type}\",\"version\":\"${escaped_version}\",\"install_path\":\"${escaped_install_path}\",\"vendor\":\"${escaped_vendor}\",\"is_installed\":true}"
fi
done
if [ "$first" = true ]; then
print_progress " No IDEs or AI desktop apps found"
fi
echo "[${ide_installations}]"
}
#==============================================================================
# AI CLI TOOLS DETECTION
#==============================================================================
# Detect standalone AI CLI tools from all major vendors
# This function checks for AI coding assistants installed as command-line tools
detect_ai_cli_tools() {
local logged_in_user=$1
print_progress "Detecting AI CLI tools..."
# Get user's home directory for config dir expansion
local user_home=$(run_as_logged_in_user "$logged_in_user" "echo ~" 2>/dev/null || echo "$HOME")
local ai_cli_tools=""
local first=true
local count=0
# Define CLI tools to detect: format is "tool_name|vendor|binary_names|config_dirs"
# binary_names and config_dirs are comma-separated lists
local tools=(
"claude-code|Anthropic|claude,~/.claude/local/claude,~/.local/bin/claude|~/.claude"
"codex|OpenAI|codex|~/.codex"
"gemini-cli|Google|gemini|~/.gemini"
"amazon-q-cli|Amazon|kiro-cli,kiro,q|~/.q,~/.kiro,~/.aws/q"
"github-copilot-cli|Microsoft|copilot,gh-copilot|~/.config/github-copilot"
"microsoft-ai-shell|Microsoft|aish,ai|~/.aish"
"aider|OpenSource|aider|~/.aider"
"opencode|OpenSource|opencode,~/.opencode/bin/opencode|~/.config/opencode"
)
for tool_def in "${tools[@]}"; do
IFS='|' read -r tool_name vendor binary_names config_dirs <<< "$tool_def"
# Try to find the binary
local binary_path=""
local version="unknown"
local found=false
# Split binary names by comma and check each
IFS=',' read -ra BINARY_ARRAY <<< "$binary_names"
for binary_name in "${BINARY_ARRAY[@]}"; do
# Check in user's PATH (run in user context)
local check_result=$(run_as_logged_in_user_with_timeout 10 "$logged_in_user" "command -v $binary_name 2>/dev/null" || echo "")
if [ -n "$check_result" ]; then
binary_path="$check_result"
found=true
# Get version - different commands for different tools
case "$tool_name" in
claude-code)
version=$(run_as_logged_in_user_with_timeout 10 "$logged_in_user" "$binary_name --version 2>/dev/null | head -1" || echo "unknown")
;;
amazon-q-cli)
# Verify it's actually Amazon Q and not another 'q' command
local verify=$(run_as_logged_in_user_with_timeout 10 "$logged_in_user" "$binary_name --version 2>/dev/null | grep -i 'amazon\\|kiro\\|q developer'" || echo "")
if [ -n "$verify" ]; then
version=$(run_as_logged_in_user_with_timeout 10 "$logged_in_user" "$binary_name --version 2>/dev/null | head -1" || echo "unknown")
else
# Not Amazon Q CLI, skip
found=false
continue
fi
;;
github-copilot-cli)
# The binary is just 'copilot', not 'gh-copilot'
version=$(run_as_logged_in_user_with_timeout 10 "$logged_in_user" "$binary_name --version 2>/dev/null | head -1" || echo "unknown")
;;
opencode)
version=$(run_as_logged_in_user_with_timeout 10 "$logged_in_user" "$binary_name -v 2>/dev/null | head -1" || echo "unknown")
;;
*)
# Generic version check
version=$(run_as_logged_in_user_with_timeout 10 "$logged_in_user" "$binary_name --version 2>/dev/null | head -1" || echo "unknown")
;;
esac
break # Found the binary, stop checking other names
fi
done
if [ "$found" = true ]; then
# Check for config directory
local config_dir=""
IFS=',' read -ra CONFIG_ARRAY <<< "$config_dirs"
for config_candidate in "${CONFIG_ARRAY[@]}"; do
# Expand ~ to home directory
local expanded_config="${config_candidate/#\~/$user_home}"
if [ -d "$expanded_config" ]; then
config_dir="$expanded_config"
break
fi
done
# Clean up version string (remove extra whitespace, newlines)
version=$(echo "$version" | tr -d '\n\r' | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')
print_progress " Found: ${tool_name} (${vendor}) v${version} at ${binary_path}"
[ "$first" = false ] && ai_cli_tools="${ai_cli_tools},"
first=false
count=$((count + 1))
# Escape strings for JSON
local escaped_tool_name=$(json_string_escape "$tool_name")
local escaped_vendor=$(json_string_escape "$vendor")
local escaped_binary_path=$(json_string_escape "$binary_path")
local escaped_version=$(json_string_escape "$version")
local escaped_config_dir=$(json_string_escape "$config_dir")
ai_cli_tools="${ai_cli_tools}{\"name\":\"${escaped_tool_name}\",\"vendor\":\"${escaped_vendor}\",\"type\":\"cli_tool\",\"version\":\"${escaped_version}\",\"binary_path\":\"${escaped_binary_path}\",\"config_dir\":\"${escaped_config_dir}\"}"
fi
done
if [ "$count" -eq 0 ]; then
print_progress " No AI CLI tools found"
else
print_progress " Found ${count} AI CLI tool(s)"
fi
echo "[${ai_cli_tools}]"
}
#==============================================================================
# GENERAL-PURPOSE AI AGENTS DETECTION
#==============================================================================
# Detect general-purpose AI agents (not just coding-focused)
# These agents can automate desktop tasks, browse the web, etc.
detect_general_ai_agents() {
local user_home=$1
print_progress "Detecting general-purpose AI agents..."
local ai_agents=""
local first=true
local count=0
# Define agents to detect: format is "agent_name|vendor|detection_paths|binary_names"
# detection_paths can be directories or files; binary_names for version extraction
local agents=(
"openclaw|OpenSource|$user_home/.openclaw|openclaw"
"clawdbot|OpenSource|$user_home/.clawdbot|clawdbot"
"moltbot|OpenSource|$user_home/.moltbot|moltbot"
"moldbot|OpenSource|$user_home/.moldbot|moldbot"
"gpt-engineer|OpenSource|$user_home/.gpt-engineer|gpt-engineer"
)
for agent_def in "${agents[@]}"; do
IFS='|' read -r agent_name vendor detection_paths binary_names <<< "$agent_def"
local found=false
local install_path=""
local version="unknown"
# Check detection paths (directories or files)
IFS=',' read -ra PATH_ARRAY <<< "$detection_paths"
for path in "${PATH_ARRAY[@]}"; do
if [ -d "$path" ] || [ -f "$path" ]; then
found=true
install_path="$path"
break
fi
done
# If not found by detection paths, check if binary exists in PATH
if [ "$found" = false ]; then
IFS=',' read -ra BINARY_ARRAY <<< "$binary_names"
for binary_name in "${BINARY_ARRAY[@]}"; do
local binary_check=$(command -v "$binary_name" 2>/dev/null || echo "")
if [ -n "$binary_check" ]; then
found=true
install_path="$binary_check"
break
fi
done
fi