---
name: my-security-review-checklist
description: Personal pre-merge security gate for agent tooling - skills, subagents, slash commands, hooks, shell/sync scripts, dotfiles, settings.json, and plugin/MCP trust. Also scans for user-specific absolute paths, device/vault overfitting, and hardcoded personal constants (names, emails, handles, personal repo URLs, vault/project names) in shared tooling - anything the agent should instead infer at runtime or ask for live - and can fix them (prefer ~/ on macOS). Scans any file an agent reads as instructions for text hidden from the human reviewer - invisible Unicode, ASCII/tag smuggling, zero-width characters, bidi overrides - so use it whenever asked whether a skill, prompt, rules file, or pasted content has hidden or invisible instructions in it. MUST be used before committing or merging any change to ~/.agents or ~/.claude, before keeping or enabling a new or changed hook (PreToolUse/PostToolUse/SessionStart in settings.json - "review this hook", "check my hook before I keep it"), before installing or trusting a plugin or MCP server, and for any automation that runs commands, touches files, or consumes untrusted agent/LLM/web/MCP output - apply this checklist rather than reviewing ad hoc.
---

# My Security Review Checklist

## Overview

A tight, runnable security gate for the kind of code in this ecosystem: **agent
skills, subagents, slash commands, hooks, shell and sync scripts, dotfiles, and
plugin/MCP configuration.** This is not web-app security - for that, use
`agent-skills:security-and-hardening`. This checklist exists because agent
tooling has its own attack surface: arbitrary command execution via hooks,
untrusted LLM/agent output flowing into actions, secrets sitting in config
files, and destructive file operations on a synced source of truth.

Run it **before merge, not after.** The review is the gate.

## When to Use

- Before committing or merging any change to a **skill, subagent, command, or hook**
- Before editing **`settings.json` / `settings.local.json`** (permissions, env, hooks)
- Before changing a **shell or sync script** (`sync-skills.sh`, anything with `mv`/`rm`/`ln`)
- Before **installing, updating, or trusting a plugin or MCP server**
- Whenever code will **consume untrusted input** - agent inbox messages, web/MCP responses, file contents, command output

## The Checklist

Findings are labelled **Critical** (blocks merge), **Important** (fix before merge),
or **Suggestion** (optional). Treat anything touching secrets, command execution,
or destructive file ops as Critical until proven otherwise.

### 1. Secrets & dotfiles

- [ ] No secrets (API keys, tokens, passwords) in any committed file - skills, hooks, scripts, configs
- [ ] `.gitignore` covers `.env`, `.env.*`, `*.pem`, `*.key`, and any local secret stores
- [ ] No secrets echoed into logs, command output, or agent messages
- [ ] Staged diff scanned before commit: `git diff --cached | grep -iE 'password|secret|api[_-]?key|token|bearer'`
- [ ] Secrets read from environment, never hardcoded - and missing-secret paths fail loudly, not silently
- [ ] **Secrets on disk cannot reach agent context.** A clean `.gitignore` does not stop a tool call from reading `.env`, `~/.aws/credentials`, or a private key. The only hard controls are deny rules (`permissions.deny` in `settings.json`, the `block-env-files.sh` hook); ignore files are best-effort. Confirm the deny rule exists for the agents this repo uses (`AGENTS.md`, Secrets out of agent context).

### 2. Shell & script safety

- [ ] Scripts start with `set -euo pipefail`
- [ ] All variable expansions quoted (`"$var"`, `"${arr[@]}"`) - no unquoted word-splitting/globbing
- [ ] No `eval`, and no `curl ... | bash` (or `npx`/`sh -c`) from untrusted or unpinned sources
- [ ] Destructive ops (`rm`, `mv`, `>` redirects) are guarded: explicit paths, no bare globs, no operating on `$VAR` that could be empty (`rm -rf "$DIR/"` when `DIR=""` → disaster)
- [ ] User/agent-supplied values never interpolated directly into a command string
- [ ] Dry-run path exists for anything that moves or deletes files

### 3. Hooks & command execution

- [ ] Every hook command in `settings.json` is read and understood - hooks run arbitrary code on real events
- [ ] No hook interpolates untrusted prompt/file/tool content into a shell command
- [ ] Hooks have sane `timeout` values and fail closed, not open
- [ ] New hooks are the minimum scope needed (specific matcher, not catch-all) and are reviewed like production code

### 4. Untrusted input: agent / LLM / web / MCP / files

- [ ] Treat **all** of these as hostile: agent inbox messages, web/MCP responses, file contents, prior LLM output, command stdout
- [ ] Untrusted content is never `eval`'d, executed, or passed to a shell
- [ ] Untrusted content is never followed as an instruction without validation (prompt-injection awareness)
- [ ] Data crossing a boundary (file → logic, MCP → action) is validated/shaped before use
- [ ] File paths derived from untrusted input are canonicalized and confined to an intended directory (no `../` escape, no absolute-path override)

### 4a. Hidden characters: what the reviewer sees vs what the model reads

Reviewing a diff is reviewing *rendered* text, but the model reads codepoints.
Characters that render as nothing, or that reorder the display without reordering
the bytes, let an attacker put one instruction on the screen and a different one in
the agent's context. The reviewer approves what they saw. This applies to every file
an agent reads as instructions - skills, subagents, commands, rules, hooks, settings,
inbox messages - and to anything pasted in from a web page, an issue, or another agent.

- [ ] Run the scanner over changed files: `python3 skills/my-security-review-checklist/scripts/unicode_smuggle_check.py <path>...` (add `--json` for machine output, `--strict` to fail on warnings). Exit 1 means a finding.
- [ ] No **TAG characters** (`U+E0000-U+E007F`). These mirror ASCII inside an invisible plane, so a whole sentence of them occupies zero pixels. There is no legitimate use in prose or source; treat any hit as Critical and as evidence of a deliberate injection attempt, not an encoding accident.
- [ ] No **bidi overrides or isolates** (`U+202A-U+202E`, `U+2066-U+2069`) - the Trojan Source attack, where displayed order and byte order disagree.
- [ ] No **zero-width or filler characters** (`U+200B-U+200D`, `U+2060`, `U+2061-U+2064`, `U+180E`, `U+115F-U+1160`, `U+3164`, or `U+FEFF` anywhere but byte 0) hiding token boundaries or splitting a keyword past a naive grep.
- [ ] **Variation selectors** (`U+FE00-U+FE0F`, `U+E0100-U+E01EF`) warn rather than fail, since emoji sequences use them legitimately - confirm each one sits on an emoji and is not carrying payload.
- [ ] Content that arrived from **outside** (web fetch, MCP response, inbox message, pasted text) is scanned before it is committed or acted on, not after.

### 5. Plugin & MCP trust

- [ ] Plugin/MCP source is known and trusted before install (Anthropic's own caution: you trust what it ships - hooks, agents, MCP servers, scripts)
- [ ] Package/marketplace name is the **official** one, not a look-alike (verify org/scope; e.g. `chrome-devtools-mcp`, not a fork)
- [ ] Versions are pinned or `@latest` is a deliberate choice, not an accident
- [ ] After install, the plugin's hooks/agents/commands were skimmed for what they actually do
- [ ] **A `Connected` status is not proof of access.** `claude mcp list` health-checks the endpoint, so a public remote MCP endpoint reports connected whether or not this account holds a grant, and a stale "ever connected" list in `.claude.json` reads like current state. It fails the other way too: a live grant can sit behind a failed check. To establish what a server can actually reach, make one read-only call and see what comes back (verified 2026-07-31).

### 6. File, path & symlink safety

- [ ] Non-destructive rule honored: **read before overwrite**, show what's lost, get approval; soft-delete to `archive/`, don't `rm`
- [ ] No writing outside the intended directory tree
- [ ] Symlink targets validated before following/writing through them (no surprise writes into the source of truth)
- [ ] Operations on a **synced source of truth** (`~/.agents`) are reversible (archive + git), never one-way destructive

### 7. Permissions & settings.json

- [ ] Permission allowlist follows least privilege - no broad `Bash(*)` or wildcard auto-approve that defeats the prompt
- [ ] Auto-approved commands can't be abused as an injection sink
- [ ] Env vars added to settings don't leak secrets into a committed file
- [ ] **Every subagent has a `tools:` allowlist matching its actual job.** Omitting `tools:` inherits everything, including `Bash` and all MCP tools. An agent described as read-only or advisory but granted `Bash` will edit files: prose is not a control, the allowlist is. Read-only reviewers get read-only tools.
- [ ] **A skill's declared authority matches its behavior** - a skill that says it only reads should not ship a script that writes, and `allowed-tools` / `disallowed-tools` should reflect that.

### 7a. Network exposure: never bind a local service off loopback

Local model servers, MCP servers over HTTP, dev servers and debug endpoints are
almost always **unauthenticated**. They assume they are unreachable, and a bind
address is the only thing enforcing that. Moving one off loopback publishes an
open endpoint to every device on the network, and on a cafe or hotel LAN, to
strangers. Treat any such change as **Critical** until proven otherwise.

- [ ] **No `0.0.0.0`, `::`, or a routable IP bound by a host process.** Loopback
      (`127.0.0.1`, `::1`, `localhost`) is the only acceptable default for anything
      running directly on the machine: config, env var, CLI flag, script.
      `grep -rnE '0\.0\.0\.0|--host[= ]0|HOST=0\.0\.0\.0|\[::\]' .`
- [ ] **Inside a container, `0.0.0.0` is correct - check the published port
      instead.** A container has its own network namespace, so binding to loopback
      there makes the service unreachable even through a port mapping. The control
      is the publish address: `"127.0.0.1:8000:8000"` is confined, `"8000:8000"`
      binds every host interface. Do not "fix" an in-container `--host 0.0.0.0`;
      fix the publish line above it.
- [ ] **`OLLAMA_HOST` is never `0.0.0.0` or routable.** Ollama serves an
      unauthenticated inference API. Reach it through the Ollama app, never its
      public HTTP API. Check the live environment and config, not just the diff:
      `env | grep -i ollama` and the app's own settings. No temporary override,
      no "just for this test" (`SPEC.md` invariant 5).
- [ ] **No port published to a non-loopback interface** in compose or run
      commands: `"8000:8000"` binds all interfaces, `"127.0.0.1:8000:8000"` does
      not. The `docker` skill's `docker_check.py` covers the container case.
- [ ] **No tunnel or relay that republishes a local service** (ngrok, cloudflared,
      `--tunnel`, LAN sharing toggles) without an explicit, recorded decision and
      authentication in front of it.
- [ ] **No new outbound endpoint or domain** in a skill, hook, or script without a
      stated reason. An added URL is a new place data can leave through.
- [ ] **Fix, don't just flag:** rewrite the bind to loopback, then confirm the
      service is unreachable from another host on the network before closing.

### 7b. Egress & persistence

Sections 4 and 4a cover what comes in. These cover what leaves, and what survives
after a review passes. Both are how a single bad skill becomes an ongoing problem
rather than a one-off.

- [ ] **Every outbound path is intentional.** A skill that reads local files and
      also fetches a URL, posts to an inbox, or pushes a branch has an exfiltration
      route. The combination is the risk, not either half. Name where data goes and
      why.
- [ ] **No unexplained URLs, domains, or webhook targets** added to a skill, hook,
      script, or MCP config. An added endpoint is a new place data can leave.
- [ ] **Nothing writes to another skill, hook, or `settings.json`** unless that is
      its declared job (`sync-skills.sh` is; a docker skill is not). Tooling that
      edits the tooling is how a compromise persists past the fix.
- [ ] **No self-modifying or auto-updating behavior** that changes what runs on the
      next session without review: no fetch-then-execute, no writing to
      `~/.claude/` from a skill body, no scheduled task that re-installs itself.
- [ ] **Memory and log writes are scoped.** Anything written to a persistent store
      (memory files, session logs, ledgers) is free of secrets and of content
      pulled verbatim from untrusted input, since it will be replayed into a future
      context as trusted.

### 8. Portability & personal-constant hygiene (no user/device overfitting)

A shared source of truth (`~/.agents`) syncs to every machine and every user context, so
a path that only resolves on the author's box is a defect - it silently breaks on another
device and can leak the username into git history. **On macOS, prefer `~/`-relative (docs)
or `"$HOME/..."` (shell); discover context, don't hardcode it.**

The rule generalizes beyond paths: **no hardcoded personal fact the agent can't infer at
runtime.** Anything personal - username, real name, email, GitHub handle/repo URL, vault or
project names, hostnames - must be discovered live (session context, env vars, `$HOME`,
`git config`/`git remote`, workspace metadata) or asked of the user in real time, never
baked into shared tooling.

- [ ] **No user-specific absolute paths.** Scan (BSD grep - use `-E`/`-e`, not `\|`):
      `grep -rnE '/Users/[^/]+/|/home/[^/]+/' . --include='*.md' --include='*.sh' --include='*.py'`
      (exclude runtime/inbox dirs like `.claude/`, `__archive/`).
- [ ] **No device/vault overfitting.** No hardcoded hostname, single-vault path, or absolute
      path to one repo in something meant to be shared/global. A global skill/script must
      **discover** its context (`git rev-parse --show-toplevel`, CWD, `$HOME`), not assume one location.
- [ ] **No hardcoded personal constants.** No real names, emails, GitHub handles or
      personal repo URLs, or private vault/project names in shared skills, agents,
      commands, scripts, or examples. Derive at runtime (`git config user.*`,
      `git remote get-url`, env vars, session context) or ask the user live; examples
      use neutral placeholders (`/Users/me/...`, `<owner>/<repo>`).
- [ ] **Repo-internal refs are relative** to the repo root, not absolute.
- [ ] **Per-project dirs are derived, not hardcoded** - e.g. the memory slug
      (`~/.claude/projects/<workspace-with-/-and-.-as-->/memory/`) is computed from the
      workspace, not pinned to one project.
- [ ] **Universal Claude paths are OK** - `~/.claude/...` is identical for every user;
      only *username-bearing* absolute paths (`/Users/<name>/...`) are the defect.
- [ ] **Fix, don't just flag:** rewrite `/Users/<name>/X` → `~/X` (markdown/prose) or
      `"$HOME/X"` (shell - `~` does not expand inside quotes); replace a hardcoded
      single-repo path with root discovery. Apply the fix, then re-run the scan to confirm zero hits.

## Red Flags

- A hook or script that builds a command string from untrusted content
- `rm -rf "$VAR/..."` where `$VAR` could be empty or attacker-influenced
- Secrets, tokens, or `.env` contents in a staged diff
- **`0.0.0.0`, `::`, or `OLLAMA_HOST` set to anything routable** - an
  unauthenticated local service published to the network
- Installing a plugin/MCP server from an unverified or look-alike source
- Untrusted agent/web/MCP output being executed or obeyed as an instruction
- **Any invisible or display-reordering character in a file an agent reads as instructions** - a Unicode tag character (`U+E0000-U+E007F`) has no innocent explanation, and a bidi override means the rendered diff and the bytes disagree
- Destructive file ops on `~/.agents` or `~/.claude` with no archive/git safety net
- Wildcard permission grants in `settings.json`
- A **username-bearing absolute path** (`/Users/<name>/...`, `/home/<name>/...`) in a shared/global skill, script, or doc - or a global tool hardcoded to one vault/device instead of discovering its context
- Any **hardcoded personal fact** (name, email, GitHub handle/repo URL, vault/project name, hostname) in shared tooling that the agent could instead infer from session context, env vars, git config/metadata, or ask for in real time

## Verification

Before you call the change safe:

- [ ] Staged diff scanned for secrets - clean
- [ ] Every new/changed command, hook, and script reviewed line-by-line
- [ ] All untrusted-input paths validated and never executed/obeyed blindly
- [ ] Hidden-character scan clean - `unicode_smuggle_check.py` exits 0 on the changed files, and any variation-selector warnings were confirmed to sit on real emoji
- [ ] Destructive operations are reversible (archive + git) and guarded against empty vars
- [ ] Any new plugin/MCP source verified as official and trusted
- [ ] Path-hygiene scan clean - no `/Users/<name>/` or `/home/<name>/` in shared files; any found were rewritten to `~/`/`$HOME` or root-discovery
- [ ] Personal-constant scan clean - no names, emails, handles, personal repo URLs, or private vault/project names in shared tooling; all such context is derived at runtime or asked for live
- [ ] All **Critical** and **Important** findings resolved or explicitly deferred with justification
