@grovetech/defender
v0.25.0
Published
Active runtime protection for vibe-coded apps — drops in as Express middleware (web + AI layer), blocks prompt injection, PII leaks, and sensitive paths in real time. By Grovetech AI.
Maintainers
Readme
@grovetech/defender
Active runtime protection for vibe-coded apps. Drop-in Express middleware that blocks prompt injection, sensitive-path probes, and PII / API-key leaks in your LLM responses — in real time, without touching anyone else's systems.
Defender is the active counterpart to the Grovetech Vibe Code Health Scanner. The scanner is a one-shot pen-test; Defender is permanent runtime defence.
Install
npm install @grovetech/defenderGet an API key at https://grovetechai.com/dashboard?tab=defender. Free plan includes 1 000 requests/month with the input guard enabled.
Try it without installing anything — open the live demo on Replit or read the source in
server.ts.
Quick start (Express)
import express from "express";
import { defender } from "@grovetech/defender";
const app = express();
app.use(express.json());
const d = defender({ apiKey: process.env.GROVETECH_DEFENDER_KEY });
// Web layer — security headers + sensitive-path block (.env, .git/*, …)
app.use(d.web());
// AI layer — screens req.body.prompt / .messages for prompt injection
app.use("/api/chat", d.ai());
// Output guard — wrap your own LLM call
app.post("/api/chat", async (req, res) => {
const reply = await callOpenAi(req.body.prompt);
const out = d.guardOutput(reply);
if (!out.allowed) return res.status(502).json({ error: "Output blocked" });
res.json({ reply });
});
app.listen(3000);Build-time secret check
Add to package.json:
{ "scripts": { "postbuild": "defender check ./dist" } }The build fails if any leaked OpenAI / Anthropic / AWS / Stripe key, GitHub PAT or PEM private key is found in the emitted bundle.
What gets blocked
| Layer | Examples |
| -------- | -------------------------------------------------------------- |
| Web | /.env, /.git/config, /wp-config.php, missing CSP/HSTS |
| AI input | "ignore previous instructions", DAN/jailbreak, token bombs |
| AI output| Leaked sk-…, AKIA…, ghp_…, PEM keys, system-prompt leak |
Detection patterns are recycled from the same engine that powers our hosted
scanner (server/security-scan.ts, server/vibe-coding-scan.ts,
server/ai-agent/attacks.json) so what we test for in audits is what we block
at runtime.
AI conversation defense (new in v2)
Three opt-in helpers for chatbot and RAG apps. None of them auto-mount — call them where you want the protection.
// 1. Multi-turn jailbreak + per-session rate limit
app.use("/api/chat", d.conversationGuard({
sessionIdFrom: (req) => req.body.sessionId,
// defaults: 20 msg/min, 50k tokens/h, window 10, threshold 6.0
}));
// 2. RAG poisoning guard — invisible Unicode, embedded directives,
// markdown image exfil, base64 smuggling. Two hooks:
const rag = d.rag();
app.post("/api/docs", async (req, res) => {
const r = await rag.inspectIngest({ id, source, content });
if (r.blocked) return res.status(400).json({ findings: r.findings });
await vectorDb.upsert({ id, source, content: r.sanitizedContent });
});
app.post("/api/chat", async (req, res) => {
const chunks = await vectorDb.query({ ... });
const { allowed } = await rag.inspectRetrieval(chunks);
// pass only `allowed` to your LLM
});
// 3. Behavioral anomaly — z-score on message length + gap between messages
const anomaly = d.anomalyDetector();
const obs = await anomaly.observe(sessionId, { text: req.body.message });
if (obs.suspicious) { /* log, captcha, ban — your choice */ }Full reference, defaults table, rollout playbook and privacy notes:
docs/defender/conversation-rag-anomaly.md.
Status: beta — covered by 599 unit tests (packages/defender/src/*.test.ts),
dogfooded on the Viki chat widget at grovetechai.com in audit-only mode. Start
with audit, flip to block after a week of clean logs.
Tool-call guard (new in 0.4.0)
Deterministic allowlist/denylist for AI agents with tools — decide which
tools the agent may call before execution. Glob patterns (* = one
segment, trailing .* = one or more), deny wins over allow, empty allow
= audit mode (nothing blocked, fail-open).
const guard = d.tool({
allow: ["search", "db.read.*"],
deny: ["db.write.*", "payments.*"],
});
// Before executing a tool call:
const v = guard.check({ tool: "db.write.payments" });
// { allowed: false, rule: "tool_denylist", matched: "db.write.*" }
// Or wrap an OpenAI/Vercel AI SDK-style tools array — disallowed tools are
// removed from the list, allowed ones get a runtime check around execute():
const safeTools = guard.wrap(tools);Violations report to telemetry as layer tool (dashboard on Pro+; local
enforcement always works regardless of plan).
Agent layer (0.6.0 – 0.9.0)
Once the agent can act, screening the prompt is no longer enough. These four additions decide on a single agent step, right before it executes.
Argument inspection (0.6.0). An attack rarely looks like
payments.send; it looks like an allowed db.query carrying
DROP TABLE. The guard now inspects what gets passed in, not just the
tool name.
guard.check({ tool: "db.query", args: { sql: "DROP TABLE users" } });
// { allowed: false, rule: "tool_args", … }Egress allowlist (0.7.0). With agents, exfiltration usually leaves
through an HTTP request. d.egress() decides on the destination. Internal
ranges and cloud metadata (169.254.169.254, metadata.google.internal)
are blocked by default.
const egress = d.egress({ allow: ["api.openai.com", "*.stripe.com"] });
await egress.check("http://169.254.169.254/latest/meta-data/");
// { allowed: false, rule: "egress_internal" }It is a decision function, not a global patch — call it where the outbound request is actually made, otherwise it protects nothing.
MCP policy + rug pull (0.8.0). The verdict of an MCP configuration scan
gets enforced at runtime. d.mcpPolicy() pulls the policy on its own (ETag,
15-minute interval, on-disk cache for cold start); unknown servers stay
fail-open, dangerous is blocked. Pass definition and a server that
rewrote its tool description after approval is blocked too — even if the
scan marked it safe, which is exactly what a rug pull looks like.
const policy = d.mcpPolicy();
const guard = d.tool({ mcp: () => policy.current() }); // function, not object:
// a fresh scan applies
// without a restartDestructive action budget (0.9.0). A cap on the number of irreversible steps per session — cost guards watch dollars, but five silent deletes cost a fraction of a cent.
d.tool({ budget: { maxDestructivePerSession: 3, destructive: ["db.write.*"] } });Destructive means you declared it, or the arguments gave it away. It is
never guessed from the tool name — search_deleted_items is a read. A
blocked call does not count against the budget, otherwise a series of
rejected attempts would exhaust the cap and turn the protection into a
denial-of-service lever.
Fail-closed mode (0.20.0, OWASP ASI02)
By default the tool guard is fail-open in ambiguous situations: an MCP server the scan has never seen is allowed, and if the policy source throws, the call proceeds with a warning. That is the safe choice for availability, but OWASP ASI02 (Tool Misuse) wants the opposite — when in doubt, stop.
d.tool({ strict: true, /* … */ });strict flips only the ambiguous defaults to block:
- an MCP server not present in the scan,
- the MCP policy source throwing an exception.
It does not override anything you set explicitly (onUnknown /
onMcpPolicyUnavailable always win) and it does not close audit mode — an
empty allow list still means "let everything through and just watch", because
that is a deliberate choice, not ambiguity. Default stays false for backward
compatibility; turn it on once you know your MCP servers.
Model & provider allowlist (0.12.0)
d.tool() guards tools, d.egress() guards generic outbound calls. Neither
catches a compromised dependency rewriting your LLM base URL — at which point
your prompts, system prompt and key go to someone else's server while the app
looks fine.
const m = d.model({ allowModels: ["gpt-4o-mini"], allowHosts: ["api.openai.com"] });
m.guard({ model: "gpt-4o-mini", baseUrl: openai.baseURL }, () => openai.chat…);It is a decision function, not a global patch — call it where the model is
called, or it protects nothing. Note: if you set allowHosts and then call
without baseUrl, the call is blocked. Passing silently would promise
protection we are not performing.
Human approval for dangerous actions (0.12.0)
A third state between allow and deny (OWASP LLM06). The agent may delete a customer or send a payment — but not without a human knowing.
const g = d.tool({
allow: ["db.*", "payments.*"],
approval: { require: ["payments.*", "db.delete.*"], onRequest: sendToSlack },
});
const v = g.check({ tool: "payments.send", session, args });
// { allowed: false, rule: "approval_required", approvalId: "apr_…" }
g.approve(v.approvalId, "ondrej"); // once a human clicksThe grant is single-use, time-limited and bound to a hash of the arguments — approving $100 will not authorise $100,000, and one "yes" does not open the door permanently. How you ask the human (Slack, email, a button in your admin) is up to you.
Order inside the guard: hard rules → approval → budget. A call awaiting approval has not run yet, so it must not consume the destructive-action budget; otherwise three unanswered requests would exhaust the cap.
Reverse proxy for non-Node stacks (0.11.0)
The SDK is Express middleware, so it only runs in Node. To guard a Java, PHP or Python backend, run Defender as a reverse proxy that sits between your TLS terminator and the app — not in front of it:
internet :443 → nginx (TLS, unchanged) → defender :8088 → your app :8080defender proxy --target http://127.0.0.1:8080 --port 8088 # audit
defender proxy --target http://127.0.0.1:8080 --port 8088 --block # enforceIt applies the same engine (sensitive paths, prompt injection on AI routes, telemetry) at the HTTP level. Measured overhead on a 1 KB response: +0 ms at the median, +2 ms at p99.
Three things worth knowing, all found by a customer and fixed in 0.11.0:
- It binds to
127.0.0.1. Earlier versions bound to0.0.0.0, which made the proxy reachable from outside and thus a way around your TLS terminator. Use--hostif you really need another interface — you will get a warning. - WebSocket is tunnelled to the backend. Earlier versions dropped the connection while the docs claimed otherwise. We do not see inside the tunnel, so that traffic is not counted as protected — route it around Defender if its contents matter to you.
- The original
Hostheader is preserved. Use--rewrite-hostfor the old behaviour.
Output guard does not work in proxy mode yet; it needs a hook inside your app.
Cost attribution (new in 0.4.0)
Break down LLM spend by client and project in the dashboard (Pro+). Just
tag your existing recordCost calls:
await d.recordCost(
{ model: "gpt-4o-mini", inputTokens, outputTokens,
tags: { client: "acme", project: "web" } },
);The SDK reports only the model, token counts, cost in micro-USD and the tags — never prompts or PII. Untagged spend shows up as "(untagged)".
Serverless (Vercel, Lambda, Cloud Run) — read this (0.19.0)
Telemetry is batched: it is sent on a timer, not on every request. On a long-running server that is what you want. On serverless it used to mean the batch was lost — the platform freezes the container the moment the response is sent, and the timer never fires.
Since 0.19.0 the SDK detects VERCEL, AWS_LAMBDA_FUNCTION_NAME, K_SERVICE,
FUNCTIONS_WORKER_RUNTIME and NETLIFY, and sends the batch immediately
instead of waiting. Override with defender({ serverless: true | false }).
This improves the odds; it is not a guarantee. If your response returns
before the outbound fetch finishes, the batch is still lost. If the numbers
matter to you, flush explicitly:
app.post("/api/contact", async (req, res) => {
res.json({ ok: true });
await d.flush(); // or the platform's waitUntil()
});Why your dashboard can look empty even though it works
Two things are worth knowing before you conclude Defender is broken:
- Static pages never reach the SDK. On Vercel, HTML is served from the CDN
and the request never touches your Node function, so
d.web()has nothing to run on. Defender only sees the routes your functions actually handle. - A CDN/WAF in front of you eats most of the traffic Defender would report.
If Cloudflare already blocks
/wp-admin/install.php,/.envand friends, those probes die before they reach the app. You are protected — just not by us, and not counted by us. Do not turn that off to make a graph move.
Until 0.19.0 the SDK also sent nothing at all when idle, so last seen meant
"traffic last passed through", not "the SDK is alive". A low-traffic endpoint
therefore showed as offline. 0.19.0 adds a heartbeat every 15 minutes
(heartbeatMs, 0 disables it), so "online" now means what it says.
Plans & limits
| Plan | Req/month | Output | Tool | Conv / RAG / Anomaly | Slack/Teams | | ------ | --------- | ------ | ---- | -------------------- | ----------- | | Free | 1 000 | – | – | rate-limit only | – | | Solo | 10 000 | yes | – | yes | – | | Pro | 100 000 | yes | yes | yes | yes | | Agency | 1 000 000 | yes | yes | yes + custom | yes (custom) |
Telemetry is best-effort — if Grovetech is unreachable, Defender still blocks locally and never crashes your app.
Licence
MIT — © Grovetech AI s.r.o.
