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

@guardion/shadow-ai

v0.1.0

Published

Cross-platform Shadow AI discovery — inventories AI apps, agents, MCP servers, skills and plugins on a device

Readme

@guardion/shadow-ai

Cross-platform Shadow AI discovery for macOS, Linux and Windows. Finds unmanaged AI apps, agents, IDE assistants, local LLM runners, MCP clients and servers, skills, plugins and subagents on a device, scores each MCP server and skill for risk, resolves where each one came from, and reports what changed since the last run.

This package is connector-agnostic — nothing in it depends on Claude Code, or on any other host. @guardion/guardion depends on it and adds only the CLI surface and transport.

plugins/guardion/
  shadow-ai/            ← this package (@guardion/shadow-ai)
  bin/cli.ts            ← @guardion/guardion: the CLI that exposes it
  core/                 ← shared CLI modules, incl. shadow.ts (transport)
                          and schedule.ts (recurring scans)

What it produces

Three things a plain inventory does not:

  1. A security score per MCP server and skill, from rules run against the asset itself — including optionally probing MCP servers for the tools they really expose.
  2. Provenance — author, source GitHub repository and source URL — so a backend can scan the upstream code.
  3. Drift — a local fingerprint baseline turns "this exists" into "this changed", including MCP tool-surface rug pulls.

Two-axis risk

Severity (is it shadow?) and security score (is the asset dangerous?) stay separate until the end:

                    security band
severity      LOW      MEDIUM    HIGH     CRITICAL
  high        P2        P1        P0        P0
  medium      P3        P2        P1        P0
  low         P3        P3        P2        P1

A sanctioned, enterprise-licensed Claude Code (severity low) running a filesystem MCP that exposes execute_command (security CRITICAL) is still P1 — the finding a pure inventory cannot produce.

Usage

import { runScan, renderReport } from '@guardion/shadow-ai';

const { snapshot } = await runScan({ profile: 'complete' });
console.log(renderReport(snapshot));

Via the CLI (@guardion/guardion):

guardion shadow-ai                      # dry run — scan, score, report locally
guardion shadow-ai --json               # full snapshot as JSON
guardion shadow-ai --changes-only       # only what drifted
guardion shadow-ai --inventory          # send one batch at the end
guardion shadow-ai --probe-mcp          # also spawn stdio MCP servers (opt-in)
guardion shadow-schedule --install --at 03:30  # recurring scan

Architecture

Two waves, not one flat fan-out:

| Wave | Modules | Needs | | ----- | --------------------------------------------------------------------------- | ------------------------- | | A | applications, binaries, extensions, mcp-config, skills, plugins, filesystem | filesystem + parsing only | | B | processes, services, packages, network, docker, mcp-probe | subprocesses or sockets |

Wave A always runs, so --no-exec is a strict subset rather than a special case, and a wave-B failure never costs wave-A results. Every module is deadline-wrapped and failure-isolated; every budget guard records what it dropped into scan.coverage, because silent truncation would read as "covered everything".

| Module | Purpose | | ---------------- | ------------------------------------------------------- | | registry.ts | signature catalog — 49 apps/agents across 8 categories | | mcp-sources.ts | the host × path × key matrix (25+ MCP hosts) | | platform.ts | PlatformAdapter for darwin / linux / win32 | | exec.ts | safe command runner | | redact.ts | redaction, sanitisation, hashing | | parse.ts | JSON / JSONC / TOML / YAML, frontmatter, bounded walker | | provenance.ts | command→package, git remote, seed catalog | | security/ | rule engine + skill/MCP rule sets | | normalize.ts | classification, dedup, two-axis risk | | baseline.ts | fingerprint store + drift | | license.ts | subscription / ownership | | report.ts | terminal renderer |

The MCP config key is not constant

A scanner keyed only on mcpServers misses five major hosts:

| Key | Hosts | | ----------------- | ------------------------------------------------------------------------------------------------- | | mcpServers | Claude Desktop/Code, Cursor, Windsurf, Gemini, Cline/Roo/Kilo, Junie, Warp, LM Studio, Amazon Q … | | servers | VS Code, Copilot-for-JetBrains | | context_servers | Zed | | mcp.servers | OpenClaw | | mcp | opencode, Crush | | extensions | Goose (also uses cmd/envs, not command/env) | | [mcp_servers.*] | Codex (TOML) |

Two more things that matter: ~/.claude.json holds most real entries under projects["<abs>"].mcpServers, so it needs a recursive walk; and enterprise configs live outside $HOME (/etc/claude-code, C:\Program Files\ClaudeCode, /etc/gemini-cli), so a home-only scan misses fleet-wide servers.

Provenance tiers

| Status | Meaning | Sources | | -------------- | --------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ | | resolved | authoritative | plugin.json ⋈ known_marketplaces.json ⋈ marketplace.json, .gemini-extension-install.json, VS Code extension package.json, git remote, seed catalog | | asserted | self-declared, spoofable | SKILL.md metadata.author, ~/.claude/skills/manifest.json | | pending | we have a locator, the backend resolves | npm/PyPI/OCI package id, remote URL, content_sha256 | | unresolvable | nothing to go on | unmanaged local builds |

author and version are not fields in the Agent Skills spec — they are conventions inside the free-form metadata map, which is why a skill's self-declared author is asserted, never resolved. The endpoint never calls a registry: it emits pending with a locator and the backend finishes the job, so the scan stays offline and resolution caches per asset fleet-wide.

Security rules

Weighted scoring:

points  = { CRITICAL: 50, HIGH: 25, MEDIUM: 10, LOW: 5 }
weights = [1.0, 0.5, 0.25]     per-rule diminishing returns, max 3 occurrences
score   = Σ points × weight × confidence            capped at 100
bands: 0–20 LOW · 21–50 MEDIUM · 51–80 HIGH · 81–100 CRITICAL

Diminishing returns stop one noisy pattern pegging the score; confidence multiplies rather than filters; and a partial inspection can never yield a clean band.

Skills: wildcard/shell allowed-tools, hidden Unicode, hidden markup comments, injection phrasing, remote-exec, obfuscation, credential access, agent snooping, self-modification, persistence, exfiltration-to-collector, provenance. MCP: unpinned launch, local build, auto-approve, headersHelper, inline secret, broad filesystem root, unknown remote, no provenance, plus probe-derived dangerous tools and tool poisoning.

The Unicode detector handles Tag-block smuggling (U+E0000–E007F) with a carve-out for well-formed RGI subdivision-flag sequences, so a payload cannot be laundered as 🏴<tags>, and a ZWJ guard so a family emoji is not a finding.

SKILL.md is deliberately exempt from the documentation de-weighting applied to other markdown: it is the agent's instruction file, where a credential reference may be a genuine instruction rather than an example.

MCP probing tiers

| Tier | What | Default | | --------- | ---------------------------------------------------------------------------------- | ---------------------- | | 0 passive | parse configs only | on | | 1 attach | JSON-RPC initialize + tools/list against already-listening localhost ports | on | | 2 spawn | launch a configured stdio server sandboxed | off (--probe-mcp) | | 3 remote | probe a remote MCP URL | off (--probe-remote) |

Tier 2 is opt-in because launching a stdio MCP server executes third-party code. It runs with a minimal environment (no inherited secrets), a temp cwd, a hard timeout, and the same refusal to execute a user-writable binary while privileged.

tools_hash = sha256 over sorted name:sha256(description + canonical inputSchema) — hashes, not text, so the payload stays small while a rug pull stays detectable.

Safety guarantees

All enforced in code and covered by tests:

  • Never execute a discovered binary to read its version. The bash prototype's "$found_path" --version on binaries in user-writable directories is a local privilege-escalation vector under MDM. Versions come from Info.plist, package.json, dist-info, Homebrew Cellar paths or the registry.
  • execFile only — never a shell, fixed argv, hard timeouts, output caps, and a trusted-command allowlist that deliberately contains no AI binaries.
  • Never print a secret. No security find-generic-password -w, no credential-helper execution (the helper's name is the evidence), no interactive keychain unlock.
  • Redaction before emit and before hashing — so rotating a secret is not mistaken for configuration drift.
  • No file contents leave the device — paths, key paths, hashes and field names only.
  • Email hashing keeps the domain (HMAC(salt, localpart)[:12]@domain) because the domain is the shadow-AI signal, while the identity is destroyed. The salt is per-deployment and never ships with the binary.

Timestamps — when it arrived, when it was last used

Every asset carries an AssetTimestamps block: installed_at, last_used_at, the raw created_at / modified_at / accessed_at of its primary path, the source each headline value was derived from, whether the last-used value is approximate, and days_since_last_use.

Fidelity differs by asset type, and the schema says so rather than papering over it:

| Asset | installed_at | last_used_at | | ----------------------- | ---------------------------------------------------------------- | --------------------------------------------------------------- | | macOS app / Windows exe | bundle or exe birthtime → Info.plist / registry InstallDate | session/log artifact → app support dir → corroborated atime | | CLI binary | birthtime, or the Homebrew Cellar/<pkg>/<ver> dir | the agent's own session dir / history file → corroborated atime | | npm / pip package | node_modules/<pkg> or *.dist-info birthtime | usually null — importing a library leaves no trace | | IDE extension | extension dir birthtime → extensions.json | per-extension globalStorage/<id> mtime | | MCP server (config) | declaring config file's birthtime (a floor) | a matching running process, else config_mtime = last edited | | Skill | SKILL.md birthtime | no host writes an invocation record → mtime/atime only | | Plugin | <mkt>/<plugin>/<version>/ birthtime — the most precise we have | null unless atime corroborates a load | | Process | the image on disk, when the command line is absolute | running_nowexact | | Network connection | null | running_now — an established provider connection is use | | Docker | container/image CreatedAt | running_now for a container, null for an image |

The atime caveat

relatime — the Linux default, and what essentially every distro mounts with — updates atime only when the file is read and the stored atime is older than mtime or more than 24h old. noatime never updates it. macOS and Windows defer it too. So atime is a real "last read" signal sometimes and silently months stale the rest of the time.

  • accessed_at is always recorded; it is real data whatever it means.
  • atime is never chosen as last_used_at on its own. It wins only when it is strictly newer than mtime, which under relatime proves a read happened after the last write — and never under noatime, where it proves nothing.
  • Anything from a proxy (a file mtime, a directory mtime, atime) sets last_used_is_approximate. Only a live process/connection, a session file or a log write is direct evidence of use.
  • Mount options come from reading /proc/mounts directly — no new entry in TRUSTED_COMMANDS — and the verdict ships as summary.timestamps.atime_trust.

A timestamp we did not observe is null. Never mtime relabelled as birthtime, never a plausible guess. A birthtime of 0, or later than mtime, is treated as unavailable.

Merge rules when many hits collapse into one asset: earliest non-null installed_at (it was installed once), latest non-null last_used_at carrying its own source, last_used_is_approximate from the winning value, filesystem times from the primary hit, days_since_last_use computed once afterwards. Timestamps are stored in the baseline for reference but are excluded from the drift comparisonlast_used_at moves on every scan, so comparing it would mark the whole estate permanently modified.

Drift

Baseline at ~/.guardion/shadow-ai/assets.json (atomic write, mode 0600). Four hashes:

| Hash | Catches | | -------------- | -------------------------------------------------------------- | | config_hash | a server repointed, args/env changed, autoApprove flipped on | | content_hash | skill instructions edited | | tree_hash | a bundled script changing while SKILL.md is identical | | tools_hash | the MCP tool surface changing (rug pull) |

Events: added, removed, modified:config|content|source|severity, moved, unchanged. modified:source — the author or repository changing for the same asset — escalates straight to high, because that is a supply-chain takeover signature. A partial scan suppresses removed, so a fast profile cannot report network-discovered assets as having disappeared.

Testing

npm test          # 274 tests

Covers the Unicode carve-outs, over-permissioning, injection, the scoring math, FP suppression, every MCP config dialect, command→package parsing for each runner form, the redaction guarantees, a reference-host fixture that collapses 150 raw hits to 11 assets with instance_count: 110 on the filesystem MCP, and the timestamp rules: earliest-install / latest-use merge, the atime corroboration requirement, and the guarantee that last_used_at never reads as drift.

License

MIT © Guardion AI.