@heysalad/junoguard
v0.1.0
Published
Supervision for AI coding agents — scan every package before it installs, cap what your agent spends. MCP server + CLI.
Downloads
110
Maintainers
Readme
@heysalad/junoguard
Supervision for AI coding agents. Juno watches what your agent installs and what it spends, and stops it before damage lands.
An AI coding agent is a program with your credentials, your shell, and no judgement. It will install packages from registries anyone can publish to, run postinstall scripts with full access to your environment, and make model calls in a loop until something stops it.
JunoGuard is the something.
Try it in ten seconds
No gateway, no account, no key — offline fixtures, no network at all:
JUNO_MOCK=1 npx @heysalad/junoguard scan @ossprey/test-packageThen wire it into your agent
npx @heysalad/junoguard init --mock # offline, to see it working end to end
npx @heysalad/junoguard init # against your own gateway (see below)That writes MCP config for every AI coding agent it finds on your machine. From then on, every package your agent tries to install is checked first.
This package is a client. It renders decisions; it does not make them. Live use needs a JunoGuard gateway — yours, self-hosted — and a project key. There is no hosted service and no default key: shipping one would point your installs at someone else's server with a credential you did not choose. Without either, every action is refused rather than waved through.
What your agent sees
When it tries to install something malicious:
╭─ JUNO · BLOCKED ─────────────────────────────────────╮
│ @ossprey/test-package (npm) │
╰──────────────────────────────────────────────────────╯
VERDICT malicious · via Ossprey
Obfuscated postinstall script
Outbound POST on install
BLAST RADIUS if this had installed
credentials in scope OPENAI_API_KEY,
SUPABASE_SERVICE_ROLE_KEY
network egress unrestricted
write access open repository
→ full production credential compromise
This package was not installed. Choose a different dependency.Blocking is table stakes. The blast radius — what would have happened — is computed from the agent's actual scope, with no LLM involved.
That last line matters more than it looks. It is written for the agent, not just for you: it says plainly that nothing was installed and that it should pick something else, so the agent self-corrects instead of retrying blindly or reaching for a different package manager.
An allow is one quiet line, because allows happen constantly:
✓ juno · allow — express (npm) · cleanConnect it to your agent
npx @heysalad/junoguard init # detect installed agents, configure each
npx @heysalad/junoguard init cursor # or name them
npx @heysalad/junoguard init --global # user-level instead of project-level
npx @heysalad/junoguard init --dry-run # show what would change, write nothing| Agent | Project scope | User scope |
|---|---|---|
| Cursor | .cursor/mcp.json | ~/.cursor/mcp.json |
| Claude Code | .mcp.json | claude mcp add -s user junoguard -- npx -y @heysalad/junoguard mcp |
| Codex | — | ~/.codex/config.toml |
| VS Code | .vscode/mcp.json | user mcp.json |
| Windsurf | — | ~/.codeium/windsurf/mcp_config.json |
init merges into whatever is already there and never replaces an existing
junoguard entry without --force. If a config file cannot be parsed, it
prints the snippet for you to paste rather than overwriting your settings.
Restart the agent, or refresh its MCP panel, to pick up the change.
Prefer to wire it yourself:
{
"mcpServers": {
"junoguard": {
"command": "npx",
"args": ["-y", "@heysalad/junoguard", "mcp"]
}
}
}Tools
| Tool | Purpose |
|---|---|
| guard_install(package, ecosystem, version) | Scan a dependency before it reaches disk |
| guard_llm(prompt, model, max_output_tokens) | Proxied model call under budget and burst policy |
| guard_status() | Spend today, budget remaining, rate, open incidents |
guard_status matters: the agent can check its own budget before a run of
expensive work, rather than discovering the limit by hitting it.
The CLI
npm i -g @heysalad/junoguardjuno status # budget, spend, rate limit, incidents
juno scan express # scan without installing
juno scan requests -e pypi # scan a PyPI package
juno npm install react zod # scan, then run the real npm
juno pip install requests # scan, then run the real pip
juno watch # tail the live decision feedThe forwarder is the one that matters. It scans every named package and only shells out to the real package manager if all of them come back clean:
$ juno npm install express @ossprey/test-package
✓ juno · allow — express (npm) · clean
╭─ JUNO · BLOCKED ─────────────────────────────────────╮
│ @ossprey/test-package (npm) │
╰──────────────────────────────────────────────────────╯
...
npm was not run. 1 of 2 packages blocked: @ossprey/test-package
$ echo $?
2Flags reach the package manager untouched and in their original order. Local paths and git/http installs cannot be scanned by name, and the CLI says so out loud rather than implying they were approved.
Exit codes
| Code | Meaning |
|---|---|
| 0 | allowed (or flagged — flags do not stop the install) |
| 2 | blocked by policy |
| 3 | the guard could not be consulted |
| 4 | no project key configured |
These are deliberately distinct: "Juno said no", "Juno was down" and "you never set Juno up" are three different problems. All of them stop the install.
Configuration
| Variable | Default | Meaning |
|---|---|---|
| JUNO_PROJECT_KEY | none — required for live use | Sent as X-Juno-Key |
| JUNO_API_URL | http://localhost:8000 | Gateway base URL |
| JUNO_MOCK | unset | 1 for offline fixtures — no network, no key needed |
| JUNO_TIMEOUT | 20 | Seconds before a gateway call gives up |
There is deliberately no default project key. Without one, every surface
returns a JUNO · NOT CONFIGURED refusal and the CLI exits 4 — it does not
fall back to running your package manager unguarded.
Running a gateway
The gateway is the policy engine: Ossprey verdicts, budgets, rate limits, the kill switch. It is a separate FastAPI service in the project repository.
git clone https://github.com/cosmicoral/TokenGuard
cd TokenGuard/backend
python -m venv .venv && source .venv/bin/activate
pip install -r requirements.txt
cp .env.example .env # add Supabase and Ossprey credentials
uvicorn app.main:app --reloadThen point this package at it:
export JUNO_API_URL=http://localhost:8000
export JUNO_PROJECT_KEY=<your project key>
npx @heysalad/junoguard initOffline mode
JUNO_MOCK=1 makes every surface return realistic canned responses with no
network — useful for trying the tool, and for demos where the gateway may not
be up.
| Input | Result |
|---|---|
| @ossprey/test-package | block · malicious · full blast radius |
| @ossprey/suspicious-package | flag · unknown provenance |
| any other package | allow · clean |
| guard_llm with max_output_tokens > 2000 | block · per-request cap |
| guard_llm otherwise | allow · fixture answer, clearly labelled |
Fixture verdicts render as "via Ossprey (offline fixture)", never as a live scan, and the fixture model answer says outright that no model was called.
Design
A block is not an error. The gateway returns HTTP 200 with
decision: "block", and the MCP tools return it as a normal successful result.
Throwing would surface as a tool failure, and a failed tool is something an
agent retries.
The return value is the interface. What the tools return is rendered straight into the agent's chat, so visual weight matches severity: an allow is a single quiet line, a flag is a square-bordered caution, a block is a rounded-border refusal with the blast radius under it.
An unreachable guard is not permission. If the gateway cannot be consulted,
every surface returns a refusal-shaped JUNO · UNAVAILABLE panel and the CLI
exits non-zero without running your package manager. Failing open would defeat
the point.
No LLM on the hot path. Package verdicts, token accounting, cost, rate limiting and budget enforcement are all deterministic. We do not spend AI tokens to protect AI tokens.
Requires Node 18+. Talks to a JunoGuard gateway; see the project README for running one.
MIT.
