diff --git a/.github/workflows/gate.yml b/.github/workflows/gate.yml
index d7288d6..ae24b6b 100644
--- a/.github/workflows/gate.yml
+++ b/.github/workflows/gate.yml
@@ -18,7 +18,7 @@ permissions:
contents: read
env:
- AIH_VERSION: 2.0.1
+ AIH_VERSION: 2.1.0
jobs:
security-gate:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index aa0b9ea..46a3d21 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,23 @@ Newest first. One line per change. Default-behavior changes marked **CHANGED**.
### CI
- **CHANGED:** the security gate (`.github/workflows/gate.yml`) runs the latest stable `am-i-hacked` release from npm, pinned to an exact version, instead of the unreleased scanner on `main` (#6).
+## 2026-10-09 [am-i-hacked 2.1.0]
+
+**BLUF:** A security release: a scanned repository's `.git/config` can no longer make `am-i-hacked` or `safe-pull` run a command, and payloads in dot-directories, ignored folders and committed build output are now read. It also adds the dark-corner system scan, Python virtualenv integrity and `--max-findings`. There was no 2.0.1; its changes are in 2.1.0. The package changelog, [`apps/am-i-hacked/CHANGELOG.md`](apps/am-i-hacked/CHANGELOG.md), has the detail.
+
+### am-i-hacked
+- Security: the scan and `safe-pull` run git with `core.fsmonitor=false`, so a `.git/config` that ships inside an archive cannot run a command.
+- **CHANGED:** the scan reads dot-directories, ignores `.ignore` and `.rgignore`, and scans tracked files that `.gitignore` matches and committed build output. Findings may appear in `dist/`, `.github/`, `.vscode/` and similar.
+- **CHANGED:** 71 official Yarn releases in `.yarn/releases` are verified by SHA256.
+- **CHANGED:** bundled code gets only high-signal checks, and embedded WebAssembly data: URLs are not flagged for line length.
+- `--system` also scans a fixed list of dark corners (virtualenvs, package-manager caches, language bin folders), and reports a check it could not run instead of passing it.
+- The folder scan verifies Python virtualenv integrity against each package's `RECORD` hashes.
+- `--max-findings N` (default 1000, was 100). **CHANGED:** over the cap, the true total and a per-rule count are printed and higher-severity findings are kept first.
+- **CHANGED:** hex and unicode escape findings inside installed dependency folders are one summary count. `bin/host-audit.sh` is now `bin/system-scan.sh`, with the old name kept as a shim.
+
+### CI
+- The security gate pins `am-i-hacked` 2.1.0.
+
## 2026-10-05
### Skills
diff --git a/README.md b/README.md
index f3c1b03..edaefb4 100644
--- a/README.md
+++ b/README.md
@@ -11,7 +11,7 @@ npx am-i-hacked
[](https://www.npmjs.com/package/am-i-being-recorded)
[](https://www.npmjs.com/package/secure-semgrep)
-**Status:** actively maintained, macOS and Linux. Current releases: `am-i-hacked` 2.0.1, `am-i-being-recorded` 1.0.0, `secure-semgrep` 1.0.1. The next `am-i-hacked` release adds a dark-corner system scan, a Python virtualenv integrity check and `--max-findings`; each package's changelog has the detail. Website: , with [guides](https://isaacbell.github.io/secure-devtools/guides/) such as [how to inspect an untrusted repository](https://isaacbell.github.io/secure-devtools/guides/inspect-untrusted-repository/) and [how to check AI-generated code](https://isaacbell.github.io/secure-devtools/guides/ai-generated-code-security/).
+**Status:** actively maintained, macOS and Linux. Current releases: `am-i-hacked` 2.1.0, `am-i-being-recorded` 1.0.0, `secure-semgrep` 1.0.1. Each package's changelog has the detail. Website: , with [guides](https://isaacbell.github.io/secure-devtools/guides/) such as [how to inspect an untrusted repository](https://isaacbell.github.io/secure-devtools/guides/inspect-untrusted-repository/) and [how to check AI-generated code](https://isaacbell.github.io/secure-devtools/guides/ai-generated-code-security/).
## Tools
diff --git a/ROADMAP.md b/ROADMAP.md
index 8ef9c12..15e354d 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -2,14 +2,14 @@
This is a plan, not a promise. Items can change, slip or be dropped. There are no dates: a release ships when it is ready. Anything that is neither listed here nor filed as an issue is not scheduled.
-Current release: **2.0.1**.
+Current release: **2.1.0**.
## How releases work
-- Every fix that merges ships as the next patch release: 2.0.2, then 2.0.3, and so on.
-- Issues labeled `release-blocker` gate the next minor release. 2.1.0 ships once every issue with that label is closed.
+- Every fix that merges ships as the next patch release: 2.1.1, then 2.1.2, and so on.
+- Issues labeled `release-blocker` gate the next minor release. 2.2.0 ships once every issue with that label is closed.
-## 2.1.0 candidates
+## 2.2.0 candidates
A minor release that widens what the scanner reads and adds an opt-in feature.
diff --git a/apps/am-i-hacked/CHANGELOG.md b/apps/am-i-hacked/CHANGELOG.md
index 34a9163..4cf6596 100644
--- a/apps/am-i-hacked/CHANGELOG.md
+++ b/apps/am-i-hacked/CHANGELOG.md
@@ -2,12 +2,12 @@
Newest first. Default-behavior changes are marked **CHANGED**.
-## Unreleased
+## 2.1.0 - 2026-10-09
-**BLUF:** The system scan now checks the fixed places tooling installs code outside any project, the folder scan verifies Python virtualenv integrity, and the findings cap rises to 1000. `bin/host-audit.sh` is renamed to `bin/system-scan.sh`, with the old name kept as a shim.
+**BLUF:** A security release with new checks. The scan no longer trusts the tree it is reviewing: a scanned repo's `.git/config` could make `am-i-hacked` or `safe-pull` run a command, and payloads in dot-directories, in folders named in `.ignore` or `.rgignore`, in tracked files matched by `.gitignore`, and in tracked build output were not read. It also adds a dark-corner system scan and Python virtualenv integrity, and raises the findings cap to 1000. `bin/host-audit.sh` is renamed to `bin/system-scan.sh`, with the old name kept as a shim. A 2.0.1 was prepared but never published; everything in it is in this release.
### Added
-- The system scan (`--system`) now also scans a fixed list of "dark corners" where tooling installs code outside any project: Python virtualenvs, the uv and pip caches, pyenv, pipx, the npx cache, the pnpm global store, cargo, bun, deno, and the go and gem bin folders. Each is scanned with the folder scan when present and skipped silently when it does not exist.
+- The system scan (`--system`) now also scans a fixed list of "dark corners" where tooling installs code outside any project: Python virtualenvs (`~/.venv` and `~/.virtualenvs`), pyenv versions, uv tools and cache, the pip cache, pipx, the npx cache, the pnpm global store, cargo's `bin`, bun, deno, go's `bin`, and the gem folder (`~/.gem`). Each is scanned with the folder scan when present and skipped silently when it does not exist.
- Python virtualenv integrity check in the folder scan, for every venv under the scanned folder: files that no longer match their package `RECORD` hashes are a `HIGH` finding; `RECORD` paths that leave the venv or pass through a symlinked folder (never opened), files and symlinks no package owns, console scripts and symlinks in `bin/` no package claims (each venv checked on its own claims), a console script name containing `|`, `RECORD` entries with no hash, a venv whose `RECORD` files or whole `dist-info` folders were deleted, a `_virtualenv.pth` that is not the stock one-line file, `.pth` and `sitecustomize` hooks that run code, unreadable files and folders (reported rather than skipped), and file names containing newlines are all reported.
- `--max-findings N` sets the maximum findings printed, from 1 to 999999999, default raised from 100 to 1000. **CHANGED:** when findings exceed the cap, the true total and a per-rule count are printed, and higher-severity findings are kept first.
- The system scan reports a check it could not run instead of passing it: `ps` missing or failing, a `crontab -l` error other than "no crontab", `codesign` missing, an AI-tool config `jq` cannot parse (JSONC), an unreadable dark corner, your own relative-path process whose working directory `lsof` could not resolve, and a login item's file that you own but cannot read (a root-owned one stays informational).
@@ -15,8 +15,15 @@ Newest first. Default-behavior changes are marked **CHANGED**.
### Changed
- **CHANGED:** hex and unicode escape findings inside installed dependency folders (`site-packages`, `dist-packages`, `node_modules`) are reported as one summary count instead of one finding per match. `vendor/` is committed with the project, so its findings are still listed.
- **CHANGED:** `bin/host-audit.sh` is now `bin/system-scan.sh`. `bin/host-audit.sh` remains as a shim, and the `host` command still works.
+- **CHANGED:** bundled code (webpack/esbuild/ncc) identified by markers in the first 4096 bytes (`__webpack_require__`, `__nccwpck_require__`, `webpackBootstrap`, `__toESM(`, `__commonJS(`) receives only high-signal checks. Routine patterns in bundles (dynamic code execution, network modules, child process) are counted but not flagged; obfuscation, capture/exfiltration and bot tokens still are.
+- **CHANGED:** embedded WebAssembly data: URLs (`data:application/(wasm|octet-stream);base64,AGFzbQ...`) on a single line are no longer flagged for line length, as they are expected to be long.
### Security
+- **CHANGED:** the scan now reads dot-directories, no longer reads `.ignore` or `.rgignore`, and scans tracked files that `.gitignore` matches. Committed build output is now scanned, so findings may appear in `dist/`, `.github/`, `.vscode/` and similar. Untracked build output listed in `.gitignore` is still skipped, and `node_modules` and `.git` are always skipped.
+- Payloads were not scanned when they sat in a dot-directory, in a folder named in `.ignore` or `.rgignore`, in a tracked file matched by `.gitignore`, or in a tracked build output folder. All of these are scanned now.
+- A scanned repo's `.git/config` could make the scan run a command: `git ls-files` runs any `core.fsmonitor` hook the config names. The scan now runs git with `core.fsmonitor=false`.
+- `safe-pull` had the same flaw: its `git status` ran the `core.fsmonitor` command named in the working tree's `.git/config`. Every git call in `safe-pull` now runs with `core.fsmonitor=false`. Config that `git fetch` itself reads, such as `core.sshCommand`, is not covered, so run `safe-pull` only in a repository whose `.git` you created.
+- **CHANGED:** 71 official Yarn releases in `.yarn/releases` are verified by SHA256 checksum; a mismatch is a finding at line 1. Unverified or malicious Yarn files no longer pass silently.
- A source file containing a NUL byte was skipped as binary, so one NUL in a comment hid the whole file from the folder scan. Source files are now always read as text.
- A systemd or autostart `Exec` line containing `*` was glob-expanded against the scanner's working directory. It is now split without globbing.
- A hook command containing a tab was cut at the tab, so a dangerous path after it was not checked.
@@ -24,25 +31,9 @@ Newest first. Default-behavior changes are marked **CHANGED**.
### Fixed
- The `ignored null byte in input` warnings are no longer emitted, including for matched lines and build-output detection.
-
-## 2.0.1 - 2026-10-01
-
-**BLUF:** A security release. The scan no longer trusts the tree it is reviewing: a scanned repo's `.git/config` could make it run a command, and payloads in dot-directories, in folders named in `.ignore` or `.rgignore`, in tracked files matched by `.gitignore`, and in tracked build output were not read. It also fixes a skip on extensionless scripts.
-
-### Security
-- **CHANGED:** the scan now reads dot-directories, no longer reads `.ignore` or `.rgignore`, and scans tracked files that `.gitignore` matches. Committed build output is now scanned, so findings may appear in `dist/`, `.github/`, `.vscode/` and similar. Untracked build output listed in `.gitignore` is still skipped, and `node_modules` and `.git` are always skipped.
-- Payloads were not scanned when they sat in a dot-directory, in a folder named in `.ignore` or `.rgignore`, in a tracked file matched by `.gitignore`, or in a tracked build output folder. All of these are scanned now.
-- A scanned repo's `.git/config` could make the scan run a command: `git ls-files` runs any `core.fsmonitor` hook the config names. The scan now runs git with `core.fsmonitor=false`.
-- **CHANGED:** 71 official Yarn releases in `.yarn/releases` are verified by SHA256 checksum; a mismatch is a finding at line 1. Unverified or malicious Yarn files no longer pass silently.
-
-### Changed
-- **CHANGED:** bundled code (webpack/esbuild/ncc) identified by markers in the first 4096 bytes (`__webpack_require__`, `__nccwpck_require__`, `webpackBootstrap`, `__toESM(`, `__commonJS(`) receives only high-signal checks. Routine patterns in bundles (dynamic code execution, network modules, child process) are counted but not flagged; obfuscation, capture/exfiltration and bot tokens still are.
-- **CHANGED:** embedded WebAssembly data: URLs (`data:application/(wasm|octet-stream);base64,AGFzbQ...`) on a single line are no longer flagged for line length, as they are expected to be long.
-
-### Fixed
- An extensionless script inside a folder with a dot in its name (for example `.devcontainer/sync-agent`) was skipped. The extensionless check now tests the file name.
-Upgrade: `pnpm add -D am-i-hacked@2.0.1`, or run the pinned version without installing: `pnpx am-i-hacked@2.0.1 .`
+Upgrade: `npm install --save-dev am-i-hacked@2.1.0`, or run the pinned version without installing: `npx am-i-hacked@2.1.0`
## 2.0.0 - 2026-10-01
diff --git a/apps/am-i-hacked/README.md b/apps/am-i-hacked/README.md
index 9ededa2..efd6a28 100644
--- a/apps/am-i-hacked/README.md
+++ b/apps/am-i-hacked/README.md
@@ -10,7 +10,7 @@
npx am-i-hacked
```
-**Status:** 2.0.1, macOS and Linux. The next release adds a dark-corner system scan, Python virtualenv integrity and `--max-findings`; the tables below document that next-release state, and [CHANGELOG.md](CHANGELOG.md) marks it Unreleased. Formerly published as `am-i-compromised`; the old command name still works.
+**Status:** 2.1.0, macOS and Linux. Formerly published as `am-i-compromised`; the old command name still works.
## Install and run
diff --git a/apps/am-i-hacked/bin/ioc-patterns.sh b/apps/am-i-hacked/bin/ioc-patterns.sh
index 015dfbe..cb0316f 100644
--- a/apps/am-i-hacked/bin/ioc-patterns.sh
+++ b/apps/am-i-hacked/bin/ioc-patterns.sh
@@ -74,7 +74,7 @@ readonly IOC_CHILD_PROCESS_OPTIONS_OBJECT_PATTERN=',[[:space:]]*\{'
# key is quoted in JSON, so a closing quote may sit between the key and the
# colon: `"task.allowAutomaticTasks": true`.
#
-# The MCP rule flags download-and-run shapes only; `uvx`, `pnpm dlx`, and `node`
+# The MCP rule flags download-and-run shapes only; `uvx`, `pnpx`, and `node`
# servers are legitimate and must not be flagged.
readonly IOC_EDITOR_PATTERNS=(
diff --git a/apps/am-i-hacked/bin/safe-pull.sh b/apps/am-i-hacked/bin/safe-pull.sh
index a7ee170..970fd9a 100755
--- a/apps/am-i-hacked/bin/safe-pull.sh
+++ b/apps/am-i-hacked/bin/safe-pull.sh
@@ -78,6 +78,15 @@ if ! command -v git >/dev/null 2>&1; then
exit 2
fi
+# Every git call below runs with the working tree's config defanged. A repository
+# received as an archive carries its own `.git/config`, and `git status` runs any
+# command that config names in `core.fsmonitor`. This does not cover config that
+# `git fetch` itself reads (for example `core.sshCommand`): run safe-pull only in a
+# repository whose `.git` you created, not one that arrived as an archive.
+git() {
+ command git -c core.fsmonitor=false "$@"
+}
+
if ! git rev-parse --is-inside-work-tree >/dev/null 2>&1; then
printf 'safe-pull: not inside a git working tree\n' >&2
exit 2
diff --git a/apps/am-i-hacked/package.json b/apps/am-i-hacked/package.json
index bf9524d..f3e1a9e 100644
--- a/apps/am-i-hacked/package.json
+++ b/apps/am-i-hacked/package.json
@@ -1,6 +1,6 @@
{
"name": "am-i-hacked",
- "version": "2.0.1",
+ "version": "2.1.0",
"description": "Scan a project for malicious code and audit your machine for persistence, capture tools and risky AI-tool config",
"license": "MIT",
"author": "Isaac Bell ",
diff --git a/apps/am-i-hacked/test/safe-pull.bats b/apps/am-i-hacked/test/safe-pull.bats
index 68305cc..b6df31e 100644
--- a/apps/am-i-hacked/test/safe-pull.bats
+++ b/apps/am-i-hacked/test/safe-pull.bats
@@ -107,6 +107,22 @@ pull_guard() {
[ ! -f "$WORK/feature.js" ]
}
+@test "core.fsmonitor from the working tree's .git/config does not run" {
+ printf 'console.log("feature")\n' >"$OTHER/feature.js"
+ commit_and_push "$OTHER" "add feature"
+
+ cd "$WORK"
+ # Set after setup, which would run the hook itself. The marker sits outside
+ # the working tree so it cannot dirty it.
+ git -C "$WORK" config core.fsmonitor "touch '$TMP/ran'; false"
+ pull_guard --dry-run
+ if [[ -e "$TMP/ran" ]]; then
+ fail "core.fsmonitor from the working tree ran during safe-pull"
+ fi
+ assert_success
+ assert_output --partial "clean (dry run)"
+}
+
# -------------------------------------------------------------------------------
# Refusals
# -------------------------------------------------------------------------------
diff --git a/apps/am-i-hacked/test/scanner.bats b/apps/am-i-hacked/test/scanner.bats
index ea7e376..a701f06 100644
--- a/apps/am-i-hacked/test/scanner.bats
+++ b/apps/am-i-hacked/test/scanner.bats
@@ -528,7 +528,7 @@ write_file() {
@test "editor MCP config: local package-runner servers are not flagged" {
write_file ".vscode/mcp.json" \
- '{"servers":{"serena":{"type":"stdio","command":"uvx","args":["--from","git+https://github.com/oraios/serena","serena","start-mcp-server"]},"context7":{"type":"stdio","command":"pnpm","args":["dlx","@upstash/context7-mcp"]}}}'
+ '{"servers":{"docs-server":{"type":"stdio","command":"uvx","args":["--from","git+https://github.com/example/docs-server","docs-server","start"]},"context7":{"type":"stdio","command":"pnpm","args":["dlx","@upstash/context7-mcp"]}}}'
scan
assert_success
}
diff --git a/docs/RELEASING.md b/docs/RELEASING.md
index 2422cef..ae6a5ad 100644
--- a/docs/RELEASING.md
+++ b/docs/RELEASING.md
@@ -44,15 +44,3 @@ at the new name:
```bash
pnpm deprecate am-i-compromised "Renamed to am-i-hacked. Run: npx am-i-hacked"
```
-
-## Public repository
-
-The public repository is staged from an allowlist, `scripts/public-paths.txt`, as one fresh
-commit:
-
-```bash
-mise run export-public "$(mktemp -d)"
-```
-
-The export copies only listed paths, removes `!` lines, refuses local files, and fails on any
-personal-data pattern hit.
diff --git a/docs/guides/audit-developer-machine/index.html b/docs/guides/audit-developer-machine/index.html
index 1b909ec..dbb05b9 100644
--- a/docs/guides/audit-developer-machine/index.html
+++ b/docs/guides/audit-developer-machine/index.html
@@ -108,7 +108,7 @@
How to check whether your developer machine is compromised
-
By Isaac Bell · Updated · Applies to am-i-hacked 2.0.1
+
By Isaac Bell · Updated · Applies to am-i-hacked 2.1.0
Run npx am-i-hacked --system. It is a read-only audit that needs no root or sudo and makes no network calls. It checks login persistence (launchd on macOS, systemd user units and autostart on Linux) and the code signatures of what they launch, the crontab, shell startup files, AI coding tool config, and running processes. Pair it with npx am-i-being-recorded for screen-capture capability. Neither is antivirus: a clean result means no warning signs were found.
@@ -121,7 +121,7 @@
What the system scan checks
Shell startup files
Remote scripts piped to a shell, decoded payloads, DYLD_INSERT_LIBRARIES or LD_PRELOAD, TLS checks disabled, a forced NODE_OPTIONS --require, sudo or ssh replaced by an alias, a redirected AI API base URL, a proxy or extra certificate authority
AI coding tool config
User-level and managed settings for Claude Code, Codex, Cursor, Gemini CLI, OpenCode and Kilo Code: permission bypass, redirected API URLs, plain-text keys, MCP servers that run unpinned code, and hooks that run from writable places or observe your prompts
Running processes
Interpreters running capture scripts or scripts from staging areas such as Downloads, caches and /tmp
-
Dark corners (next release)
Folders where tooling installs code outside any project: virtualenvs, pip and uv caches, pyenv, pipx, the npx cache, the pnpm store, cargo, bun, deno, go and gem bin folders
+
Dark corners
Folders where tooling installs code outside any project: virtualenvs, pip and uv caches, pyenv, pipx, the npx cache, the pnpm store, cargo, bun, deno, go and gem bin folders
How to safely inspect an untrusted repository before you run it
-
By Isaac Bell · Updated · Applies to am-i-hacked 2.0.1
+
By Isaac Bell · Updated · Applies to am-i-hacked 2.1.0
Treat the repository as data until you have checked it. Do not open it in an editor, install its dependencies, or run its scripts or tests. Download it to a throwaway folder, scan it with npx am-i-hacked <dir>, read every flagged file as plain text, and list the files that must never run. A clean scan means no warning signs were found; it does not prove the code is safe.
Is this macOS login item or background item malware?
-
By Isaac Bell · Updated · Applies to am-i-hacked 2.0.1
+
By Isaac Bell · Updated · Applies to am-i-hacked 2.1.0
The name in System Settings is the code signer, not the plist. Find the launchd plist behind it, read the program it runs, and verify that program's code signature and Team ID against the vendor's own apps on your machine. A valid vendor signature on a vendor-labelled entry closes the case. Anything unsigned, ad-hoc signed, failing verification or signed by the wrong team stays open. npx am-i-hacked --system --verbose does these checks for every login item at once.
How to detect malicious VS Code tasks that run when a folder opens
-
By Isaac Bell · Updated · Applies to am-i-hacked 2.0.1
+
By Isaac Bell · Updated · Applies to am-i-hacked 2.1.0
VS Code, and editors built on it such as Cursor, can run a task automatically when a folder opens if .vscode/tasks.json sets "runOn": "folderOpen". A malicious repository uses this to run a download-and-execute command the moment you look at it. Before opening an unfamiliar folder, scan it with npx am-i-hacked <dir>, which flags editor auto-run tasks and download-and-run commands in editor config, and keep Workspace Trust turned on.
How to catch a supply-chain attack that npm audit misses
-
By Isaac Bell · Updated · Applies to am-i-hacked 2.0.1
+
By Isaac Bell · Updated · Applies to am-i-hacked 2.1.0
Advisory scanners such as npm audit, OSV-Scanner and Snyk compare your dependency versions with known, published vulnerabilities. They cannot see an attack committed into the repository itself, or one nobody has reported yet. Add an indicator-based check that reads the code: run npx am-i-hacked before the dev server and in CI, and pull with safe-pull so incoming commits are inspected before they reach your working tree. Run both kinds of check.
@@ -142,7 +142,7 @@
Other signals it checks
Official Yarn release files committed to the repository, verified by SHA256.
.env files in the git index.
-
Python virtualenvs whose files no longer match their package RECORD hashes, files no package owns, and .pth hooks that run code. This check ships in the next am-i-hacked release, after 2.0.1.
+
Python virtualenvs whose files no longer match their package RECORD hashes, files no package owns, and .pth hooks that run code. This check was added in am-i-hacked 2.1.0.
Limits
diff --git a/docs/llms.txt b/docs/llms.txt
index fd22ac7..3867737 100644
--- a/docs/llms.txt
+++ b/docs/llms.txt
@@ -4,7 +4,7 @@
secure-devtools is a collection of local security tools for macOS and Linux. They check a project folder, and the machine, for supply-chain and dev-environment attack indicators. They need no signup, account or API key, have no npm runtime dependencies, and exit non-zero on findings so they drop into dev scripts or CI.
-**Current releases:** `am-i-hacked` 2.0.1, `am-i-being-recorded` 1.0.0, `secure-semgrep` 1.0.1. In the next `am-i-hacked` release: a dark-corner system scan, a Python virtualenv integrity check, and `--max-findings`.
+**Current releases:** `am-i-hacked` 2.1.0, `am-i-being-recorded` 1.0.0, `secure-semgrep` 1.0.1.
**Website:** https://isaacbell.github.io/secure-devtools/
**Repository:** https://github.com/IsaacBell/secure-devtools
**Maintainer:** Isaac Bell — https://isaacbell.io — https://github.com/IsaacBell
@@ -54,8 +54,6 @@ Install all with `npx skills add IsaacBell/secure-devtools -g`, or one with `--s
## am-i-hacked in detail
-The dark-corner system scan, the Python virtualenv integrity check and `--max-findings` below are unreleased: they ship in the next `am-i-hacked` release and are not in 2.0.1.
-
- **Folder scan.** Reads JS/TS, Python, Rust, Ruby, C, C++, and C# sources. Skips `node_modules` and `.git`; also reads dot-directories and tracked files that `.gitignore` matches. Detects dynamic code execution (`eval`, `new Function`), child processes, direct network module access, runtime global writes; encoded or obfuscated payloads and unusually long lines; suspicious `package.json` scripts; editor config that runs code on folder open; download-and-run commands in editor config; executable payloads disguised as assets; clipboard/keystroke/screen capture paired with an exfiltration endpoint; `.env` files in the git index; official Yarn releases verified by SHA256; Python virtualenv integrity (package RECORD hashes, unowned files, code-running `.pth` hooks). In bundled code, only high-signal patterns are reported.
- **System scan (`--system`, alias `--full-system-scan`).** Read-only, no root or sudo. Adds login persistence (launchd on macOS; systemd user units and XDG autostart on Linux) and macOS code signatures, crontab, shell startup files, user-level and managed AI-tool config, running processes, and the "dark corners" where tooling installs code outside any project. `am-i-hacked host [dir]` checks only a folder's AI-tool config.
- **Flags:** ``; `--system`/`--full-system-scan`; `--max-findings N` (folder scan, default 1000); `-v`/`--verbose`; `-h`/`--help`.
diff --git a/docs/security/index.html b/docs/security/index.html
index b362981..c40087a 100644
--- a/docs/security/index.html
+++ b/docs/security/index.html
@@ -156,7 +156,7 @@
am-i-hacked is a command-line scanner that checks the code you are about to run, and the machine you run it on, for signs of a supply-chain or dev-environment attack. npx am-i-hacked scans the current folder; --system also audits the machine. It is read-only, makes no network calls, needs no account, and exits 1 on findings. It is not antivirus: a clean result means no warning signs were found.
@@ -187,7 +187,7 @@
What the folder scan detects
.env files in the git index.
Official Yarn releases, verified by SHA256.
The folder's AI-tool config: .claude/settings*.json and .mcp.json.
-
Python virtualenv integrity: files that no longer match their package RECORD hashes, files no package owns, and .pth or startup hooks that run code. Next release.
+
Python virtualenv integrity: files that no longer match their package RECORD hashes, files no package owns, and .pth or startup hooks that run code.
Languages read: JS/TS, Python, Rust, Ruby, C, C++ and C#. Dot-directories and tracked files that .gitignore matches are read; node_modules and .git are skipped. In bundled output (webpack, esbuild, ncc) only high-signal patterns are reported.
@@ -197,7 +197,7 @@
What the system scan detects
Shell startup files: piped remote scripts, injected libraries, hijacked sudo or ssh, background launchers, redirected AI API base URLs, proxies and extra certificate authorities.
User-level and managed AI-tool config for Claude Code, Codex, Cursor, Gemini CLI, OpenCode and Kilo Code: permission bypass, plain-text keys, MCP servers that run unpinned code, and hooks.
Running processes that run capture scripts or scripts from staging areas.
-
"Dark corners" where tooling installs code outside any project, such as virtualenvs, package-manager caches and language bin folders. Next release.
+
"Dark corners" where tooling installs code outside any project, such as virtualenvs, package-manager caches and language bin folders.
Example output
@@ -224,7 +224,7 @@
Flags
<dir>
Directory to scan (default: the current directory)
host [dir]
Check only a folder's AI-tool config
--system, --full-system-scan
Also audit the whole machine
-
--max-findings N
Maximum findings printed (default 1000; the true total is always shown). Next release.
+
--max-findings N
Maximum findings printed (default 1000; the true total is always shown).
-v, --verbose
System scan: also list informational items and every persistence entry with its signer
-h, --help
Show usage
diff --git a/mise.toml b/mise.toml
index 995a20e..79064f5 100644
--- a/mise.toml
+++ b/mise.toml
@@ -95,10 +95,6 @@ run = ["bash scripts/release.sh login", { task = "check" }, "bash scripts/releas
description = "List the files one package would publish. Usage: mise run publish-dry-run apps/"
run = "bash scripts/release.sh dry-run"
-[tasks.export-public]
-description = "Stage the public repository as one fresh commit. Usage: mise run export-public "
-run = "bash scripts/export-public.sh"
-
[tasks.doctor]
description = "Diagnose the dev environment (mise doctor)"
run = "mise doctor"
diff --git a/scripts/export-public.sh b/scripts/export-public.sh
deleted file mode 100755
index 2a37080..0000000
--- a/scripts/export-public.sh
+++ /dev/null
@@ -1,173 +0,0 @@
-#!/usr/bin/env bash
-set -euo pipefail
-
-# scripts/export-public.sh
-#
-# Stage the public secure-devtools repository with exactly one commit, so none of
-# the private history is published. Only the paths in scripts/public-paths.txt are
-# copied; add a tool or skill there once it has passed review. A line starting with
-# `!` removes that path from the copy (for example, live malware samples).
-#
-# scripts/export-public.sh "$(mktemp -d)"
-#
-# Refuses a non-empty destination, never copies node_modules, VCS folders, local
-# settings, logs or env files, runs the personal-data pattern check over the copy
-# and fails on any hit, then `git init -b main` and one commit,
-# "Initial public release". It never adds a remote and never pushes.
-#
-# Patterns come from scripts/silver-gate-patterns.json in the enclosing workspace
-# (found by walking up from this repo). Override with PII_PATTERNS_JSON=.
-# The check fails closed: a missing file, a missing tool or a pattern that does not
-# compile is a failure, not a pass.
-#
-# Needs: git, jq, rsync, rg (ripgrep built with PCRE2; the patterns use look-ahead).
-
-die() {
- echo "export-public: $*" >&2
- exit 1
-}
-
-[[ $# -eq 1 && -n "$1" ]] || {
- echo "usage: export-public.sh " >&2
- exit 2
-}
-
-SRC="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
-MANIFEST="$SRC/scripts/public-paths.txt"
-DEST_ARG="$1"
-
-for tool in git jq rg rsync; do
- command -v "$tool" >/dev/null 2>&1 || die "$tool is required but was not found on PATH"
-done
-[[ -f "$MANIFEST" ]] || die "missing $MANIFEST"
-
-# --- destination: must be empty (or absent), and outside the source folder -----------
-
-if [[ -e "$DEST_ARG" && ! -d "$DEST_ARG" ]]; then
- die "'$DEST_ARG' exists and is not a directory"
-fi
-if [[ -d "$DEST_ARG" && -n "$(ls -A "$DEST_ARG")" ]]; then
- die "'$DEST_ARG' is not empty; refusing to export into it"
-fi
-mkdir -p "$DEST_ARG"
-DEST="$(cd "$DEST_ARG" && pwd)"
-case "$DEST/" in
-"$SRC"/*) die "destination must be outside $SRC" ;;
-esac
-
-# --- personal-data patterns -----------------------------------------------------------
-
-find_patterns_json() {
- local dir="$SRC"
- if [[ -n "${PII_PATTERNS_JSON:-}" ]]; then
- printf '%s' "$PII_PATTERNS_JSON"
- return 0
- fi
- while [[ "$dir" != "/" ]]; do
- if [[ -f "$dir/scripts/silver-gate-patterns.json" ]]; then
- printf '%s' "$dir/scripts/silver-gate-patterns.json"
- return 0
- fi
- dir="$(dirname "$dir")"
- done
- return 1
-}
-
-PATTERNS_JSON="$(find_patterns_json)" || die "no scripts/silver-gate-patterns.json above $SRC; set PII_PATTERNS_JSON"
-[[ -f "$PATTERNS_JSON" ]] || die "pattern file '$PATTERNS_JSON' does not exist"
-
-WORK="$(mktemp -d)"
-cleanup() {
- local status=$?
- rm -rf "$WORK"
- # A copy that failed a check must not be left behind to be published by hand.
- if ((status != 0)) && [[ -d "$DEST" ]]; then
- find "$DEST" -mindepth 1 -delete 2>/dev/null || true
- fi
-}
-trap cleanup EXIT
-
-# Block-severity personal and secret patterns, email and personal-profile URLs,
-# plus the name, internal-framing and family-reference groups. Warn-level shapes
-# that fire on ordinary code (IP addresses, dates, ZIP codes) are left out.
-jq -r '
- (.pii[] | select(.severity == "block" or .name == "Email Address" or .name == "Personal URL")),
- .internal_framing[],
- .family_references[]
- | .pattern
-' "$PATTERNS_JSON" >"$WORK/patterns.txt" || die "could not read patterns from $PATTERNS_JSON"
-[[ -s "$WORK/patterns.txt" ]] || die "no patterns were read from $PATTERNS_JSON"
-
-# --- copy the manifest -------------------------------------------------------------------
-
-excluded=()
-while IFS= read -r path || [[ -n "$path" ]]; do
- [[ -z "$path" || "$path" == \#* ]] && continue
- if [[ "$path" == !* ]]; then
- excluded+=("${path#!}")
- continue
- fi
- [[ "$path" != /* && "$path" != *..* ]] || die "unsafe manifest path: $path"
- [[ -e "$SRC/$path" ]] || die "manifest lists $path, which does not exist"
- mkdir -p "$DEST/$(dirname "$path")"
- rsync -a \
- --exclude node_modules --exclude .git --exclude .jj --exclude .DS_Store \
- --exclude '.env*' --exclude settings.local.json --exclude '*.log' --exclude reports \
- "$SRC/$path" "$DEST/$(dirname "$path")/"
-done <"$MANIFEST"
-
-for path in ${excluded[@]+"${excluded[@]}"}; do
- [[ "$path" != /* && "$path" != *..* && -n "$path" ]] || die "unsafe exclusion: $path"
- rm -rf "${DEST:?}/$path"
- [[ ! -e "$DEST/$path" ]] || die "could not remove excluded path $path"
-done
-
-forbidden="$(find "$DEST" \( -name node_modules -o -name .git -o -name .jj -o -name reports -o -name '.env*' -o -name 'settings.local.json' -o -name '*.log' \) -print)"
-[[ -z "$forbidden" ]] || die "refusing to publish local files:
-$forbidden"
-
-# --- personal-data check on the copy ----------------------------------------------------
-
-# Matched text that is allowed to ship: each package author's own contact address
-# (published on purpose) and placeholder addresses on reserved domains used by the
-# tests. Every other hit fails the export.
-AUTHOR_EMAILS="$(find "$DEST" -name package.json -not -path '*/node_modules/*' -exec jq -r '(.author // "") | if type == "string" then (capture("<(?[^>]+)>").e // empty) else (.email // empty) end' {} \; | sort -u)"
-PLACEHOLDER_EMAIL='^[^@]+@(example\.(com|org|net)|[A-Za-z0-9.-]+\.(test|invalid|example))$'
-
-rc=0
-rg --pcre2 -o -n --no-heading --hidden --color never \
- -f "$WORK/patterns.txt" -- "$DEST" >"$WORK/hits.txt" 2>"$WORK/rg.err" || rc=$?
-if ((rc == 2)); then
- cat "$WORK/rg.err" >&2
- die "the pattern check could not run (is rg built with PCRE2? does a pattern compile?); nothing was exported"
-fi
-
-# rg -o prints path:line:matched-text; judge the matched text only.
-hits="$(AUTHORS="$AUTHOR_EMAILS" OK="$PLACEHOLDER_EMAIL" awk '
- BEGIN { n = split(ENVIRON["AUTHORS"], a, "\n"); for (i = 1; i <= n; i++) if (a[i] != "") allowed[a[i]] = 1 }
- {
- text = $0
- sub(/^[^:]*:[0-9]+:/, "", text)
- if (text in allowed) next
- if (text ~ ENVIRON["OK"]) next
- print
- }
-' "$WORK/hits.txt")"
-if [[ -n "$hits" ]]; then
- printf '%s\n' "$hits" | sed "s#$DEST/##" >&2
- die "personal-data pattern hits in the copy (listed above); nothing was exported"
-fi
-
-# --- one commit ----------------------------------------------------------------------------
-
-git -C "$DEST" init -q -b main
-git -C "$DEST" add -A
-# Hooks off: a global hooks path from another project must not run here.
-git -C "$DEST" -c core.hooksPath=/dev/null commit -q -m "Initial public release"
-
-[[ "$(git -C "$DEST" rev-list --count HEAD)" == "1" ]] || die "expected exactly one commit"
-[[ "$(git -C "$DEST" log -1 --format=%B | sed '/^$/d')" == "Initial public release" ]] || die "commit message is not exactly the subject"
-[[ -z "$(git -C "$DEST" remote)" ]] || die "unexpected git remote"
-
-echo "export-public: staged $(git -C "$DEST" ls-files | wc -l | tr -d ' ') files as one commit in $DEST"
-echo "export-public: no remote added, nothing pushed"
diff --git a/skills/secure-skill-pull/SKILL.md b/skills/secure-skill-pull/SKILL.md
index d4c6ef8..0db0f0b 100644
--- a/skills/secure-skill-pull/SKILL.md
+++ b/skills/secure-skill-pull/SKILL.md
@@ -54,7 +54,7 @@ echo "== files =="; (cd "$work/src" && find . -type f ! -path './.git/*' | sort)
echo "== executable files =="; find "$work/src" -type f -perm -u+x
echo "== scanner =="; npx am-i-hacked "$work/src"
echo "== risky lines =="
-grep -rInE '(curl|wget)[^|]*\|[[:space:]]*(sh|bash)|base64[[:space:]]+(-d|--decode)|eval[[:space:](]|(npx|pnpx|pnpm dlx)[[:space:]]|pip3? install|npm (i|install)[[:space:]]|chmod \+x|\.ssh|\.env|ignore (all |any )?(previous|prior) instructions|disregard (the |all )?(above|previous)' "$work/src" --exclude-dir=.git
+grep -rInE '(curl|wget)[^|]*\|[[:space:]]*(sh|bash)|base64[[:space:]]+(-d|--decode)|eval[[:space:](]|(^|[^[:alnum:]_-])(npx|pnpx|bunx|(pnpm|yarn)[[:space:]]+dlx|npm[[:space:]]+(exec|x)|bun[[:space:]]+x)[[:space:]]|pip3? install|npm (i|install)[[:space:]]|chmod \+x|\.ssh|\.env|ignore (all |any )?(previous|prior) instructions|disregard (the |all )?(above|previous)' "$work/src" --exclude-dir=.git
echo "== invisible characters =="
find "$work/src" -type f ! -path '*/.git/*' -print0 | xargs -0 perl -CSD -ne 'print "$ARGV:$.: invisible character\n" if /[\x{200B}-\x{200F}\x{202A}-\x{202E}\x{2060}-\x{2064}\x{E0000}-\x{E007F}]/'
echo "== main file =="; find "$work/src" -name SKILL.md | while read -r f; do shasum -a 256 "$f"; done