# Check for PII being sent by your AI models

> Source: <https://gist.github.com/Azayzel/4657d4f262f8b3fc56b1cde8c78c70da>
> Published: 2026-10-23 11:30:39+00:00

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