@codexguild/mcp 0.0.0-stage → 1.4.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/LICENSE +21 -0
- package/README.md +58 -2
- package/dist/codexguild-mcp.js +1243 -0
- package/package.json +53 -4
|
@@ -0,0 +1,1243 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/index.ts
|
|
4
|
+
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
5
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
6
|
+
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
7
|
+
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
|
|
8
|
+
import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
|
|
9
|
+
import { access, readFile as readFile3 } from "node:fs/promises";
|
|
10
|
+
import { isAbsolute, join as join3 } from "node:path";
|
|
11
|
+
|
|
12
|
+
// src/local-scan.ts
|
|
13
|
+
import { readdir, readFile, stat } from "node:fs/promises";
|
|
14
|
+
import { homedir } from "node:os";
|
|
15
|
+
import { join, relative, resolve } from "node:path";
|
|
16
|
+
|
|
17
|
+
// ../scanner/src/index.ts
|
|
18
|
+
import { createHash } from "crypto";
|
|
19
|
+
|
|
20
|
+
// ../scanner/src/rules.ts
|
|
21
|
+
var SENSITIVE = /~\/\.ssh|\bid_(rsa|ed25519|ecdsa)\b|\.aws\/credentials|\.npmrc|\.netrc|\.git-credentials|\.pypirc|\.docker\/config\.json|\.kube\/config|(^|[\s"'/])\.env\b|\b(GITHUB_TOKEN|GH_TOKEN|ANTHROPIC_API_KEY|OPENAI_API_KEY|AWS_SECRET_ACCESS_KEY|NPM_TOKEN|SLACK_TOKEN)\b|security\s+find-(generic|internet)-password|Login Data|Cookies\.binarycookies|keychain/i;
|
|
22
|
+
var OFFICIAL_TOKEN_USE = new RegExp(
|
|
23
|
+
[
|
|
24
|
+
String.raw`^(?!.*https?:\/\/(?!(?:(api\.)?github\.com|uploads\.github\.com)(?![\w.-]))[\w.-]+).*\$\{?(GITHUB_TOKEN|GH_TOKEN)\b`,
|
|
25
|
+
String.raw`^(?!.*https?:\/\/(?!(?:api\.anthropic\.com)(?![\w.-]))[\w.-]+).*\$\{?ANTHROPIC_API_KEY\b`,
|
|
26
|
+
String.raw`^(?!.*https?:\/\/(?!(?:api\.openai\.com)(?![\w.-]))[\w.-]+).*\$\{?OPENAI_API_KEY\b`,
|
|
27
|
+
String.raw`^(?!.*https?:\/\/(?!(?:registry\.npmjs\.org)(?![\w.-]))[\w.-]+).*\$\{?NPM_TOKEN\b`
|
|
28
|
+
].join("|"),
|
|
29
|
+
"i"
|
|
30
|
+
);
|
|
31
|
+
var NETWORK_SEND = /\bcurl\b|\bwget\b|\bfetch\s*\(|requests\.(post|put|get)\b|urllib|http\.request|axios\.|\bnc\s|\bncat\b|\bscp\s|\brsync\s.*@|Invoke-WebRequest|\bwebhook\b/i;
|
|
32
|
+
var WRITE_OP = /(^|[^-=<>&\d])>>?\s*["']?[~/$.\w]|\btee\b|writeFile(Sync)?\s*\(|appendFile(Sync)?\s*\(|open\([^)]*['"][wa]\+?['"]|\bcp\s|\bmv\s|\bln\s+-s|\bsed\s+-i\b|Set-Content|Add-Content/i;
|
|
33
|
+
var AGENT_CONFIG = /\.claude\/(settings(\.local)?\.json|hooks)|(^|[\s"'/`])CLAUDE\.md|(^|[\s"'/`])AGENTS\.md|\.cursor\/rules|\.cursorrules|\.mcp\.json|\.codex\/config|\.gemini\/settings|\.windsurfrules|\.clinerules/i;
|
|
34
|
+
var AGENT_EXTENSIONS = /\.(claude|codex|agents|hermes|cursor)\/(commands|agents|skills)\//i;
|
|
35
|
+
var SHELL_RC = /~\/\.(bashrc|zshrc|bash_profile|profile|zprofile|zshenv)|\$HOME\/\.(bashrc|zshrc|profile)/i;
|
|
36
|
+
var PERSISTENCE = /\bcrontab\b|LaunchAgents|LaunchDaemons|systemctl\s+(--user\s+)?enable|authorized_keys|\/etc\/(sudoers|hosts|profile|rc\.local)|\/etc\/cron/i;
|
|
37
|
+
var EXEC_PAYLOAD = /\b(curl|wget|nc|ncat|eval|base64|python3?\s+-c|node\s+-e)\b/i;
|
|
38
|
+
var LINE_RULES = [
|
|
39
|
+
// ── critical: remote code execution & exfiltration ──
|
|
40
|
+
{
|
|
41
|
+
id: "pipe-to-shell",
|
|
42
|
+
severity: "critical",
|
|
43
|
+
title: "Downloads and executes remote code",
|
|
44
|
+
why: "Piping a download into a shell runs whatever the server returns at that moment \u2014 the content can change after review.",
|
|
45
|
+
scope: "all",
|
|
46
|
+
pattern: /\b(curl|wget|iwr|Invoke-WebRequest|Invoke-RestMethod)\b[^\n|]*\|\s*(sudo\s+(-\S+\s+)*)?((ba|z|da|k|fi)?sh|python3?|node|perl|ruby|iex|Invoke-Expression)(?=\s*$|\s*[;&|)`'"]|\s+-s\b|\s+-\s|\s+--\s)|\b(eval|source|\.)\s+["']?[$<]\(\s*(curl|wget)\b|\bpython3?\s+-c\s+["']?\$\(\s*(curl|wget)/i
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
id: "reverse-shell",
|
|
50
|
+
severity: "critical",
|
|
51
|
+
title: "Reverse shell pattern",
|
|
52
|
+
why: "Opens an interactive shell to a remote host \u2014 full remote control of the machine.",
|
|
53
|
+
scope: "all",
|
|
54
|
+
pattern: (
|
|
55
|
+
// /dev/tcp only when a shell's stdin/stdout is wired to it — `cat < /dev/null > /dev/tcp/host/port` is a port probe.
|
|
56
|
+
/(>&|<>|0<&)\s*\/dev\/(tcp|udp)\/|\/dev\/(tcp|udp)\/[^\n]*(0>&1|[<>]&\s*\d|\|\s*\/?(bin\/)?(ba|z)?sh\b)|\bnc(at)?\b[^\n]*\s(-e|-c|--exec|--sh-exec)\s|\bsocat\b[^\n]*\bexec:|\bbash\s+-i\s*>&|pty\.spawn\(\s*["']\/bin\/(ba)?sh/i
|
|
57
|
+
)
|
|
58
|
+
},
|
|
59
|
+
{
|
|
60
|
+
id: "encoded-exec",
|
|
61
|
+
severity: "critical",
|
|
62
|
+
title: "Executes base64/obfuscated payload",
|
|
63
|
+
why: "Decoding a payload straight into a shell or eval hides what actually runs from reviewers.",
|
|
64
|
+
scope: "all",
|
|
65
|
+
pattern: /base64\s+(-d|--decode|-D)\b[^\n]*\|\s*(sudo\s+)?((ba|z)?sh|python3?|node|perl)\b|\beval\s*\(\s*(atob|Buffer\.from|base64\.b64decode|bytes\.fromhex)\s*\(|\bexec\s*\(\s*(base64\.b64decode|codecs\.decode|zlib\.decompress)|powershell[^\n]*-(e|enc|encodedcommand)\s+[A-Za-z0-9+/=]{20,}/i
|
|
66
|
+
},
|
|
67
|
+
{
|
|
68
|
+
id: "secret-exfiltration",
|
|
69
|
+
severity: "critical",
|
|
70
|
+
title: "Reads credentials and sends them over the network",
|
|
71
|
+
why: "Combines access to secrets (SSH keys, cloud creds, tokens, .env) with a network call on the same line.",
|
|
72
|
+
scope: "all",
|
|
73
|
+
pattern: SENSITIVE,
|
|
74
|
+
alsoRequires: NETWORK_SEND,
|
|
75
|
+
unless: OFFICIAL_TOKEN_USE
|
|
76
|
+
},
|
|
77
|
+
{
|
|
78
|
+
id: "credential-access",
|
|
79
|
+
severity: "medium",
|
|
80
|
+
title: "Sends an API token to its own service",
|
|
81
|
+
why: "Normal API usage (e.g. $GITHUB_TOKEN to api.github.com), listed so reviewers know the skill handles secrets.",
|
|
82
|
+
scope: "all",
|
|
83
|
+
pattern: OFFICIAL_TOKEN_USE
|
|
84
|
+
},
|
|
85
|
+
{
|
|
86
|
+
id: "known-exfil-endpoint",
|
|
87
|
+
severity: "high",
|
|
88
|
+
title: "Sends data to a request-capture service",
|
|
89
|
+
why: "webhook.site, requestbin, interactsh/OAST and similar capture every request \u2014 the classic exfiltration sink.",
|
|
90
|
+
scope: "all",
|
|
91
|
+
// URL form only: naming the tool in prose (e.g. a pentest guide) is not a request.
|
|
92
|
+
pattern: /https?:\/\/[\w.-]*\b(webhook\.site|requestbin\.(com|net)|pipedream\.net|interact\.sh|oast\.(fun|live|me|pro|site)|burpcollaborator\.net|hookbin\.com|beeceptor\.com|canarytokens\.com)\b/i
|
|
93
|
+
},
|
|
94
|
+
// ── high: agent hijack, destruction, safety bypass, persistence ──
|
|
95
|
+
{
|
|
96
|
+
id: "prompt-injection",
|
|
97
|
+
severity: "high",
|
|
98
|
+
title: "Instructions that override the agent or hide actions from the user",
|
|
99
|
+
why: "Skills are loaded into the agent context verbatim; these phrases try to subvert the agent's instructions or keep the user uninformed.",
|
|
100
|
+
scope: "markdown",
|
|
101
|
+
pattern: /\b(ignore|disregard|forget|override)\s+(all\s+|any\s+|the\s+)?(previous|prior|above|earlier|system|original)\s+(instructions|prompts?|rules|guidelines|directives)|\b(do\s+not|don't|never)\s+(tell|inform|notify|alert|mention\s+(this\s+)?to)\s+the\s+user(?!\s+to\b)|\bhide\s+(this|these|it|the\s+\w+)\s+(from\s+the\s+user(?!'s)|actions?)|\byou\s+are\s+now\s+(in\s+)?(developer|dan|jailbreak|unrestricted|god)\s*mode|(?<!\b(would|could|might|may|can|will|to)\s)\bsilently\s+(run|execute|install|delete|send|upload)\b/i,
|
|
102
|
+
// "Do not tell the user their tickets are for sale unless it says live" keeps the user from being misinformed.
|
|
103
|
+
unless: /\b(do\s+not|don't|never)\s+(tell|inform)\s+the\s+user\b[^.!?]*\b(is|are)\b[^.!?]*\b(unless|until)\b/i
|
|
104
|
+
},
|
|
105
|
+
{
|
|
106
|
+
id: "prompt-injection",
|
|
107
|
+
severity: "high",
|
|
108
|
+
title: "Instructions to act without the user's knowledge",
|
|
109
|
+
why: 'Telling the agent to act "without asking the user" removes the human from consequential decisions.',
|
|
110
|
+
scope: "markdown",
|
|
111
|
+
pattern: /\bwithout\s+(first\s+)?(asking|telling|informing|notifying|confirming\s+with|consulting)\s+the\s+user/i,
|
|
112
|
+
// "Never … without asking the user" is a safety rule, not an injection.
|
|
113
|
+
unless: /\b(never|don't|do\s+not|must\s+not|mustn't|should\s+not|shouldn't|cannot|can't|no)\b[^.!?]*\bwithout\s+(first\s+)?(asking|telling|informing|notifying|confirming|consulting)/i
|
|
114
|
+
},
|
|
115
|
+
{
|
|
116
|
+
id: "shell-rc-edit",
|
|
117
|
+
severity: "medium",
|
|
118
|
+
title: "Edits shell startup files",
|
|
119
|
+
why: "Appending to ~/.bashrc/~/.zshrc changes every future shell \u2014 often just PATH or env vars, but review what is added.",
|
|
120
|
+
scope: "all",
|
|
121
|
+
pattern: SHELL_RC,
|
|
122
|
+
alsoRequires: WRITE_OP,
|
|
123
|
+
unless: EXEC_PAYLOAD
|
|
124
|
+
// reported by 'persistence' as high instead
|
|
125
|
+
},
|
|
126
|
+
{
|
|
127
|
+
id: "safety-bypass",
|
|
128
|
+
severity: "high",
|
|
129
|
+
title: "Disables agent or tool safety checks",
|
|
130
|
+
why: "Turning off permission prompts, sandboxes, hooks or TLS verification removes the guardrails that catch mistakes and attacks.",
|
|
131
|
+
scope: "all",
|
|
132
|
+
pattern: /--dangerously-skip-permissions|dangerouslyDisableSandbox|bypassPermissions|--yolo\b|--full-auto\b|--dangerously-bypass-approvals|--trust-all-tools|autoApprove["']?\s*:\s*true|\bGIT_SSL_NO_VERIFY\b|NODE_TLS_REJECT_UNAUTHORIZED\s*=\s*['"]?0|\bverify\s*=\s*False\b/i
|
|
133
|
+
},
|
|
134
|
+
{
|
|
135
|
+
id: "weakened-checks",
|
|
136
|
+
severity: "medium",
|
|
137
|
+
title: "Skips hooks, TLS checks or browser sandbox",
|
|
138
|
+
why: "Sometimes needed (CI containers, local certs) but each one turns off a check that exists for a reason.",
|
|
139
|
+
scope: "all",
|
|
140
|
+
pattern: /git\s+(commit|push)\b[^\n]*--no-verify|--no-sandbox\b|\bcurl\b[^\n]*\s(-k|--insecure)\b/i
|
|
141
|
+
},
|
|
142
|
+
{
|
|
143
|
+
id: "destructive-command",
|
|
144
|
+
severity: "high",
|
|
145
|
+
title: "Destructive filesystem or disk command",
|
|
146
|
+
why: "Can irreversibly delete the home directory, the whole filesystem, or wipe a disk.",
|
|
147
|
+
scope: "all",
|
|
148
|
+
pattern: /\brm\s+(-[a-zA-Z]*\s+)*-[a-zA-Z]*r[a-zA-Z]*\s+(-[a-zA-Z]+\s+)*(\/|~|\$HOME|\/\*|~\/\*|\*|\.\/\*|"\$HOME")(\s|$|;|&)|\bmkfs(\.\w+)?\s|\bdd\s+[^\n]*of=\/dev\/(sd|disk|nvme|hd)|:\(\)\s*\{\s*:\|:&\s*\};:|\bshred\s+[^\n]*\/dev\//
|
|
149
|
+
},
|
|
150
|
+
{
|
|
151
|
+
id: "agent-config-tamper",
|
|
152
|
+
severity: "high",
|
|
153
|
+
title: "Modifies agent configuration or instructions",
|
|
154
|
+
why: "Writing to settings, hooks, MCP config or CLAUDE.md/AGENTS.md lets a skill persist behavior or grant itself permissions beyond its own scope.",
|
|
155
|
+
scope: "all",
|
|
156
|
+
pattern: AGENT_CONFIG,
|
|
157
|
+
alsoRequires: WRITE_OP
|
|
158
|
+
},
|
|
159
|
+
{
|
|
160
|
+
id: "agent-extension-write",
|
|
161
|
+
severity: "medium",
|
|
162
|
+
title: "Writes agent commands, subagents or skills",
|
|
163
|
+
why: "Adds instructions the agent may load later; check what gets written.",
|
|
164
|
+
scope: "all",
|
|
165
|
+
pattern: AGENT_EXTENSIONS,
|
|
166
|
+
alsoRequires: WRITE_OP
|
|
167
|
+
},
|
|
168
|
+
{
|
|
169
|
+
id: "persistence",
|
|
170
|
+
severity: "high",
|
|
171
|
+
title: "Installs persistence (cron, launch agents, SSH keys, sudoers, shell rc payloads)",
|
|
172
|
+
why: "Changes that survive the session and run code later or grant remote access.",
|
|
173
|
+
scope: "all",
|
|
174
|
+
pattern: PERSISTENCE,
|
|
175
|
+
alsoRequires: WRITE_OP
|
|
176
|
+
},
|
|
177
|
+
{
|
|
178
|
+
id: "persistence",
|
|
179
|
+
severity: "high",
|
|
180
|
+
title: "Installs persistence (cron, launch agents, SSH keys, sudoers, shell rc payloads)",
|
|
181
|
+
why: "Code appended to shell startup files runs in every future shell.",
|
|
182
|
+
scope: "all",
|
|
183
|
+
// rc file + executable payload on the same line (plus a write, below)
|
|
184
|
+
pattern: new RegExp(`^(?=.*(?:${SHELL_RC.source}))(?=.*(?:${EXEC_PAYLOAD.source}))`, "i"),
|
|
185
|
+
alsoRequires: WRITE_OP
|
|
186
|
+
},
|
|
187
|
+
{
|
|
188
|
+
id: "download-and-run",
|
|
189
|
+
severity: "high",
|
|
190
|
+
title: "Downloads a binary and runs it",
|
|
191
|
+
why: "Fetching an executable and running it bypasses package-manager integrity checks.",
|
|
192
|
+
scope: "all",
|
|
193
|
+
pattern: /\b(curl|wget)\b[^\n]*(-o|-O|--output)\s*\S+[^\n]*&&[^\n]*(chmod\s+\+x|\.\/\S+|sudo\s+\S+)/i
|
|
194
|
+
},
|
|
195
|
+
// ── medium: suspicious but often legitimate ──
|
|
196
|
+
{
|
|
197
|
+
id: "credential-access",
|
|
198
|
+
severity: "medium",
|
|
199
|
+
title: "Reads credential files or secret env vars",
|
|
200
|
+
why: "Legitimate for some tools, but a skill touching secrets deserves a human look.",
|
|
201
|
+
scope: "code",
|
|
202
|
+
pattern: SENSITIVE,
|
|
203
|
+
unless: NETWORK_SEND
|
|
204
|
+
// same-line network use is already reported as critical
|
|
205
|
+
},
|
|
206
|
+
{
|
|
207
|
+
id: "raw-ip-url",
|
|
208
|
+
severity: "medium",
|
|
209
|
+
title: "Talks to a raw IP address",
|
|
210
|
+
why: "Hardcoded public IPs are a common way to hide command-and-control or exfil hosts.",
|
|
211
|
+
scope: "all",
|
|
212
|
+
pattern: /\b(https?|ftp|tcp):\/\/(\d{1,3}\.){3}\d{1,3}\b/i,
|
|
213
|
+
unless: /\b(https?|ftp|tcp):\/\/(127\.|0\.0\.0\.0|10\.|192\.168\.|172\.(1[6-9]|2\d|3[01])\.|169\.254\.)/i
|
|
214
|
+
},
|
|
215
|
+
{
|
|
216
|
+
id: "url-shortener",
|
|
217
|
+
severity: "medium",
|
|
218
|
+
title: "Uses a URL shortener",
|
|
219
|
+
why: "Shortened links hide the real destination from reviewers.",
|
|
220
|
+
scope: "all",
|
|
221
|
+
pattern: /\bhttps?:\/\/(bit\.ly|tinyurl\.com|t\.co|goo\.gl|is\.gd|cutt\.ly|rb\.gy|shorturl\.at|ow\.ly)\//i
|
|
222
|
+
},
|
|
223
|
+
{
|
|
224
|
+
id: "obfuscated-blob",
|
|
225
|
+
severity: "medium",
|
|
226
|
+
title: "Large encoded blob",
|
|
227
|
+
why: "Long base64/hex strings can smuggle payloads past review.",
|
|
228
|
+
scope: "all",
|
|
229
|
+
pattern: /[A-Za-z0-9+/]{240,}={0,2}|(\\x[0-9a-fA-F]{2}){40,}/,
|
|
230
|
+
unless: /data:(image|font)\/|sha(256|384|512)-|integrity=/i
|
|
231
|
+
},
|
|
232
|
+
{
|
|
233
|
+
id: "privilege-escalation",
|
|
234
|
+
severity: "medium",
|
|
235
|
+
title: "Uses sudo or world-writable permissions",
|
|
236
|
+
why: "Root access or chmod 777 widens the blast radius of anything the skill runs.",
|
|
237
|
+
scope: "all",
|
|
238
|
+
pattern: /(^|[\s;&|`(])sudo\s+\S|\bchmod\s+(-R\s+)?(777|a\+rwx|o\+w)\b/
|
|
239
|
+
},
|
|
240
|
+
{
|
|
241
|
+
id: "history-rewrite",
|
|
242
|
+
severity: "medium",
|
|
243
|
+
title: "Rewrites or discards git history",
|
|
244
|
+
why: "Force-push and hard reset can destroy other people's work or uncommitted changes.",
|
|
245
|
+
scope: "all",
|
|
246
|
+
pattern: /\bgit\s+push\b[^\n]*(--force\b|-f\b)(?!-with-lease)|\bgit\s+reset\s+--hard\b|\bgit\s+clean\s+-[a-zA-Z]*f[a-zA-Z]*d|\bgit\s+filter-(branch|repo)\b/
|
|
247
|
+
},
|
|
248
|
+
// ── low: informational ──
|
|
249
|
+
{
|
|
250
|
+
id: "dynamic-eval",
|
|
251
|
+
severity: "low",
|
|
252
|
+
title: "Dynamic code evaluation",
|
|
253
|
+
why: "eval/exec of computed strings makes behavior hard to audit.",
|
|
254
|
+
scope: "code",
|
|
255
|
+
pattern: /\beval\s*\(|\bnew\s+Function\s*\(|\bexec\s*\(\s*compile\(/
|
|
256
|
+
},
|
|
257
|
+
{
|
|
258
|
+
id: "unpinned-install",
|
|
259
|
+
severity: "low",
|
|
260
|
+
title: "Installs packages without pinned versions",
|
|
261
|
+
why: "Unpinned installs pull whatever is latest \u2014 the vector used in the 2026 npm supply-chain wave.",
|
|
262
|
+
scope: "all",
|
|
263
|
+
pattern: /\bnpm\s+(i|install)\s+(-g\s+)?(?![^\n]*@\d)[a-z@][\w@/.-]*(\s|$)|\bpip3?\s+install\s+(?![^\n]*(==|-r\s|-e\s|\.))[a-zA-Z][\w.-]*(\s|$)/i,
|
|
264
|
+
unless: /\bnpm\s+(i|install)\s*$|--save-dev\s*$|npm\s+ci\b/
|
|
265
|
+
}
|
|
266
|
+
];
|
|
267
|
+
var HIDDEN_UNICODE = /[----]|[\u{E0000}-\u{E007F}\u{E0100}-\u{E01EF}]/gu;
|
|
268
|
+
|
|
269
|
+
// ../scanner/src/instructions.ts
|
|
270
|
+
var WEIGHT = { high: 25, medium: 10, low: 3 };
|
|
271
|
+
var README_HEADINGS = /^#{1,3}\s*(installation|install|license|licen[cs]e|contributing|contributors|badges?|table of contents|toc|features|screenshots?|demo|acknowledg(e)?ments?|sponsors?|changelog|roadmap|faq|support|authors?)\s*$/i;
|
|
272
|
+
var COMMAND = /\b(npm|pnpm|yarn|bun|npx|bunx|pip|uv|poetry|pytest|python3?|go|cargo|make|just|gradle|\.\/gradlew|mvn|dotnet|bundle|rake|rails|php|composer|mix|deno|docker( compose)?|kubectl|terraform|tox|nox|ruff|eslint|prettier|tsc|jest|vitest|playwright)\s+[\w:./-]+/;
|
|
273
|
+
var COMMAND_START = new RegExp(`^(?:sudo\\s+|\\w+=\\S+\\s+)*${COMMAND.source}`);
|
|
274
|
+
var VAGUE = /\b(write (clean|good|readable|maintainable) code|follow best practices|use (good|meaningful|descriptive) (names|naming)|be careful|make sure (the )?code (is|works)|keep (it|things|the code) simple|use common sense|write (good|proper) tests)\b/i;
|
|
275
|
+
var SECRET = /\b(sk-(?:ant-|proj-)?[A-Za-z0-9_-]{20,}|AKIA[0-9A-Z]{16}|gh[pousr]_[A-Za-z0-9]{30,}|xox[abpr]-[A-Za-z0-9-]{10,}|AIza[0-9A-Za-z_-]{30,}|-----BEGIN [A-Z ]*PRIVATE KEY-----|cgk_[A-Za-z0-9]{20,})/;
|
|
276
|
+
var ABS_PATH = /(?:^|[\s`'"(])(\/Users\/[^/\s`'"]+|\/home\/[^/\s`'"]+|[A-Z]:\\Users\\[^\\\s`'"]+)/;
|
|
277
|
+
var SHOUT = /\b(IMPORTANT|CRITICAL|MUST|NEVER|ALWAYS|DO NOT)\b/g;
|
|
278
|
+
function grade(score) {
|
|
279
|
+
return score >= 90 ? "A" : score >= 75 ? "B" : score >= 60 ? "C" : score >= 40 ? "D" : "F";
|
|
280
|
+
}
|
|
281
|
+
function lintInstructions(content, filename = "AGENTS.md") {
|
|
282
|
+
const issues = [];
|
|
283
|
+
const lines = content.split(/\r?\n/);
|
|
284
|
+
const approxTokens = Math.ceil(content.length / 4);
|
|
285
|
+
let inFence = false;
|
|
286
|
+
let codeBlocks = 0;
|
|
287
|
+
let commands2 = 0;
|
|
288
|
+
let headings = 0;
|
|
289
|
+
const seen = /* @__PURE__ */ new Map();
|
|
290
|
+
lines.forEach((raw, i) => {
|
|
291
|
+
const line = raw.trimEnd();
|
|
292
|
+
const n = i + 1;
|
|
293
|
+
if (/^\s*(```|~~~)/.test(line)) {
|
|
294
|
+
if (!inFence) codeBlocks++;
|
|
295
|
+
inFence = !inFence;
|
|
296
|
+
return;
|
|
297
|
+
}
|
|
298
|
+
const code = inFence ? line : /^\s*\$\s/.test(line) ? line.replace(/^\s*\$\s/, "") : [...line.matchAll(/`([^`]+)`/g)].map((m) => m[1]).join("\n");
|
|
299
|
+
if (code && code.split("\n").some((c) => COMMAND_START.test(c.trim()))) commands2++;
|
|
300
|
+
if (inFence) return;
|
|
301
|
+
if (/^#{1,6}\s/.test(line)) {
|
|
302
|
+
headings++;
|
|
303
|
+
if (README_HEADINGS.test(line.trim())) {
|
|
304
|
+
issues.push({ id: "readme-section", severity: "medium", line: n, title: `README section "${line.replace(/^#+\s*/, "")}"`, detail: "Install/license/contributing sections are for humans and already in the README. In an instruction file they cost tokens every session without changing what the agent does." });
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
if (/!\[[^\]]*\]\(https?:\/\/(img\.)?shields\.io|badge(n)?\.svg|\[!\[/.test(line)) {
|
|
308
|
+
issues.push({ id: "badges", severity: "low", line: n, title: "Badges", detail: "Badges carry no instructions; drop them from the agent file." });
|
|
309
|
+
}
|
|
310
|
+
if (VAGUE.test(line)) {
|
|
311
|
+
issues.push({ id: "vague-rule", severity: "low", line: n, title: "Generic advice", detail: `"${line.trim().slice(0, 80)}" \u2014 agents already try to do this. Replace it with the project-specific rule you actually mean (naming scheme, error handling pattern, which test command).` });
|
|
312
|
+
}
|
|
313
|
+
if (SECRET.test(line)) {
|
|
314
|
+
issues.push({ id: "secret", severity: "high", line: n, title: "Looks like a secret", detail: "Instruction files are committed and sent to the model provider every session. Move the value to an env var or secret store and rotate it." });
|
|
315
|
+
}
|
|
316
|
+
if (ABS_PATH.test(line)) {
|
|
317
|
+
issues.push({ id: "absolute-path", severity: "low", line: n, title: "Machine-specific path", detail: "A home-directory path only works on one machine; use paths relative to the repo root." });
|
|
318
|
+
}
|
|
319
|
+
const key = line.trim().toLowerCase();
|
|
320
|
+
if (key.length > 30) {
|
|
321
|
+
if (seen.has(key)) issues.push({ id: "duplicate", severity: "low", line: n, title: "Repeated line", detail: `Same as line ${seen.get(key)}.` });
|
|
322
|
+
else seen.set(key, n);
|
|
323
|
+
}
|
|
324
|
+
});
|
|
325
|
+
if (approxTokens > 8e3) {
|
|
326
|
+
issues.push({ id: "too-long", severity: "high", title: `~${approxTokens.toLocaleString("en-US")} tokens`, detail: `${filename} is loaded into every session. Past a few thousand tokens it crowds out the task and rules get ignored \u2014 keep the essentials here and move reference material into docs the agent reads on demand (or skills).` });
|
|
327
|
+
} else if (approxTokens > 2500) {
|
|
328
|
+
issues.push({ id: "long", severity: "medium", title: `~${approxTokens.toLocaleString("en-US")} tokens`, detail: `${filename} is loaded into every session; aim for under ~2,500 tokens of rules and commands.` });
|
|
329
|
+
}
|
|
330
|
+
if (content.trim() && !commands2) {
|
|
331
|
+
issues.push({ id: "no-commands", severity: "medium", title: "No build/test commands", detail: "The most useful thing an instruction file gives an agent is the exact commands to build, test and lint. Without them it guesses (and often runs the wrong ones)." });
|
|
332
|
+
}
|
|
333
|
+
if (lines.length > 40 && headings < 2) {
|
|
334
|
+
issues.push({ id: "no-structure", severity: "low", title: "No sections", detail: "Split the file into a few headed sections (Commands, Architecture, Conventions, Don'ts) so rules are easy to find and update." });
|
|
335
|
+
}
|
|
336
|
+
const shouts = content.match(SHOUT)?.length ?? 0;
|
|
337
|
+
if (shouts > 15) {
|
|
338
|
+
issues.push({ id: "shouting", severity: "low", title: `${shouts} all-caps emphasis words`, detail: "When everything is IMPORTANT nothing is. Keep caps for the two or three rules that really are hard limits." });
|
|
339
|
+
}
|
|
340
|
+
const scan = scanSkill([{ path: filename.endsWith(".md") ? filename : `${filename}.md`, content }]);
|
|
341
|
+
for (const f of scan.findings) {
|
|
342
|
+
issues.push({ id: `security:${f.ruleId}`, severity: f.severity === "critical" || f.severity === "high" ? "high" : f.severity === "medium" ? "medium" : "low", line: f.line, title: f.title, detail: `${f.why} \u2014 ${f.snippet}` });
|
|
343
|
+
}
|
|
344
|
+
const order = { high: 0, medium: 1, low: 2 };
|
|
345
|
+
issues.sort((a, b) => order[a.severity] - order[b.severity] || (a.line ?? 0) - (b.line ?? 0));
|
|
346
|
+
const counted = /* @__PURE__ */ new Set();
|
|
347
|
+
let penalty = 0;
|
|
348
|
+
for (const i of issues) {
|
|
349
|
+
const k = i.severity === "low" ? i.id : `${i.id}:${i.line ?? ""}`;
|
|
350
|
+
if (counted.has(k)) continue;
|
|
351
|
+
counted.add(k);
|
|
352
|
+
penalty += WEIGHT[i.severity];
|
|
353
|
+
}
|
|
354
|
+
const score = content.trim() ? Math.max(0, 100 - penalty) : 0;
|
|
355
|
+
return { score, grade: grade(score), stats: { lines: lines.length, approxTokens, headings, codeBlocks, commands: commands2 }, issues: issues.slice(0, 100) };
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
// ../scanner/src/index.ts
|
|
359
|
+
var SCANNER_VERSION = "1.2.0";
|
|
360
|
+
var MAX_PER_RULE_PER_FILE = 5;
|
|
361
|
+
var SEVERITY_ORDER = ["critical", "high", "medium", "low"];
|
|
362
|
+
var MARKDOWN_EXT = /\.(md|mdx|markdown|txt|rst)$/i;
|
|
363
|
+
var SCANNABLE_EXT = /\.(md|mdx|markdown|txt|rst|sh|bash|zsh|fish|ps1|psm1|bat|cmd|py|js|mjs|cjs|ts|mts|cts|rb|pl|php|lua|go|rs|json|jsonc|ya?ml|toml|ini|cfg|conf|env|plist)$|(^|\/)(Makefile|Dockerfile|justfile)$/i;
|
|
364
|
+
function fileScope(path) {
|
|
365
|
+
return MARKDOWN_EXT.test(path) || !/\.[a-z0-9]+$/i.test(path.split("/").pop() ?? "") ? "markdown" : "code";
|
|
366
|
+
}
|
|
367
|
+
function visible(s) {
|
|
368
|
+
return s.replace(HIDDEN_UNICODE, (c) => `\u27E6U+${c.codePointAt(0).toString(16).toUpperCase().padStart(4, "0")}\u27E7`);
|
|
369
|
+
}
|
|
370
|
+
function snippetOf(line) {
|
|
371
|
+
const t = visible(line.trim());
|
|
372
|
+
return t.length > 160 ? `${t.slice(0, 157)}\u2026` : t;
|
|
373
|
+
}
|
|
374
|
+
function looksQuoted(line, rule) {
|
|
375
|
+
const m = rule.pattern.exec(line);
|
|
376
|
+
if (!m) return false;
|
|
377
|
+
const before = line.slice(0, m.index);
|
|
378
|
+
if (/^\s*>/.test(line)) return true;
|
|
379
|
+
if (/(\s-c|\beval|\bexec(Sync)?|\bsystem|\bspawn|\bsubprocess\.\w+|\bos\.popen)\s*\(?\s*[\[(]?\s*["'`][^"'`]*$/.test(before)) return false;
|
|
380
|
+
const opens = (before.match(/["“'`]/g) ?? []).length;
|
|
381
|
+
return opens % 2 === 1;
|
|
382
|
+
}
|
|
383
|
+
var ATTACK_REFERENCE = /(^|\/)(references?|docs?|examples?|patterns?)\/[^/]*(attack|exploit|vuln|dangerous|malicious|injection|payload|threat|cve|pwn|insecure|supply-chain|evasion)[^/]*\.(md|mdx|txt)$/i;
|
|
384
|
+
var CODE_COMMENT = /^\s*(#(?!!)|\/\/|--\s|\/?\*|<!--|REM\s|::)/i;
|
|
385
|
+
var PATTERN_DEFINITION = /\\[sbdwSW]|\bre\.(compile|search|match)\b|new RegExp\(|\br["']\S/;
|
|
386
|
+
var DESCRIPTIVE_KEY = /^\s*["']?(example|examples|description|desc|pattern|patterns|message|reason|title|name|label|comment|note|bad|dangerous|blocked|deny|denylist)["']?\s*[:=]/i;
|
|
387
|
+
var NEGATED_BEFORE = /\b(not|never|avoid|instead\s+of|rather\s+than|don't\s+use|do\s+not\s+use)\s*[`"'(]*$/i;
|
|
388
|
+
var WARNING_CONTEXT = /\b(never|avoid|dangerous|malicious|attackers?|vulnerable|insecure|anti-?pattern)\b|❌|⚠️?|🚫|⛔/i;
|
|
389
|
+
function adjust(rule, scope, path, lines, idx) {
|
|
390
|
+
const line = lines[idx];
|
|
391
|
+
if (rule.severity === "low" || rule.severity === "medium") {
|
|
392
|
+
return { severity: rule.severity };
|
|
393
|
+
}
|
|
394
|
+
if (scope === "markdown" && ATTACK_REFERENCE.test(path)) {
|
|
395
|
+
return { severity: "medium", note: "in attack/vulnerability reference material" };
|
|
396
|
+
}
|
|
397
|
+
if (/(^|\/)\.(github\/workflows|gitlab-ci|circleci)\b|(^|\/)\.gitlab-ci\.ya?ml$/.test(path)) {
|
|
398
|
+
return { severity: "medium", note: "runs in CI, not on the agent's machine" };
|
|
399
|
+
}
|
|
400
|
+
if (scope === "code") {
|
|
401
|
+
if (CODE_COMMENT.test(line)) return { severity: "low", note: "in a code comment" };
|
|
402
|
+
if (PATTERN_DEFINITION.test(line)) return { severity: "low", note: "in a pattern/denylist definition" };
|
|
403
|
+
if (/\.(json|jsonc|ya?ml|toml)$/i.test(path) && DESCRIPTIVE_KEY.test(line)) {
|
|
404
|
+
return { severity: "low", note: "descriptive data field" };
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
if (rule.id === "prompt-injection" && looksQuoted(line, rule)) {
|
|
408
|
+
return { severity: "medium", note: "quoted \u2014 likely an example" };
|
|
409
|
+
}
|
|
410
|
+
if (scope === "markdown" && rule.id !== "prompt-injection") {
|
|
411
|
+
const m = rule.pattern.exec(line);
|
|
412
|
+
if (m && NEGATED_BEFORE.test(line.slice(0, m.index))) return { severity: "medium", note: "advised against" };
|
|
413
|
+
}
|
|
414
|
+
if (scope === "markdown" && rule.id !== "prompt-injection") {
|
|
415
|
+
const prose = [];
|
|
416
|
+
for (let j = idx - 1; j >= 0 && j >= idx - 6 && prose.length < 2; j--) {
|
|
417
|
+
if (lines[j].trim() && !/^\s*(```|~~~)/.test(lines[j])) prose.push(lines[j]);
|
|
418
|
+
}
|
|
419
|
+
const context = [...prose, line.replace(rule.pattern, "")].join("\n");
|
|
420
|
+
if (WARNING_CONTEXT.test(context)) return { severity: "medium", note: "appears in a warning/example" };
|
|
421
|
+
}
|
|
422
|
+
return { severity: rule.severity };
|
|
423
|
+
}
|
|
424
|
+
var PICTOGRAPH = new RegExp("\\p{Extended_Pictographic}", "u");
|
|
425
|
+
function hiddenChars(line, isFirstLine) {
|
|
426
|
+
const found = [];
|
|
427
|
+
for (const m of line.matchAll(HIDDEN_UNICODE)) {
|
|
428
|
+
const ch = m[0];
|
|
429
|
+
const i = m.index ?? 0;
|
|
430
|
+
if (ch === "\uFEFF" && isFirstLine && i === 0) continue;
|
|
431
|
+
if (ch === "\u200D") {
|
|
432
|
+
const neighbors = [i - 1, i - 2, i + 1].filter((j) => j >= 0 && j < line.length).map((j) => String.fromCodePoint(line.codePointAt(j)));
|
|
433
|
+
if (neighbors.some((c) => PICTOGRAPH.test(c))) continue;
|
|
434
|
+
}
|
|
435
|
+
found.push(`U+${ch.codePointAt(0).toString(16).toUpperCase().padStart(4, "0")}`);
|
|
436
|
+
}
|
|
437
|
+
return found;
|
|
438
|
+
}
|
|
439
|
+
function matches(rule, text2) {
|
|
440
|
+
return rule.pattern.test(text2) && (!rule.alsoRequires || rule.alsoRequires.test(text2)) && !(rule.unless && rule.unless.test(text2));
|
|
441
|
+
}
|
|
442
|
+
function scanSkill(files) {
|
|
443
|
+
const findings = [];
|
|
444
|
+
const hash = createHash("sha256");
|
|
445
|
+
const sorted = [...files].sort((a, b) => a.path.localeCompare(b.path));
|
|
446
|
+
for (const file of sorted) {
|
|
447
|
+
hash.update(file.path).update("\0").update(file.content).update("\0");
|
|
448
|
+
const scope = fileScope(file.path);
|
|
449
|
+
const perRule = /* @__PURE__ */ new Map();
|
|
450
|
+
const push = (f) => {
|
|
451
|
+
const n = perRule.get(f.ruleId) ?? 0;
|
|
452
|
+
if (n >= MAX_PER_RULE_PER_FILE) return;
|
|
453
|
+
perRule.set(f.ruleId, n + 1);
|
|
454
|
+
findings.push(f);
|
|
455
|
+
};
|
|
456
|
+
const lines = file.content.split(/\r?\n/);
|
|
457
|
+
let inFence = false;
|
|
458
|
+
lines.forEach((physical, idx) => {
|
|
459
|
+
if (scope === "markdown" && /^\s*(```|~~~)/.test(physical)) inFence = !inFence;
|
|
460
|
+
const lineNo = idx + 1;
|
|
461
|
+
if (idx > 0 && /\\\s*$/.test(lines[idx - 1])) return;
|
|
462
|
+
let line = physical;
|
|
463
|
+
for (let j = idx; /\\\s*$/.test(lines[j] ?? "") && j + 1 < lines.length && j - idx < 20; j++) {
|
|
464
|
+
line = `${line.replace(/\\\s*$/, " ")}${lines[j + 1]}`;
|
|
465
|
+
}
|
|
466
|
+
const hidden = hiddenChars(line, idx === 0);
|
|
467
|
+
if (hidden.length) {
|
|
468
|
+
const inReference = scope === "markdown" && ATTACK_REFERENCE.test(file.path);
|
|
469
|
+
push({
|
|
470
|
+
ruleId: "hidden-unicode",
|
|
471
|
+
severity: inReference ? "medium" : "critical",
|
|
472
|
+
title: `Hidden unicode characters (${[...new Set(hidden)].join(", ")})${inReference ? " (in attack/vulnerability reference material)" : ""}`,
|
|
473
|
+
why: "Invisible or direction-changing characters can hide instructions from human reviewers while the agent still reads them (TrapDoor / Trojan Source).",
|
|
474
|
+
file: file.path,
|
|
475
|
+
line: lineNo,
|
|
476
|
+
snippet: snippetOf(line)
|
|
477
|
+
});
|
|
478
|
+
}
|
|
479
|
+
const prose = scope === "markdown" && !inFence ? line.replace(/^\s*(?:>\s?)+/, "").replace(/<[a-z][\w -]{0,30}>/gi, "") : line;
|
|
480
|
+
for (const rule of LINE_RULES) {
|
|
481
|
+
if (rule.scope !== "all" && rule.scope !== scope) continue;
|
|
482
|
+
if (!matches(rule, rule.alsoRequires === WRITE_OP ? prose : line)) continue;
|
|
483
|
+
if (scope === "markdown" && rule.alsoRequires && !inFence && line.includes("`")) {
|
|
484
|
+
const spans = line.match(/`[^`]+`/g) ?? [];
|
|
485
|
+
if (!spans.some((sp) => matches(rule, sp))) continue;
|
|
486
|
+
}
|
|
487
|
+
const { severity, note } = adjust(rule, scope, file.path, lines, idx);
|
|
488
|
+
const title = note ? `${rule.title} (${note})` : rule.title;
|
|
489
|
+
push({ ruleId: rule.id, severity, title, why: rule.why, file: file.path, line: lineNo, snippet: snippetOf(line) });
|
|
490
|
+
}
|
|
491
|
+
});
|
|
492
|
+
}
|
|
493
|
+
findings.sort((a, b) => SEVERITY_ORDER.indexOf(a.severity) - SEVERITY_ORDER.indexOf(b.severity));
|
|
494
|
+
const counts = { critical: 0, high: 0, medium: 0, low: 0 };
|
|
495
|
+
for (const f of findings) counts[f.severity]++;
|
|
496
|
+
return {
|
|
497
|
+
status: statusOf(counts),
|
|
498
|
+
counts,
|
|
499
|
+
findings,
|
|
500
|
+
filesScanned: files.length,
|
|
501
|
+
contentHash: hash.digest("hex"),
|
|
502
|
+
scannerVersion: SCANNER_VERSION
|
|
503
|
+
};
|
|
504
|
+
}
|
|
505
|
+
function statusOf(counts) {
|
|
506
|
+
if (counts.critical > 0 || counts.high > 0) return "flagged";
|
|
507
|
+
if (counts.medium > 0) return "warn";
|
|
508
|
+
return "passed";
|
|
509
|
+
}
|
|
510
|
+
function summarize(result) {
|
|
511
|
+
const parts = SEVERITY_ORDER.filter((s) => result.counts[s] > 0).map((s) => `${result.counts[s]} ${s}`);
|
|
512
|
+
return parts.length ? `${result.status}: ${parts.join(", ")}` : result.status;
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
// src/local-scan.ts
|
|
516
|
+
var MAX_FILE_BYTES = 1e5;
|
|
517
|
+
var MAX_FILES_PER_SKILL = 200;
|
|
518
|
+
var SKIP_DIRS = /* @__PURE__ */ new Set(["node_modules", ".git", "dist", "build", ".venv", "venv", "__pycache__"]);
|
|
519
|
+
function defaultSkillRoots(cwd = process.cwd()) {
|
|
520
|
+
const h = homedir();
|
|
521
|
+
return [
|
|
522
|
+
join(h, ".claude", "skills"),
|
|
523
|
+
join(h, ".claude", "plugins"),
|
|
524
|
+
join(h, ".codex", "skills"),
|
|
525
|
+
join(h, ".agents", "skills"),
|
|
526
|
+
join(h, ".hermes", "skills"),
|
|
527
|
+
join(h, ".config", "opencode", "skills"),
|
|
528
|
+
join(h, ".cursor", "skills"),
|
|
529
|
+
join(h, ".openclaude", "skills"),
|
|
530
|
+
join(h, ".gemini", "skills"),
|
|
531
|
+
join(h, ".kimi-code", "skills"),
|
|
532
|
+
join(h, ".openclaw", "skills"),
|
|
533
|
+
join(h, ".copilot", "skills"),
|
|
534
|
+
join(h, ".codeium", "windsurf", "skills"),
|
|
535
|
+
join(h, ".config", "devin", "skills"),
|
|
536
|
+
join(h, ".cline", "skills"),
|
|
537
|
+
join(cwd, ".claude", "skills"),
|
|
538
|
+
join(cwd, ".agents", "skills"),
|
|
539
|
+
join(cwd, ".cursor", "skills"),
|
|
540
|
+
join(cwd, ".github", "skills"),
|
|
541
|
+
join(cwd, ".devin", "skills"),
|
|
542
|
+
join(cwd, ".windsurf", "skills"),
|
|
543
|
+
join(cwd, ".cline", "skills"),
|
|
544
|
+
join(cwd, ".clinerules", "skills"),
|
|
545
|
+
join(cwd, ".opencode", "skills"),
|
|
546
|
+
join(cwd, ".gemini", "skills")
|
|
547
|
+
];
|
|
548
|
+
}
|
|
549
|
+
async function exists(p) {
|
|
550
|
+
try {
|
|
551
|
+
await stat(p);
|
|
552
|
+
return true;
|
|
553
|
+
} catch {
|
|
554
|
+
return false;
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
async function findSkillDirs(root, depth = 6, out = []) {
|
|
558
|
+
let entries;
|
|
559
|
+
try {
|
|
560
|
+
entries = await readdir(root, { withFileTypes: true });
|
|
561
|
+
} catch {
|
|
562
|
+
return out;
|
|
563
|
+
}
|
|
564
|
+
if (entries.some((e) => e.isFile() && /^SKILL\.md$/i.test(e.name))) out.push(root);
|
|
565
|
+
if (depth > 0) {
|
|
566
|
+
for (const e of entries) {
|
|
567
|
+
if (e.isDirectory() && !SKIP_DIRS.has(e.name)) await findSkillDirs(join(root, e.name), depth - 1, out);
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
return out;
|
|
571
|
+
}
|
|
572
|
+
async function collectFiles(dir) {
|
|
573
|
+
const files = [];
|
|
574
|
+
const skipped = [];
|
|
575
|
+
async function walk(d, depth) {
|
|
576
|
+
if (depth < 0) return;
|
|
577
|
+
for (const e of await readdir(d, { withFileTypes: true })) {
|
|
578
|
+
const full = join(d, e.name);
|
|
579
|
+
if (e.isDirectory()) {
|
|
580
|
+
if (!SKIP_DIRS.has(e.name)) await walk(full, depth - 1);
|
|
581
|
+
} else if (e.isFile() && SCANNABLE_EXT.test(e.name)) {
|
|
582
|
+
const rel = relative(dir, full);
|
|
583
|
+
const size = (await stat(full)).size;
|
|
584
|
+
if (size > MAX_FILE_BYTES || files.length >= MAX_FILES_PER_SKILL) skipped.push(rel);
|
|
585
|
+
else files.push({ path: rel, content: await readFile(full, "utf8") });
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
await walk(dir, 5);
|
|
590
|
+
return { files, skipped };
|
|
591
|
+
}
|
|
592
|
+
async function scanLocalSkills(paths) {
|
|
593
|
+
const roots = [];
|
|
594
|
+
for (const p of paths?.length ? paths.map((x) => resolve(x.replace(/^~(?=$|\/)/, homedir()))) : defaultSkillRoots()) {
|
|
595
|
+
if (await exists(p)) roots.push(p);
|
|
596
|
+
}
|
|
597
|
+
const dirs = [...new Set((await Promise.all(roots.map((r) => findSkillDirs(r)))).flat())];
|
|
598
|
+
const reports = [];
|
|
599
|
+
for (const dir of dirs) {
|
|
600
|
+
const { files, skipped } = await collectFiles(dir);
|
|
601
|
+
reports.push({ dir, result: scanSkill(files), skipped });
|
|
602
|
+
}
|
|
603
|
+
const rank = { flagged: 0, warn: 1, passed: 2 };
|
|
604
|
+
reports.sort((a, b) => rank[a.result.status] - rank[b.result.status] || a.dir.localeCompare(b.dir));
|
|
605
|
+
return { roots, reports };
|
|
606
|
+
}
|
|
607
|
+
function formatLocalScan({ roots, reports }, verbose = false) {
|
|
608
|
+
if (!roots.length) return "No skill directories found. Pass `paths` to scan a specific location.";
|
|
609
|
+
const home = homedir();
|
|
610
|
+
const short = (p) => p.startsWith(home) ? `~${p.slice(home.length)}` : p;
|
|
611
|
+
const counts = { flagged: 0, warn: 0, passed: 0 };
|
|
612
|
+
for (const r of reports) counts[r.result.status]++;
|
|
613
|
+
const lines = [
|
|
614
|
+
`Scanned ${reports.length} skills locally (nothing was uploaded) in: ${roots.map(short).join(", ")}`,
|
|
615
|
+
`flagged ${counts.flagged} \xB7 warn ${counts.warn} \xB7 passed ${counts.passed}`,
|
|
616
|
+
""
|
|
617
|
+
];
|
|
618
|
+
for (const r of reports) {
|
|
619
|
+
if (r.result.status === "passed" && !verbose) continue;
|
|
620
|
+
lines.push(`[${r.result.status.toUpperCase()}] ${short(r.dir)} \u2014 ${summarize(r.result)}`);
|
|
621
|
+
const shown = r.result.findings.filter((f) => verbose || f.severity !== "low").slice(0, verbose ? 50 : 5);
|
|
622
|
+
for (const f of shown) lines.push(` - ${f.severity}: ${f.title} \u2014 ${f.file}:${f.line} ${f.snippet}`);
|
|
623
|
+
if (r.skipped.length) lines.push(` - not scanned (too large/many): ${r.skipped.slice(0, 5).join(", ")}`);
|
|
624
|
+
}
|
|
625
|
+
if (counts.flagged) {
|
|
626
|
+
lines.push(
|
|
627
|
+
"",
|
|
628
|
+
"Flagged skills need a human decision. Show these findings to the user and ask before using, updating or removing them. Do not delete or modify skills on your own."
|
|
629
|
+
);
|
|
630
|
+
}
|
|
631
|
+
return lines.join("\n");
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
// src/stack-detect.ts
|
|
635
|
+
import { createHash as createHash2 } from "node:crypto";
|
|
636
|
+
import { readdir as readdir2, readFile as readFile2, stat as stat2 } from "node:fs/promises";
|
|
637
|
+
import { basename, dirname, join as join2, relative as relative2, resolve as resolve2, sep } from "node:path";
|
|
638
|
+
async function read(path) {
|
|
639
|
+
try {
|
|
640
|
+
return await readFile2(path, "utf8");
|
|
641
|
+
} catch {
|
|
642
|
+
return null;
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
async function isDir(path) {
|
|
646
|
+
try {
|
|
647
|
+
return (await stat2(path)).isDirectory();
|
|
648
|
+
} catch {
|
|
649
|
+
return false;
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
function isExactVersion(v) {
|
|
653
|
+
return !!v && /^v?\d+\.\d+\.\d+(?:[-+.][0-9A-Za-z.-]+)?$/.test(v.trim());
|
|
654
|
+
}
|
|
655
|
+
var pep503 = (name) => name.toLowerCase().replace(/[-_.]+/g, "-");
|
|
656
|
+
var unquote = (s) => s.trim().replace(/^['"]|['"]$/g, "");
|
|
657
|
+
function fromPackageJson(raw) {
|
|
658
|
+
try {
|
|
659
|
+
const pkg = JSON.parse(raw);
|
|
660
|
+
return [pkg.dependencies, pkg.devDependencies, pkg.optionalDependencies].flatMap((deps) => Object.entries(deps ?? {})).map(([name, spec]) => ({ name, spec: String(spec).trim() })).filter((d) => !/^(workspace:|file:|link:|portal:)/.test(d.spec));
|
|
661
|
+
} catch {
|
|
662
|
+
return [];
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
function nodeEngine(raw) {
|
|
666
|
+
try {
|
|
667
|
+
const e = JSON.parse(raw).engines?.node;
|
|
668
|
+
return e ? String(e).trim().slice(0, 64) : null;
|
|
669
|
+
} catch {
|
|
670
|
+
return null;
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
function npmLockResolver(raw, rel) {
|
|
674
|
+
let lock;
|
|
675
|
+
try {
|
|
676
|
+
lock = JSON.parse(raw);
|
|
677
|
+
} catch {
|
|
678
|
+
return () => null;
|
|
679
|
+
}
|
|
680
|
+
const prefix = rel && rel !== "." ? `${rel.split(sep).join("/")}/` : "";
|
|
681
|
+
return (name) => {
|
|
682
|
+
const p = lock.packages;
|
|
683
|
+
if (p) {
|
|
684
|
+
const hit = prefix && p[`${prefix}node_modules/${name}`] || p[`node_modules/${name}`];
|
|
685
|
+
if (hit && !hit.link && hit.version && !/^(file|link):/.test(hit.version)) return hit.version;
|
|
686
|
+
return null;
|
|
687
|
+
}
|
|
688
|
+
return lock.dependencies?.[name]?.version ?? null;
|
|
689
|
+
};
|
|
690
|
+
}
|
|
691
|
+
function pnpmLockResolver(raw, rel) {
|
|
692
|
+
const importer = !rel || rel === "." ? "." : rel.split(sep).join("/");
|
|
693
|
+
const out = /* @__PURE__ */ new Map();
|
|
694
|
+
const clean = (v) => unquote(v).replace(/\(.*$/, "").replace(/_.*$/, "");
|
|
695
|
+
let inImporters = false;
|
|
696
|
+
let current = null;
|
|
697
|
+
let pkg = null;
|
|
698
|
+
let inDeps = false;
|
|
699
|
+
for (const line of raw.split("\n")) {
|
|
700
|
+
if (/^importers:\s*$/.test(line)) {
|
|
701
|
+
inImporters = true;
|
|
702
|
+
continue;
|
|
703
|
+
}
|
|
704
|
+
if (!inImporters) continue;
|
|
705
|
+
if (/^\S/.test(line)) break;
|
|
706
|
+
let m = line.match(/^ {2}(\S.*?):\s*$/);
|
|
707
|
+
if (m) {
|
|
708
|
+
current = unquote(m[1]);
|
|
709
|
+
inDeps = false;
|
|
710
|
+
continue;
|
|
711
|
+
}
|
|
712
|
+
if (current !== importer) continue;
|
|
713
|
+
m = line.match(/^ {4}(\S+):\s*$/);
|
|
714
|
+
if (m) {
|
|
715
|
+
inDeps = /^(dependencies|devDependencies|optionalDependencies)$/.test(m[1]);
|
|
716
|
+
continue;
|
|
717
|
+
}
|
|
718
|
+
if (!inDeps) continue;
|
|
719
|
+
m = line.match(/^ {6}(\S.*?):\s*(.*)$/);
|
|
720
|
+
if (m) {
|
|
721
|
+
pkg = unquote(m[1]);
|
|
722
|
+
if (m[2] && !/^\s*$/.test(m[2])) out.set(pkg, clean(m[2]));
|
|
723
|
+
continue;
|
|
724
|
+
}
|
|
725
|
+
m = line.match(/^ {8}version:\s*(.+)$/);
|
|
726
|
+
if (m && pkg) out.set(pkg, clean(m[1]));
|
|
727
|
+
}
|
|
728
|
+
return (name) => {
|
|
729
|
+
const v = out.get(name);
|
|
730
|
+
return v && !/^(link|file|workspace):/.test(v) ? v : null;
|
|
731
|
+
};
|
|
732
|
+
}
|
|
733
|
+
function yarnLockResolver(raw) {
|
|
734
|
+
const bySpec = /* @__PURE__ */ new Map();
|
|
735
|
+
const byName = /* @__PURE__ */ new Map();
|
|
736
|
+
let keys = [];
|
|
737
|
+
for (const line of raw.split("\n")) {
|
|
738
|
+
if (/^\S.*:\s*$/.test(line) && !line.startsWith("#") && !line.startsWith("__metadata")) {
|
|
739
|
+
keys = line.replace(/:\s*$/, "").split(/,\s*/).map(unquote).filter(Boolean);
|
|
740
|
+
continue;
|
|
741
|
+
}
|
|
742
|
+
const m = line.match(/^ {2}version:?\s+"?([^"\s]+)"?\s*$/);
|
|
743
|
+
if (m && keys.length) {
|
|
744
|
+
for (const k of keys) {
|
|
745
|
+
const at = k.lastIndexOf("@");
|
|
746
|
+
if (at <= 0) continue;
|
|
747
|
+
const name = k.slice(0, at);
|
|
748
|
+
const spec = k.slice(at + 1).replace(/^npm:/, "");
|
|
749
|
+
bySpec.set(`${name}@${spec}`, m[1]);
|
|
750
|
+
if (!byName.has(name)) byName.set(name, m[1]);
|
|
751
|
+
}
|
|
752
|
+
keys = [];
|
|
753
|
+
}
|
|
754
|
+
}
|
|
755
|
+
return (name, spec) => bySpec.get(`${name}@${spec}`) ?? byName.get(name) ?? null;
|
|
756
|
+
}
|
|
757
|
+
function bunLockResolver(raw) {
|
|
758
|
+
let pkgs = {};
|
|
759
|
+
try {
|
|
760
|
+
pkgs = JSON.parse(raw.replace(/,(\s*[}\]])/g, "$1")).packages ?? {};
|
|
761
|
+
} catch {
|
|
762
|
+
return () => null;
|
|
763
|
+
}
|
|
764
|
+
return (name) => {
|
|
765
|
+
const id = pkgs[name]?.[0];
|
|
766
|
+
if (typeof id !== "string") return null;
|
|
767
|
+
const v = id.slice(id.lastIndexOf("@") + 1);
|
|
768
|
+
return /^\d/.test(v) ? v : null;
|
|
769
|
+
};
|
|
770
|
+
}
|
|
771
|
+
function fromRequirements(raw) {
|
|
772
|
+
return raw.split("\n").map((l) => l.replace(/#.*/, "").trim()).filter((l) => l && !l.startsWith("-") && !/^(git\+|https?:)/.test(l)).map((l) => {
|
|
773
|
+
const m = l.match(/^([A-Za-z0-9_.-]+)(?:\[.*?\])?\s*(===?|~=|>=|<=|!=|>|<)?\s*([\w.*+-]+)?/);
|
|
774
|
+
if (!m) return null;
|
|
775
|
+
const [, name, op, v] = m;
|
|
776
|
+
if (!v) return { name };
|
|
777
|
+
return { name, version: op === "==" || op === "===" ? v : `${op ?? ""}${v}`.slice(0, 64) };
|
|
778
|
+
}).filter((d) => d !== null);
|
|
779
|
+
}
|
|
780
|
+
function fromPyproject(raw) {
|
|
781
|
+
const pep621 = raw.match(/^\s*dependencies\s*=\s*\[([\s\S]*?)\]/m)?.[1] ?? "";
|
|
782
|
+
const deps = fromRequirements([...pep621.matchAll(/["']([^"']+)["']/g)].map((m) => m[1]).join("\n"));
|
|
783
|
+
const poetry = raw.match(/^\[tool\.poetry\.(?:dev-)?dependencies\]\s*$([\s\S]*?)(?=^\[|(?![\s\S]))/m)?.[1] ?? "";
|
|
784
|
+
for (const m of poetry.matchAll(/^([A-Za-z0-9_.-]+)\s*=\s*(?:"([^"]*)"|\{[^}]*version\s*=\s*"([^"]*)")/gm)) {
|
|
785
|
+
if (m[1].toLowerCase() === "python") continue;
|
|
786
|
+
deps.push({ name: m[1], ...m[2] ?? m[3] ? { version: (m[2] ?? m[3]).slice(0, 64) } : {} });
|
|
787
|
+
}
|
|
788
|
+
return deps;
|
|
789
|
+
}
|
|
790
|
+
function tomlPackages(raw, normalize = pep503) {
|
|
791
|
+
const out = /* @__PURE__ */ new Map();
|
|
792
|
+
for (const block of raw.split(/^\[\[package\]\]\s*$/m).slice(1)) {
|
|
793
|
+
const name = block.match(/^name\s*=\s*"([^"]+)"/m)?.[1];
|
|
794
|
+
const version = block.match(/^version\s*=\s*"([^"]+)"/m)?.[1];
|
|
795
|
+
if (name && version && !out.has(normalize(name))) out.set(normalize(name), version);
|
|
796
|
+
}
|
|
797
|
+
return out;
|
|
798
|
+
}
|
|
799
|
+
function pipfileLock(raw) {
|
|
800
|
+
const out = /* @__PURE__ */ new Map();
|
|
801
|
+
try {
|
|
802
|
+
const j = JSON.parse(raw);
|
|
803
|
+
for (const section of [j.default, j.develop]) {
|
|
804
|
+
for (const [name, v] of Object.entries(section ?? {})) {
|
|
805
|
+
const ver = v.version?.replace(/^==/, "");
|
|
806
|
+
if (ver) out.set(pep503(name), ver);
|
|
807
|
+
}
|
|
808
|
+
}
|
|
809
|
+
} catch {
|
|
810
|
+
}
|
|
811
|
+
return out;
|
|
812
|
+
}
|
|
813
|
+
function fromGoMod(raw) {
|
|
814
|
+
return [...raw.matchAll(/^\s*(?:require\s+)?([\w.-]+\.[\w.-]+\/[\w./-]+)\s+(v[\w.+-]+)/gm)].map((m) => ({ name: m[1], version: m[2] }));
|
|
815
|
+
}
|
|
816
|
+
function fromCargoToml(raw) {
|
|
817
|
+
const deps = [];
|
|
818
|
+
const sections = raw.matchAll(/^\[(?:workspace\.)?(?:dev-|build-)?dependencies\]\s*$([\s\S]*?)(?=^\[|(?![\s\S]))/gm);
|
|
819
|
+
for (const s of sections) {
|
|
820
|
+
for (const m of s[1].matchAll(/^([A-Za-z0-9_-]+)\s*=\s*(?:"([^"]*)"|\{([^}]*)\})/gm)) {
|
|
821
|
+
const v = m[2] ?? m[3]?.match(/version\s*=\s*"([^"]*)"/)?.[1];
|
|
822
|
+
deps.push({ name: m[1], ...v ? { version: v.slice(0, 64) } : {} });
|
|
823
|
+
}
|
|
824
|
+
}
|
|
825
|
+
return deps;
|
|
826
|
+
}
|
|
827
|
+
async function runtimes(dir, pkgRaw) {
|
|
828
|
+
const out = [];
|
|
829
|
+
const node = (await read(join2(dir, ".nvmrc")))?.trim() || (await read(join2(dir, ".node-version")))?.trim();
|
|
830
|
+
const tool = await read(join2(dir, ".tool-versions"));
|
|
831
|
+
const toolNode = tool?.match(/^(?:nodejs|node)\s+(\S+)/m)?.[1];
|
|
832
|
+
const nodeVersion = node || toolNode || (pkgRaw ? nodeEngine(pkgRaw) : null);
|
|
833
|
+
if (nodeVersion && /\d/.test(nodeVersion)) out.push({ name: "node", version: nodeVersion.replace(/^v/, "").slice(0, 64), runtime: true });
|
|
834
|
+
const py = (await read(join2(dir, ".python-version")))?.trim().split("\n")[0] || tool?.match(/^python\s+(\S+)/m)?.[1];
|
|
835
|
+
if (py && /^\d/.test(py)) out.push({ name: "python", version: py.slice(0, 64), runtime: true });
|
|
836
|
+
return out;
|
|
837
|
+
}
|
|
838
|
+
var SKIP = /* @__PURE__ */ new Set(["node_modules", ".git", "dist", "build", ".next", "vendor", "venv", ".venv", "coverage", "tmp", "target", "__pycache__"]);
|
|
839
|
+
async function subdirs(dir) {
|
|
840
|
+
try {
|
|
841
|
+
return (await readdir2(dir, { withFileTypes: true })).filter((e) => e.isDirectory() && !e.name.startsWith(".") && !SKIP.has(e.name)).map((e) => join2(dir, e.name));
|
|
842
|
+
} catch {
|
|
843
|
+
return [];
|
|
844
|
+
}
|
|
845
|
+
}
|
|
846
|
+
async function projectRoot(cwd) {
|
|
847
|
+
let dir = resolve2(cwd);
|
|
848
|
+
for (; ; ) {
|
|
849
|
+
if (await isDir(join2(dir, ".git")) || await read(join2(dir, ".git")) !== null) return dir;
|
|
850
|
+
const up = dirname(dir);
|
|
851
|
+
if (up === dir) return resolve2(cwd);
|
|
852
|
+
dir = up;
|
|
853
|
+
}
|
|
854
|
+
}
|
|
855
|
+
async function projectKey(cwd) {
|
|
856
|
+
const root = await projectRoot(cwd);
|
|
857
|
+
return `${basename(root).slice(0, 100)}#${createHash2("sha256").update(root).digest("hex").slice(0, 8)}`;
|
|
858
|
+
}
|
|
859
|
+
async function findUp(dir, stop, names) {
|
|
860
|
+
let d = dir;
|
|
861
|
+
for (; ; ) {
|
|
862
|
+
for (const n of names) if (await read(join2(d, n)) !== null) return { path: join2(d, n), dir: d };
|
|
863
|
+
if (d === stop || dirname(d) === d) return null;
|
|
864
|
+
d = dirname(d);
|
|
865
|
+
}
|
|
866
|
+
}
|
|
867
|
+
var NPM_LOCKS = ["package-lock.json", "npm-shrinkwrap.json", "pnpm-lock.yaml", "yarn.lock", "bun.lock"];
|
|
868
|
+
async function npmResolver(dir, root, used) {
|
|
869
|
+
const lock = await findUp(dir, root, NPM_LOCKS);
|
|
870
|
+
if (!lock) return () => null;
|
|
871
|
+
const raw = await read(lock.path) ?? "";
|
|
872
|
+
used.add(lock.path);
|
|
873
|
+
const rel = relative2(lock.dir, dir) || ".";
|
|
874
|
+
const file = basename(lock.path);
|
|
875
|
+
if (file === "pnpm-lock.yaml") return pnpmLockResolver(raw, rel);
|
|
876
|
+
if (file === "yarn.lock") return yarnLockResolver(raw);
|
|
877
|
+
if (file === "bun.lock") return bunLockResolver(raw);
|
|
878
|
+
return npmLockResolver(raw, rel);
|
|
879
|
+
}
|
|
880
|
+
async function pythonLock(dir, root, used) {
|
|
881
|
+
const lock = await findUp(dir, root, ["poetry.lock", "uv.lock", "Pipfile.lock"]);
|
|
882
|
+
if (!lock) return /* @__PURE__ */ new Map();
|
|
883
|
+
used.add(lock.path);
|
|
884
|
+
const raw = await read(lock.path) ?? "";
|
|
885
|
+
return basename(lock.path) === "Pipfile.lock" ? pipfileLock(raw) : tomlPackages(raw);
|
|
886
|
+
}
|
|
887
|
+
async function detectStack(cwd = process.cwd()) {
|
|
888
|
+
const base = resolve2(cwd);
|
|
889
|
+
const root = await projectRoot(base);
|
|
890
|
+
const level1 = await subdirs(base);
|
|
891
|
+
const level2 = (await Promise.all(level1.map(subdirs))).flat();
|
|
892
|
+
const dirs = [base, ...level1, ...level2].slice(0, 400);
|
|
893
|
+
const deps = /* @__PURE__ */ new Map();
|
|
894
|
+
const manifests = [];
|
|
895
|
+
const locks = /* @__PURE__ */ new Set();
|
|
896
|
+
const add = (d) => {
|
|
897
|
+
const key = `${d.runtime ? "runtime" : d.ecosystem}:${d.ecosystem === "pypi" ? pep503(d.name) : d.name.toLowerCase()}@${d.version ?? ""}`;
|
|
898
|
+
if (!deps.has(key)) deps.set(key, d);
|
|
899
|
+
};
|
|
900
|
+
const rel = (p) => relative2(base, p) || basename(p);
|
|
901
|
+
for (const dir of dirs) {
|
|
902
|
+
const pkgRaw = await read(join2(dir, "package.json"));
|
|
903
|
+
if (pkgRaw !== null) {
|
|
904
|
+
manifests.push(rel(join2(dir, "package.json")));
|
|
905
|
+
const resolveVersion = await npmResolver(dir, root, locks);
|
|
906
|
+
for (const { name, spec } of fromPackageJson(pkgRaw)) {
|
|
907
|
+
const locked = resolveVersion(name, spec);
|
|
908
|
+
add({ name, ecosystem: "npm", ...locked ?? spec ? { version: (locked ?? spec).slice(0, 64) } : {} });
|
|
909
|
+
}
|
|
910
|
+
}
|
|
911
|
+
for (const r of await runtimes(dir, pkgRaw)) add(r);
|
|
912
|
+
const req = await read(join2(dir, "requirements.txt"));
|
|
913
|
+
const pyproject = await read(join2(dir, "pyproject.toml"));
|
|
914
|
+
if (req !== null || pyproject !== null) {
|
|
915
|
+
const locked = await pythonLock(dir, root, locks);
|
|
916
|
+
const pyDeps = [...req !== null ? fromRequirements(req) : [], ...pyproject !== null ? fromPyproject(pyproject) : []];
|
|
917
|
+
if (req !== null) manifests.push(rel(join2(dir, "requirements.txt")));
|
|
918
|
+
if (pyproject !== null) manifests.push(rel(join2(dir, "pyproject.toml")));
|
|
919
|
+
for (const d of pyDeps) {
|
|
920
|
+
const v = locked.get(pep503(d.name)) ?? d.version;
|
|
921
|
+
add({ name: d.name, ecosystem: "pypi", ...v ? { version: v } : {} });
|
|
922
|
+
}
|
|
923
|
+
}
|
|
924
|
+
const gomod = await read(join2(dir, "go.mod"));
|
|
925
|
+
if (gomod !== null) {
|
|
926
|
+
manifests.push(rel(join2(dir, "go.mod")));
|
|
927
|
+
for (const d of fromGoMod(gomod)) add({ ...d, ecosystem: "go" });
|
|
928
|
+
}
|
|
929
|
+
const cargo = await read(join2(dir, "Cargo.toml"));
|
|
930
|
+
if (cargo !== null) {
|
|
931
|
+
manifests.push(rel(join2(dir, "Cargo.toml")));
|
|
932
|
+
const lock = await findUp(dir, root, ["Cargo.lock"]);
|
|
933
|
+
const locked = lock ? tomlPackages(await read(lock.path) ?? "", (n) => n.toLowerCase()) : /* @__PURE__ */ new Map();
|
|
934
|
+
if (lock) locks.add(lock.path);
|
|
935
|
+
for (const d of fromCargoToml(cargo)) {
|
|
936
|
+
const v = locked.get(d.name.toLowerCase()) ?? d.version;
|
|
937
|
+
add({ name: d.name, ecosystem: "cargo", ...v ? { version: v } : {} });
|
|
938
|
+
}
|
|
939
|
+
}
|
|
940
|
+
}
|
|
941
|
+
const all = [...deps.values()];
|
|
942
|
+
const exactOf = (d) => d.ecosystem === "go" && !!d.version || isExactVersion(d.version);
|
|
943
|
+
all.sort((a, b) => Number(exactOf(b)) - Number(exactOf(a)));
|
|
944
|
+
const sent = all.slice(0, 300);
|
|
945
|
+
const exact = sent.filter((d) => !d.runtime && exactOf(d)).length;
|
|
946
|
+
return {
|
|
947
|
+
deps: sent,
|
|
948
|
+
manifests,
|
|
949
|
+
lockfiles: [...locks].map((p) => relative2(base, p) || basename(p)),
|
|
950
|
+
exact,
|
|
951
|
+
ranges: sent.filter((d) => !d.runtime).length - exact
|
|
952
|
+
};
|
|
953
|
+
}
|
|
954
|
+
async function installedSkillNames(skillDirs) {
|
|
955
|
+
const names = /* @__PURE__ */ new Set();
|
|
956
|
+
for (const dir of skillDirs) {
|
|
957
|
+
const md = await read(join2(dir, "SKILL.md"));
|
|
958
|
+
const fm = md?.match(/^---\r?\n[\s\S]*?^name:\s*["']?([^"'\n]+)["']?\s*$/m)?.[1]?.trim();
|
|
959
|
+
names.add((fm || basename(dir)).slice(0, 120));
|
|
960
|
+
}
|
|
961
|
+
return [...names];
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
// src/guard.ts
|
|
965
|
+
function commands(line) {
|
|
966
|
+
return line.split(/&&|\|\||[;|\n]/).map((c) => (c.match(/"[^"]*"|'[^']*'|\S+/g) ?? []).map((w) => w.replace(/^["']|["']$/g, ""))).filter((w) => w.length);
|
|
967
|
+
}
|
|
968
|
+
var VALUE_FLAGS = /* @__PURE__ */ new Set(["-r", "--requirement", "-c", "--constraint", "-e", "--editable", "-i", "--index-url", "--extra-index-url", "-f", "--find-links", "--registry", "--tag", "--filter", "-w", "--workspace", "--cwd", "--prefix", "-C", "--dir", "--python", "-p", "--group", "-G", "--source", "--features", "-F", "--package", "--with"]);
|
|
969
|
+
function args(words) {
|
|
970
|
+
const out = [];
|
|
971
|
+
for (let i = 0; i < words.length; i++) {
|
|
972
|
+
const w = words[i];
|
|
973
|
+
if (w.startsWith("-")) {
|
|
974
|
+
if (VALUE_FLAGS.has(w)) i++;
|
|
975
|
+
continue;
|
|
976
|
+
}
|
|
977
|
+
out.push(w);
|
|
978
|
+
}
|
|
979
|
+
return out;
|
|
980
|
+
}
|
|
981
|
+
var isLocalOrUrl = (s) => /^(\.{0,2}\/|~|file:|git[+:]|https?:|github:|link:|workspace:)/.test(s) || /\.(tgz|tar\.gz|whl|zip)$/.test(s);
|
|
982
|
+
function npmSpec(s) {
|
|
983
|
+
if (isLocalOrUrl(s) || /^[\w.-]+\/[\w.-]+$/.test(s)) return null;
|
|
984
|
+
const m = s.match(/^(@[^/@\s]+\/[^@\s]+|[^@\s]+)(?:@(.+))?$/);
|
|
985
|
+
if (!m || !/^(@[a-z0-9-~][a-z0-9-._~]*\/)?[a-z0-9-~][a-z0-9-._~]*$/i.test(m[1])) return null;
|
|
986
|
+
const version = m[2] && /\d/.test(m[2]) ? m[2] : void 0;
|
|
987
|
+
return { name: m[1], ecosystem: "npm", ...version ? { version } : {} };
|
|
988
|
+
}
|
|
989
|
+
function pySpec(s) {
|
|
990
|
+
if (isLocalOrUrl(s)) return null;
|
|
991
|
+
const m = s.match(/^([A-Za-z0-9][A-Za-z0-9._-]*)(?:\[[^\]]*\])?\s*(?:(==|~=|>=|<=|>|<|!=)\s*([^,;\s]+))?/);
|
|
992
|
+
if (!m) return null;
|
|
993
|
+
return { name: m[1], ecosystem: "pypi", ...m[3] ? { version: m[3] } : {} };
|
|
994
|
+
}
|
|
995
|
+
function cargoSpec(s) {
|
|
996
|
+
const m = s.match(/^([A-Za-z0-9_-]+)(?:@(.+))?$/);
|
|
997
|
+
return m ? { name: m[1], ecosystem: "cargo", ...m[2] ? { version: m[2] } : {} } : null;
|
|
998
|
+
}
|
|
999
|
+
function goSpec(s) {
|
|
1000
|
+
const m = s.match(/^([a-z0-9.-]+\.[a-z]{2,}\/[^@\s]+?)(?:\/\.\.\.)?(?:@(.+))?$/i);
|
|
1001
|
+
if (!m) return null;
|
|
1002
|
+
const version = m[2] && /^v\d/.test(m[2]) ? m[2] : void 0;
|
|
1003
|
+
return { name: m[1], ecosystem: "go", ...version ? { version } : {} };
|
|
1004
|
+
}
|
|
1005
|
+
function packagesInCommand(line) {
|
|
1006
|
+
const out = [];
|
|
1007
|
+
for (let words of commands(line)) {
|
|
1008
|
+
while (words.length && /^(sudo|env|\w+=\S*|time|nice|command)$/.test(words[0])) words = words.slice(1);
|
|
1009
|
+
const [tool, sub, ...rest] = words;
|
|
1010
|
+
if (!tool) continue;
|
|
1011
|
+
const bin = tool.split("/").pop();
|
|
1012
|
+
let specs = [];
|
|
1013
|
+
if (bin === "npm" && ["install", "i", "add", "in", "isntall"].includes(sub)) specs = args(rest).map(npmSpec);
|
|
1014
|
+
else if (bin === "pnpm" && ["add", "install", "i"].includes(sub)) specs = args(rest).map(npmSpec);
|
|
1015
|
+
else if (bin === "yarn" && sub === "add") specs = args(rest).map(npmSpec);
|
|
1016
|
+
else if (bin === "bun" && ["add", "install", "i"].includes(sub)) specs = args(rest).map(npmSpec);
|
|
1017
|
+
else if (["npx", "bunx"].includes(bin)) specs = [npmSpec(args([sub, ...rest].filter(Boolean))[0] ?? "")];
|
|
1018
|
+
else if ((bin === "pnpm" || bin === "yarn") && sub === "dlx") specs = [npmSpec(args(rest)[0] ?? "")];
|
|
1019
|
+
else if (/^pip3?$/.test(bin) && sub === "install") specs = args(rest).map(pySpec);
|
|
1020
|
+
else if (/^python3?$/.test(bin) && sub === "-m" && /^pip3?$/.test(rest[0] ?? "") && rest[1] === "install") specs = args(rest.slice(2)).map(pySpec);
|
|
1021
|
+
else if (bin === "uv" && sub === "add") specs = args(rest).map(pySpec);
|
|
1022
|
+
else if (bin === "uv" && sub === "pip" && rest[0] === "install") specs = args(rest.slice(1)).map(pySpec);
|
|
1023
|
+
else if (bin === "uv" && sub === "tool" && rest[0] === "install") specs = args(rest.slice(1)).map(pySpec);
|
|
1024
|
+
else if (bin === "uvx" || bin === "pipx" && sub === "run") specs = [pySpec(args(bin === "uvx" ? [sub, ...rest].filter(Boolean) : rest)[0] ?? "")];
|
|
1025
|
+
else if (bin === "pipx" && sub === "install") specs = args(rest).map(pySpec);
|
|
1026
|
+
else if (bin === "poetry" && sub === "add") specs = args(rest).map(pySpec);
|
|
1027
|
+
else if (bin === "cargo" && (sub === "add" || sub === "install")) specs = args(rest).map(cargoSpec);
|
|
1028
|
+
else if (bin === "go" && (sub === "get" || sub === "install")) specs = args(rest).map(goSpec);
|
|
1029
|
+
for (const s of specs) if (s) out.push(s);
|
|
1030
|
+
}
|
|
1031
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1032
|
+
return out.filter((p) => !seen.has(`${p.ecosystem}:${p.name}`) && !!seen.add(`${p.ecosystem}:${p.name}`)).slice(0, 25);
|
|
1033
|
+
}
|
|
1034
|
+
async function readStdin() {
|
|
1035
|
+
const chunks = [];
|
|
1036
|
+
for await (const c of process.stdin) chunks.push(c);
|
|
1037
|
+
return Buffer.concat(chunks).toString("utf8");
|
|
1038
|
+
}
|
|
1039
|
+
async function runGuard(opts) {
|
|
1040
|
+
let command = "";
|
|
1041
|
+
try {
|
|
1042
|
+
const payload = JSON.parse(await readStdin());
|
|
1043
|
+
command = payload.tool_input?.command ?? "";
|
|
1044
|
+
} catch {
|
|
1045
|
+
return 0;
|
|
1046
|
+
}
|
|
1047
|
+
const packages = packagesInCommand(command);
|
|
1048
|
+
if (!packages.length) return 0;
|
|
1049
|
+
if (!opts.key) {
|
|
1050
|
+
console.error("codexguild guard: CODEXGUILD_API_KEY is not set \u2014 packages were not checked.");
|
|
1051
|
+
return 0;
|
|
1052
|
+
}
|
|
1053
|
+
let results;
|
|
1054
|
+
try {
|
|
1055
|
+
const res = await fetch(`${opts.api}/packages/check`, {
|
|
1056
|
+
method: "POST",
|
|
1057
|
+
headers: { "Content-Type": "application/json", Authorization: `Bearer ${opts.key}` },
|
|
1058
|
+
body: JSON.stringify({ packages }),
|
|
1059
|
+
signal: AbortSignal.timeout(15e3)
|
|
1060
|
+
});
|
|
1061
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
1062
|
+
results = (await res.json()).results;
|
|
1063
|
+
} catch (e) {
|
|
1064
|
+
console.error(`codexguild guard: check failed (${e instanceof Error ? e.message : e}) \u2014 command not blocked.`);
|
|
1065
|
+
return 0;
|
|
1066
|
+
}
|
|
1067
|
+
const decision = guardDecision(results);
|
|
1068
|
+
if (decision) {
|
|
1069
|
+
process.stdout.write(JSON.stringify({ hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: decision.decision, permissionDecisionReason: decision.reason } }));
|
|
1070
|
+
}
|
|
1071
|
+
return 0;
|
|
1072
|
+
}
|
|
1073
|
+
function guardDecision(results) {
|
|
1074
|
+
const flagged = results.filter((r) => r.verdict === "block" || r.verdict === "warn");
|
|
1075
|
+
if (!flagged.length) return null;
|
|
1076
|
+
const lines = flagged.map((r) => `${r.verdict === "block" ? "BLOCKED" : "Check"} ${r.ecosystem}:${r.name}${r.version ? `@${r.version}` : ""} \u2014 ${r.reasons.join(" ")}`);
|
|
1077
|
+
const blocked = flagged.some((r) => r.verdict === "block");
|
|
1078
|
+
return {
|
|
1079
|
+
decision: blocked ? "deny" : "ask",
|
|
1080
|
+
reason: `CodexGuild package check:
|
|
1081
|
+
${lines.join("\n")}${blocked ? "\nUse the correct package name, or ask the user before trying again." : ""}`
|
|
1082
|
+
};
|
|
1083
|
+
}
|
|
1084
|
+
|
|
1085
|
+
// src/index.ts
|
|
1086
|
+
var API = (process.env.CODEXGUILD_API_URL || "https://api.codexguild.com/v1").replace(/\/$/, "");
|
|
1087
|
+
var KEY = process.env.CODEXGUILD_API_KEY ?? "";
|
|
1088
|
+
var VERSION = "1.4.0";
|
|
1089
|
+
if (process.argv[2] === "guard") process.exit(await runGuard({ api: API, key: KEY }));
|
|
1090
|
+
var INSTRUCTION_FILES = ["CLAUDE.md", ".claude/CLAUDE.md", "AGENTS.md", "AGENTS.override.md", ".hermes.md", "HERMES.md", "GEMINI.md", ".cursorrules"];
|
|
1091
|
+
async function existingInstructionFiles(cwd) {
|
|
1092
|
+
const present = await Promise.all(INSTRUCTION_FILES.map((f) => access(join3(cwd, f)).then(() => f, () => null)));
|
|
1093
|
+
return present.filter((f) => f !== null);
|
|
1094
|
+
}
|
|
1095
|
+
var remote = null;
|
|
1096
|
+
function remoteClient() {
|
|
1097
|
+
if (!KEY) return Promise.reject(new Error("CODEXGUILD_API_KEY is not set \u2014 create an agent key in the CodexGuild dashboard."));
|
|
1098
|
+
remote ??= (async () => {
|
|
1099
|
+
const client = new Client({ name: "codexguild-local", version: VERSION });
|
|
1100
|
+
await client.connect(new StreamableHTTPClientTransport(new URL(`${API}/mcp`), { requestInit: { headers: { Authorization: `Bearer ${KEY}` } } }));
|
|
1101
|
+
return client;
|
|
1102
|
+
})().catch((e) => {
|
|
1103
|
+
remote = null;
|
|
1104
|
+
throw e;
|
|
1105
|
+
});
|
|
1106
|
+
return remote;
|
|
1107
|
+
}
|
|
1108
|
+
var SCAN_TOOL = {
|
|
1109
|
+
name: "codexguild_scan_skills",
|
|
1110
|
+
description: "Security-audit the skills installed on THIS machine (~/.claude/skills, Claude plugins, ~/.codex/skills, ~/.agents/skills, ~/.hermes/skills, project skills\u2026). Detects pipe-to-shell, credential exfiltration, hidden unicode, prompt injection, agent-config tampering and safety bypasses. Runs locally \u2014 nothing is uploaded.",
|
|
1111
|
+
inputSchema: {
|
|
1112
|
+
type: "object",
|
|
1113
|
+
properties: {
|
|
1114
|
+
paths: { type: "array", items: { type: "string" }, description: "Optional directories to scan instead of the defaults" },
|
|
1115
|
+
verbose: { type: "boolean", description: "Include passed skills and all findings" }
|
|
1116
|
+
}
|
|
1117
|
+
},
|
|
1118
|
+
annotations: { readOnlyHint: true, openWorldHint: false }
|
|
1119
|
+
};
|
|
1120
|
+
var ENHANCED = {
|
|
1121
|
+
codexguild_sync: "Runs locally first: the stack (package.json, requirements.txt, go.mod, \u2026) and installed skill NAMES are detected automatically \u2014 only names/versions are sent, never file contents; you only need force/since.",
|
|
1122
|
+
codexguild_advise: "Runs locally first: stack, installed skills and the instruction files present in the project are detected automatically; you only need harness."
|
|
1123
|
+
};
|
|
1124
|
+
function enhance(t) {
|
|
1125
|
+
const note = ENHANCED[t.name];
|
|
1126
|
+
if (!note) return t;
|
|
1127
|
+
return {
|
|
1128
|
+
...t,
|
|
1129
|
+
description: `${t.description ?? ""}
|
|
1130
|
+
${note}`,
|
|
1131
|
+
inputSchema: {
|
|
1132
|
+
...t.inputSchema,
|
|
1133
|
+
properties: { ...t.inputSchema.properties, cwd: { type: "string", description: "Project directory (defaults to the current directory)" } }
|
|
1134
|
+
}
|
|
1135
|
+
};
|
|
1136
|
+
}
|
|
1137
|
+
async function detected(name, cwd) {
|
|
1138
|
+
const [{ deps, manifests, lockfiles, exact, ranges }, local, project] = await Promise.all([detectStack(cwd), scanLocalSkills(), projectKey(cwd)]);
|
|
1139
|
+
const installedSkills = await installedSkillNames(local.reports.map((r) => r.dir));
|
|
1140
|
+
if (name === "codexguild_advise") {
|
|
1141
|
+
return { args: { stack: deps, installedSkills, existingInstructionFiles: await existingInstructionFiles(cwd), project } };
|
|
1142
|
+
}
|
|
1143
|
+
const flagged = local.reports.filter((x) => x.result.status === "flagged");
|
|
1144
|
+
const shown = (xs) => xs.length > 8 ? `${xs.slice(0, 8).join(", ")} +${xs.length - 8} more` : xs.join(", ");
|
|
1145
|
+
const summary = [
|
|
1146
|
+
`Project ${project}: ${deps.length} dependencies from ${shown(manifests) || "no manifest"} \u2014 ${exact} exact${lockfiles.length ? ` (lockfiles: ${shown(lockfiles)})` : ""}, ${ranges} ranges${ranges ? " (no lockfile entry; reported as ranges, not exact versions)" : ""}.`,
|
|
1147
|
+
`Local skill scan: ${local.reports.length} skills \u2014 ${flagged.length} flagged, ${local.reports.filter((x) => x.result.status === "warn").length} to review.`,
|
|
1148
|
+
...flagged.slice(0, 10).map((x) => ` - FLAGGED ${x.dir}: ${x.result.findings[0]?.title ?? ""}`),
|
|
1149
|
+
...flagged.length ? [" Run codexguild_scan_skills for details and ask the user before using flagged skills."] : []
|
|
1150
|
+
].join("\n");
|
|
1151
|
+
return { args: { stack: deps, installedSkills, project }, summary };
|
|
1152
|
+
}
|
|
1153
|
+
var LINT_TOOL = {
|
|
1154
|
+
name: "codexguild_lint_instructions",
|
|
1155
|
+
description: "Lint the agent instruction files in this project (AGENTS.md, CLAUDE.md, GEMINI.md, .cursorrules\u2026): size per session, missing build/test commands, README copy, generic advice, secrets, machine-specific paths, prompt-injection and other scanner findings. Runs locally \u2014 file contents are not uploaded.",
|
|
1156
|
+
inputSchema: {
|
|
1157
|
+
type: "object",
|
|
1158
|
+
properties: { files: { type: "array", items: { type: "string" }, description: "Paths to lint instead of the instruction files found in the project root" } }
|
|
1159
|
+
}
|
|
1160
|
+
};
|
|
1161
|
+
async function lintLocal(cwd, files) {
|
|
1162
|
+
const targets = files?.length ? files : await existingInstructionFiles(cwd);
|
|
1163
|
+
if (!targets.length) return "No instruction files found (looked for CLAUDE.md, AGENTS.md, GEMINI.md, .cursorrules, \u2026).";
|
|
1164
|
+
const out = [];
|
|
1165
|
+
for (const f of targets) {
|
|
1166
|
+
const content = await readFile3(isAbsolute(f) ? f : join3(cwd, f), "utf8").catch(() => null);
|
|
1167
|
+
if (content === null) {
|
|
1168
|
+
out.push(`## ${f}
|
|
1169
|
+
Could not read the file.`);
|
|
1170
|
+
continue;
|
|
1171
|
+
}
|
|
1172
|
+
const r = lintInstructions(content, f.split("/").pop());
|
|
1173
|
+
out.push(`## ${f} \u2014 ${r.score}/100 (${r.grade}) \xB7 ~${r.stats.approxTokens} tokens \xB7 ${r.stats.commands} commands`);
|
|
1174
|
+
out.push(...r.issues.length ? r.issues.slice(0, 25).map((i) => `- [${i.severity}]${i.line ? ` line ${i.line}` : ""} ${i.title}: ${i.detail}`) : ["- No issues."]);
|
|
1175
|
+
}
|
|
1176
|
+
return out.join("\n");
|
|
1177
|
+
}
|
|
1178
|
+
var text = (t, isError = false) => ({ content: [{ type: "text", text: t }], ...isError ? { isError } : {} });
|
|
1179
|
+
var server = new Server({ name: "codexguild", version: VERSION }, { capabilities: { tools: { listChanged: true } } });
|
|
1180
|
+
var LOCAL_TOOLS = [SCAN_TOOL, LINT_TOOL];
|
|
1181
|
+
var RETRY_MS = [1e3, 3e3];
|
|
1182
|
+
var RECONNECT_MS = 3e4;
|
|
1183
|
+
var reconnectTimer = null;
|
|
1184
|
+
async function remoteTools() {
|
|
1185
|
+
for (let attempt = 0; ; attempt++) {
|
|
1186
|
+
try {
|
|
1187
|
+
return (await (await remoteClient()).listTools()).tools;
|
|
1188
|
+
} catch (e) {
|
|
1189
|
+
if (!KEY || attempt >= RETRY_MS.length) throw e;
|
|
1190
|
+
await new Promise((r) => setTimeout(r, RETRY_MS[attempt]));
|
|
1191
|
+
}
|
|
1192
|
+
}
|
|
1193
|
+
}
|
|
1194
|
+
function scheduleReconnect() {
|
|
1195
|
+
if (reconnectTimer || !KEY) return;
|
|
1196
|
+
reconnectTimer = setInterval(async () => {
|
|
1197
|
+
try {
|
|
1198
|
+
await (await remoteClient()).listTools();
|
|
1199
|
+
clearInterval(reconnectTimer);
|
|
1200
|
+
reconnectTimer = null;
|
|
1201
|
+
console.error("CodexGuild: remote endpoint reachable again \u2014 tool list updated.");
|
|
1202
|
+
await server.sendToolListChanged();
|
|
1203
|
+
} catch {
|
|
1204
|
+
}
|
|
1205
|
+
}, RECONNECT_MS);
|
|
1206
|
+
reconnectTimer.unref();
|
|
1207
|
+
}
|
|
1208
|
+
server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
1209
|
+
try {
|
|
1210
|
+
return { tools: [...LOCAL_TOOLS, ...(await remoteTools()).map(enhance)] };
|
|
1211
|
+
} catch (e) {
|
|
1212
|
+
console.error(`CodexGuild: remote tools unavailable (${e instanceof Error ? e.message : String(e)}) \u2014 serving local tools only; retrying in the background.`);
|
|
1213
|
+
scheduleReconnect();
|
|
1214
|
+
return { tools: LOCAL_TOOLS };
|
|
1215
|
+
}
|
|
1216
|
+
});
|
|
1217
|
+
server.setRequestHandler(CallToolRequestSchema, async (req) => {
|
|
1218
|
+
const { name } = req.params;
|
|
1219
|
+
const args2 = { ...req.params.arguments ?? {} };
|
|
1220
|
+
try {
|
|
1221
|
+
if (name === LINT_TOOL.name) {
|
|
1222
|
+
return text(await lintLocal(process.cwd(), Array.isArray(args2.files) ? args2.files.map(String) : void 0));
|
|
1223
|
+
}
|
|
1224
|
+
if (name === SCAN_TOOL.name) {
|
|
1225
|
+
const paths = Array.isArray(args2.paths) ? args2.paths.map(String) : void 0;
|
|
1226
|
+
return text(formatLocalScan(await scanLocalSkills(paths), args2.verbose === true));
|
|
1227
|
+
}
|
|
1228
|
+
let summary;
|
|
1229
|
+
if (name in ENHANCED) {
|
|
1230
|
+
const cwd = typeof args2.cwd === "string" ? args2.cwd : process.cwd();
|
|
1231
|
+
delete args2.cwd;
|
|
1232
|
+
const d = await detected(name, cwd);
|
|
1233
|
+
summary = d.summary;
|
|
1234
|
+
Object.assign(args2, { ...d.args, ...args2 });
|
|
1235
|
+
}
|
|
1236
|
+
const r = await (await remoteClient()).callTool({ name, arguments: args2 });
|
|
1237
|
+
return summary ? { ...r, content: [{ type: "text", text: summary }, ...r.content ?? []] } : r;
|
|
1238
|
+
} catch (e) {
|
|
1239
|
+
return text(`CodexGuild error: ${e instanceof Error ? e.message : String(e)}`, true);
|
|
1240
|
+
}
|
|
1241
|
+
});
|
|
1242
|
+
await server.connect(new StdioServerTransport());
|
|
1243
|
+
console.error(`CodexGuild MCP (stdio) \u2192 ${API}/mcp`);
|