Watch out for Hades

According to Reddit, horrible people have made a nasty supply-chain attack called Hades: [Link]

Looks like it’s marked at this time by:

  • the presence of suspicious *-setup.pth files in your python site-packages
  • A suspicious .bun_ran marker in your tmp dirs
  • Weird instructions in your agent harnesses or config files

If you find any of these things, you’ll want to disable your network connection immediately, clean them up fully, and then rotate any and all credentials your computer had on-disk.

Looking for trouble

You can ask your agent to look for these things for you, but it’s possible that malicious instructions could alter the agent’s behavior to try to conceal the bad stuff, so here’s a script you can read yourself (hah, as if Bash were readable) to check for the symptomatic files in site-packages and tmp, and to give you a list of agent config files for you to check manually for “weird instructions”.

Copy it to a script file and run it on your Mac. If you’re on a PC or Linux, you’ll need to adapt the script to your platform.

hades-symptom-scan.sh
#!/usr/bin/env bash
# Basic triage for Mini Shai-Hulud / supply-chain worm indicators and AI harness configs.
# Written by Cursor and Composer 2.5 in June 2026
#
# Automated checks:
#   1. *-setup.pth files in Python site-packages
#   2. .bun_ran marker files in temp directories
#   3. gh-token-monitor persistence (dead-man's switch — remove before rotating tokens)
#
# Manual follow-up:
#   4. Lists known AI harness config paths for visual inspection
#
# Usage: hades-symptom-check.sh [--global-only]
#   --global-only   Skip project-level config search under $HOME (faster).
#                   Default searches $HOME (depth 10) or $PROJECT_SEARCH_ROOTS (colon-separated).
#
# Exit codes:
#   0 — No automated IOCs detected. Manual config review still required; not a clean bill of health.
#   1 — Automated IOC detected. Do NOT rotate credentials until persistence is removed.

set -uo pipefail

SEARCH_HOME=1
[[ "${1:-}" == "--global-only" ]] && SEARCH_HOME=0

RED=$'\033[1;31m'
GREEN=$'\033[0;32m'
YELLOW=$'\033[1;33m'
CYAN=$'\033[0;36m'
BOLD=$'\033[1m'
DIM=$'\033[2m'
NC=$'\033[0m'

FOUND_ISSUES=0
HOME_DIR="${HOME:-$(cd ~ && pwd)}"
IOC_FILE="$(mktemp "${TMPDIR:-/tmp}/check-ai-harness-ioc.XXXXXX")"
SCAN_ERRORS_FILE="$(mktemp "${TMPDIR:-/tmp}/check-ai-harness-errors.XXXXXX")"

print_link() {
  local path="$1"
  local encoded="${path// /%20}"
  printf "  ${BOLD}%s${NC}\n" "$path"
  printf "  ${DIM}path:${NC}  %s\n" "$path"
  printf "  ${DIM}link:${NC}  file://%s\n" "$encoded"
}

scream() {
  FOUND_ISSUES=1
  printf '\n'
  printf "${RED}${BOLD}"
  printf "╔══════════════════════════════════════════════════════════════╗\n"
  printf "║  ⚠️  SUSPICIOUS ARTIFACT FOUND — INVESTIGATE IMMEDIATELY  ⚠️  ║\n"
  printf "╚══════════════════════════════════════════════════════════════╝\n"
  printf "${NC}\n"
  printf "  ${BOLD}%s${NC}\n" "$1"
  printf "  ${DIM}%s${NC}\n\n" "$2"
}

report_ioc() {
  local title="$1"
  local path="$2"
  if grep -Fxq "$path" "$IOC_FILE" 2>/dev/null; then
    return 0
  fi
  printf '%s\n' "$path" >> "$IOC_FILE"
  scream "$title" "$path"
  print_link "$path"
}

section() {
  printf "\n${BOLD}${CYAN}── %s ──${NC}\n" "$1"
}

# Record find(1) permission / access errors instead of discarding them.
record_find_errors() {
  local err_file="$1"
  local line path reason

  [[ -f "$err_file" ]] || return 0
  while IFS= read -r line; do
    case "$line" in
      find:*": Permission denied"|find:*": Operation not permitted"|find:*": Input/output error")
        path="${line#find: }"
        reason="${path##*: }"
        path="${path%: ${reason}}"
        printf '%s\n' "$path" >> "$SCAN_ERRORS_FILE"
        ;;
    esac
  done < "$err_file"
}

run_find() {
  local err_file out_file
  err_file="$(mktemp "${TMPDIR:-/tmp}/check-ai-harness-find-err.XXXXXX")"
  out_file="$(mktemp "${TMPDIR:-/tmp}/check-ai-harness-find-out.XXXXXX")"
  find "$@" >"$out_file" 2>"$err_file"
  local find_status=$?
  cat "$out_file"
  record_find_errors "$err_file"
  rm -f "$err_file" "$out_file"
  return "$find_status"
}

exists_any() {
  local p
  for p in "$@"; do
    [[ -e "$p" ]] && return 0
  done
  return 1
}

emit_existing() {
  local p match
  for p in "$@"; do
    case "$p" in
      *\*|*\?)
        for match in $p; do
          [[ -e "$match" ]] && printf '%s\n' "$match"
        done
        ;;
      *)
        [[ -e "$p" ]] && printf '%s\n' "$p"
        ;;
    esac
  done
}

search_roots() {
  local -a roots=()
  local entry

  if [[ -n "${PROJECT_SEARCH_ROOTS:-}" ]]; then
    local IFS=':'
    for entry in $PROJECT_SEARCH_ROOTS; do
      [[ -n "$entry" && -d "$entry" ]] && roots+=("$entry")
    done
  else
    roots=("$HOME_DIR")
  fi

  printf '%s\n' "${roots[@]}"
}

emit_project_configs() {
  local pattern="$1"
  local root

  [[ "$SEARCH_HOME" -eq 1 ]] || return 0

  while IFS= read -r root; do
    [[ -z "$root" ]] && continue
    run_find "$root" -maxdepth 10 \
      \( \
        -path '*/node_modules/*' -o -path '*/.git/*' -o -path '*/.cache/*' \
        -o -path '*/.npm/*' -o -path '*/.cargo/*' -o -path '*/.Trash/*' \
      \) -prune -o \
      -type f -path "$pattern" -print
  done < <(search_roots)
}

dedupe_paths() {
  awk '!seen[$0]++'
}

collect_paths() {
  dedupe_paths
}

print_harness_configs() {
  local name="$1"
  local paths_file="$2"
  local count
  count=$(wc -l < "$paths_file" | tr -d ' ')

  if [[ "$count" -eq 0 ]]; then
    printf "\n  ${DIM}%s — present, but no known config files found${NC}\n" "$name"
    return
  fi

  printf "\n  ${BOLD}%s${NC} ${DIM}(%s file(s))${NC}\n" "$name" "$count"
  while IFS= read -r p; do
    [[ -z "$p" ]] && continue
    print_link "$p"
    printf '\n'
  done < "$paths_file"
}

harness_present() {
  case "$1" in
    claude)
      exists_any "$HOME_DIR/.claude" || command -v claude >/dev/null 2>&1
      ;;
    codex)
      exists_any "$HOME_DIR/.codex" "$HOME_DIR/.config/codex" || command -v codex >/dev/null 2>&1
      ;;
    cursor)
      exists_any \
        "$HOME_DIR/.cursor" \
        "$HOME_DIR/Library/Application Support/Cursor" \
        || command -v cursor >/dev/null 2>&1
      ;;
    opencode)
      exists_any "$HOME_DIR/.config/opencode" || command -v opencode >/dev/null 2>&1
      ;;
    pi)
      exists_any "$HOME_DIR/.pi" || command -v pi >/dev/null 2>&1
      ;;
    antigravity)
      exists_any \
        "$HOME_DIR/.antigravity" \
        "$HOME_DIR/Library/Application Support/Antigravity" \
        "$HOME_DIR/Library/Application Support/Antigravity IDE"
      ;;
  esac
}

PATHS_FILE="$(mktemp "${TMPDIR:-/tmp}/check-ai-harness-paths.XXXXXX")"
trap 'rm -f "$IOC_FILE" "$PATHS_FILE" "$SCAN_ERRORS_FILE"' EXIT

# ── 1. *-setup.pth in Python site-packages ───────────────────────────────────

section "Python site-packages: *-setup.pth"

found_setup_pth=0
for py in python3 python3.14 python3.13 python3.12 python3.11 python; do
  py_path="$(command -v "$py" 2>/dev/null)" || continue
  while IFS= read -r hit; do
    [[ -z "$hit" ]] && continue
    found_setup_pth=1
    report_ioc "Suspicious *-setup.pth file" "$hit"
  done < <("$py_path" -c "
import glob, os, site
paths = []
for fn in (site.getsitepackages, site.getusersitepackages):
    try:
        p = fn()
        paths.extend(p if isinstance(p, list) else [p])
    except Exception:
        pass
for sp in paths:
    for p in glob.glob(os.path.join(sp, '*-setup.pth')):
        print(p)
" 2>/dev/null)
done

if [[ "$found_setup_pth" -eq 0 ]]; then
  while IFS= read -r hit; do
    [[ -z "$hit" ]] && continue
    found_setup_pth=1
    report_ioc "Suspicious *-setup.pth file" "$hit"
  done < <(run_find \
    "$HOME_DIR/Library/Python" \
    "$HOME_DIR/.local/lib" \
    /opt/homebrew/lib \
    /usr/local/lib \
    -name '*-setup.pth')
fi

[[ "$found_setup_pth" -eq 0 ]] && \
  printf "  ${GREEN}✓ No *-setup.pth files found in scanned Python paths.${NC}\n"

# ── 2. .bun_ran in temp ──────────────────────────────────────────────────────

section "Temp directories: .bun_ran"

found_bun_ran=0
TEMP_ROOTS=("/tmp")
[[ -n "${TMPDIR:-}" && "$TMPDIR" != "/tmp" ]] && TEMP_ROOTS+=("$TMPDIR")
[[ -d "/var/folders" ]] && TEMP_ROOTS+=("/var/folders")
[[ -n "${XDG_RUNTIME_DIR:-}" ]] && TEMP_ROOTS+=("$XDG_RUNTIME_DIR")
[[ -d "/var/tmp" ]] && TEMP_ROOTS+=("/var/tmp")

for root in "${TEMP_ROOTS[@]}"; do
  while IFS= read -r hit; do
    [[ -z "$hit" ]] && continue
    found_bun_ran=1
    report_ioc "Suspicious .bun_ran marker file" "$hit"
  done < <(run_find "$root" -iname '.bun_ran')
done

[[ "$found_bun_ran" -eq 0 ]] && \
  printf "  ${GREEN}✓ No .bun_ran files found in scanned temp paths.${NC}\n"

# ── 3. gh-token-monitor (dead-man's switch) ───────────────────────────────────

section "gh-token-monitor persistence (remove before rotating tokens)"

found_gh_monitor=0
GH_MONITOR_PATHS=(
  "$HOME_DIR/Library/LaunchAgents/com.user.gh-token-monitor.plist"
  "$HOME_DIR/.config/systemd/user/gh-token-monitor.service"
  "$HOME_DIR/.local/bin/gh-token-monitor.sh"
)

for hit in "${GH_MONITOR_PATHS[@]}"; do
  if [[ -e "$hit" ]]; then
    found_gh_monitor=1
    report_ioc "gh-token-monitor persistence artifact" "$hit"
  fi
done

if command -v launchctl >/dev/null 2>&1; then
  while IFS= read -r label; do
    [[ -z "$label" ]] && continue
    found_gh_monitor=1
    report_ioc "gh-token-monitor launch agent loaded" "launchctl label: $label"
  done < <(launchctl list 2>/dev/null | awk '/gh-token-monitor/ {print $3}')
fi

if command -v systemctl >/dev/null 2>&1; then
  if systemctl --user is-active gh-token-monitor.service >/dev/null 2>&1; then
    found_gh_monitor=1
    report_ioc "gh-token-monitor systemd user service active" "systemctl --user: gh-token-monitor.service"
  fi
fi

[[ "$found_gh_monitor" -eq 0 ]] && \
  printf "  ${GREEN}✓ No gh-token-monitor artifacts found in scanned paths.${NC}\n"

printf "${DIM}  If this monitor is present, revoking stolen tokens may trigger home-directory deletion.${NC}\n"
printf "${DIM}  Remove persistence first, then rotate credentials.${NC}\n"

# ── 4. AI harness config discovery ───────────────────────────────────────────

section "AI harness configs (manual inspection)"

printf "${DIM}Open each linked file and look for instructions that do not belong there.${NC}\n"
printf "${DIM}Red flags include:${NC}\n"
printf "  ${DIM}• Shell commands on startup (curl, wget, bash -c, node setup.mjs, bun)${NC}\n"
printf "  ${DIM}• Requests to read, copy, or exfiltrate secrets (.env, API keys, tokens, SSH keys)${NC}\n"
printf "  ${DIM}• Hooks/tasks/rules that run automatically without your knowledge${NC}\n"
printf "  ${DIM}• Obfuscated text, base64 blobs, or \"ignore previous instructions\" style prompts${NC}\n"
printf "  ${DIM}• Unknown domains, webhooks, or paste/upload endpoints${NC}\n"
printf "${DIM}Compare against your own notes or git history if a file looks recently changed.${NC}\n"

if [[ "$SEARCH_HOME" -eq 0 ]]; then
  printf "${YELLOW}  Note: project-level config search skipped (--global-only).${NC}\n"
fi

if harness_present claude; then
  {
    emit_existing \
      "$HOME_DIR/.claude/settings.json" \
      "$HOME_DIR/.claude/settings.local.json" \
      "$HOME_DIR/.claude/CLAUDE.md" \
      "$HOME_DIR/.claude/hooks/"*
    emit_project_configs "*/.claude/settings.json"
    emit_project_configs "*/.claude/settings.local.json"
  } | collect_paths > "$PATHS_FILE"
  print_harness_configs "Claude Code" "$PATHS_FILE"
fi

if harness_present codex; then
  {
    emit_existing \
      "$HOME_DIR/.codex/config.toml" \
      "$HOME_DIR/.codex/auth.json" \
      "$HOME_DIR/.codex/AGENTS.md" \
      "$HOME_DIR/.config/codex/"*
    emit_project_configs "*/.codex/config.toml"
  } | collect_paths > "$PATHS_FILE"
  print_harness_configs "Codex" "$PATHS_FILE"
fi

if harness_present cursor; then
  {
    emit_existing \
      "$HOME_DIR/.cursor/cli-config.json" \
      "$HOME_DIR/.cursor/hooks/hooks.json" \
      "$HOME_DIR/.cursor/plugins/local/"*/hooks/hooks.json \
      "$HOME_DIR/.cursor/plugins/local/"*/hooks/*.sh \
      "$HOME_DIR/.cursor/rules/"* \
      "$HOME_DIR/Library/Application Support/Cursor/User/settings.json" \
      "$HOME_DIR/Library/Application Support/Cursor/User/keybindings.json"
    emit_project_configs "*/.cursor/rules/*"
    emit_project_configs "*/.cursor/hooks/hooks.json"
    emit_project_configs "*/.vscode/tasks.json"
  } | collect_paths > "$PATHS_FILE"
  print_harness_configs "Cursor" "$PATHS_FILE"
fi

if harness_present opencode; then
  emit_existing \
    "$HOME_DIR/.config/opencode/opencode.json" \
    "$HOME_DIR/.config/opencode/opencode.jsonc" \
    | collect_paths > "$PATHS_FILE"
  print_harness_configs "OpenCode" "$PATHS_FILE"
fi

if harness_present pi; then
  {
    emit_existing \
      "$HOME_DIR/.pi/agent/settings.json" \
      "$HOME_DIR/.pi/agent/models.json"
    run_find "$HOME_DIR/.pi/agent/extensions" -maxdepth 1 -type f
    run_find "$HOME_DIR/.pi/agent/skills" -maxdepth 2 -type f -name 'SKILL.md'
  } | collect_paths > "$PATHS_FILE"
  print_harness_configs "Pi" "$PATHS_FILE"
fi

if harness_present antigravity; then
  {
    emit_existing \
      "$HOME_DIR/Library/Application Support/Antigravity/User/settings.json" \
      "$HOME_DIR/Library/Application Support/Antigravity/User/keybindings.json" \
      "$HOME_DIR/Library/Application Support/Antigravity IDE/User/settings.json" \
      "$HOME_DIR/.antigravity/rules/"* \
      "$HOME_DIR/.antigravity/"*
    emit_project_configs "*/.antigravity/settings.json"
    emit_project_configs "*/.antigravity/rules/*"
  } | collect_paths > "$PATHS_FILE"
  print_harness_configs "Antigravity" "$PATHS_FILE"
fi

section "Harnesses not installed (IOC checks above still apply)"
missing=0
for harness in claude codex cursor opencode pi antigravity; do
  harness_present "$harness" && continue
  missing=1
  case "$harness" in
    claude) printf "  ${DIM}• Claude Code${NC}\n" ;;
    codex) printf "  ${DIM}• Codex${NC}\n" ;;
    cursor) printf "  ${DIM}• Cursor${NC}\n" ;;
    opencode) printf "  ${DIM}• OpenCode${NC}\n" ;;
    pi) printf "  ${DIM}• Pi${NC}\n" ;;
    antigravity) printf "  ${DIM}• Antigravity${NC}\n" ;;
  esac
done
[[ "$missing" -eq 0 ]] && printf "  ${DIM}(all listed harnesses appear to be installed)${NC}\n"

# ── Limitations ──────────────────────────────────────────────────────────────

section "What this script does NOT do"

printf "${DIM}This is a basic triage helper, not a malware scanner or clean bill of health.${NC}\n"
printf "  ${DIM}• Does not read or analyze config file contents — listing only${NC}\n"
printf "  ${DIM}• Does not scan node_modules, npm lockfiles, or compromised package versions${NC}\n"
printf "  ${DIM}• Does not search for other persistence (router_runtime.js, setup.mjs, workflow injections)${NC}\n"
printf "  ${DIM}• Only flags *-setup.pth by name — renamed or other .pth files are not checked${NC}\n"
printf "  ${DIM}• Python scan uses interpreters on PATH plus a few common install dirs only${NC}\n"
printf "  ${DIM}• Does not scan inactive project .venv / .venv dirs, pyenv, conda, pipx, or uv envs${NC}\n"
printf "  ${DIM}• An activated venv/conda env is scanned only if that interpreter is on PATH when you run this${NC}\n"
printf "  ${DIM}• .bun_ran is ephemeral; absence does not prove the Bun stage never ran${NC}\n"
printf "  ${DIM}• gh-token-monitor check is path/service based, not behavioral${NC}\n"
printf "  ${DIM}• Project search skips node_modules, .git, .cache, .npm, .cargo (max depth 10)${NC}\n"
printf "  ${DIM}• macOS-oriented temp and app-support paths; Linux/Windows coverage is best-effort${NC}\n"
printf "  ${DIM}• Does not check cloud tokens, GitHub repos, or network indicators${NC}\n"

scan_was_incomplete() {
  [[ -s "$SCAN_ERRORS_FILE" ]]
}

# ── Scan access warnings ─────────────────────────────────────────────────────

if scan_was_incomplete; then
  section "Paths that could not be read (scan incomplete)"
  printf "${YELLOW}  Some directories were skipped due to permission or access errors.${NC}\n"
  printf "${YELLOW}  A clear result below does NOT include anything under these paths:${NC}\n\n"
  sort -u "$SCAN_ERRORS_FILE" | while IFS= read -r p; do
    [[ -z "$p" ]] && continue
    printf "  ${DIM}• %s${NC}\n" "$p"
  done
  printf "\n"
  if [[ "$(uname -s 2>/dev/null)" == "Darwin" ]]; then
    printf "${DIM}  On macOS: System Settings → Privacy & Security → Full Disk Access → enable your terminal app.${NC}\n"
    if [[ -t 0 ]]; then
      printf "${DIM}  Then re-run this script for a more complete scan.${NC}\n"
      read -r -p "  Open Full Disk Access settings now? [y/N] " open_fda || open_fda=""
      if [[ "$open_fda" == [yY] || "$open_fda" == [yY][eE][sS] ]]; then
        open "x-apple-systempreferences:com.apple.preference.security?Privacy_AllFiles" 2>/dev/null \
          || open "/System/Settings/PrivacySecurity" 2>/dev/null \
          || true
      fi
    fi
  else
    printf "${DIM}  Re-run with read access to the paths above, or set PROJECT_SEARCH_ROOTS to directories you can read.${NC}\n"
  fi
fi

# ── Summary ──────────────────────────────────────────────────────────────────

printf "\n${BOLD}── Summary ──${NC}\n"
if [[ "$FOUND_ISSUES" -eq 1 ]]; then
  printf "${RED}${BOLD}  AUTOMATED IOCs DETECTED — exit 1${NC}\n"
  if scan_was_incomplete; then
    printf "${YELLOW}  Scan was incomplete — see unreadable paths above.${NC}\n"
  fi
  printf "${YELLOW}  Isolate the machine. Remove persistence before rotating any credentials.${NC}\n"
  printf "${YELLOW}  Then manually inspect the harness config links above.${NC}\n"
  exit 1
fi

printf "${GREEN}  No automated IOCs detected in scanned paths — exit 0${NC}\n"
if scan_was_incomplete; then
  printf "${YELLOW}  Scan was incomplete — see unreadable paths above.${NC}\n"
fi
printf "${YELLOW}  This does NOT mean your machine is clean.${NC}\n"
printf "${YELLOW}  You must still manually review the harness config links above.${NC}\n"
exit 0

Leave a Comment

Your email address will not be published. Required fields are marked *