#!/bin/bash
#
# sbom-collect.sh — agentless non-binary software inventory for macOS ("Assess" mode)
#
# Enumerates known ecosystem roots (packages, extensions, models, MCP servers,
# plugins) without crawling the disk. Pure filesystem reads: no package managers
# are invoked, no network calls are made unless you explicitly pass -u.
#
# Usage:
#   ./sbom-collect.sh                  scan current user, write report to cwd
#   ./sbom-collect.sh -o DIR           write report files to DIR
#   ./sbom-collect.sh -p               also probe RUNNING AI apps (ps/lsof +
#                                      localhost APIs: Ollama, LM Studio)
#   ./sbom-collect.sh -u URL [-t TOK]  POST the JSONL report to URL (opt-in)
#   ./sbom-collect.sh -b               also print report as gzip+base64 blob
#                                      (for MDM consoles that only capture stdout)
#   sudo ./sbom-collect.sh             fleet/MDM mode: scans every user in /Users
#
# Env-var equivalents (easier for MDM): SBOM_OUT_DIR, SBOM_UPLOAD_URL, SBOM_TOKEN
#
# Output: JSONL — one record per artifact:
#   {"ecosystem":..,"name":..,"version":..,"path":..,"user":..,
#    "mtime":..,"mtime_epoch":..,"detail":..}
# plus a leading {"record":"meta",...} line, and a human summary on stdout.

set -u

VERSION="0.3.1"
OUT_DIR="${SBOM_OUT_DIR:-}"
UPLOAD_URL="${SBOM_UPLOAD_URL:-}"
TOKEN="${SBOM_TOKEN:-}"
QUIET="${SBOM_QUIET:-0}"
PROBE="${SBOM_PROBE:-0}"
EMIT_B64="${SBOM_EMIT_B64:-0}"

while [ $# -gt 0 ]; do
  case "$1" in
    -o) OUT_DIR="$2"; shift 2 ;;
    -u) UPLOAD_URL="$2"; shift 2 ;;
    -t) TOKEN="$2"; shift 2 ;;
    -p) PROBE=1; shift ;;
    -b) EMIT_B64=1; shift ;;
    -q) QUIET=1; shift ;;
    -h|--help) sed -n '2,22p' "$0"; exit 0 ;;
    *) shift ;;  # ignore MDM positional params (Jamf passes mount point etc.)
  esac
done

# MDM/root runs get a persistent system location; interactive runs get cwd
if [ -z "$OUT_DIR" ]; then
  if [ "$(id -u)" -eq 0 ]; then OUT_DIR="/Library/Application Support/sbom"; else OUT_DIR="$PWD"; fi
fi

HOSTN="$(scutil --get LocalHostName 2>/dev/null || hostname -s)"
SERIAL="$(ioreg -rd1 -c IOPlatformExpertDevice 2>/dev/null | awk -F'"' '/IOPlatformSerialNumber/{print $4}')"
OSVER="$(sw_vers -productVersion 2>/dev/null || echo unknown)"
STAMP="$(date +%Y%m%dT%H%M%S)"
NOW_EPOCH="$(date +%s)"
OUT="$OUT_DIR/sbom-$HOSTN-$STAMP.jsonl"

mkdir -p "$OUT_DIR" || { echo "cannot write to $OUT_DIR" >&2; exit 1; }
: > "$OUT" || { echo "cannot write to $OUT" >&2; exit 1; }

log() { [ "$QUIET" -eq 1 ] || echo "$@" >&2; }

json_escape() {
  printf '%s' "$1" | tr -d '\000-\037' | sed -e 's/\\/\\\\/g' -e 's/"/\\"/g'
}

# Pull a top-level string field out of a JSON file without a JSON parser.
# Good enough for package.json / manifest.json; not a general parser.
json_field() { # file key
  sed -n 's/.*"'"$2"'"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' "$1" 2>/dev/null | head -1
}

emit() { # ecosystem name version path user [detail]
  local eco="$1" name="$2" ver="$3" path="$4" user="$5" detail="${6:-}"
  local me="" miso=""
  if [ -e "$path" ]; then
    me="$(stat -f %m "$path" 2>/dev/null || echo "")"
    miso="$(stat -f %Sm -t %Y-%m-%dT%H:%M:%S%z "$path" 2>/dev/null || echo "")"
  fi
  printf '{"ecosystem":"%s","name":"%s","version":"%s","path":"%s","user":"%s","mtime":"%s","mtime_epoch":%s,"detail":"%s"}\n' \
    "$(json_escape "$eco")" "$(json_escape "$name")" "$(json_escape "$ver")" \
    "$(json_escape "$path")" "$(json_escape "$user")" "$miso" "${me:-0}" \
    "$(json_escape "$detail")" >> "$OUT"
}

# name-version splitting for dirs/files like foo-1.2.3 (gems, crates)
nv_name() { printf '%s' "$1" | sed -E 's/^(.*)-[0-9][0-9A-Za-z.+-]*$/\1/'; }
nv_ver()  { printf '%s' "$1" | sed -E 's/^.*-([0-9][0-9A-Za-z.+-]*)$/\1/'; }

# python3 only if it won't trigger the Xcode CLT install dialog
have_py3() {
  local p; p="$(command -v python3 2>/dev/null)" || return 1
  if [ "$p" = "/usr/bin/python3" ] && ! xcode-select -p >/dev/null 2>&1; then return 1; fi
  return 0
}

git_head() { # repo-dir -> short sha or ""
  local g="$1/.git" ref
  [ -f "$g/HEAD" ] || return 0
  ref="$(head -1 "$g/HEAD" 2>/dev/null)"
  case "$ref" in
    ref:*) ref="${ref#ref: }"
           [ -f "$g/$ref" ] && cut -c1-12 "$g/$ref" 2>/dev/null ;;
    *)     printf '%s' "$ref" | cut -c1-12 ;;
  esac
}

git_remote() { # repo-dir -> origin url or ""
  [ -f "$1/.git/config" ] || return 0
  sed -n 's/^[[:space:]]*url = //p' "$1/.git/config" 2>/dev/null | head -1
}

# ---------------------------------------------------------------- collectors

scan_node_modules() { # dir user label
  local dir="$1" user="$2" label="$3" d s pj name ver scoped
  [ -d "$dir" ] || return 0
  for d in "$dir"/*; do
    [ -d "$d" ] || continue
    scoped=0
    case "${d##*/}" in .*) continue ;; @*) scoped=1 ;; esac
    if [ "$scoped" -eq 1 ]; then   # @scope dir: packages one level down
      for s in "$d"/*; do
        [ -d "$s" ] || continue
        pj="$s/package.json"
        [ -f "$pj" ] || continue
        name="$(json_field "$pj" name)"; ver="$(json_field "$pj" version)"
        emit npm "${name:-${d##*/}/${s##*/}}" "$ver" "$s" "$user" "$label"
      done
    else
      pj="$d/package.json"
      [ -f "$pj" ] || continue
      name="$(json_field "$pj" name)"; ver="$(json_field "$pj" version)"
      emit npm "${name:-${d##*/}}" "$ver" "$d" "$user" "$label"
    fi
  done
}

scan_site_packages() { # dir user label
  local dir="$1" user="$2" label="$3" d base
  [ -d "$dir" ] || return 0
  for d in "$dir"/*.dist-info "$dir"/*.egg-info; do
    [ -e "$d" ] || continue
    base="${d##*/}"; base="${base%.dist-info}"; base="${base%.egg-info}"
    emit pypi "${base%%-*}" "$(printf '%s' "${base#*-}" | sed 's/-py[0-9.]*$//')" "$d" "$user" "$label"
  done
}

scan_vscode_family() { # extroot user label
  local root="$1" user="$2" label="$3" d pj name pub ver
  [ -d "$root" ] || return 0
  for d in "$root"/*; do
    [ -d "$d" ] || continue
    case "${d##*/}" in .*) continue ;; esac
    pj="$d/package.json"
    if [ -f "$pj" ]; then
      name="$(json_field "$pj" name)"; pub="$(json_field "$pj" publisher)"; ver="$(json_field "$pj" version)"
      emit vscode-extension "${pub:-?}.${name:-${d##*/}}" "$ver" "$d" "$user" "$label"
      case "$(printf '%s.%s' "$pub" "$name" | tr '[:upper:]' '[:lower:]')" in
        *copilot*|*claude*|*codeium*|*continue*|*tabnine*|*cody*|*gpt*|*openai*|*gemini*|*cline*|*roo*|*augment*|*supermaven*)
          emit ai-access-extension "${pub:-?}.${name:-${d##*/}}" "$ver" "$d" "$user" \
            "$label — IDE extensions run unsandboxed with full user privileges" ;;
      esac
    else
      emit vscode-extension "${d##*/}" "" "$d" "$user" "$label"
    fi
  done
}

resolve_msg_name() { # extdir raw-name -> resolved or raw
  local dir="$1" raw="$2" key loc m
  case "$raw" in __MSG_*__) ;; *) printf '%s' "$raw"; return ;; esac
  key="${raw#__MSG_}"; key="${key%__}"
  for loc in en en_US en_GB; do
    m="$dir/_locales/$loc/messages.json"
    if [ -f "$m" ]; then
      # find "<key>" : { ... "message": "..." — grab first message after key
      sed -n '/"'"$key"'"/,/}/s/.*"message"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' "$m" 2>/dev/null | head -1
      return
    fi
  done
  printf '%s' "$raw"
}

scan_chromium_profile() { # profile-dir user browser
  local prof="$1" user="$2" browser="$3" extid verdir mf name perms hostp
  scan_web_grants "$prof" "$user" "$browser"
  [ -d "$prof/Extensions" ] || return 0
  for extid in "$prof/Extensions"/*; do
    [ -d "$extid" ] || continue
    case "${extid##*/}" in Temp|.*) continue ;; esac
    for verdir in "$extid"/*; do
      [ -d "$verdir" ] || continue
      mf="$verdir/manifest.json"
      [ -f "$mf" ] || continue
      name="$(json_field "$mf" name)"
      name="$(resolve_msg_name "$verdir" "${name:-${extid##*/}}")"
      emit browser-extension "${name:-${extid##*/}}" "$(json_field "$mf" version)" "$verdir" "$user" "$browser id=${extid##*/}"
      case "$(printf '%s' "$name" | tr '[:upper:]' '[:lower:]')" in
        *chatgpt*|*gpt*|*claude*|*gemini*|*copilot*|*openai*|*perplexity*|*monica*|*sider*|*merlin*|*harpa*|*deepseek*|*grok*|*"ai assistant"*)
          perms="$(tr -d '\n\r' < "$mf" 2>/dev/null | sed -n 's/.*"permissions"[[:space:]]*:[[:space:]]*\[\([^]]*\)\].*/\1/p' | tr -d '" ' | cut -c1-160)"
          hostp="$(tr -d '\n\r' < "$mf" 2>/dev/null | sed -n 's/.*"host_permissions"[[:space:]]*:[[:space:]]*\[\([^]]*\)\].*/\1/p' | tr -d '" ' | cut -c1-160)"
          emit ai-access-extension "$name" "$(json_field "$mf" version)" "$verdir" "$user" \
            "$browser perms=${perms:--} hosts=${hostp:--}" ;;
      esac
    done
  done
}

scan_mcp_config() { # file user label
  local f="$1" user="$2" label="$3"
  [ -f "$f" ] || return 0
  if have_py3; then
    python3 - "$f" <<'PYEOF' 2>/dev/null | while IFS=$'\t' read -r sname scmd spaths senv surl; do
import json, sys
def field(x): return (x or "-").replace("\t", " ").replace("\n", " ")
def walk(o):
    if isinstance(o, dict):
        for k, v in o.items():
            if k == "mcpServers" and isinstance(v, dict):
                for name, cfg in v.items():
                    if not isinstance(cfg, dict): continue
                    cmd = cfg.get("command", "")
                    args = cfg.get("args", [])
                    args = [str(a) for a in args] if isinstance(args, list) else []
                    url = cfg.get("url", "") or cfg.get("serverUrl", "")
                    env = cfg.get("env", {})
                    envk = ",".join(sorted(env.keys())) if isinstance(env, dict) else ""
                    paths = ",".join(a for a in args if a.startswith("/") or a.startswith("~"))
                    line = (cmd + " " + " ".join(args)).strip() or url
                    print("\t".join([field(name), field(line), field(paths), field(envk), field(url)]))
            walk(v)
    elif isinstance(o, list):
        for i in o: walk(i)
try:
    walk(json.load(open(sys.argv[1])))
except Exception:
    pass
PYEOF
      [ -n "$sname" ] || continue
      det="$label cmd=$scmd"
      [ "$spaths" != "-" ] && det="$det fs-access=$spaths"
      [ "$surl" != "-" ] && det="$det remote=$surl"
      [ "$senv" != "-" ] && det="$det env-keys=$senv"
      emit mcp-server "$sname" "" "$f" "$user" "$det"
      [ "$senv" != "-" ] && emit ai-credential "mcp:$sname" "" "$f" "$user" "credentials passed via env: $senv (key names only)"
      [ "$spaths" != "-" ] && emit ai-scope "mcp:$sname" "" "$f" "$user" "filesystem access granted: $spaths"
    done
  else
    grep -q '"mcpServers"' "$f" 2>/dev/null && emit mcp-config "${f##*/}" "" "$f" "$user" "$label (python3 unavailable; not parsed)"
  fi
}

scan_plugin_dir() { # dir user ecosystem label  (git-cloned plugin dirs)
  local dir="$1" user="$2" eco="$3" label="$4" d sha url
  [ -d "$dir" ] || return 0
  for d in "$dir"/*; do
    [ -d "$d" ] || continue
    case "${d##*/}" in .*|example) continue ;; esac
    sha="$(git_head "$d")"; url="$(git_remote "$d")"
    emit "$eco" "${d##*/}" "${sha:-}" "$d" "$user" "$label${url:+ origin=$url}"
  done
}

# ------------------------------------------------------- shadow AI discovery
# Curated signature list. In the real product this ships as signed config from
# the cloud (per the design doc's "generic engine, rules from cloud" principle);
# here it's inline. Match input is "<app name> <bundle id>", lowercased.

ai_category() { # lc-string -> category on stdout, or return 1
  case "$1" in
    *macpaw*) return 1 ;;                       # Gemini the duplicate-finder, not the LLM
    *chatgpt*|*com.openai*|*openai*)                          echo assistant ;;
    *claude*|*anthropic*)                                     echo assistant ;;
    *copilot*)                                                echo assistant ;;
    *perplexity*|*gemini*|*grok*|*deepseek*)                  echo assistant ;;
    *boltai*|*typingmind*|*chatbox*|*cherrystudio*|*msty*)    echo assistant ;;
    *granola*|*superwhisper*|*macwhisper*|*voiceink*|*otter.ai*) echo ai-notetaker ;;
    *cursor*|*windsurf*|*codeium*|*dev.zed.zed*|*trae*)       echo ai-editor ;;
    *"lm studio"*|*lmstudio*|*lm-studio*)                     echo local-llm ;;
    *ollama*|*gpt4all*|*jan.ai*|*pinokio*)                    echo local-llm ;;
    *comfy*|*diffusionbee*|*drawthings*|*invokeai*|*fooocus*|*stability*) echo image-gen ;;
    *midjourney*|*huggingface*)                               echo ml-tool ;;
    *) return 1 ;;
  esac
}

AI_CLI_NAMES="claude codex gemini aider llm ollama sgpt mods fabric goose opencode aichat interpreter openai"

scan_ai_apps_dir() { # dir user
  local dir="$1" user="$2" app base bid ver lc cat
  [ -d "$dir" ] || return 0
  for app in "$dir"/*.app; do
    [ -d "$app" ] || continue
    base="${app##*/}"; base="${base%.app}"
    bid="$(defaults read "$app/Contents/Info" CFBundleIdentifier 2>/dev/null || echo "")"
    lc="$(printf '%s %s' "$base" "$bid" | tr '[:upper:]' '[:lower:]')"
    cat="$(ai_category "$lc")" || continue
    ver="$(defaults read "$app/Contents/Info" CFBundleShortVersionString 2>/dev/null || echo "")"
    emit ai-app "$base" "$ver" "$app" "$user" "category=$cat bundle=$bid"
  done
}

scan_ai_clis() { # user dir...
  local user="$1" dir name; shift
  for dir in "$@"; do
    [ -d "$dir" ] || continue
    for name in $AI_CLI_NAMES; do
      [ -x "$dir/$name" ] && emit ai-cli "$name" "" "$dir/$name" "$user" ""
    done
  done
}

scan_ai_footprints() { # home user
  local home="$1" user="$2" line rel label
  while IFS='|' read -r rel label; do
    [ -n "$rel" ] || continue
    [ -e "$home/$rel" ] && emit ai-footprint "$label" "" "$home/$rel" "$user" "config/data dir present"
  done <<'FPEOF'
.claude|Claude Code
.codex|OpenAI Codex CLI
.gemini|Gemini CLI
.aider|Aider
.continue|Continue.dev
.codeium|Codeium / Windsurf
.ollama|Ollama
.lmstudio|LM Studio
.cache/lm-studio|LM Studio
.config/github-copilot|GitHub Copilot
.cache/huggingface|Hugging Face
.config/openai|OpenAI CLI
Library/Application Support/ChatGPT|ChatGPT Desktop
Library/Application Support/Claude|Claude Desktop
Library/Application Support/Cursor|Cursor
Library/Application Support/Windsurf|Windsurf
Library/Application Support/Perplexity|Perplexity Desktop
Library/Application Support/Jan|Jan
Library/Application Support/GPT4All|GPT4All
FPEOF
}

# --------------------------------------------------- AI access surface (v0.3)
# "What did I give AI access to?" — every collector here emits grant FACTS.
# Secret values are never read or emitted, only key/item names.

tcc_label() {
  case "$1" in
    kTCCServiceSystemPolicyAllFiles)        echo "Full Disk Access" ;;
    kTCCServiceScreenCapture)               echo "Screen Recording" ;;
    kTCCServiceAccessibility)               echo "Accessibility (control UI)" ;;
    kTCCServiceMicrophone)                  echo "Microphone" ;;
    kTCCServiceCamera)                      echo "Camera" ;;
    kTCCServiceAppleEvents)                 echo "Apple Events (automation)" ;;
    kTCCServiceListenEvent)                 echo "Input Monitoring" ;;
    kTCCServicePostEvent)                   echo "Send Keystrokes" ;;
    kTCCServiceSystemPolicyDocumentsFolder) echo "Documents folder" ;;
    kTCCServiceSystemPolicyDesktopFolder)   echo "Desktop folder" ;;
    kTCCServiceSystemPolicyDownloadsFolder) echo "Downloads folder" ;;
    kTCCServiceSystemPolicyNetworkVolumes)  echo "Network volumes" ;;
    *)                                      echo "$1" ;;
  esac
}

scan_tcc() { # db user scope
  local db="$1" user="$2" scope="$3" rows svc client auth lc cat
  [ -f "$db" ] || return 0
  if ! sqlite3 -readonly "$db" 'SELECT count(*) FROM access;' >/dev/null 2>&1; then
    emit scan-gap "TCC db ($scope) unreadable" "" "$db" "$user" \
      "permission grants not visible: invoker needs Full Disk Access (MDM agents have it; or grant Terminal FDA)"
    return 0
  fi
  sqlite3 -readonly -separator '|' "$db" \
    'SELECT service,client,auth_value FROM access WHERE auth_value=2;' 2>/dev/null | \
  while IFS='|' read -r svc client auth; do
    [ -n "$svc" ] || continue
    lc="$(printf '%s' "$client" | tr '[:upper:]' '[:lower:]')"
    if cat="$(ai_category "$lc")"; then
      emit ai-access-tcc "$client → $(tcc_label "$svc")" "" "$db" "$user" "category=$cat scope=$scope"
    else
      # terminals/editors that host CLI agents inherit their grants to the agent
      case "$lc" in
        *iterm*|*apple.terminal*|*dev.warp*|*ghostty*|*kitty*|*alacritty*|*microsoft.vscode*)
          case "$svc" in
            kTCCServiceSystemPolicyAllFiles|kTCCServiceAccessibility|kTCCServiceScreenCapture)
              emit ai-access-tcc "$client → $(tcc_label "$svc")" "" "$db" "$user" \
                "host app for CLI agents (agents inherit this grant) scope=$scope" ;;
          esac ;;
      esac
    fi
  done
  return 0
}

scan_claude_autonomy() { # home user
  local home="$1" user="$2" f k v
  for f in "$home/.claude/settings.json" "$home/.claude/settings.local.json"; do
    [ -f "$f" ] || continue
    if have_py3; then
      python3 - "$f" <<'PYEOF' 2>/dev/null | while IFS=$'\t' read -r k v; do
import json, sys
try: s = json.load(open(sys.argv[1]))
except Exception: sys.exit(0)
if not isinstance(s, dict): sys.exit(0)
p = s.get("permissions") or {}
if isinstance(p, dict):
    a = p.get("allow") or []
    if a: print("allow-rules\t%d auto-approved: %s" % (len(a), "; ".join(map(str, a[:8]))))
    dm = p.get("defaultMode")
    if dm: print("defaultMode\t%s" % dm)
    ad = p.get("additionalDirectories") or []
    if ad: print("additionalDirectories\t%s" % "; ".join(map(str, ad[:8])))
h = s.get("hooks")
if isinstance(h, dict) and h: print("hooks\t%s" % ",".join(list(h)[:8]))
PYEOF
        [ -n "$k" ] && emit ai-autonomy "claude-code $k" "" "$f" "$user" "$(printf '%.300s' "$v")"
      done
    else
      grep -qE 'bypassPermissions|acceptEdits' "$f" 2>/dev/null && \
        emit ai-autonomy "claude-code permissive-mode flag" "" "$f" "$user" "raw match (python3 unavailable)"
    fi
  done
  f="$home/.claude.json"
  if [ -f "$f" ] && have_py3; then
    python3 - "$f" <<'PYEOF' 2>/dev/null | while IFS=$'\t' read -r k v; do
import json, sys
try: s = json.load(open(sys.argv[1]))
except Exception: sys.exit(0)
pr = s.get("projects") if isinstance(s, dict) else None
if isinstance(pr, dict) and pr:
    print("%d\t%s" % (len(pr), "; ".join(list(pr)[:10])))
PYEOF
      [ -n "$k" ] && emit ai-scope "claude-code project dirs" "$k" "$f" "$user" "$(printf '%.300s' "$v")"
    done
  fi
  if [ -f "$home/.codex/config.toml" ]; then
    grep -E '^(approval_policy|sandbox_mode|full_auto)[[:space:]]*=' "$home/.codex/config.toml" 2>/dev/null | head -5 | \
    while read -r k; do
      emit ai-autonomy "codex: $k" "" "$home/.codex/config.toml" "$user" ""
    done
  fi
  return 0
}

check_autonomy_file() { # file user label — generic auto-approve flag hunt
  local f="$1" user="$2" label="$3" hits
  [ -f "$f" ] || return 0
  hits="$(tr -d '\n\r' < "$f" 2>/dev/null | \
    grep -oE '"[^"]*([aA]uto[AR][pu][pn]rove|autoRun|yolo|autoAccept|auto_approve|dangerously[^"]*)"[[:space:]]*:[[:space:]]*(true|"[^"]*")' 2>/dev/null | head -5)"
  [ -n "$hits" ] && emit ai-autonomy "$label" "" "$f" "$user" "$(printf '%s' "$hits" | tr '\n' ' ' | cut -c1-250)"
  return 0
}

scan_web_grants() { # chromium-profile-dir user browser
  local prof="$1" user="$2" browser="$3" svc label origin
  [ -f "$prof/Preferences" ] || return 0
  have_py3 || return 0
  python3 - "$prof/Preferences" <<'PYEOF' 2>/dev/null | while IFS=$'\t' read -r svc label origin; do
import json, sys
try: p = json.load(open(sys.argv[1]))
except Exception: sys.exit(0)
exc = p.get("profile", {}).get("content_settings", {}).get("exceptions", {})
doms = ("chatgpt.com","chat.openai.com","claude.ai","gemini.google.com","perplexity.ai",
        "copilot.microsoft.com","poe.com","chat.deepseek.com","grok.com","huggingface.co",
        "chat.mistral.ai","notebooklm.google.com","aistudio.google.com")
for svc, label in (("media_stream_mic","microphone"),("media_stream_camera","camera"),
                   ("clipboard","clipboard"),("notifications","notifications"),
                   ("file_system_write_guard","file-system")):
    for origin, cfg in (exc.get(svc) or {}).items():
        if isinstance(cfg, dict) and cfg.get("setting") == 1:
            o = origin.split(",")[0]
            if any(d in o for d in doms):
                print(svc + "\t" + label + "\t" + o)
PYEOF
    [ -n "$label" ] && emit ai-access-web "$label → $origin" "" "$prof/Preferences" "$user" "$browser"
  done
  return 0
}

scan_keychain() { # user — item NAMES only, secrets are never read
  local user="$1" s
  [ "$(id -u)" -eq 0 ] && return 0    # cannot dump other users' keychains
  command -v security >/dev/null 2>&1 || return 0
  security dump-keychain 2>/dev/null | sed -n 's/.*"svce"<blob>="\(.*\)"/\1/p' | sort -u | \
    grep -iE 'anthropic|claude|openai|chatgpt|cursor|copilot|gemini|codeium|windsurf|perplexity|hugging|ollama|deepseek|mistral|groq|api[ _-]?key' 2>/dev/null | \
    head -30 | while read -r s; do
      emit ai-credential "$s" "" "login-keychain" "$user" "keychain item name only; secret not read"
    done
  return 0
}

scan_ai_persistence() { # home user — AI tools that auto-start
  local home="$1" user="$2" f
  for f in "$home/Library/LaunchAgents"/*.plist; do
    [ -f "$f" ] || continue
    case "$(printf '%s' "${f##*/}" | tr '[:upper:]' '[:lower:]')" in
      *ollama*|*claude*|*anthropic*|*openai*|*chatgpt*|*lmstudio*|*lm-studio*|*cursor*|*copilot*|*windsurf*)
        emit ai-persistence "${f##*/}" "" "$f" "$user" "LaunchAgent (auto-starts at login)" ;;
    esac
  done
  # Claude Desktop local connectors (.dxt) — locally installed MCP servers
  for f in "$home/Library/Application Support/Claude/Claude Extensions"/*; do
    [ -e "$f" ] || continue
    emit mcp-server "${f##*/}" "" "$f" "$user" "claude-desktop-extension (local connector)"
  done
  return 0
}

run_probe() { # opt-in (-p): query the RUNNING AI surface. localhost only.
  local pid addr pcmd t
  log "  [probe] runtime AI surface (localhost only)"
  ps -axo pid=,command= 2>/dev/null | \
    grep -iE 'ollama|lm.studio|claude|cursor|windsurf|copilot|mcp-server|comfyui|aider|open-interpreter' | \
    grep -viE 'grep|sbom-collect|/System/Library|crashpad|Helper' | head -40 | \
    while read -r pid t; do
      pcmd="$(ps -p "$pid" -o comm= 2>/dev/null)"
      case "$pcmd" in /System/Library/*|"") continue ;; esac
      printf '%s|%s|%s\n' "${pcmd##*/}" "$pid" "$(printf '%.150s' "$t")"
    done | awk -F'|' '!seen[$1]++' | while IFS='|' read -r pcmd pid t; do
      emit ai-runtime "$pcmd" "" "pid:$pid" "$(id -un)" "running: $t"
    done
  lsof -nP -iTCP -sTCP:LISTEN 2>/dev/null | tail -n +2 | awk '{print $2"|"$(NF-1)}' | sort -u | \
    while IFS='|' read -r pid addr; do
      pcmd="$(ps -p "$pid" -o comm= 2>/dev/null | awk -F/ '{print $NF}')"
      printf '%s' "$pcmd" | grep -qiE 'ollama|lm.?studio|claude|cursor|windsurf|comfy|jan|gpt4all' || continue
      case "$addr" in
        \*:*|0.0.0.0:*|\[::\]:*) emit ai-runtime "$pcmd listening on $addr" "" "pid:$pid" "$(id -un)" "EXPOSED TO NETWORK (not localhost-bound)" ;;
        *)                       emit ai-runtime "$pcmd listening on $addr" "" "pid:$pid" "$(id -un)" "localhost only" ;;
      esac
    done
  t="$(curl -s -m 2 http://127.0.0.1:11434/api/tags 2>/dev/null)" && [ -n "$t" ] && \
    emit ai-runtime "ollama API" "" "127.0.0.1:11434" "$(id -un)" \
      "models served: $(printf '%s' "$t" | sed -n 's/[^"]*"name":"\([^"]*\)"[^"]*/\1 /gp' | cut -c1-160)"
  t="$(curl -s -m 2 http://127.0.0.1:1234/v1/models 2>/dev/null)" && [ -n "$t" ] && \
    emit ai-runtime "LM Studio API" "" "127.0.0.1:1234" "$(id -un)" \
      "models served: $(printf '%s' "$t" | sed -n 's/[^"]*"id":"\([^"]*\)"[^"]*/\1 /gp' | cut -c1-160)"
  return 0
}

scan_user() {
  local home="$1" user="$2" d v p f b prof vault

  log "  [$user] language packages"
  # -- npm and friends (global installs)
  for d in "$home/.nvm/versions/node"/*/lib/node_modules; do scan_node_modules "$d" "$user" nvm-global; done
  for d in "$home/.fnm/node-versions"/*/installation/lib/node_modules; do scan_node_modules "$d" "$user" fnm-global; done
  scan_node_modules "$home/.config/yarn/global/node_modules" "$user" yarn-global
  scan_node_modules "$home/.bun/install/global/node_modules" "$user" bun-global
  for d in "$home/Library/pnpm/global"/*/node_modules; do scan_node_modules "$d" "$user" pnpm-global; done
  for d in "$home/.volta/tools/image/node"/*/lib/node_modules; do scan_node_modules "$d" "$user" volta; done

  # -- Python
  for d in "$home/Library/Python"/*/lib/python/site-packages; do scan_site_packages "$d" "$user" user-site; done
  for d in "$home/.pyenv/versions"/*/lib/python*/site-packages; do scan_site_packages "$d" "$user" pyenv; done
  for d in "$home/.virtualenvs"/*/lib/python*/site-packages; do scan_site_packages "$d" "$user" virtualenv; done
  for d in "$home/.local/pipx/venvs"/*/lib/python*/site-packages; do scan_site_packages "$d" "$user" pipx; done
  for d in "$home/.local/share/uv/tools"/*/lib/python*/site-packages; do scan_site_packages "$d" "$user" uv-tool; done
  for b in "$home/miniconda3" "$home/anaconda3" "$home/miniforge3" "$home/mambaforge"; do
    for d in "$b/lib"/python*/site-packages; do scan_site_packages "$d" "$user" conda-base; done
    for d in "$b/envs"/*/lib/python*/site-packages; do scan_site_packages "$d" "$user" conda-env; done
  done

  # -- Rust: installed tools + downloaded crates
  if [ -f "$home/.cargo/.crates.toml" ]; then
    sed -n 's/^"\([^ ]*\) \([^ ]*\) .*/\1 \2/p' "$home/.cargo/.crates.toml" | while read -r n v; do
      emit cargo "$n" "$v" "$home/.cargo/.crates.toml" "$user" cargo-install
    done
  fi
  for f in "$home/.cargo/registry/cache"/*/*.crate; do
    [ -f "$f" ] || continue
    b="${f##*/}"; b="${b%.crate}"
    emit cargo "$(nv_name "$b")" "$(nv_ver "$b")" "$f" "$user" registry-cache
  done

  # -- Go module cache
  if [ -d "$home/go/pkg/mod" ]; then
    find "$home/go/pkg/mod" -mindepth 1 -maxdepth 6 -type d -name '*@*' -prune 2>/dev/null | while read -r d; do
      p="${d#"$home/go/pkg/mod/"}"
      emit gomod "${p%%@*}" "${p##*@}" "$d" "$user" module-cache
    done
  fi

  # -- RubyGems
  for d in "$home/.gem/ruby"/*/gems/* "$home/.rbenv/versions"/*/lib/ruby/gems/*/gems/*; do
    [ -d "$d" ] || continue
    b="${d##*/}"
    emit rubygems "$(nv_name "$b")" "$(nv_ver "$b")" "$d" "$user" ""
  done

  # -- PowerShell modules (PSGallery)
  for d in "$home/.local/share/powershell/Modules"/*/*; do
    [ -d "$d" ] || continue
    p="${d%/*}"
    emit psgallery "${p##*/}" "${d##*/}" "$d" "$user" ""
  done

  log "  [$user] editor & browser extensions"
  # -- VS Code family
  scan_vscode_family "$home/.vscode/extensions" "$user" vscode
  scan_vscode_family "$home/.vscode-insiders/extensions" "$user" vscode-insiders
  scan_vscode_family "$home/.vscode-oss/extensions" "$user" vscodium
  scan_vscode_family "$home/.cursor/extensions" "$user" cursor
  scan_vscode_family "$home/.windsurf/extensions" "$user" windsurf

  # -- JetBrains plugins
  for d in "$home/Library/Application Support/JetBrains"/*/plugins/*; do
    [ -e "$d" ] || continue
    p="${d%/plugins/*}"
    emit jetbrains-plugin "${d##*/}" "" "$d" "$user" "${p##*/}"
  done

  # -- Chromium-family browser extensions
  for b in "Google/Chrome" "Google/Chrome Beta" "Google/Chrome Canary" "Chromium" \
           "Microsoft Edge" "BraveSoftware/Brave-Browser" "Vivaldi" "Arc/User Data"; do
    p="$home/Library/Application Support/$b"
    [ -d "$p" ] || continue
    for prof in "$p/Default" "$p"/Profile*; do
      scan_chromium_profile "$prof" "$user" "$b"
    done
  done

  # -- Firefox
  for f in "$home/Library/Application Support/Firefox/Profiles"/*/extensions/*.xpi; do
    [ -f "$f" ] || continue
    b="${f##*/}"; b="${b%.xpi}"
    v="$(unzip -p "$f" manifest.json 2>/dev/null | sed -n 's/.*"version"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | head -1)"
    emit browser-extension "$b" "${v:-}" "$f" "$user" firefox
  done

  log "  [$user] AI artifacts"
  # -- Hugging Face cache
  for d in "$home/.cache/huggingface/hub"/models--* "$home/.cache/huggingface/hub"/datasets--*; do
    [ -d "$d" ] || continue
    b="${d##*/}"
    case "$b" in models--*) b="${b#models--}";; datasets--*) b="${b#datasets--}";; esac
    v=""
    for p in "$d/snapshots"/*; do [ -d "$p" ] && v="$(printf '%s' "${p##*/}" | cut -c1-12)" && break; done
    emit huggingface "$(printf '%s' "$b" | sed 's/--/\//g')" "$v" "$d" "$user" ""
  done

  # -- Ollama models
  if [ -d "$home/.ollama/models/manifests" ]; then
    find "$home/.ollama/models/manifests" -mindepth 4 -maxdepth 5 -type f 2>/dev/null | while read -r f; do
      p="${f#"$home/.ollama/models/manifests/"}"   # registry/ns/model/tag
      b="$(printf '%s' "$p" | awk -F/ '{ if ($2=="library") printf "%s:%s",$3,$NF; else printf "%s/%s:%s",$2,$3,$NF }')"
      emit ollama "$b" "" "$f" "$user" "${p%%/*}"
    done
  fi

  # -- MCP server configurations
  scan_mcp_config "$home/Library/Application Support/Claude/claude_desktop_config.json" "$user" claude-desktop
  scan_mcp_config "$home/.claude.json" "$user" claude-code
  scan_mcp_config "$home/.cursor/mcp.json" "$user" cursor
  scan_mcp_config "$home/.codeium/windsurf/mcp_config.json" "$user" windsurf
  scan_mcp_config "$home/Library/Application Support/Code/User/mcp.json" "$user" vscode
  scan_mcp_config "$home/.gemini/settings.json" "$user" gemini-cli

  # NOTE: Claude Code skills (~/.claude/skills) and subagents (~/.claude/agents)
  # are intentionally NOT inventoried yet. Their risk lives in file *content*
  # (instructions an agent will execute), and a name-only record isn't useful
  # for that. Revisit once we've decided the content/hash privacy trade-off.

  log "  [$user] productivity & shell plugins"
  # -- Obsidian plugins (vaults discovered from obsidian.json)
  f="$home/Library/Application Support/obsidian/obsidian.json"
  if [ -f "$f" ]; then
    grep -o '"path":"[^"]*"' "$f" 2>/dev/null | sed 's/^"path":"//;s/"$//' | while read -r vault; do
      for d in "$vault/.obsidian/plugins"/*; do
        [ -d "$d" ] || continue
        emit obsidian-plugin "$(json_field "$d/manifest.json" id)" "$(json_field "$d/manifest.json" version)" "$d" "$user" "vault=${vault##*/}"
      done
    done
  fi

  # -- Raycast extensions
  for d in "$home/.config/raycast/extensions"/*; do
    [ -d "$d" ] || continue
    emit raycast-extension "$(json_field "$d/package.json" name)" "" "$d" "$user" ""
  done

  log "  [$user] shadow AI discovery"
  scan_ai_apps_dir "$home/Applications" "$user"
  scan_ai_clis "$user" "$home/.local/bin" "$home/bin" "$home/.cargo/bin" \
    "$home/go/bin" "$home/.bun/bin" "$home/.claude/local"
  scan_ai_footprints "$home" "$user"

  log "  [$user] AI access surface"
  scan_tcc "$home/Library/Application Support/com.apple.TCC/TCC.db" "$user" user
  scan_claude_autonomy "$home" "$user"
  check_autonomy_file "$home/Library/Application Support/Cursor/User/settings.json" "$user" "cursor settings"
  check_autonomy_file "$home/Library/Application Support/Code/User/settings.json" "$user" "vscode settings"
  check_autonomy_file "$home/Library/Application Support/Windsurf/User/settings.json" "$user" "windsurf settings"
  check_autonomy_file "$home/.gemini/settings.json" "$user" "gemini-cli settings"
  check_autonomy_file "$home/.continue/config.json" "$user" "continue.dev config"
  scan_ai_persistence "$home" "$user"
  scan_keychain "$user"

  # -- Shell & editor plugin managers (git-cloned software)
  scan_plugin_dir "$home/.oh-my-zsh/custom/plugins" "$user" zsh-plugin oh-my-zsh
  scan_plugin_dir "$home/.zinit/plugins" "$user" zsh-plugin zinit
  scan_plugin_dir "$home/.local/share/zinit/plugins" "$user" zsh-plugin zinit
  scan_plugin_dir "$home/.vim/plugged" "$user" vim-plugin vim-plug
  scan_plugin_dir "$home/.local/share/nvim/lazy" "$user" vim-plugin lazy.nvim
  scan_plugin_dir "$home/.local/share/nvim/site/pack/packer/start" "$user" vim-plugin packer
}

scan_system() {
  local d
  log "  [system] Homebrew & system-level roots"
  for d in /opt/homebrew/Cellar/*/* /usr/local/Cellar/*/*; do
    [ -d "$d" ] || continue
    emit homebrew "$(basename "$(dirname "$d")")" "${d##*/}" "$d" system formula
  done
  for d in /opt/homebrew/Caskroom/*/* /usr/local/Caskroom/*/*; do
    [ -d "$d" ] || continue
    case "${d##*/}" in .metadata*) continue ;; esac
    emit homebrew-cask "$(basename "$(dirname "$d")")" "${d##*/}" "$d" system cask
  done
  scan_node_modules /usr/local/lib/node_modules system system-global
  scan_node_modules /opt/homebrew/lib/node_modules system brew-global
  for d in /usr/local/lib/python*/site-packages /opt/homebrew/lib/python*/site-packages; do
    scan_site_packages "$d" system system
  done
  scan_ai_apps_dir /Applications system
  scan_ai_apps_dir /Applications/Setapp system
  scan_ai_apps_dir /Applications/Utilities system
  scan_ai_clis system /opt/homebrew/bin /usr/local/bin
  scan_tcc "/Library/Application Support/com.apple.TCC/TCC.db" system system
}

# -------------------------------------------------------------------- main

printf '{"record":"meta","host":"%s","serial":"%s","os":"%s","collected_at":"%s","collector_version":"%s","mode":"%s"}\n' \
  "$(json_escape "$HOSTN")" "$(json_escape "$SERIAL")" "$OSVER" \
  "$(date +%Y-%m-%dT%H:%M:%S%z)" "$VERSION" \
  "$([ "$(id -u)" -eq 0 ] && echo fleet || echo single-user)" >> "$OUT"

log "sbom-collect $VERSION on $HOSTN (macOS $OSVER)"

if [ "$(id -u)" -eq 0 ]; then
  for home in /Users/*; do
    [ -d "$home" ] || continue
    case "${home##*/}" in Shared|Guest|.*) continue ;; esac
    scan_user "$home" "${home##*/}"
  done
else
  scan_user "$HOME" "$(id -un)"
fi
scan_system
[ "$PROBE" = "1" ] && run_probe

# ------------------------------------------------------------------ summary

TOTAL=$(($(wc -l < "$OUT") - 1))
RECENT=$(awk -v now="$NOW_EPOCH" -F'"mtime_epoch":' 'NF>1{split($2,a,/[,}]/); if (a[1]>0 && now-a[1] < 172800) c++} END{print c+0}' "$OUT")

echo ""
echo "Device: $HOSTN  serial=$SERIAL  macOS $OSVER  collector=$VERSION  mode=$([ "$(id -u)" -eq 0 ] && echo fleet || echo single-user)"
echo ""
echo "── Inventory: $TOTAL artifacts ──────────────────────────"
grep -o '"ecosystem":"[^"]*"' "$OUT" | sed 's/"ecosystem":"//;s/"$//' | sort | uniq -c | sort -rn | \
  awk '{printf "  %6d  %s\n", $1, $2}'
echo "─────────────────────────────────────────────────────────"
echo "  $RECENT artifact(s) modified in the last 48h (age-gate window)"

# Shadow AI section
AI_APPS="$(grep '"ecosystem":"ai-app"' "$OUT" | awk -F'"' '{v=$12; printf "%s%s%s\n", $8, (v==""?"":" "), v}' | sort -u)"
AI_CLIS_FOUND="$(grep '"ecosystem":"ai-cli"' "$OUT" | awk -F'"' '{print $8}' | sort -u)"
AI_FOOT="$(grep '"ecosystem":"ai-footprint"' "$OUT" | awk -F'"' '{print $8}' | sort -u)"
AI_MODELS=$(grep -c '"ecosystem":"huggingface"\|"ecosystem":"ollama"' "$OUT")
AI_EXTS=$(grep '"ecosystem":"vscode-extension"\|"ecosystem":"browser-extension"' "$OUT" | \
  grep -icE 'chatgpt|copilot|claude|gpt|gemini|codeium|tabnine|openai|ai assistant' || true)

echo ""
echo "── Shadow AI ────────────────────────────────────────────"
if [ -n "$AI_APPS" ]; then
  echo "  AI applications:"
  printf '%s\n' "$AI_APPS" | sed 's/^/    /'
fi
[ -n "$AI_CLIS_FOUND" ] && echo "  AI CLI tools:      $(printf '%s' "$AI_CLIS_FOUND" | tr '\n' ' ')"
[ -n "$AI_FOOT" ]       && echo "  AI tool footprints: $(printf '%s' "$AI_FOOT" | tr '\n' ',' | sed 's/,$//;s/,/, /g')"
echo "  AI models on disk:  $AI_MODELS (Hugging Face + Ollama)"
echo "  Extensions matching AI keywords: ${AI_EXTS:-0} (name heuristic)"

sum_section() { # ecosystem title
  local rows n
  rows="$(grep "\"ecosystem\":\"$1\"" "$OUT" | awk -F'"' '{print $8}' | sort -u)"
  [ -n "$rows" ] || return 0
  n="$(printf '%s\n' "$rows" | wc -l | tr -d ' ')"
  echo "  $2 ($n):"
  printf '%s\n' "$rows" | head -10 | sed 's/^/    /'
  [ "$n" -gt 10 ] && echo "    … $((n-10)) more in report"
  return 0
}

if grep -q '"ecosystem":"ai-access-tcc"\|"ecosystem":"ai-autonomy"\|"ecosystem":"ai-credential"\|"ecosystem":"ai-access-web"\|"ecosystem":"ai-access-extension"\|"ecosystem":"ai-scope"\|"ecosystem":"ai-persistence"\|"ecosystem":"ai-runtime"\|"ecosystem":"scan-gap"' "$OUT"; then
  echo ""
  echo "── AI Access & Autonomy ─────────────────────────────────"
  sum_section ai-access-tcc      "OS permission grants to AI apps (TCC)"
  sum_section ai-autonomy        "Agent autonomy settings"
  sum_section ai-scope           "Filesystem / workspace scope granted"
  sum_section ai-credential      "Credentials reachable by AI tools (names only)"
  sum_section ai-access-web      "Browser grants to AI sites"
  sum_section ai-access-extension "AI extensions and their permissions"
  sum_section ai-persistence     "AI tools that auto-start"
  sum_section ai-runtime         "Runtime probe findings"
  sum_section scan-gap           "Scan gaps (sources present but unreadable)"
fi
echo ""
echo "Report: $OUT"

# keep only the 5 most recent reports (matters for recurring MDM runs)
ls -t "$OUT_DIR"/sbom-*.jsonl 2>/dev/null | tail -n +6 | while read -r f; do rm -f "$f"; done

# inline the full report into stdout for MDM consoles with no file-return
# channel: copy the blob from the script log, then
#   base64 -d < blob.txt | gunzip > report.jsonl
if [ "$EMIT_B64" = "1" ]; then
  GZSIZE="$(gzip -c "$OUT" | wc -c | tr -d ' ')"
  if [ "$GZSIZE" -lt 300000 ]; then
    echo ""
    echo "───BEGIN-SBOM-GZ-B64───"
    gzip -c "$OUT" | base64
    echo "───END-SBOM-GZ-B64───"
  else
    echo "(report too large to inline: ${GZSIZE}B gzipped — retrieve $OUT from disk or use -u upload)"
  fi
fi

if [ -n "$UPLOAD_URL" ]; then
  log "uploading report to $UPLOAD_URL"
  if curl -fsS -X POST -H "Content-Type: application/x-ndjson" \
       ${TOKEN:+-H "Authorization: Bearer $TOKEN"} \
       --data-binary @"$OUT" "$UPLOAD_URL" >/dev/null; then
    log "upload ok"
  else
    echo "upload failed (report retained at $OUT)" >&2
    exit 2
  fi
fi
