cd /news/ai-agents/check-for-pii-being-sent-by-your-ai-… · home › topics › ai-agents › article
[ARTICLE · art-156497] src=gist.github.com ↗ pub= topic=ai-agents verified=true sentiment=· neutral

Check for PII being sent by your AI models

A developer released ai-log-grep.sh, a bash script that searches local AI assistant chat logs and session transcripts for a sensitive string such as an email address, surfacing where an agent may have echoed it into commands, headers, or outbound requests. The tool covers logs from Claude Code, Claude Desktop, OpenAI Codex CLI, ChatGPT Desktop, Gemini CLI, Cursor, VS Code Copilot Chat, Windsurf, Continue, Zed, LM Studio, and Open WebUI, with a --deep flag that also scans the home directory for aider histories. It notes that web-only chats live server-side and cannot be scanned from disk, and that SQLite stores are searched via strings, so matches indicate presence in the database rather than a parsed message.

by read13 min views3 publishedOct 23, 2026

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

── more in #ai-agents 4 stories · sorted by recency
── more on @claude code 3 stories trending now
sponsored brought to you by zahid.host 4,200+ EU-deployed projects
reading about agents? ship yours in a single git push.

Run your AI side-project on zahid.host

EU-based hosting, git-push deploys, automatic HTTPS, no cold starts. Free tier with a custom domain — perfect for shipping the agent you just read about.

$git push zahid main
→ Live at https://your-agent.zahid.host ✓
Get free account → Pricing
from €0/mo · no card required
LIVE [news/check-for-pii-being-…] indexed:0 read:13min 2026-10-23 · —