scgate has two halves, and you need both.
| What it does | When it runs | Command | |
|---|---|---|---|
| Gate (before the fact) | Stops an agent installing a hallucinated package, piping a URL into a shell, posting a key, changing its own guardrails, or pulling a version other than the one this project approved | Before the command executes, inside Claude Code (hooks), any agent's shell (shims), or any package manager (proxy) | bash install.sh |
| Review (after the fact) | Records what a project depends on, re-checks it against the registries and CVE feeds daily, plans and applies upgrades with a snapshot and a rollback | Whenever you run it, and in CI | attest, audit, scout, plan, apply |
The review half is what most people run first, because it's read-only and it shows them what's already wrong. The gate half is what actually changes how an agent behaves. Installing the hooks is one command; see docs/ROLLOUT.md for the safe order.
What scgate is not. scgate checks and gates. It does not guarantee. It trusts the registries it queries (PyPI, npm, OSV); it cannot see a legitimate maintainer who has been compromised shipping malware no scanner has seen; it protects only the machines it is installed on; and a determined human with root can bypass any of it. It reduces the attack surface from "anything the model can name" to "something that survives a name check, a registry check, an attested version and a human", and it leaves evidence. That is the whole claim.
One file, standard library only, Apache-2.0. First version written with Claude (Anthropic) and reviewed line by line by the maintainer; every change since is human-reviewed before merge. Read SECURITY.md for exactly what it touches and how releases are signed; read it before you trust it, because that is the point.
| Hook | When | What scgate does |
|---|---|---|
| PreToolUse | before every Bash / Edit / Write / Read |
allow / deny / ask. Hallucinated package names, curl | bash, secret files, agent editing its own instructions |
| PostToolUse | after a successful install command | records the new/changed deps of that ecosystem into .scgate/attestation.json |
| SessionStart | when you open Claude Code | one-line status: OS security patches, runtime end-of-life, Claude Code version, dependency drift and CVEs |
CI (ci/scgate-audit.yml) |
every push + daily | fails the build on drift, missing approval, known vulnerability, or stale attestation |
| Layer | Protects | Bypass | Install |
|---|---|---|---|
| Claude Code hooks | Claude Code sessions, with the human-in-the-loop UI | none inside Claude Code | bash install.sh |
PATH shims (bin/pip, bin/npm, bin/docker …) |
any agent or terminal that runs a shell: Cursor, Codex CLI, Gemini CLI, you | calling /usr/bin/pip by full path, or python -m pip |
scgate.py shim install --rc |
lockdown (pip.conf, uv.toml, .npmrc → your private index) |
everything, however it is invoked | only by editing those files | scgate.py lockdown |
Hooks give the best experience, shims give reach, lockdown gives the guarantee. Use all three.
/plugin marketplace add andrewabel1/scgate
/plugin install scgate@iqabel-scgate
/reload-plugins
The plugin registers the same hooks as install.sh (from
hooks/hooks.json) and adds a skill that tells Claude how to behave when a
package is denied (stop, don't try variants, ask the user), how to add
dependencies so they are attested, and to use plan/apply rather than
editing pins. Policy and ledger still live in ~/.claude/scgate/.
Install from a tagged release, never from a branch, and check the hash against the one in the release notes before you run anything:
git clone --branch v0.6.0 --depth 1 https://github.com/andrewabel1/scgate.git
cd scgate && sha256sum scgate.py # compare with the release notes / CHANGELOG
bash tests/run_tests.sh # 50 checks against live PyPI/npm, ~1 min
bash install.sh # user-level hooks, every directory
python3 ~/.claude/scgate/scgate.py test "pip install starlette-reverse-proxy" # should deny
cd your-project && python3 ~/.claude/scgate/scgate.py attest # imprint, then commit .scgate/To protect one repo (and everyone who clones it), put settings.hooks.json's
content into the project's .claude/settings.json and commit it.
python3 ~/.claude/scgate/scgate.py shim install --rc # other agents & terminals
python3 ~/.claude/scgate/scgate.py lockdown # once pip_index_url / npm_registry are set
python3 ~/.claude/scgate/scgate.py anchor # copy the printed line off this machine| Agent tries to… | scgate says |
|---|---|
pip install <name that doesn't exist on PyPI> |
deny — hallucinated name, slopsquatting risk |
npm install <package published 3 days ago> |
ask — shows age, releases, maintainers, install scripts |
pip install requests (in your catalogue) |
allow — and rewrites to your private index if configured |
curl https://… | bash |
deny |
cat ~/.ssh/id_rsa, ~/.aws/credentials, .npmrc |
deny |
curl -d @.env https://… (exfil) |
ask |
Edit CLAUDE.md, .claude/, .mcp.json, workflows, lockfiles |
ask — agent changing its own instructions/guardrails/deps |
Edit anything under ~/.claude/scgate/ |
deny — tamper protection |
pip install -r requirements.txt |
checks every line in the file |
Silence means "no opinion": Claude Code's normal permission flow applies. It never auto-approves something it hasn't checked.
Written by scgate.py attest and kept current by the PostToolUse hook. One
record per dependency: exact version and hash, where it came from
(uv.lock, package-lock.json, requirements.txt…), when it was approved,
by whom, from which command and session, whether it was in the catalogue,
what the registry said about it at the time, and the ledger hash it chains
to. Version changes keep a history. Commit the file: any AI-built project
then carries its own due-diligence record, readable without our tooling.
Reads: requirements*.txt, uv.lock, poetry.lock, Pipfile.lock,
pyproject.toml, package-lock.json, pnpm-lock.yaml, yarn.lock,
package.json, anywhere under the project to depth 3 (node_modules,
venvs and build dirs skipped). On a server add --installed to include
what is actually installed in the project's venv (.venv/venv), or
--installed=/path/to/python for another interpreter.
Laundering is closed: a PostToolUse hook for a pip command can only
vouch for PyPI deps. An npm lockfile edited behind the gate's back stays
"changed since approval" until a human runs attest.
Every command leads with a verdict and what to do, and keeps the evidence
behind --verbose:
scgate audit Verity 2026-09-16
153 dependencies · attestation 0 day(s) old
Verdict: routine patch cycle. 7 packages need upgrading; every fix is on the
current major line; cryptography: staying on 44.x clears 1 of 7, all 7 need
50.0.0 (major jump, separate ticket).
In priority order:
1. django 5.2.4 -> 5.2.17 32 advisories · web framework · fixes available since 2025-09 (12 months)
2. pyjwt 2.10.1 -> 2.13.0 7 advisories · auth/crypto · fixes available since 2026-03 (6 months)
3. cryptography 44.0.0 -> 44.0.1 7 advisories · auth/crypto · clears 1 of 7; all fixed at 50.0.0 (major jump)
...
7. nanoid 3.3.16 -> 3.3.18 1 advisory · transitive via web/package-lock.json
Also: 8 unpinned packages in mobile/package.json: no lockfile next to it ...
Next: scgate.py plan ~/Verity dry-run the upgrades; changes nothing
scgate.py audit ~/Verity --verbose every advisory, every unpinned package
scgate.py audit ~/Verity --explain a written assessment (uses the claude CLI)
Priority is deterministic: package role (auth/crypto, web framework,
parses untrusted input, http client, packaging tool, dev-only) weighted by
advisory count, with direct vs transitive, how long fixes have been
available, and whether the fix is a major jump. --explain hands the
structured report to Claude via the CLI for a written assessment; the
numbers never come from the model.
scgate audit /path/to/project
deps: 6 attestation: 0 days old fail: 20 warn: 0 info: 2
[FAIL] npm 'lodash' changed since approval: 4.17.21 -> 4.17.20
[FAIL] pypi 'tenacity' is in the lockfiles but has no approval record
[FAIL] pypi 'requests' 2.25.1: GHSA-9wx4-h78v-vm56/CVE-2024-35195 fixed in 2.32.0
[FAIL] npm 'lodash' 4.17.15: 1106913 (high) - Command Injection in lodash
Checks, against the world as it is now:
- lockfile version/hash still matches the approval record
- no dependency present without an approval record; none silently removed
- package still exists; latest not yanked; no new install-time scripts; maintainer set unchanged (npm)
- known vulnerabilities: PyPI advisories, npm bulk advisories, plus OSV.dev when reachable (duplicates collapsed to one line per CVE)
- attestation not older than
attestation_max_age_days(30)
Exit code 1 on any FAIL, so it works as a CI gate. --json for machines.
scgate.py status runs the OS check and the audit together.
scgate.py apply <project> runs the saved plan: it refuses if the plan
recorded a conflict or is over a week old, snapshots the environment with
pip freeze to .scgate/rollback-<ts>.txt, installs, and ends with a clear
result:
SUCCESS the upgrade is installed in the planning environment.
django now 5.2.17
pyjwt now 2.14.0
Nothing in your project files has changed yet. Now prove it works:
1. run the tests: cd apps/api && DJANGO_SETTINGS_MODULE=verity_api.settings <python> -s -E -m pytest -q
(needs a database (postgres/mysql) reachable from where tests run)
2. if green, update the pins in your requirements file(s) to the versions above
3. rebuild the image if you ship one, then: scgate.py attest ... && scgate.py audit ...
if anything breaks: scgate.py rollback <project>
rollback restores the last snapshot. Apply never runs unattended and never
on a schedule; it is the one action that changes running software.
In a project with an attestation, the hook holds the agent to it:
pip install requests is rewritten to requests==<attested>; pip install requests==<other> asks ("attested at X, this installs Y; approve or run
plan"); the attested version passes silently. Policy: pin_to_attestation,
ask_on_version_change.
Lists what a project actually has before anything is planned: every
requirements/lockfile with its path, virtualenvs inside the tree, conda envs
named after the project or its app folders, Dockerfiles with their FROM
image and install lines, compose services, .python-version/.nvmrc. It
also looks at how the tests would run: the runner, whether a pytest config
exists, whether DJANGO_SETTINGS_MODULE is set anywhere (and the one-line
pytest.ini that fixes it if not), and whether the suite needs a database or
redis. It ends with what plan will use, or, if there is no local
environment, the exact commands to create one. plan and prepare run this
first.
The one command that installs anything, and it only installs the project's own declared pins into the project's own environment:
scgate prepare Verity
environment: ~/miniconda3/envs/verity/bin/python (conda env 'verity' (matches the project name), 0 package(s) installed)
requirements: apps/api/requirements.txt, apps/api/requirements-dev.txt
gate check: 41 package(s) in the requirements, ok all pass
Will run:
~/miniconda3/envs/verity/bin/python -s -E -m pip install -U pip
~/miniconda3/envs/verity/bin/python -s -E -m pip install -q -r apps/api/requirements.txt -r apps/api/requirements-dev.txt
python3 scgate.py plan ~/Verity
Proceed? [y/N]
It takes what scout found, runs the gate's own checks on every package in
the requirements (a hallucinated name refuses the whole thing), shows the
exact commands, asks once, creates the environment if there isn't one
(conda if conda is present, else a .venv; --conda/--venv to choose),
installs with -s -E, then runs plan. --yes for scripts.
scgate plan Verity dry run: nothing is installed or changed
environment: /home/andrew/Verity/.venv/bin/python (python 3.12.3, .venv/ in the project)
Python: 3 upgrades requested Resolved by pip:
pyjwt 2.10.1 -> 2.13.0 pyjwt 2.10.1 -> 2.14.0 (newer than the 2.13.0 minimum)
...
Downstream: 1 other package would change
pygments (new) -> 2.21.0
Upstream: 3 constraints checked, FAIL 1 conflict
- fakeapp requires requests <2.33, but requests would become 2.34.2
Risk notes:
- pyjwt 2.14.0 was published 5 day(s) ago; consider waiting a week
- pytest: major version jump 8.3.4 -> 9.1.1; read its changelog first
Commands (not run):
/home/andrew/Verity/.venv/bin/python -m pip install 'pyjwt>=2.13.0,<3' ...
Scouts the environment deliberately (--env=, else .venv/venv in the
project, else a conda env named after the project) and never the active
shell, so a plan can't land in the wrong environment. Asks pip to resolve
without installing (--dry-run --report, pip 23.1+) for the downstream
graph, checks every installed package's declared constraints against the new
versions for upstream conflicts, and runs the same due diligence on the
targets (too new, yanked, major jump). Writes .scgate/plan.json. Applying
is deliberately a separate, human-driven step.
scgate.py env-audit /path/to/python audits what an interpreter has
installed, independent of any project (a conda env, a server venv).
Dockerfiles count as manifests: inline RUN pip install x==1.2 lines are
read into the attestation like a requirements file, and scout flags
compose services on floating image tags (:latest or none).
scgate environment Ubuntu 24.04.4 LTS (checked 2026-09-15T20:42:22+00:00)
[FAIL] 190 package update(s) pending, 175 security
[OK] Python 3.12 supported until 2028-10-02
[OK] Node 22 supported until 2027-04-30
apt / dnf / pacman / macOS software update / Homebrew; reboot-required;
stale apt lists; Python and Node end-of-life dates; Claude Code version vs
npm latest; WSL and container notices. Read-only, never patches anything,
cached 24h (--force to refresh). Runs at SessionStart so the first thing
you see in a project is its health.
scgate.py self-update # fetch, verify, show diff + hashes, askReleases are signed with an OpenSSH ed25519 key (ssh-keygen -Y, available
on any Linux/macOS since OpenSSH 8.0). The installed copy holds the
maintainer's public key in policy.json → release_signers. A download that
fails verification, or that was re-signed with a different key, is refused,
nothing on disk changes, and the attempt is written to the ledger. There is
no silent or automatic path; you see the version, both hashes and the line
counts, and you say yes. A backup of the previous file is kept.
Maintainer side:
ssh-keygen -t ed25519 -C "release@yourorg" -f release_key
python3 scgate.py sign release_key scgate.py # -> scgate.py.sig
# publish scgate.py + scgate.py.sig at release_url; ship release_signers = ["release@yourorg ssh-ed25519 AAAA..."]shim install writes a small wrapper for each package manager found on the
machine into ~/.claude/scgate/bin/ and puts that directory first on PATH.
The wrapper asks the gate, then either runs the real binary, refuses, or
asks you at the terminal. When there is no terminal (an agent's shell),
"ask" becomes "refuse with instructions": approve it once in a terminal, or
add it to the catalogue. Nothing the agent can type turns an ask into a yes.
Inside Claude Code the two layers cooperate: when the hook says ask and you approve in the UI, the hook leaves a signed, single-use, 15-minute approval record so the shim underneath doesn't ask again. A forged record (wrong HMAC) is ignored.
Agents that spawn their own shells must see the PATH: set it in the agent's environment settings or launch the agent from a shell that has it.
Calls. The mcp__.* hook inspects every MCP tool call: unknown server →
ask (catalogue them in policy.mcp.allow_servers); write/send-shaped tool
names → ask; arguments carrying anything that looks like an API key, token,
private key or JWT, or referencing a secret file path → deny; oversized
payloads → ask (bulk exfil).
Servers. scgate.py mcp-audit reads .mcp.json and ~/.claude.json
and checks what the agent will start: npx -y <pkg> servers are looked
up like any package (a hallucinated server name is a deny), unpinned ones
are flagged because npx fetches latest on every launch, remote servers
over plain http fail, and credentials sitting in env are flagged.
judge_command is run on every ask. The built-in one
("judge_command": "python3 ~/.claude/scgate/scgate.py judge") reads the
human's last messages from the session transcript, wraps task, action and
findings as data inside delimiters, and asks a small model
(judge_model, default haiku, via the claude CLI) one question: is this
action consistent with what the human asked, and how risky is it? Its
sentence is appended to the prompt you see. It is advisory only: it can be
injected too, so it never turns an ask into an allow.
python3 ~/.claude/scgate/scgate.py proxy --port=8791
# then in policy.json: pip_index_url = http://127.0.0.1:8791/simple/ npm_registry = http://127.0.0.1:8791/
python3 ~/.claude/scgate/scgate.py lockdownA ~100-line PyPI-simple / npm-metadata proxy, stdlib only. It serves only
catalogue packages (proxied from upstream; files still come straight from
files.pythonhosted.org / registry.npmjs.org). Anything else gets a 404
with an approval instruction and a quarantine ledger record. That means
/usr/bin/pip, python -m pip, a Dockerfile, a Makefile, or any agent
under any name cannot reach a package you haven't approved. Tested with
real pip download and npm view.
Make the catalogue strict and usable with allow --with-deps, which
resolves the dependency tree from the registry (requests → certifi,
charset-normalizer, idna, urllib3; express → 62 packages).
scgate.py quarantine-report lists every name that was asked for and
refused: hallucinations, typos and slopsquat probes. That list, anonymised
and aggregated, is what registries need and don't have.
Every name an agent asked for that scgate refused is a data point registries don't have. Once a month:
python3 ~/.claude/scgate/scgate.py quarantine-reportIt prints package names and counts only, never paths, hostnames or commands.
Read it, delete anything that names a private/internal package, then either
open a Quarantine report issue on GitHub or email it to
reports@iqabel.com. Aggregated and anonymised, this is what goes to PyPI
and npm.
~/.claude/scgate/ledger.jsonl: every gate decision (hook or shim), attest,
audit, anchor and self-update attempt, hash-chained (SHA-256 over previous
hash + canonical record). verify-ledger walks the chain and names the
exact line if anything was altered.
A hash chain is tamper-evident, not tamper-proof: someone with root can
rewrite the whole chain consistently. scgate.py anchor prints the
current head hash and record count, writes it to anchors.log and, inside
a project, to .scgate/anchor.json (commit it; a pushed signed commit is a
witness the machine can't reach). verify-ledger checks every anchor; a
consistent rewrite that erases a record fails with ANCHOR MISMATCH.
Copy the anchor line somewhere off-machine on a cadence. Set ledger_url
to POST each record to a signing backend as well.
| Key | Default | Meaning |
|---|---|---|
mode |
enforce |
audit = log everything, block nothing. Run this for a day first. |
min_package_age_days |
30 | Younger than this → ask |
prompt_on_clean_unknown |
true | Ask even for packages that look fine but aren't in the catalogue |
auto_allow_catalogue |
true | Skip Claude Code's own prompt for catalogue-only installs |
attestation_max_age_days |
30 | Audit fails past this |
session_max_lookups |
40 | Cap live checks at SessionStart; full check via audit |
use_osv |
true | Also query OSV.dev (fails soft) |
pip_index_url / npm_registry |
null | Rewrite installs through your private proxy |
ledger_url |
null | POST every record here (Bearer token from $SCGATE_LEDGER_TOKEN) |
judge_command |
null | Runs on every ask; receives hook JSON + findings; stdout appended to the reason |
release_url / release_signers |
null / [] | Self-update source and the allowed signing keys |
| judge_model / judge_binary / judge_timeout_seconds | haiku / claude / 40 | Built-in judge settings |
| mcp.allow_servers / mcp.deny_servers | [] / [] | MCP server catalogue |
| mcp.ask_on_unknown_server / mcp.ask_on_write_tools | true / true | |
More CLI: shim install [--rc] [--all] | uninstall | status, anchor,
lockdown, verify-ledger [head[:count]], mcp-audit, judge,
proxy [--port=N], quarantine-report, allow <eco> <name> --with-deps.
Unreadable input → exit 0, stays out of the way. Internal error → ask, never a silent allow. Registry unreachable → ask with "could not verify". Signature unverifiable → refuse, log, change nothing.
- Shims can be walked around with a full path (
/usr/bin/pip) orpython -m pip; that's whatlockdownis for, and the proxy behind it should serve only the catalogue. - A long-lived legitimate package with a compromised maintainer shipping something no scanner has seen. Re-verification catches known-bad and changed-since-approval; the sandbox with no credentials catches the rest.
- cargo / go / gem / apt / brew: recognised and asked about, no registry or vulnerability lookup yet.
- Windows native: hooks run under bash; use WSL.