Skip to content
andrewabel1Public

About

Supply-chain gate for AI coding agents: blocks hallucinated packages, curl|sh and secret exfiltration before they run; attests and re-audits every project's dependencies; hash-chained ledger. One file, stdlib only.

Topics

Resources

Contributing

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

scgate — supply-chain gate for AI coding agents (v0.6)

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.

The four hooks

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

Where enforcement sits (three layers, one file)

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.

Install as a Claude Code plugin

/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 by hand

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

What the gate stops (PreToolUse)

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.

The imprint: .scgate/attestation.json

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.

What the output looks like

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.

Re-verification: scgate.py audit

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.

Apply and rollback

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.

Version enforcement (the gate half of "correct versions")

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.

Scout: scgate.py scout

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.

Prepare: scgate.py prepare

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.

Plan: scgate.py plan (changes nothing)

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).

OS / runtime check: scgate.py os

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.

Signed self-update

scgate.py self-update          # fetch, verify, show diff + hashes, ask

Releases 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..."]

PATH shims: the same gate for every agent

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.

MCP: the calls and the servers

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.

The judge

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.

The proxy: catalogue-only, with quarantine telemetry

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 lockdown

A ~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.

The ask: send your quarantine report

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-report

It 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.

Ledger

~/.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.

Tuning (~/.claude/scgate/policy.json)

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.

Fail-safe behaviour

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.

What it does not do (yet)

  • Shims can be walked around with a full path (/usr/bin/pip) or python -m pip; that's what lockdown is 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.

About

Supply-chain gate for AI coding agents: blocks hallucinated packages, curl|sh and secret exfiltration before they run; attests and re-audits every project's dependencies; hash-chained ledger. One file, stdlib only.

Topics

Resources

Contributing

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages