| | #!/usr/bin/env bash | | | # | | | # ai-log-grep.sh — search local AI assistant chat logs / session transcripts | | | # for a sensitive string (e.g. your email) to find where an agent may have | | | # echoed it into commands, headers, or outbound requests. | | | # | | | # Usage: | | | # ./ai-log-grep.sh "you@example.com" | | | # ./ai-log-grep.sh --context "you@example.com" # print text around each match |
| | # ./ai-log-grep.sh --deep "you@example.com" # also scan $HOME for aider histories (slow) |
| | # ./ai-log-grep.sh --all "you@example.com" # list every matching file (default: top 10 per folder) |
| | # |
| | # Covers (where present on macOS/Linux): |
| | # Claude Code, Claude Desktop, OpenAI Codex CLI, ChatGPT Desktop, |
| | # Gemini CLI, Cursor, VS Code (Copilot Chat), Windsurf, Continue, |
| | # Zed, LM Studio, Open WebUI (default sqlite path), aider (--deep) |
| | # |
| | # Notes: |
| | # - Only local logs. Web-only chats (claude.ai, chatgpt.com, gemini.google.com) |
| | # live server-side and can't be scanned from disk. |
| | # - SQLite stores (Cursor, VS Code, Open WebUI) are scanned via strings, |
| | # so matches there mean "present in the DB", not a parsed message. |
| | # - Matches that are only git metadata (commit authors, Co-authored-by |
| | # trailers, git config user.email, ...) and anything under .git/ are ignored. |
| | # - Set NO_COLOR=1 to disable colors. |
| | set -uo pipefail |
| | DEEP=0 |
| | CONTEXT=0 |
| | SHOW_ALL=0 |
| | while [[ $# -gt 0 ]]; do |
| | case "$1" in |
| | --deep) DEEP=1; shift ;; |
| | --context) CONTEXT=1; shift ;; |
| | --all) SHOW_ALL=1; shift ;; |
| | --) shift; break ;; |
| | *) break ;; |
| | esac | | | done |
| | NEEDLE="${1:?usage: $(basename "$0") [--deep] [--context] [--all] \"string-to-find\"}" |
| | case "$(uname -s)" in |
| | Darwin) APPDATA="$HOME/Library/Application Support" ;; |
| | *) APPDATA="${XDG_CONFIG_HOME:-$HOME/.config}" ;; |
| | esac |
| | # --- Terminal styling --------------------------------------------------------- |
| | TTY=0; [[ -t 1 ]] && TTY=1 |
| | COLOR=0; [[ $TTY -eq 1 && -z "${NO_COLOR:-}" ]] && COLOR=1 |
| | if [[ $COLOR -eq 1 ]]; then |
| | B=$'\033[1m' DIM=$'\033[2m' RS=$'\033[0m' |
| | RED=$'\033[31m' GRN=$'\033[32m' YEL=$'\033[33m' MAG=$'\033[35m' CYN=$'\033[36m' |
| | else | | | B='' DIM='' RS='' RED='' GRN='' YEL='' MAG='' CYN='' | | | fi |
| | FRAMES=(⠋ ⠙ ⠹ ⠸ ⠼ ⠴ ⠦ ⠧ ⠇ ⠏) |
| | WORK="$(mktemp -d "${TMPDIR:-/tmp}/ai-log-grep.XXXXXX")" \|\| exit 1 |
| | WORKER_PID= |
| | cleanup() { |
| | [[ -n "$WORKER_PID" ]] && kill "$WORKER_PID" 2>/dev/null |
| | [[ $TTY -eq 1 ]] && printf '\033[?25h' |
| | rm -rf "$WORK" | | | } | | | trap cleanup EXIT | | | trap 'exit 130' INT TERM | | | # --- Matching ----------------------------------------------------------------- | | | # Reads one file (or stdin). If NEEDLE occurs outside of git metadata, prints |
| | # the occurrence count, then (with --context) up to 3 one-line excerpts. |
| | find_matches() { |
| | local perl_program=' |
| | my $n = $ENV{NEEDLE}; my $p = 0; my $count = 0; my $last_end = -1; my @ex; |
| | my ($hl, $rs) = $ENV{COLOR} ? ("\e[1;33m", "\e[0m") : ("", ""); |
| | while (($p = index($_, $n, $p)) >= 0) { |
| | my $s = $p > 150 ? $p - 150 : 0; |
| | my $pre = substr($_, $s, $p - $s); |
| | my $match = $p; |
| | $p += length $n; |
| | next if $pre =~ /(?:Author\|Commit\|Committer\|Co-authored-by\|Signed-off-by\|Tagger\|Reviewed-by\|Acked-by\|Helped-by\|Reported-by)\s*:[^<]{0,100}<\z/i; |
| | next if $pre =~ /(?:author\|committer)-mail <\z/; |
| | next if $pre =~ /\b(?:author\|committer)_?email\\?"?\s*[:=]\s*\\?"?\z/i; |
| | next if $pre =~ /user\.email\W{0,5}\z/; |
| | $count++; |
| | next unless $ENV{CONTEXT} && @ex < 3 && $match >= $last_end; |
| | my $start = $match > 80 ? $match - 80 : 0; |
| | my $end = $match + length($n) + 80; |
| | $end = length($_) if $end > length($_); |
| | $last_end = $end; |
| | my $c = substr($_, $start, $end - $start); |
| | $c =~ s/\\[nrt]/ /g; |
| | $c =~ s/[\x00-\x1f\x7f]+/ /g; |
| | $c =~ s/ {2,}/ /g; |
| | $c =~ s/\Q$n\E/$hl$n$rs/g; |
| | push @ex, ($start > 0 ? "…" : "") . $c . ($end < length($_) ? "…" : ""); |
| | } | | | END { if ($count) { print "$count\n"; print "$_\n" for @ex; exit 0 } exit 1 } | | | ' |
| | if [[ $# -gt 0 ]]; then |
| | NEEDLE="$NEEDLE" CONTEXT="$CONTEXT" COLOR="$COLOR" perl -0777 -ne "$perl_program" -- "$1" |
| | else | | | NEEDLE="$NEEDLE" CONTEXT="$CONTEXT" COLOR="$COLOR" perl -0777 -ne "$perl_program" | | | fi | | | } | | | # --- Source groups -------------------------------------------------------------- | | | # Each group: label, kind (text|sqlite|deep), newline-separated paths. |
| | G_LABEL=() G_KIND=() G_PATHS=() G_STATE=() |
| | add_group() { # kind, label, path... |
| | local kind="$1" label="$2"; shift 2 |
| | local paths="" state=missing p |
| | for p in "$@"; do | | | paths+="$p"$'\n' |
| | if [[ $kind == sqlite ]]; then [[ -f "$p" ]] && state=waiting |
| | else [[ -e "$p" ]] && state=waiting |
| | fi | | | done | | | G_LABEL+=("$label") G_KIND+=("$kind") G_PATHS+=("$paths") G_STATE+=("$state") | | | } | | | add_group text "Claude Code" \ | | | "$HOME/.claude/projects" "$HOME/.claude/history.jsonl" "$HOME/.claude/todos" | | | add_group text "Claude Desktop" \ | | | "$APPDATA/Claude/logs" "$APPDATA/Claude/Session Storage" | | | add_group text "Codex CLI" \ | | | "$HOME/.codex/sessions" "$HOME/.codex/history.jsonl" "$HOME/.codex/log" | | | add_group text "ChatGPT Desktop" \ | | | "$APPDATA/com.openai.chat" "$APPDATA/ChatGPT" | | | add_group text "Gemini CLI" \ | | | "$HOME/.gemini/tmp" "$HOME/.gemini/history" | | | add_group text "Cursor (chats)" \ | | | "$APPDATA/Cursor/User/workspaceStorage" "$APPDATA/Cursor/User/globalStorage" | | | add_group sqlite "Cursor (state DBs)" \ | | | "$APPDATA/Cursor/User/workspaceStorage"//state.vscdb \ | | | "$APPDATA/Cursor/User/globalStorage/state.vscdb" | | | add_group text "VS Code / Copilot Chat" \ | | | "$APPDATA/Code/User/workspaceStorage" | | | add_group sqlite "VS Code (state DBs)" \ | | | "$APPDATA/Code/User/workspaceStorage"//state.vscdb \ | | | "$APPDATA/Code/User/globalStorage/state.vscdb" | | | add_group text "Windsurf" \ | | | "$HOME/.codeium/windsurf" "$APPDATA/Windsurf/User/workspaceStorage" | | | add_group text "Continue" \ | | | "$HOME/.continue/sessions" | | | add_group text "Zed (AI threads)" \ | | | "$HOME/.local/share/zed/threads" "$APPDATA/Zed/threads" | | | add_group text "LM Studio" \ | | | "$HOME/.lmstudio/conversations" "$HOME/.cache/lm-studio/conversations" | | | add_group sqlite "Open WebUI" \ | | | "$HOME/open-webui/webui.db" "$HOME/.open-webui/webui.db" |
| | [[ $DEEP -eq 1 ]] && add_group deep "Deep scan (aider histories)" "$HOME" |
| | NG=${#G_LABEL[@]} |
| | # --- Scanning (runs in a background worker per group) --------------------------- |
| | record_match() { # results-file, path, find_matches-output |
| | local res="$1" path="$2" out="$3" |
| | local ctx="$res.ctx.$(wc -l < "$res" \| tr -d ' ')" |
| | printf '%s\n' "$out" | tail -n +2 > "$ctx" | | | printf '%s\t%s\t%s\n' "${out%%$'\n'*}" "$path" "$ctx" >> "$res" | | | } | | | scan_group() { # index | | | local i=$1 res="$WORK/res.$1" p f out |
| | : > "$res" |
| | while IFS= read -r p; do |
| | [[ -n "$p" ]] \|\| continue |
| | case "${G_KIND[i]}" in |
| | text) |
| | [[ -e "$p" ]] \|\| continue |
| | while IFS= read -r f; do |
| | out="$(find_matches "$f")" && record_match "$res" "$f" "$out" |
| | done < <(grep -rIls --exclude-dir='.git' --exclude='*.vscdb' --exclude='*.db' --exclude='*.sqlite' -- "$NEEDLE" "$p" 2>/dev/null) |
| | ;; |
| | sqlite) |
| | [[ -f "$p" ]] \|\| continue |
| | out="$(strings -- "$p" 2>/dev/null \| find_matches)" && record_match "$res" "$p" "$out" |
| | ;; |
| | deep) | | | while IFS= read -r f; do | | | out="$(find_matches "$f")" && record_match "$res" "$f" "$out" | | | done < <(find "$p" \ |
| | $ -path "$HOME/Library" -o -path "$HOME/.Trash" -o -name node_modules -o -name .git $ -prune -o \ |
| | -type f $ -name ".aider.chat.history.md" -o -name ".aider.input.history" $ -print 2>/dev/null) |
| | ;; |
| | esac | | | done <<< "${G_PATHS[i]}" | | | } |
| | files_in() { [[ -f "$WORK/res.$1" ]] && wc -l < "$WORK/res.$1" \| tr -d ' ' \|\| echo 0; } |
| | matches_in() { [[ -f "$WORK/res.$1" ]] && awk -F'\t' '{s+=$1} END {print s+0}' "$WORK/res.$1" \|\| echo 0; } |
| | plural() { [[ $1 -eq 1 ]] && printf "%s %s" "$1" "$2" \|\| printf "%s %s" "$1" "${3:-${2}s}"; } |
| | # --- Live dashboard ---------------------------------------------------------------- |
| | TICK=0 | | | DRAWN=0 | | | draw_row() { | | | local i=$1 icon status color |
| | case "${G_STATE[i]}" in |
| | missing) color=$DIM icon='–' status="not found" ;; |
| | waiting) color=$DIM icon='·' status="waiting" ;; |
| | running) color=$CYN icon="${FRAMES[TICK % ${#FRAMES[@]}]}" |
| | status="scanning… $(plural "$(files_in "$i")" file) so far" ;; |
| | done) |
| | local nf; nf=$(files_in "$i") |
| | if [[ $nf -gt 0 ]]; then |
| | color=$YEL icon='●' status="$(plural "$nf" file) · $(plural "$(matches_in "$i")" match matches)" |
| | else |
| | color=$GRN icon='✔' status="clean" |
| | fi ;; |
| | esac | | | printf '\033[2K %s%s%s %-30s %s%s%s\n' "$color" "$icon" "$RS" "${G_LABEL[i]}" "$color" "$status" "$RS" | | | } |
| | draw_all() { |
| | [[ $DRAWN -eq 1 ]] && printf '\033[%dA' "$NG" |
| | local i | | | for ((i = 0; i < NG; i++)); do draw_row "$i"; done | | | DRAWN=1 | | | } | | | printf '\n %s%s◆ AI LOG SCOUT%s\n' "$B" "$MAG" "$RS" | | | printf ' %slooking for%s %s%s%s\n\n' "$DIM" "$RS" "$B" "$NEEDLE" "$RS" | | | SECONDS=0 |
| | [[ $TTY -eq 1 ]] && { printf '\033[?25l'; draw_all; } |
| | for ((i = 0; i < NG; i++)); do |
| | [[ ${G_STATE[i]} == missing ]] && continue |
| | G_STATE[i]=running |
| | [[ $TTY -eq 0 ]] && printf ' scanning %s…' "${G_LABEL[i]}" |
| | scan_group "$i" & | | | WORKER_PID=$! |
| | while kill -0 "$WORKER_PID" 2>/dev/null; do |
| | [[ $TTY -eq 1 ]] && draw_all |
| | TICK=$((TICK + 1)) |
| | sleep 0.1 | | | done | | | wait "$WORKER_PID" 2>/dev/null | | | WORKER_PID= |
| | G_STATE[i]=done |
| | [[ $TTY -eq 0 ]] && printf ' %s\n' "$(plural "$(files_in "$i")" file)" |
| | done | | | [[ $TTY -eq 1 ]] && { draw_all; printf '\033[?25h'; } | | | ELAPSED=$SECONDS |
| | # --- Results ------------------------------------------------------------------------- |
| | pretty_path() { local h="~"; printf "%s" "${1/#$HOME/$h}"; } |
| | # VS Code-style workspaceStorage/<hash>/ dirs: resolve the hash to a project name. | | | workspace_name() { | | | local d="$1" ws | | | for ws in "$d/workspace.json" "$(dirname "$d")/workspace.json"; do |
| | [[ -f "$ws" ]] \|\| continue |
| | perl -ne 'if (/"(?:folder\|workspace)"\s*:\s*"([^"]+)"/) { my $x = $1; $x =~ s/%([0-9A-Fa-f]{2})/chr hex $1/ge; $x =~ s{/+$}{}; $x =~ s{.*/}{}; print $x; exit }' "$ws" |
| | return | | | done | | | } |
| | TOTAL_FILES=0 TOTAL_MATCHES=0 SCANNED=0 |
| | for ((i = 0; i < NG; i++)); do |
| | [[ ${G_STATE[i]} == done ]] \|\| continue |
| | SCANNED=1 |
| | TOTAL_FILES=$((TOTAL_FILES + $(files_in "$i"))) |
| | TOTAL_MATCHES=$((TOTAL_MATCHES + $(matches_in "$i"))) |
| | done | | | echo | | | if [[ $SCANNED -eq 0 ]]; then | | | printf ' %s!%s No known AI tool log directories found on this machine.\n\n' "$YEL" "$RS" | | | exit 0 | | | fi | | | if [[ $TOTAL_FILES -eq 0 ]]; then | | | printf ' %s✔ All clear%s — no matches in any scanned logs %s(%ss)%s\n\n' "$GRN$B" "$RS" "$DIM" "$ELAPSED" "$RS" | | | exit 0 | | | fi | | | printf ' %s%s─── Results ───%s\n' "$B" "$MAG" "$RS" |
| | for ((i = 0; i < NG; i++)); do |
| | nf=$(files_in "$i") |
| | [[ ${G_STATE[i]} == done && $nf -gt 0 ]] \|\| continue |
| | nm=$(matches_in "$i") |
| | printf '\n %s%s%s %s%s · %s%s\n' "$B" "${G_LABEL[i]}" "$RS" "$DIM" "$(plural "$nf" file)" "$(plural "$nm" match matches)" "$RS" |
| | # Group files by directory (busiest first), then by match count. |
| | awk -F'\t' '{ d = $2; sub(/\/[^\/]*$/, "", d); t[d] += $1; line[NR] = $0; dir[NR] = d } |
| | END { for (k = 1; k <= NR; k++) print t[dir[k]] "\t" dir[k] "\t" line[k] }' "$WORK/res.$i" \| |
| | sort -t$'\t' -k1,1nr -k2,2 -k3,3nr \| |
| | { |
| | last= per_dir=0 hidden=0 |
| | flush_hidden() { [[ $hidden -gt 0 ]] && printf " %s… and %d more (use --all)%s\n" "$DIM" "$hidden" "$RS"; hidden=0; } |
| | while IFS=$'\t' read -r _ dir count path ctx; do |
| | if [[ "$dir" != "$last" ]]; then |
| | flush_hidden; per_dir=0 |
| | ws="$(workspace_name "$dir")" |
| | printf ' %s%s%s' "$CYN" "$(pretty_path "$dir")" "$RS" | | | [[ -n "$ws" ]] && printf ' %s(%s)%s' "$B" "$ws" "$RS" | | | printf '\n' | | | last="$dir" | | | fi |
| | per_dir=$((per_dir + 1)) |
| | if [[ $SHOW_ALL -eq 0 && $per_dir -gt 10 ]]; then hidden=$((hidden + 1)); continue; fi |
| | printf ' %s%4s×%s %s\n' "$YEL" "$count" "$RS" "${path##*/}" | | | if [[ $CONTEXT -eq 1 && -s "$ctx" ]]; then | | | shown=0 | | | while IFS= read -r line; do | | | printf ' %s│%s %s\n' "$DIM" "$RS" "$line" | | | shown=$((shown + 1)) | | | done < "$ctx" | | | [[ $count -gt $shown ]] && printf ' %s│ … %d more%s\n' "$DIM" "$((count - shown))" "$RS" | | | fi | | | done | | | flush_hidden | | | } | | | done | | | echo | | | printf ' %s%s●%s %s across %s %s(%ss)%s\n' "$B" "$YEL" "$RS" \ | | | "$(plural "$TOTAL_MATCHES" match matches)" "$(plural "$TOTAL_FILES" file)" "$DIM" "$ELAPSED" "$RS" | | | printf ' %sLook for the string inside curl/fetch commands, User-Agent values, URLs, or\n' "$DIM" | | | printf ' request payloads — that is the difference between "the model knew it" and\n' | | | printf ' "the model sent it".%s\n' "$RS" | | | [[ $CONTEXT -eq 0 ]] && printf ' %sTip: re-run with --context to see the text around each match.%s\n' "$DIM" "$RS" | | | echo |