@guardion/shadow-ai
v0.1.0
Published
Cross-platform Shadow AI discovery — inventories AI apps, agents, MCP servers, skills and plugins on a device
Maintainers
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:
- 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.
- Provenance — author, source GitHub repository and source URL — so a backend can scan the upstream code.
- 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 P1A 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 scanArchitecture
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 CRITICALDiminishing 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.mdis 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" --versionon binaries in user-writable directories is a local privilege-escalation vector under MDM. Versions come fromInfo.plist,package.json,dist-info, Homebrew Cellar paths or the registry. execFileonly — 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_now — exact |
| 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_atis always recorded; it is real data whatever it means.- atime is never chosen as
last_used_aton 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 undernoatime, 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/mountsdirectly — no new entry inTRUSTED_COMMANDS— and the verdict ships assummary.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 comparison — last_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 testsCovers 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.
