Skip to content

iOS, macOS, web, and science. Hacked.

  • Articles
  • About
  • Colophon

Search

Auto-Sanitize Blog Posts Before Publishing: A Security Audit Pipeline with Claude Code and fswatch

February 27, 2026 22 min read Blogging, Coding

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 fswatch and macOS FSEvents
  • ✅ Background daemon via launchd that 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
  • fswatch installed (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:

  1. fswatch — macOS filesystem event monitoring via FSEvents
  2. security-audit-posts.sh — regex-based scanner with configurable patterns
  3. launchd LaunchAgent — background service that starts at login
  4. Claude Code slash command — AI orchestrator that spawns parallel scanning agents
  5. 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:

  1. Finds files to scan (all files or recently modified)
  2. Spawns a parallel Task agent per file
  3. Each agent reads the full file and uses contextual judgment
  4. Results are aggregated into a dated summary
  5. 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.100 is safe (like citing 8.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.com flagged as email (3 files)
  • 100.100.100.100 flagged as Tailscale IP (it’s Tailscale’s public DNS)
  • password=yourpassword flagged 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.100 and 100.100.100.10 as deliberate tutorial examples (round numbers, consistent use, paired with “replace with your…” instructions)
  • 100.100.100.100 as Tailscale’s public MagicDNS resolver
  • git@github.com as a standard SSH URL
  • your_secure_monitoring_password as 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-tools for file watching (inotifywait replaces fswatch)
  • libnotify for desktop notifications (notify-send)
  • Claude Code installed via npm
  • grep with 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_DIR to your content directory path (common locations are ~/Documents/Posts/, ~/blog/content/, or a Syncthing-managed folder)
  • Change LOG_DIR to $HOME/.local/logs or wherever you prefer
  • The osascript notification call should be replaced with notify-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-tools installed 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)
  • BurntToast module 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

  1. Regex catches patterns, AI understands context — use both for defense in depth
  2. Parallel agents scale linearly — scanning 11 files takes the same wall-clock time as scanning 2
  3. Safe patterns reduce noise — maintaining an allowlist is essential for regex-based scanning
  4. launchd LaunchAgents are the right way to run background daemons on macOS
  5. Always show findings before applying changes — automated modification of content is risky without human review

Next Steps

  1. Add patterns for any credential formats specific to your stack
  2. Create a pre-publish checklist that includes running /security-audit
  3. Extend the scanner to other content directories (guides, notes, documentation)
  4. 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.plist man 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.

Written by Michael Henry

Post navigation

Previous: Setting Up and Using SSH Keys in 1Password
Michael Henry

Michael Henry

© 2026 Digital Javelina, LLC