Build a dual-layer content scanner that catches leaked IPs, credentials, and personal data in your markdown files — with AI-powered context awareness and real-time file monitoring
—
Introduction
If you write technical blog posts — especially about homelabs, self-hosting, or networking — you’ve probably leaked something sensitive at least once. A real Tailscale IP in a Samba tutorial. An API key in a code block you forgot to redact. Your actual email address instead of user@example.com.
The problem is that these leaks are easy to miss during manual review. A 1,000-line tutorial might have one real IP buried in a code block among dozens of intentional example values. Your eyes glaze over.
I was running security audits on my blog posts manually — at least five separate sessions of opening Claude Code, asking it to scan a file, reviewing findings, and applying fixes. That’s a lot of repetitive work for something that should be automated.
This guide shows how to build an autonomous security audit pipeline that:
- Watches your content directory for changes in real time
- Scans markdown files using regex patterns (fast, runs in the background)
- Provides an on-demand Claude Code slash command for intelligent, context-aware scanning with parallel agents
- Generates dated audit reports
- Sends macOS notifications when sensitive data is detected
What We’re Building
By the end of this tutorial, you’ll have:
- ✅ Shell script with regex pattern matching for common sensitive data
- ✅ Real-time file watcher using
fswatchand macOS FSEvents - ✅ Background daemon via
launchdthat starts at login - ✅ macOS notifications when findings are detected
- ✅ Claude Code slash command (
/security-audit) for AI-powered scanning - ✅ Parallel agent orchestration — one scanning agent per file, running simultaneously
- ✅ Dated audit summaries saved as markdown files
- ✅ Safe placeholder replacements with user approval before any changes
Why Two Layers?
This pipeline uses two complementary approaches because they have different strengths:
| Feature | Shell Script (regex) | Claude Code (AI agents) |
|---|---|---|
| Speed | Instant | ~50 seconds for 11 files |
| Trigger | Automatic on save |
On-demand (/security-audit)
|
| Context awareness | None — pure pattern matching | Understands tutorial context vs. real data |
| False positives |
More (flags git@github.com as an email)
|
Fewer (knows it’s a standard SSH URL) |
| Auto-fix | Report only | Interactive replacement with approval |
| Runs in background |
Yes (launchd)
|
No — requires Claude Code session |
| Best for | Catching obvious leaks immediately | Thorough pre-publication review |
The shell script is your smoke detector — always on, catches the obvious stuff. The Claude Code command is your security consultant — thorough, context-aware, called in before you publish.
Prerequisites
What Is fswatch?
fswatch is a command-line tool that watches files and folders for changes, then runs a command when something is modified. It works by hooking into your operating system’s native file monitoring — on macOS, that’s FSEvents, a low-level API that the Finder itself uses to detect changes. This means fswatch doesn’t need to repeatedly scan your directory on a timer (polling) — it gets notified instantly by the OS when a file changes, making it both faster and lighter on resources.
In this project, fswatch is the trigger for the real-time scanning layer: every time you save a markdown file, fswatch detects the change and kicks off a security scan automatically.
What You’ll Need
Not on macOS? This guide is written for macOS, but the core concepts work on any platform. See Part 5: Adapting for Linux and Windows for platform-specific instructions.
System Requirements:
- macOS (Monterey 12+)
- Homebrew installed
fswatchinstalled (brew install fswatch)- Claude Code installed and configured
Knowledge Prerequisites:
- Basic terminal usage on macOS
- Familiarity with
launchd(macOS service manager) - Basic understanding of Claude Code slash commands
Verify fswatch
# Install if needed
brew install fswatch
# Verify
fswatch --version
Architecture Overview
┌─────────────────────────┐
│ You save a .md file │
│ in your Posts folder │
└────────────┬────────────┘
│
┌────────────────┴────────────────┐
│ │
▼ ▼
┌────────────────────────┐ ┌──────────────────────────┐
│ Layer 1: fswatch │ │ Layer 2: Claude Code │
│ (always running) │ │ (on-demand) │
│ │ │ │
│ • Regex patterns │ │ • /security-audit │
│ • Instant detection │ │ • Parallel Task agents │
│ • macOS notification │ │ • Context-aware │
│ • Background daemon │ │ • Interactive fixes │
└────────────┬───────────┘ └─────────────┬────────────┘
│ │
▼ ▼
┌────────────────────────┐ ┌──────────────────────────┐
│ AUDIT-REPORT.txt │ │ AUDIT-SUMMARY- │
│ (latest scan result) │ │ YYYY-MM-DD.md │
└────────────────────────┘ └──────────────────────────┘
Key Components:
- fswatch — macOS filesystem event monitoring via FSEvents
- security-audit-posts.sh — regex-based scanner with configurable patterns
- launchd LaunchAgent — background service that starts at login
- Claude Code slash command — AI orchestrator that spawns parallel scanning agents
- Task agents — one Claude agent per file, running in parallel for speed
—
Part 1: Build the Shell Script Scanner
This script scans markdown files for sensitive patterns using regular expressions. It supports one-off scans, batch scanning, and a watch mode for continuous monitoring.
Create the Script
mkdir -p ~/scripts
Create ~/scripts/security-audit-posts.sh:
#!/usr/bin/env bash
# security-audit-posts.sh — Lightweight PII/credential scanner for blog posts
#
# Usage:
# ./security-audit-posts.sh # Scan files modified in last 24h
# ./security-audit-posts.sh --all # Scan all .md files
# ./security-audit-posts.sh --watch # Watch for changes with fswatch
# ./security-audit-posts.sh <file.md> # Scan a specific file
set -euo pipefail
POSTS_DIR="$HOME/path/to/your/posts" # <-- Change this to your content directory
LOG_DIR="$HOME/Library/Logs"
LOG_FILE="$LOG_DIR/security-audit-posts.log"
REPORT_FILE="$POSTS_DIR/AUDIT-REPORT.txt"
# Colors
RED='\033[0;31m'
YELLOW='\033[0;33m'
GREEN='\033[0;32m'
CYAN='\033[0;36m'
BOLD='\033[1m'
NC='\033[0m'
Define Detection Patterns
The core of the script is an array of patterns. Each entry follows the format CATEGORY|REGEX|DESCRIPTION:
PATTERNS=(
# Tailscale IPs (100.x.x.x) — almost always real infrastructure
'IP|100\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}|Tailscale IP address'
# Private IPs
'IP|10\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}|Private IP (10.x.x.x)'
'IP|172\.(1[6-9]|2[0-9]|3[01])\.[0-9]{1,3}\.[0-9]{1,3}|Private IP (172.16-31.x.x)'
'IP|192\.168\.[0-9]{1,3}\.[0-9]{1,3}|Private IP (192.168.x.x)'
# API Keys & Tokens
'CREDENTIAL|sk-[a-zA-Z0-9]{20,}|OpenAI/Stripe secret key'
'CREDENTIAL|ghp_[a-zA-Z0-9]{36}|GitHub personal access token'
'CREDENTIAL|gho_[a-zA-Z0-9]{36}|GitHub OAuth token'
'CREDENTIAL|AKIA[0-9A-Z]{16}|AWS access key'
'CREDENTIAL|xox[bpras]-[0-9a-zA-Z-]{10,}|Slack token'
'CREDENTIAL|Bearer [a-zA-Z0-9_\-\.]{20,}|Bearer token'
'CREDENTIAL|glpat-[a-zA-Z0-9_\-]{20,}|GitLab personal access token'
# SSH/PGP Keys
'CREDENTIAL|BEGIN (RSA|EC|OPENSSH|DSA) PRIVATE KEY|SSH private key'
'CREDENTIAL|BEGIN PGP PRIVATE KEY|PGP private key'
# Passwords in config
'CREDENTIAL|password[[:space:]]*[:=][[:space:]]*[^[:space:]]{4,}|Plaintext password'
'CREDENTIAL|secret[[:space:]]*[:=][[:space:]]*[^[:space:]]{8,}|Secret value'
# Internal hostnames
'HOSTNAME|[a-zA-Z0-9_-]+\.(local|internal|lan|home|localdomain)\b|Internal hostname'
# Email addresses
'PII|[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}|Email address'
# URLs with embedded credentials
'URL|https?://[^[:space:]]*[?&](token|key|api_key|secret|password|access_token)=[^[:space:]&]+|URL with embedded credential'
# Webhook URLs
'URL|https://discord(app)?\.com/api/webhooks/[0-9]+/[a-zA-Z0-9_-]+|Discord webhook URL'
'URL|https://hooks\.slack\.com/[^[:space:]]+|Slack webhook URL'
# Database connection strings
'CREDENTIAL|(mongodb|postgres|mysql|redis)://[^[:space:]]+@[^[:space:]]+|Database connection string'
# MAC addresses
'PII|([0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}|MAC address'
)
Define Safe Patterns (Allowlist)
These patterns are always safe and should be skipped to reduce false positives:
SAFE_PATTERNS=(
'203\.0\.113\.' # RFC 5737 documentation range
'198\.51\.100\.' # RFC 5737 documentation range
'192\.0\.2\.' # RFC 5737 documentation range
'example\.(com|org|net)' # Reserved documentation domains
'user@example' # Common placeholder
'YOUR_.*_HERE' # Explicit placeholder pattern
'your_secure_' # Tutorial placeholder
'yourusername' # Tutorial placeholder
'yourpassword' # Tutorial placeholder
'AKIAIOSFODNN7EXAMPLE' # AWS example key from docs
'git@github\.com' # Standard Git SSH URL, not an email
'git@gitlab\.com' # Standard Git SSH URL
'100\.100\.100\.100' # Tailscale's public DNS resolver
)
Why the safe patterns matter: Without them, git@github.com triggers as an email address, and every tutorial that mentions Tailscale’s MagicDNS resolver (100.100.100.100) gets flagged. These are never sensitive.
The Scanning Functions
is_safe_match() {
local match="$1"
for safe in "${SAFE_PATTERNS[@]}"; do
if echo "$match" | grep -qE "$safe"; then
return 0 # Safe, skip it
fi
done
return 1 # Not safe, flag it
}
scan_file() {
local filepath="$1"
local filename
filename=$(basename "$filepath")
local findings=0
local file_output=""
for pattern_def in "${PATTERNS[@]}"; do
IFS='|' read -r category regex description <<< "$pattern_def"
while IFS=: read -r line_num match_line; do
if is_safe_match "$match_line"; then
continue
fi
findings=$((findings + 1))
file_output+="$(printf " %-12s Line %-4s %-35s %s\n" \
"[$category]" "$line_num" "$description" \
"($(echo "$match_line" | head -c 60)...)")\n"
done < <(grep -nE "$regex" "$filepath" 2>/dev/null || true)
done
if [[ $findings -gt 0 ]]; then
echo -e "${RED}${BOLD}$filename${NC} — ${RED}$findings finding(s)${NC}"
echo -e "$file_output"
return 1
else
echo -e "${GREEN}$filename${NC} — clean"
return 0
fi
}
The Watch Mode (fswatch Integration)
This is the real-time monitoring component. When a .md file is saved, fswatch detects the change and triggers a scan. If findings are detected, you get a macOS notification:
resolve_fswatch() {
# launchd runs with minimal PATH — resolve fswatch explicitly
local bin
bin="$(command -v fswatch 2>/dev/null || true)"
if [[ -z "$bin" ]]; then
if [[ -x /opt/homebrew/bin/fswatch ]]; then
bin="/opt/homebrew/bin/fswatch"
elif [[ -x /usr/local/bin/fswatch ]]; then
bin="/usr/local/bin/fswatch"
fi
fi
if [[ -z "$bin" ]]; then
echo -e "${RED}fswatch not found. Install with: brew install fswatch${NC}"
exit 1
fi
echo "$bin"
}
watch_mode() {
local FSWATCH_BIN
FSWATCH_BIN="$(resolve_fswatch)"
"$FSWATCH_BIN" -0 --include='\.md$' --exclude='.*' "$POSTS_DIR" \
| while IFS= read -r -d '' filepath; do
local fname
fname=$(basename "$filepath")
# Skip audit files
if [[ "$fname" == AUDIT-SUMMARY*.md || "$fname" == "AUDIT-REPORT.txt" ]]; then
continue
fi
echo -e "\n${YELLOW}File changed: $fname${NC}"
if ! scan_file "$filepath"; then
# Findings detected — send macOS notification
osascript -e "display notification \"Sensitive data found in $fname\" \
with title \"Security Audit\" sound name \"Basso\"" 2>/dev/null || true
fi
done
}
Why resolve fswatch explicitly? When launchd runs your script as a background daemon, it uses a minimal PATH (/usr/bin:/bin:/usr/sbin:/sbin). Homebrew’s /opt/homebrew/bin isn’t in it. If you just call fswatch directly, the daemon silently fails. This function checks common Homebrew locations as a fallback.
Make It Executable
chmod +x ~/scripts/security-audit-posts.sh
Test It
# Scan all files
~/scripts/security-audit-posts.sh --all
# Scan recent changes only
~/scripts/security-audit-posts.sh
# Scan a specific file
~/scripts/security-audit-posts.sh ~/path/to/your/post.md
Example output:
Security Audit — 2026-02-27 09:01:33
Scanning 11 file(s)...
nfs-mac-das-to-proxmox-tutorial.md — 3 finding(s)
[IP] Line 320 Tailscale IP address (/Volumes/DAS/nfs-storage -alldirs -maproot=root:wheel -netwo...)
[IP] Line 332 Tailscale IP address (/Volumes/DAS/nfs-storage -alldirs -maproot=root:wheel -netwo...)
[IP] Line 842 Private IP (10.x.x.x) ( If your Proxmox IP is `192.168.x.x` but exports shows `-n...)
Colophon.md — clean
running-notebooks-with-uv.md — clean
migrate-miniforge-conda-to-uv.md — clean
─────────────────────────────────────
3 of 11 file(s) have findings
Run /security-audit in Claude Code for intelligent scanning + auto-fix
─────────────────────────────────────
—
Part 2: Set Up the Background Daemon (launchd)
The watch mode is useful, but you don’t want to keep a terminal tab open for it. Let’s make it run automatically in the background using launchd.
Create the LaunchAgent
cat > "$HOME/Library/LaunchAgents/com.security-audit.posts-watcher.plist" << 'EOF'
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>com.security-audit.posts-watcher</string>
<key>ProgramArguments</key>
<array>
<string>/bin/bash</string>
<string>/Users/YOUR_USERNAME/scripts/security-audit-posts.sh</string>
<string>--watch</string>
</array>
<key>WorkingDirectory</key>
<string>/Users/YOUR_USERNAME</string>
<key>EnvironmentVariables</key>
<dict>
<key>PATH</key>
<string>/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin</string>
</dict>
<key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>
<true/>
<key>ThrottleInterval</key>
<integer>10</integer>
<key>StandardOutPath</key>
<string>/Users/YOUR_USERNAME/Library/Logs/security-audit-posts.log</string>
<key>StandardErrorPath</key>
<string>/Users/YOUR_USERNAME/Library/Logs/security-audit-posts.error.log</string>
</dict>
</plist>
Replace YOUR_USERNAME with your macOS username. Use absolute paths — launchd doesn’t expand ~ or $HOME in plist files.
Important: Set WorkingDirectory to your home directory, not your content folder. If your content is in iCloud (~/Library/Mobile Documents/...), launchd can throw getcwd: Operation not permitted errors due to macOS sandboxing. The script uses absolute paths internally, so it doesn’t need to be in the watched directory.
Validate and Load
# Validate plist syntax
plutil -lint "$HOME/Library/LaunchAgents/com.security-audit.posts-watcher.plist"
# Unload old version if exists
launchctl bootout gui/$(id -u) \
"$HOME/Library/LaunchAgents/com.security-audit.posts-watcher.plist" 2>/dev/null || true
# Load the agent
launchctl bootstrap gui/$(id -u) \
"$HOME/Library/LaunchAgents/com.security-audit.posts-watcher.plist"
# Enable at login
launchctl enable gui/$(id -u)/com.security-audit.posts-watcher
Verify It’s Running
launchctl print gui/$(id -u)/com.security-audit.posts-watcher
You should see state = running. Check the logs:
tail -f ~/Library/Logs/security-audit-posts.log
Managing the Daemon
# Stop the watcher
launchctl bootout gui/$(id -u) \
"$HOME/Library/LaunchAgents/com.security-audit.posts-watcher.plist"
# Restart the watcher
launchctl bootout gui/$(id -u) \
"$HOME/Library/LaunchAgents/com.security-audit.posts-watcher.plist" 2>/dev/null
launchctl bootstrap gui/$(id -u) \
"$HOME/Library/LaunchAgents/com.security-audit.posts-watcher.plist"
# Check status
launchctl print gui/$(id -u)/com.security-audit.posts-watcher | head -6
—
Part 3: Build the Claude Code Slash Command
The shell script catches obvious patterns. But regex can’t understand context. It doesn’t know that 192.168.1.100 in a tutorial is a deliberate teaching example, or that git@github.com is a standard SSH URL rather than a personal email address.
That’s where Claude Code comes in. A slash command lets you invoke an AI-powered audit from within Claude Code that:
- Finds files to scan (all files or recently modified)
- Spawns a parallel Task agent per file
- Each agent reads the full file and uses contextual judgment
- Results are aggregated into a dated summary
- You approve fixes before anything changes
Create the Slash Command
Create ~/.claude/commands/security-audit.md:
---
name: security-audit
description: Scan Posts for PII, credentials, real IPs, and sensitive data using parallel agents
argument-hint: "[--all | --hours N]"
allowed-tools:
- Read
- Write
- Edit
- Glob
- Grep
- Bash
- Task
- AskUserQuestion
---
The body of the slash command is a prompt that instructs Claude on the full workflow. Here’s the structure:
Step 1 — Determine scope: Parse arguments (--all scans everything, --hours N scans recent files, default is 24 hours). Use find to get the file list.
Step 2 — Parallel scanning: For each file, spawn a Task agent with subagent_type: "general-purpose". Each agent gets a detailed prompt that defines what to scan for, what to skip, and how to format findings.
Step 3 — Aggregate: Collect findings from all agents, group by file, sort by confidence.
Step 4 — Generate report: Write AUDIT-SUMMARY-YYYY-MM-DD.md with a structured summary table.
Step 5 — User approval: Present findings and ask how to proceed:
- Apply high+medium confidence fixes
- Apply all fixes
- Review individually
- Report only (no changes)
Step 6 — Apply fixes: Use the Edit tool to make approved replacements.
The Scanning Prompt
The key to this approach is the prompt given to each scanning agent. It needs to be specific about:
What to flag:
- Tailscale IPs (100.x.x.x) — almost always real
- Private IPs that look like real infrastructure
- API keys, tokens, passwords in any format
- Internal hostnames (.local, .lan, .home)
- Real email addresses
- Webhook URLs with embedded tokens
- Database connection strings
What to skip:
- RFC 5737 documentation IPs (203.0.113.x, 198.51.100.x, 192.0.2.x)
- example.com/example.org/example.net
- Placeholder values (YOUR_API_KEY_HERE, yourpassword)
- Author bylines and attribution names
git@github.com(standard SSH URL)
Context-awareness instructions:
- If an IP is used consistently throughout a tutorial with “replace with your…” instructions, it’s likely an intentional example — flag with LOW confidence
- If an IP appears in config that looks specific to real infrastructure, flag with HIGH confidence
- Tailscale’s public DNS resolver
100.100.100.100is safe (like citing8.8.8.8)
Using the Command
# In Claude Code:
/security-audit # Scan files modified in last 24 hours
/security-audit --all # Scan all markdown files
/security-audit --hours 48 # Scan files modified in last 48 hours
The command spawns all agents in parallel — for 11 files, the entire scan completes in about 50 seconds.
—
Part 4: How the AI Scanning Differs
To see the difference between regex and AI-powered scanning, here’s what each approach found when scanning the same 11 blog posts:
Shell Script Results (regex)
6 of 11 file(s) have findings (before safe pattern tuning)
3 of 11 file(s) have findings (after adding safe patterns)
Notable false positives before tuning:
git@github.comflagged as email (3 files)100.100.100.100flagged as Tailscale IP (it’s Tailscale’s public DNS)password=yourpasswordflagged as plaintext password (it’s a placeholder)
Claude Code Results (parallel agents)
2 of 11 file(s) have findings — all LOW confidence
The agents correctly identified:
192.168.1.100and100.100.100.10as deliberate tutorial examples (round numbers, consistent use, paired with “replace with your…” instructions)100.100.100.100as Tailscale’s public MagicDNS resolvergit@github.comas a standard SSH URLyour_secure_monitoring_passwordas a placeholder
Every finding was marked LOW confidence with a clear explanation of why it’s likely intentional.
The Takeaway
The regex scanner is fast and always running — it catches the obvious cases and trains you to use safe placeholders. The Claude Code scanner understands your content — it won’t waste your time on false positives and gives you confidence that nothing real slipped through.
—
Part 5: Adapting for Linux and Windows
The shell script and Claude Code slash command work on any platform — the scanning logic is pure bash regex and Claude Code is cross-platform. What differs is the file watcher and the background service layer.
Platform Requirements at a Glance
| Component | macOS | Linux | Windows |
|---|---|---|---|
| Shell | bash (built-in) | bash (built-in) | Git Bash, WSL, or PowerShell |
| File watcher |
fswatch (FSEvents)
|
inotifywait (inotify)
|
WSL + inotifywait, or PowerShell FileSystemWatcher
|
| Background service |
launchd LaunchAgent
|
systemd user service
|
Task Scheduler or WSL systemd
|
| Notifications |
osascript
|
notify-send
|
BurntToast PowerShell module or WSL notify-send
|
| Claude Code | Works natively | Works natively | Works natively (or via WSL) |
| grep -E | BSD grep (built-in) | GNU grep (built-in) | Git Bash or WSL grep |
Linux
Requirements
- bash 4+ (ships with virtually all distros)
inotify-toolsfor file watching (inotifywaitreplacesfswatch)libnotifyfor desktop notifications (notify-send)- Claude Code installed via npm
grepwith extended regex support (GNU grep, standard on all distros)
Install Dependencies
# Debian/Ubuntu
sudo apt install inotify-tools libnotify-bin
# Fedora/RHEL
sudo dnf install inotify-tools libnotify
# Arch
sudo pacman -S inotify-tools libnotify
Adapt the Watch Mode
Replace the fswatch watch loop in the script with inotifywait:
watch_mode() {
echo "Watching $POSTS_DIR for changes..."
inotifywait -m -r -e modify,create --include '\.md$' "$POSTS_DIR" \
| while read -r directory event filename; do
local filepath="${directory}${filename}"
local fname
fname=$(basename "$filepath")
# Skip audit files
if [[ "$fname" == AUDIT-SUMMARY*.md || "$fname" == "AUDIT-REPORT.txt" ]]; then
continue
fi
echo "File changed: $fname"
if ! scan_file "$filepath"; then
notify-send "Security Audit" "Sensitive data found in $fname" \
--urgency=critical 2>/dev/null || true
fi
done
}
The resolve_fswatch function isn’t needed on Linux — just replace it entirely.
Background Service (systemd)
Create a user service instead of a LaunchAgent:
mkdir -p ~/.config/systemd/user
Create ~/.config/systemd/user/security-audit-posts.service:
[Unit]
Description=Security Audit Watcher for Posts
After=default.target
[Service]
Type=simple
ExecStart=/bin/bash /home/YOUR_USERNAME/scripts/security-audit-posts.sh --watch
Restart=always
RestartSec=10
StandardOutput=append:/home/YOUR_USERNAME/.local/logs/security-audit-posts.log
StandardError=append:/home/YOUR_USERNAME/.local/logs/security-audit-posts.error.log
[Install]
WantedBy=default.target
Enable and start:
mkdir -p ~/.local/logs
systemctl --user daemon-reload
systemctl --user enable security-audit-posts.service
systemctl --user start security-audit-posts.service
# Check status
systemctl --user status security-audit-posts.service
# View logs
journalctl --user -u security-audit-posts.service -f
Tip: Enable loginctl enable-linger YOUR_USERNAME if you want the service to run even when you’re not logged in (e.g., on a headless server).
Other Linux Changes
- Change
POSTS_DIRto your content directory path (common locations are~/Documents/Posts/,~/blog/content/, or a Syncthing-managed folder) - Change
LOG_DIRto$HOME/.local/logsor wherever you prefer - The
osascriptnotification call should be replaced withnotify-send(already shown above)
Windows
There are two recommended approaches: running fully inside WSL (simpler) or running natively with PowerShell (no WSL required).
Option A: WSL (Recommended)
If you use Windows Subsystem for Linux, the entire Linux setup above works as-is inside your WSL distro. This is the easiest path.
Requirements:
- WSL 2 with Ubuntu or Debian
inotify-toolsinstalled inside WSL- Claude Code installed inside WSL
- Your posts directory accessible from WSL (typically at
/mnt/c/Users/YOUR_USERNAME/...)
# Inside WSL
sudo apt install inotify-tools
# Point POSTS_DIR to your Windows vault
POSTS_DIR="/mnt/c/Users/YOUR_USERNAME/Documents/Posts"
For background service, use systemd inside WSL 2 (requires systemd=true in /etc/wsl.conf).
For notifications from WSL to Windows, use wsl-notify-send or call PowerShell from WSL:
# Replace notify-send/osascript with:
powershell.exe -Command "New-BurntToastNotification -Text 'Security Audit', 'Sensitive data found in $fname'" 2>/dev/null || true
Option B: Native PowerShell (No WSL)
If you prefer to stay native, you can port the file watcher using PowerShell’s FileSystemWatcher. The scanning script itself still needs bash (install Git for Windows which includes Git Bash).
Requirements:
- Git for Windows (provides Git Bash with
grep,bash,find) - PowerShell 5.1+ (built into Windows 10/11)
BurntToastmodule for notifications (optional)- Claude Code installed via npm
Install BurntToast for notifications:
Install-Module -Name BurntToast -Scope CurrentUser
PowerShell file watcher:
# security-audit-watcher.ps1
$postsDir = "C:\Users\YOUR_USERNAME\Documents\Posts"
$scriptPath = "C:\Users\YOUR_USERNAME\scripts\security-audit-posts.sh"
$watcher = New-Object System.IO.FileSystemWatcher
$watcher.Path = $postsDir
$watcher.Filter = "*.md"
$watcher.IncludeSubdirectories = $false
$watcher.EnableRaisingEvents = $true
$action = {
$name = $Event.SourceEventArgs.Name
if ($name -like "AUDIT-SUMMARY*" -or $name -eq "AUDIT-REPORT.txt") { return }
Write-Host "File changed: $name"
# Run the bash scanner via Git Bash
$result = & "C:\Program Files\Git\bin\bash.exe" -c "$using:scriptPath '$using:postsDir/$name'" 2>&1
Write-Host $result
if ($LASTEXITCODE -ne 0) {
New-BurntToastNotification -Text "Security Audit", "Sensitive data found in $name"
}
}
Register-ObjectEvent $watcher "Changed" -Action $action
Register-ObjectEvent $watcher "Created" -Action $action
Write-Host "Watching $postsDir for changes... Press Ctrl+C to stop."
while ($true) { Start-Sleep -Seconds 1 }
Background service with Task Scheduler:
# Register as a scheduled task that runs at login
$action = New-ScheduledTaskAction `
-Execute "powershell.exe" `
-Argument "-WindowStyle Hidden -File C:\Users\YOUR_USERNAME\scripts\security-audit-watcher.ps1"
$trigger = New-ScheduledTaskTrigger -AtLogOn -User "YOUR_USERNAME"
$settings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries
Register-ScheduledTask `
-TaskName "SecurityAuditPostsWatcher" `
-Action $action `
-Trigger $trigger `
-Settings $settings `
-Description "Watches Posts directory for sensitive data in markdown files"
Manage the task:
# Check status
Get-ScheduledTask -TaskName "SecurityAuditPostsWatcher"
# Stop
Stop-ScheduledTask -TaskName "SecurityAuditPostsWatcher"
# Remove
Unregister-ScheduledTask -TaskName "SecurityAuditPostsWatcher"
The Claude Code Slash Command — No Changes Needed
The /security-audit slash command works identically on all platforms. Claude Code’s Task agents, Read, Edit, and Bash tools are cross-platform. The only change you may need is updating the POSTS_DIR path in the slash command file (~/.claude/commands/security-audit.md) to match your OS:
# macOS (iCloud)
/Users/yourname/path/to/Posts
# Linux
/home/yourname/Documents/Posts
# Windows (native path or WSL mount)
C:\Users\yourname\Documents\Posts # native
/mnt/c/Users/yourname/Documents/Posts # WSL
—
Troubleshooting
LaunchAgent Not Starting
# Validate plist syntax
plutil -lint "$HOME/Library/LaunchAgents/com.security-audit.posts-watcher.plist"
# Check for load errors
launchctl print gui/$(id -u)/com.security-audit.posts-watcher
# Check error log
cat ~/Library/Logs/security-audit-posts.error.log
fswatch Not Found in Daemon
If the error log shows fswatch not found, the script can’t find fswatch in launchd’s minimal PATH. Verify:
# Check where fswatch is installed
which fswatch
# Should be /opt/homebrew/bin/fswatch on Apple Silicon
# or /usr/local/bin/fswatch on Intel Macs
The script already checks both locations. If yours is installed elsewhere, add the path to the resolve_fswatch function.
iCloud Path Permission Errors
If you see getcwd: Operation not permitted in the error log, it’s because launchd struggles with iCloud’s sandboxed path as a working directory. Set WorkingDirectory in the plist to your home directory instead — the script uses absolute paths internally.
Too Many False Positives
Add patterns to the SAFE_PATTERNS array in the shell script. Common additions:
# Your specific placeholder conventions
'your_server_'
'CHANGEME'
'placeholder'
Multiple fswatch Events Per Save
iCloud sync can trigger multiple filesystem events per save (write, sync metadata, extended attributes). This is normal — the scan runs multiple times but produces the same result. For high-volume directories, add debouncing logic (see my fswatch auto-commit guide for the debouncing pattern).
—
Customization
Adding New Patterns
Add entries to the PATTERNS array in the shell script:
# Custom pattern format: 'CATEGORY|REGEX|DESCRIPTION'
'CREDENTIAL|dop_v1_[a-f0-9]{64}|DigitalOcean personal access token'
'URL|https://hooks\.example\.com/[^[:space:]]+|Internal webhook URL'
Scanning Other Directories
Change POSTS_DIR in the script, and create a second LaunchAgent with a different label and script path. You can run multiple watchers simultaneously.
Adjusting Scan Frequency
For the Claude Code command, change the default timeframe by modifying the --hours default in the slash command file. For the daemon, it runs continuously — there’s no polling interval to adjust.
—
Conclusion
You now have a dual-layer security audit pipeline that:
- ✅ Watches your content directory in real time with
fswatch - ✅ Runs regex scans automatically on every save
- ✅ Sends macOS notifications when sensitive data is detected
- ✅ Starts automatically at login via
launchd - ✅ Provides on-demand AI-powered scanning with
/security-audit - ✅ Spawns parallel agents for fast, context-aware analysis
- ✅ Generates dated audit reports for your records
- ✅ Requires explicit approval before modifying any files
Key Takeaways
- Regex catches patterns, AI understands context — use both for defense in depth
- Parallel agents scale linearly — scanning 11 files takes the same wall-clock time as scanning 2
- Safe patterns reduce noise — maintaining an allowlist is essential for regex-based scanning
- launchd LaunchAgents are the right way to run background daemons on macOS
- Always show findings before applying changes — automated modification of content is risky without human review
Next Steps
- Add patterns for any credential formats specific to your stack
- Create a pre-publish checklist that includes running
/security-audit - Extend the scanner to other content directories (guides, notes, documentation)
- Add debouncing to the watch mode if iCloud sync triggers too many duplicate scans
Further Resources
- Claude Code Documentation
- Claude Code Slash Commands
- Automating Git Commits with fswatch on macOS
- fswatch GitHub Repository
launchd.plistman page- RFC 5737 — IPv4 Address Blocks Reserved for Documentation
—
This pipeline was built and tested using Claude Code with parallel Task agents. The security audit slash command scanned all 11 blog posts simultaneously, each handled by a separate agent — a pattern that turns a manual, repetitive chore into a one-command operation.