@codexguild/mcp 1.1.0 → 1.5.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 +22 -4
- package/dist/codexguild-mcp.js +911 -52
- package/package.json +5 -4
package/README.md
CHANGED
|
@@ -7,16 +7,18 @@ forum, chat) and adds what has to run on your machine:
|
|
|
7
7
|
|
|
8
8
|
| Local tool | What it does |
|
|
9
9
|
|---|---|
|
|
10
|
-
| `codexguild_scan_skills` | Audits
|
|
11
|
-
| `codexguild_sync` | Detects your stack from
|
|
10
|
+
| `codexguild_scan_skills` | Audits the skills installed for every supported harness (`~/.claude/skills`, `~/.claude/plugins`, `~/.codex/skills`, `~/.agents/skills`, `~/.hermes/skills`, `~/.cursor/skills`, `~/.gemini/skills`, … and the project's own `.claude/`, `.agents/`, `.github/` skills) **offline**, with the same scanner CodexGuild runs server-side (pipe-to-shell, secret exfiltration, prompt injection, hidden unicode, config tampering…). Nothing is uploaded. |
|
|
11
|
+
| `codexguild_sync` | Detects your stack with the **exact installed versions from your lockfiles** (package-lock, pnpm-lock, yarn.lock, bun.lock, poetry.lock, uv.lock, Pipfile.lock, Cargo.lock; go.mod), the Node/Python runtime (.nvmrc, .tool-versions, engines) and your installed skills, keys it to the current repository (each project keeps its own sync window), then asks CodexGuild what changed and which installed versions have known vulnerabilities. |
|
|
12
12
|
| `codexguild_advise` | Detects the instruction files in your project (`CLAUDE.md`, `AGENTS.md`, …) before proposing setup changes. Proposals always need your approval. |
|
|
13
|
+
| `codexguild_scan_workflows` | Reviews the project's GitHub Actions workflows that run coding agents: untrusted triggers with secrets, issue/PR text in prompts or shell scripts, `pull_request_target` with fork code, unrestricted agent tools, permissions, unpinned agent actions. Runs locally. |
|
|
14
|
+
| `codexguild_lint_instructions` | Lints the project's agent instruction files (`CLAUDE.md`, `AGENTS.md`, `GEMINI.md`, `.cursorrules`…): size per session, missing build/test commands, README copy, generic advice, secrets, machine-specific paths, prompt injection. Runs locally — file contents are not uploaded. |
|
|
13
15
|
|
|
14
16
|
If your harness can't start local processes, use the remote endpoint `https://api.codexguild.com/v1/mcp` directly
|
|
15
17
|
(see [MCP docs](https://codexguild.com/docs/mcp)).
|
|
16
18
|
|
|
17
19
|
## Setup
|
|
18
20
|
|
|
19
|
-
1. Create an agent key in the [CodexGuild dashboard](https://codexguild.com/dashboard
|
|
21
|
+
1. Create an agent key in the [CodexGuild dashboard](https://codexguild.com/dashboard/agents) (`cgk_…`).
|
|
20
22
|
2. Add the server to your harness:
|
|
21
23
|
|
|
22
24
|
```json
|
|
@@ -39,11 +41,27 @@ claude mcp add codexguild --scope user -e CODEXGUILD_API_KEY=cgk_... -- npx -y @
|
|
|
39
41
|
|
|
40
42
|
Per-harness snippets (Codex, Hermes, OpenCode, Cursor, Gemini CLI, …): <https://codexguild.com/connect>.
|
|
41
43
|
|
|
44
|
+
## Guard hook (Claude Code PreToolUse)
|
|
45
|
+
|
|
46
|
+
`codexguild-mcp guard` reads every Bash command before it runs:
|
|
47
|
+
|
|
48
|
+
- **Destructive commands** — checked locally, no key or network: denies `rm -rf` of `/`, your home or system
|
|
49
|
+
directories, deletes through an unset variable (`rm -rf $DIR/`), disk writes and secrets sent over the network; asks
|
|
50
|
+
before deletes outside the project, force pushes, `git reset --hard`, database resets, `terraform destroy`, volume and
|
|
51
|
+
cloud-resource deletes. Rules: https://codexguild.com/docs/command-guard
|
|
52
|
+
- **Packages** — before an install command (npm/pnpm/yarn/bun add, npx/bunx/pnpm dlx, pip/uv/poetry/pipx, cargo add,
|
|
53
|
+
go get/install) it checks the packages with CodexGuild: `block` (doesn't exist or reported malicious) → deny; `warn` →
|
|
54
|
+
ask. Only package names and versions are sent; errors let the command run.
|
|
55
|
+
|
|
56
|
+
```json
|
|
57
|
+
{ "hooks": { "PreToolUse": [ { "matcher": "Bash", "hooks": [{ "type": "command", "command": "npx -y @codexguild/mcp@latest guard", "timeout": 20 }] } ] } }
|
|
58
|
+
```
|
|
59
|
+
|
|
42
60
|
## Configuration
|
|
43
61
|
|
|
44
62
|
| Variable | Default | |
|
|
45
63
|
|---|---|---|
|
|
46
|
-
| `CODEXGUILD_API_KEY` | — | Agent key. Without it only the offline `codexguild_scan_skills`
|
|
64
|
+
| `CODEXGUILD_API_KEY` | — | Agent key. Without it only the offline `codexguild_scan_skills`, `codexguild_lint_instructions`, `codexguild_scan_workflows` and the guard's command checks work. |
|
|
47
65
|
| `CODEXGUILD_API_URL` | `https://api.codexguild.com/v1` | Override for self-hosted or local development. |
|
|
48
66
|
|
|
49
67
|
Requires Node.js 20 or newer.
|
package/dist/codexguild-mcp.js
CHANGED
|
@@ -6,8 +6,8 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
|
|
|
6
6
|
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
7
7
|
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
|
|
8
8
|
import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
|
|
9
|
-
import { access } from "node:fs/promises";
|
|
10
|
-
import { join as
|
|
9
|
+
import { access, readdir as readdir3, readFile as readFile3 } from "node:fs/promises";
|
|
10
|
+
import { isAbsolute as isAbsolute2, join as join4 } from "node:path";
|
|
11
11
|
|
|
12
12
|
// src/local-scan.ts
|
|
13
13
|
import { readdir, readFile, stat } from "node:fs/promises";
|
|
@@ -51,7 +51,10 @@ var LINE_RULES = [
|
|
|
51
51
|
title: "Reverse shell pattern",
|
|
52
52
|
why: "Opens an interactive shell to a remote host \u2014 full remote control of the machine.",
|
|
53
53
|
scope: "all",
|
|
54
|
-
pattern:
|
|
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
|
+
)
|
|
55
58
|
},
|
|
56
59
|
{
|
|
57
60
|
id: "encoded-exec",
|
|
@@ -95,7 +98,9 @@ var LINE_RULES = [
|
|
|
95
98
|
title: "Instructions that override the agent or hide actions from the user",
|
|
96
99
|
why: "Skills are loaded into the agent context verbatim; these phrases try to subvert the agent's instructions or keep the user uninformed.",
|
|
97
100
|
scope: "markdown",
|
|
98
|
-
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|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)/i
|
|
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
|
|
99
104
|
},
|
|
100
105
|
{
|
|
101
106
|
id: "prompt-injection",
|
|
@@ -261,8 +266,225 @@ var LINE_RULES = [
|
|
|
261
266
|
];
|
|
262
267
|
var HIDDEN_UNICODE = /[----]|[\u{E0000}-\u{E007F}\u{E0100}-\u{E01EF}]/gu;
|
|
263
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/workflows.ts
|
|
359
|
+
var WEIGHT2 = { critical: 45, high: 25, medium: 10, low: 3 };
|
|
360
|
+
var AGENT_ACTIONS = [
|
|
361
|
+
[/anthropics\/claude-code(-base)?-action/i, "Claude Code"],
|
|
362
|
+
[/google-github-actions\/run-gemini-cli|google-gemini\/gemini-cli-action/i, "Gemini CLI"],
|
|
363
|
+
[/openai\/codex-action/i, "Codex"],
|
|
364
|
+
[/sst\/opencode\/github|opencode-ai\/opencode-action/i, "OpenCode"],
|
|
365
|
+
[/cline\/cline-action|cline-bot/i, "Cline"],
|
|
366
|
+
[/aider-ai\/aider-action|paul-gauthier\/aider/i, "Aider"],
|
|
367
|
+
[/github\/copilot-(swe-)?agent/i, "Copilot agent"]
|
|
368
|
+
];
|
|
369
|
+
var AGENT_CLIS = [
|
|
370
|
+
[/\bclaude\s+(-p|--print)\b|@anthropic-ai\/claude-code\b/, "Claude Code"],
|
|
371
|
+
[/\bcodex\s+(exec|e)\b|@openai\/codex\b/, "Codex"],
|
|
372
|
+
[/\bgemini\s+(-p|--prompt)\b|@google\/gemini-cli\b/, "Gemini CLI"],
|
|
373
|
+
[/\bopencode\s+run\b/, "OpenCode"],
|
|
374
|
+
[/\baider\s+.*--(message|msg)\b/, "Aider"]
|
|
375
|
+
];
|
|
376
|
+
var UNTRUSTED_EVENTS = ["pull_request_target", "issue_comment", "issues", "pull_request_review", "pull_request_review_comment", "discussion", "discussion_comment", "workflow_run"];
|
|
377
|
+
var UNTRUSTED_EXPR = /\$\{\{\s*(github\.event\.(issue|pull_request|comment|review|review_comment|discussion|head_commit|commits\[\d+\]|workflow_run)\.(?:[\w.]*\.)?(title|body|message|name|label|ref|head_branch|display_title)|github\.head_ref|github\.event\.pull_request\.head\.(ref|label))\s*\}\}/;
|
|
378
|
+
var AUTHOR_GATE = /author_association|github\.actor\s*==|github\.triggering_actor|github\.event\.sender\.login\s*==|contains\(fromJSON\(['"]\[["'](OWNER|MEMBER|COLLABORATOR)/i;
|
|
379
|
+
var DANGEROUS_FLAGS = [
|
|
380
|
+
[/--dangerously-skip-permissions/, "Claude Code runs every tool without asking (--dangerously-skip-permissions)"],
|
|
381
|
+
[/--dangerously-bypass-approvals-and-sandbox|--yolo\b/, "the agent runs without approvals or sandbox (--yolo / --dangerously-bypass-approvals-and-sandbox)"],
|
|
382
|
+
[/\b(allowed_tools|allowedTools|--allowedTools|--allowed-tools)\b[^\n]*["',\s[]Bash(?!\()/, "the agent may run any shell command (Bash allowed without a command pattern)"]
|
|
383
|
+
];
|
|
384
|
+
function grade2(score) {
|
|
385
|
+
return score >= 90 ? "A" : score >= 75 ? "B" : score >= 60 ? "C" : score >= 40 ? "D" : "F";
|
|
386
|
+
}
|
|
387
|
+
function blocks(lines) {
|
|
388
|
+
const out = [];
|
|
389
|
+
let kind = null;
|
|
390
|
+
let indent = -1;
|
|
391
|
+
for (const raw of lines) {
|
|
392
|
+
const ind = raw.length - raw.trimStart().length;
|
|
393
|
+
if (kind && raw.trim() && ind <= indent) kind = null;
|
|
394
|
+
const m = raw.match(/^(\s*)(?:-\s+)?(run|prompt|direct_prompt|override_prompt|custom_instructions|claude_args|codex_args|gemini_args|args|message):\s*(.*)$/);
|
|
395
|
+
if (m) {
|
|
396
|
+
const k = m[2] === "run" ? "run" : "prompt";
|
|
397
|
+
out.push(k);
|
|
398
|
+
kind = /^[|>][-+]?\s*$/.test(m[3]) || m[3] === "" ? k : null;
|
|
399
|
+
indent = m[1].length + (raw.trimStart().startsWith("-") ? 2 : 0);
|
|
400
|
+
continue;
|
|
401
|
+
}
|
|
402
|
+
out.push(kind);
|
|
403
|
+
}
|
|
404
|
+
return out;
|
|
405
|
+
}
|
|
406
|
+
function lintWorkflow(content) {
|
|
407
|
+
const issues = [];
|
|
408
|
+
const lines = content.split(/\r?\n/);
|
|
409
|
+
const text2 = lines.map((l) => l.replace(/\s+#.*$/, "")).join("\n");
|
|
410
|
+
const agents = /* @__PURE__ */ new Set();
|
|
411
|
+
const agentLines = [];
|
|
412
|
+
lines.forEach((l, i) => {
|
|
413
|
+
for (const [re, name] of [...AGENT_ACTIONS, ...AGENT_CLIS]) if (re.test(l)) {
|
|
414
|
+
agents.add(name);
|
|
415
|
+
agentLines.push(i + 1);
|
|
416
|
+
}
|
|
417
|
+
});
|
|
418
|
+
const untrustedTriggers = UNTRUSTED_EVENTS.filter((e) => new RegExp(`(^|[\\s,[{'"-])${e}\\s*(:|,|\\]|$)`, "m").test(text2));
|
|
419
|
+
const hasAgent = agents.size > 0;
|
|
420
|
+
const secrets = [...text2.matchAll(/\$\{\{\s*secrets\.(\w+)\s*\}\}/g)].map((m) => m[1]).filter((s) => s !== "GITHUB_TOKEN");
|
|
421
|
+
const gated = AUTHOR_GATE.test(text2);
|
|
422
|
+
const kinds = blocks(lines);
|
|
423
|
+
const firstAgent = agentLines[0];
|
|
424
|
+
if (hasAgent && untrustedTriggers.length && secrets.length && !gated) {
|
|
425
|
+
issues.push({
|
|
426
|
+
id: "untrusted-trigger-with-secrets",
|
|
427
|
+
severity: "critical",
|
|
428
|
+
line: firstAgent,
|
|
429
|
+
title: `Anyone can start ${[...agents].join(" / ")} with your secrets (${untrustedTriggers.join(", ")})`,
|
|
430
|
+
detail: `${untrustedTriggers.join(", ")} runs for outside contributors, and this job gives the agent ${[...new Set(secrets)].slice(0, 4).join(", ")}. A crafted title, body or comment can steer the agent into revealing or misusing them. Restrict who can trigger it (check github.event.comment.author_association / sender against OWNER, MEMBER, COLLABORATOR), or move secrets to a workflow that only maintainers trigger.`
|
|
431
|
+
});
|
|
432
|
+
} else if (hasAgent && untrustedTriggers.length && !gated) {
|
|
433
|
+
issues.push({
|
|
434
|
+
id: "untrusted-trigger",
|
|
435
|
+
severity: "medium",
|
|
436
|
+
line: firstAgent,
|
|
437
|
+
title: `Outside contributors can start the agent (${untrustedTriggers.join(", ")})`,
|
|
438
|
+
detail: "No author check was found. Even without secrets, the agent spends your quota and can write to the repository with the job token. Gate it on author_association."
|
|
439
|
+
});
|
|
440
|
+
}
|
|
441
|
+
if (untrustedTriggers.includes("pull_request_target")) {
|
|
442
|
+
lines.forEach((l, i) => {
|
|
443
|
+
if (/^\s*ref:\s*\$\{\{\s*(github\.event\.pull_request\.head\.(sha|ref)|github\.head_ref)\s*\}\}/.test(l)) {
|
|
444
|
+
issues.push({ id: "prt-checkout-head", severity: "critical", line: i + 1, title: "pull_request_target checks out the pull request code", detail: "The job runs with your secrets and a write token, and now with code from the fork. Anything the agent or a build step executes from that checkout can steal them. Use pull_request for untrusted code, or check out the base branch only." });
|
|
445
|
+
}
|
|
446
|
+
});
|
|
447
|
+
}
|
|
448
|
+
lines.forEach((l, i) => {
|
|
449
|
+
const m = l.match(UNTRUSTED_EXPR);
|
|
450
|
+
if (!m) return;
|
|
451
|
+
const kind = kinds[i];
|
|
452
|
+
if (kind === "run") {
|
|
453
|
+
issues.push({ id: "untrusted-in-run", severity: "high", line: i + 1, title: `Untrusted ${m[1].split(".").slice(-2).join(".")} expanded inside a shell`, detail: 'The value is pasted into the script before it runs, so a title like `"; curl \u2026 | sh #` executes. Pass it through env: (VALUE: ${{ \u2026 }}) and use "$VALUE" in the script.' });
|
|
454
|
+
} else if (kind === "prompt" || hasAgent) {
|
|
455
|
+
issues.push({ id: "untrusted-in-prompt", severity: hasAgent ? "high" : "medium", line: i + 1, title: `Untrusted ${m[1].split(".").slice(-2).join(".")} inserted into the agent's prompt`, detail: "Text from outside contributors becomes instructions to the agent \u2014 the prompt-injection path behind the 2026 agent-action key leaks. Let the agent read the issue/PR through its tools as data, keep tools and permissions minimal, and never give this job secrets it does not need." });
|
|
456
|
+
}
|
|
457
|
+
});
|
|
458
|
+
lines.forEach((l, i) => {
|
|
459
|
+
for (const [re, what] of DANGEROUS_FLAGS) {
|
|
460
|
+
if (re.test(l)) {
|
|
461
|
+
const severe = untrustedTriggers.length > 0 && !gated;
|
|
462
|
+
issues.push({ id: "agent-unrestricted", severity: severe ? "high" : "medium", line: i + 1, title: `In CI, ${what}`, detail: severe ? "Combined with a trigger outside contributors control, a prompt injection can run arbitrary commands with the job token and secrets. Allow only the commands the task needs (e.g. Bash(npm test))." : "Allow only the commands the task needs (e.g. Bash(npm test)); a prompt injection anywhere in the input then cannot run arbitrary commands." });
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
});
|
|
466
|
+
if (/^\s*permissions:\s*write-all\b/m.test(text2)) {
|
|
467
|
+
const n = lines.findIndex((l) => /permissions:\s*write-all/.test(l)) + 1;
|
|
468
|
+
issues.push({ id: "write-all", severity: hasAgent ? "high" : "medium", line: n, title: "permissions: write-all", detail: "The job token can push code, change releases and edit workflows. Grant only what the job needs (e.g. contents: read, pull-requests: write)." });
|
|
469
|
+
} else if (hasAgent && !/^\s*permissions:/m.test(text2)) {
|
|
470
|
+
issues.push({ id: "no-permissions", severity: "medium", title: "No permissions block", detail: "Without one, the job token gets the repository default, which is often read/write for everything. Declare the minimum (contents: read, plus what the agent must write)." });
|
|
471
|
+
}
|
|
472
|
+
lines.forEach((l, i) => {
|
|
473
|
+
const m = l.match(/uses:\s*([\w.-]+\/[\w./-]+)@([\w.-]+)/);
|
|
474
|
+
if (!m || !AGENT_ACTIONS.some(([re]) => re.test(m[1]))) return;
|
|
475
|
+
if (!/^[0-9a-f]{40}$/.test(m[2])) {
|
|
476
|
+
issues.push({ id: "unpinned-agent-action", severity: "medium", line: i + 1, title: `${m[1]}@${m[2]} is not pinned to a commit`, detail: "This action receives your API keys. A moved tag or a compromised release changes what runs with them. Pin the full commit SHA (with the version in a comment) and update deliberately." });
|
|
477
|
+
}
|
|
478
|
+
});
|
|
479
|
+
if (hasAgent && /secrets:\s*inherit/.test(text2)) {
|
|
480
|
+
issues.push({ id: "secrets-inherit", severity: "medium", line: lines.findIndex((l) => /secrets:\s*inherit/.test(l)) + 1, title: "secrets: inherit", detail: "Every repository secret is passed on. Pass only the key the agent needs." });
|
|
481
|
+
}
|
|
482
|
+
const score = Math.max(0, 100 - issues.reduce((n, x) => n + WEIGHT2[x.severity], 0));
|
|
483
|
+
return { score: hasAgent || issues.length ? score : 100, grade: grade2(score), agents: [...agents], untrustedTriggers, issues: issues.sort((a, b) => WEIGHT2[b.severity] - WEIGHT2[a.severity] || (a.line ?? 0) - (b.line ?? 0)) };
|
|
484
|
+
}
|
|
485
|
+
|
|
264
486
|
// ../scanner/src/index.ts
|
|
265
|
-
var SCANNER_VERSION = "1.
|
|
487
|
+
var SCANNER_VERSION = "1.2.0";
|
|
266
488
|
var MAX_PER_RULE_PER_FILE = 5;
|
|
267
489
|
var SEVERITY_ORDER = ["critical", "high", "medium", "low"];
|
|
268
490
|
var MARKDOWN_EXT = /\.(md|mdx|markdown|txt|rst)$/i;
|
|
@@ -382,9 +604,10 @@ function scanSkill(files) {
|
|
|
382
604
|
snippet: snippetOf(line)
|
|
383
605
|
});
|
|
384
606
|
}
|
|
607
|
+
const prose = scope === "markdown" && !inFence ? line.replace(/^\s*(?:>\s?)+/, "").replace(/<[a-z][\w -]{0,30}>/gi, "") : line;
|
|
385
608
|
for (const rule of LINE_RULES) {
|
|
386
609
|
if (rule.scope !== "all" && rule.scope !== scope) continue;
|
|
387
|
-
if (!matches(rule, line)) continue;
|
|
610
|
+
if (!matches(rule, rule.alsoRequires === WRITE_OP ? prose : line)) continue;
|
|
388
611
|
if (scope === "markdown" && rule.alsoRequires && !inFence && line.includes("`")) {
|
|
389
612
|
const spans = line.match(/`[^`]+`/g) ?? [];
|
|
390
613
|
if (!spans.some((sp) => matches(rule, sp))) continue;
|
|
@@ -537,8 +760,9 @@ function formatLocalScan({ roots, reports }, verbose = false) {
|
|
|
537
760
|
}
|
|
538
761
|
|
|
539
762
|
// src/stack-detect.ts
|
|
540
|
-
import {
|
|
541
|
-
import {
|
|
763
|
+
import { createHash as createHash2 } from "node:crypto";
|
|
764
|
+
import { readdir as readdir2, readFile as readFile2, stat as stat2 } from "node:fs/promises";
|
|
765
|
+
import { basename, dirname, join as join2, relative as relative2, resolve as resolve2, sep } from "node:path";
|
|
542
766
|
async function read(path) {
|
|
543
767
|
try {
|
|
544
768
|
return await readFile2(path, "utf8");
|
|
@@ -546,31 +770,200 @@ async function read(path) {
|
|
|
546
770
|
return null;
|
|
547
771
|
}
|
|
548
772
|
}
|
|
773
|
+
async function isDir(path) {
|
|
774
|
+
try {
|
|
775
|
+
return (await stat2(path)).isDirectory();
|
|
776
|
+
} catch {
|
|
777
|
+
return false;
|
|
778
|
+
}
|
|
779
|
+
}
|
|
780
|
+
function isExactVersion(v) {
|
|
781
|
+
return !!v && /^v?\d+\.\d+\.\d+(?:[-+.][0-9A-Za-z.-]+)?$/.test(v.trim());
|
|
782
|
+
}
|
|
783
|
+
var pep503 = (name) => name.toLowerCase().replace(/[-_.]+/g, "-");
|
|
784
|
+
var unquote = (s) => s.trim().replace(/^['"]|['"]$/g, "");
|
|
549
785
|
function fromPackageJson(raw) {
|
|
550
786
|
try {
|
|
551
787
|
const pkg = JSON.parse(raw);
|
|
552
|
-
return [pkg.dependencies, pkg.devDependencies, pkg.
|
|
788
|
+
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));
|
|
553
789
|
} catch {
|
|
554
790
|
return [];
|
|
555
791
|
}
|
|
556
792
|
}
|
|
793
|
+
function nodeEngine(raw) {
|
|
794
|
+
try {
|
|
795
|
+
const e = JSON.parse(raw).engines?.node;
|
|
796
|
+
return e ? String(e).trim().slice(0, 64) : null;
|
|
797
|
+
} catch {
|
|
798
|
+
return null;
|
|
799
|
+
}
|
|
800
|
+
}
|
|
801
|
+
function npmLockResolver(raw, rel) {
|
|
802
|
+
let lock;
|
|
803
|
+
try {
|
|
804
|
+
lock = JSON.parse(raw);
|
|
805
|
+
} catch {
|
|
806
|
+
return () => null;
|
|
807
|
+
}
|
|
808
|
+
const prefix = rel && rel !== "." ? `${rel.split(sep).join("/")}/` : "";
|
|
809
|
+
return (name) => {
|
|
810
|
+
const p = lock.packages;
|
|
811
|
+
if (p) {
|
|
812
|
+
const hit = prefix && p[`${prefix}node_modules/${name}`] || p[`node_modules/${name}`];
|
|
813
|
+
if (hit && !hit.link && hit.version && !/^(file|link):/.test(hit.version)) return hit.version;
|
|
814
|
+
return null;
|
|
815
|
+
}
|
|
816
|
+
return lock.dependencies?.[name]?.version ?? null;
|
|
817
|
+
};
|
|
818
|
+
}
|
|
819
|
+
function pnpmLockResolver(raw, rel) {
|
|
820
|
+
const importer = !rel || rel === "." ? "." : rel.split(sep).join("/");
|
|
821
|
+
const out = /* @__PURE__ */ new Map();
|
|
822
|
+
const clean = (v) => unquote(v).replace(/\(.*$/, "").replace(/_.*$/, "");
|
|
823
|
+
let inImporters = false;
|
|
824
|
+
let current = null;
|
|
825
|
+
let pkg = null;
|
|
826
|
+
let inDeps = false;
|
|
827
|
+
for (const line of raw.split("\n")) {
|
|
828
|
+
if (/^importers:\s*$/.test(line)) {
|
|
829
|
+
inImporters = true;
|
|
830
|
+
continue;
|
|
831
|
+
}
|
|
832
|
+
if (!inImporters) continue;
|
|
833
|
+
if (/^\S/.test(line)) break;
|
|
834
|
+
let m = line.match(/^ {2}(\S.*?):\s*$/);
|
|
835
|
+
if (m) {
|
|
836
|
+
current = unquote(m[1]);
|
|
837
|
+
inDeps = false;
|
|
838
|
+
continue;
|
|
839
|
+
}
|
|
840
|
+
if (current !== importer) continue;
|
|
841
|
+
m = line.match(/^ {4}(\S+):\s*$/);
|
|
842
|
+
if (m) {
|
|
843
|
+
inDeps = /^(dependencies|devDependencies|optionalDependencies)$/.test(m[1]);
|
|
844
|
+
continue;
|
|
845
|
+
}
|
|
846
|
+
if (!inDeps) continue;
|
|
847
|
+
m = line.match(/^ {6}(\S.*?):\s*(.*)$/);
|
|
848
|
+
if (m) {
|
|
849
|
+
pkg = unquote(m[1]);
|
|
850
|
+
if (m[2] && !/^\s*$/.test(m[2])) out.set(pkg, clean(m[2]));
|
|
851
|
+
continue;
|
|
852
|
+
}
|
|
853
|
+
m = line.match(/^ {8}version:\s*(.+)$/);
|
|
854
|
+
if (m && pkg) out.set(pkg, clean(m[1]));
|
|
855
|
+
}
|
|
856
|
+
return (name) => {
|
|
857
|
+
const v = out.get(name);
|
|
858
|
+
return v && !/^(link|file|workspace):/.test(v) ? v : null;
|
|
859
|
+
};
|
|
860
|
+
}
|
|
861
|
+
function yarnLockResolver(raw) {
|
|
862
|
+
const bySpec = /* @__PURE__ */ new Map();
|
|
863
|
+
const byName = /* @__PURE__ */ new Map();
|
|
864
|
+
let keys = [];
|
|
865
|
+
for (const line of raw.split("\n")) {
|
|
866
|
+
if (/^\S.*:\s*$/.test(line) && !line.startsWith("#") && !line.startsWith("__metadata")) {
|
|
867
|
+
keys = line.replace(/:\s*$/, "").split(/,\s*/).map(unquote).filter(Boolean);
|
|
868
|
+
continue;
|
|
869
|
+
}
|
|
870
|
+
const m = line.match(/^ {2}version:?\s+"?([^"\s]+)"?\s*$/);
|
|
871
|
+
if (m && keys.length) {
|
|
872
|
+
for (const k of keys) {
|
|
873
|
+
const at = k.lastIndexOf("@");
|
|
874
|
+
if (at <= 0) continue;
|
|
875
|
+
const name = k.slice(0, at);
|
|
876
|
+
const spec = k.slice(at + 1).replace(/^npm:/, "");
|
|
877
|
+
bySpec.set(`${name}@${spec}`, m[1]);
|
|
878
|
+
if (!byName.has(name)) byName.set(name, m[1]);
|
|
879
|
+
}
|
|
880
|
+
keys = [];
|
|
881
|
+
}
|
|
882
|
+
}
|
|
883
|
+
return (name, spec) => bySpec.get(`${name}@${spec}`) ?? byName.get(name) ?? null;
|
|
884
|
+
}
|
|
885
|
+
function bunLockResolver(raw) {
|
|
886
|
+
let pkgs = {};
|
|
887
|
+
try {
|
|
888
|
+
pkgs = JSON.parse(raw.replace(/,(\s*[}\]])/g, "$1")).packages ?? {};
|
|
889
|
+
} catch {
|
|
890
|
+
return () => null;
|
|
891
|
+
}
|
|
892
|
+
return (name) => {
|
|
893
|
+
const id = pkgs[name]?.[0];
|
|
894
|
+
if (typeof id !== "string") return null;
|
|
895
|
+
const v = id.slice(id.lastIndexOf("@") + 1);
|
|
896
|
+
return /^\d/.test(v) ? v : null;
|
|
897
|
+
};
|
|
898
|
+
}
|
|
557
899
|
function fromRequirements(raw) {
|
|
558
|
-
return raw.split("\n").map((l) => l.replace(/#.*/, "").trim()).filter((l) => l && !l.startsWith("-")).map((l) => {
|
|
559
|
-
const m = l.match(/^([A-Za-z0-9_.-]+)(?:\[
|
|
560
|
-
|
|
900
|
+
return raw.split("\n").map((l) => l.replace(/#.*/, "").trim()).filter((l) => l && !l.startsWith("-") && !/^(git\+|https?:)/.test(l)).map((l) => {
|
|
901
|
+
const m = l.match(/^([A-Za-z0-9_.-]+)(?:\[.*?\])?\s*(===?|~=|>=|<=|!=|>|<)?\s*([\w.*+-]+)?/);
|
|
902
|
+
if (!m) return null;
|
|
903
|
+
const [, name, op, v] = m;
|
|
904
|
+
if (!v) return { name };
|
|
905
|
+
return { name, version: op === "==" || op === "===" ? v : `${op ?? ""}${v}`.slice(0, 64) };
|
|
561
906
|
}).filter((d) => d !== null);
|
|
562
907
|
}
|
|
563
908
|
function fromPyproject(raw) {
|
|
564
|
-
const
|
|
565
|
-
|
|
909
|
+
const pep621 = raw.match(/^\s*dependencies\s*=\s*\[([\s\S]*?)\]/m)?.[1] ?? "";
|
|
910
|
+
const deps = fromRequirements([...pep621.matchAll(/["']([^"']+)["']/g)].map((m) => m[1]).join("\n"));
|
|
911
|
+
const poetry = raw.match(/^\[tool\.poetry\.(?:dev-)?dependencies\]\s*$([\s\S]*?)(?=^\[|(?![\s\S]))/m)?.[1] ?? "";
|
|
912
|
+
for (const m of poetry.matchAll(/^([A-Za-z0-9_.-]+)\s*=\s*(?:"([^"]*)"|\{[^}]*version\s*=\s*"([^"]*)")/gm)) {
|
|
913
|
+
if (m[1].toLowerCase() === "python") continue;
|
|
914
|
+
deps.push({ name: m[1], ...m[2] ?? m[3] ? { version: (m[2] ?? m[3]).slice(0, 64) } : {} });
|
|
915
|
+
}
|
|
916
|
+
return deps;
|
|
917
|
+
}
|
|
918
|
+
function tomlPackages(raw, normalize2 = pep503) {
|
|
919
|
+
const out = /* @__PURE__ */ new Map();
|
|
920
|
+
for (const block of raw.split(/^\[\[package\]\]\s*$/m).slice(1)) {
|
|
921
|
+
const name = block.match(/^name\s*=\s*"([^"]+)"/m)?.[1];
|
|
922
|
+
const version = block.match(/^version\s*=\s*"([^"]+)"/m)?.[1];
|
|
923
|
+
if (name && version && !out.has(normalize2(name))) out.set(normalize2(name), version);
|
|
924
|
+
}
|
|
925
|
+
return out;
|
|
926
|
+
}
|
|
927
|
+
function pipfileLock(raw) {
|
|
928
|
+
const out = /* @__PURE__ */ new Map();
|
|
929
|
+
try {
|
|
930
|
+
const j = JSON.parse(raw);
|
|
931
|
+
for (const section of [j.default, j.develop]) {
|
|
932
|
+
for (const [name, v] of Object.entries(section ?? {})) {
|
|
933
|
+
const ver = v.version?.replace(/^==/, "");
|
|
934
|
+
if (ver) out.set(pep503(name), ver);
|
|
935
|
+
}
|
|
936
|
+
}
|
|
937
|
+
} catch {
|
|
938
|
+
}
|
|
939
|
+
return out;
|
|
566
940
|
}
|
|
567
941
|
function fromGoMod(raw) {
|
|
568
|
-
return [...raw.matchAll(/^\s*(?:require\s+)?([\w.-]+\.[\w.-]+\/[\w./-]+)\s+(v[\w.+-]+)/gm)].map((m) => ({
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
942
|
+
return [...raw.matchAll(/^\s*(?:require\s+)?([\w.-]+\.[\w.-]+\/[\w./-]+)\s+(v[\w.+-]+)/gm)].map((m) => ({ name: m[1], version: m[2] }));
|
|
943
|
+
}
|
|
944
|
+
function fromCargoToml(raw) {
|
|
945
|
+
const deps = [];
|
|
946
|
+
const sections = raw.matchAll(/^\[(?:workspace\.)?(?:dev-|build-)?dependencies\]\s*$([\s\S]*?)(?=^\[|(?![\s\S]))/gm);
|
|
947
|
+
for (const s of sections) {
|
|
948
|
+
for (const m of s[1].matchAll(/^([A-Za-z0-9_-]+)\s*=\s*(?:"([^"]*)"|\{([^}]*)\})/gm)) {
|
|
949
|
+
const v = m[2] ?? m[3]?.match(/version\s*=\s*"([^"]*)"/)?.[1];
|
|
950
|
+
deps.push({ name: m[1], ...v ? { version: v.slice(0, 64) } : {} });
|
|
951
|
+
}
|
|
952
|
+
}
|
|
953
|
+
return deps;
|
|
572
954
|
}
|
|
573
|
-
|
|
955
|
+
async function runtimes(dir, pkgRaw) {
|
|
956
|
+
const out = [];
|
|
957
|
+
const node = (await read(join2(dir, ".nvmrc")))?.trim() || (await read(join2(dir, ".node-version")))?.trim();
|
|
958
|
+
const tool = await read(join2(dir, ".tool-versions"));
|
|
959
|
+
const toolNode = tool?.match(/^(?:nodejs|node)\s+(\S+)/m)?.[1];
|
|
960
|
+
const nodeVersion = node || toolNode || (pkgRaw ? nodeEngine(pkgRaw) : null);
|
|
961
|
+
if (nodeVersion && /\d/.test(nodeVersion)) out.push({ name: "node", version: nodeVersion.replace(/^v/, "").slice(0, 64), runtime: true });
|
|
962
|
+
const py = (await read(join2(dir, ".python-version")))?.trim().split("\n")[0] || tool?.match(/^python\s+(\S+)/m)?.[1];
|
|
963
|
+
if (py && /^\d/.test(py)) out.push({ name: "python", version: py.slice(0, 64), runtime: true });
|
|
964
|
+
return out;
|
|
965
|
+
}
|
|
966
|
+
var SKIP = /* @__PURE__ */ new Set(["node_modules", ".git", "dist", "build", ".next", "vendor", "venv", ".venv", "coverage", "tmp", "target", "__pycache__"]);
|
|
574
967
|
async function subdirs(dir) {
|
|
575
968
|
try {
|
|
576
969
|
return (await readdir2(dir, { withFileTypes: true })).filter((e) => e.isDirectory() && !e.name.startsWith(".") && !SKIP.has(e.name)).map((e) => join2(dir, e.name));
|
|
@@ -578,27 +971,113 @@ async function subdirs(dir) {
|
|
|
578
971
|
return [];
|
|
579
972
|
}
|
|
580
973
|
}
|
|
974
|
+
async function projectRoot(cwd) {
|
|
975
|
+
let dir = resolve2(cwd);
|
|
976
|
+
for (; ; ) {
|
|
977
|
+
if (await isDir(join2(dir, ".git")) || await read(join2(dir, ".git")) !== null) return dir;
|
|
978
|
+
const up = dirname(dir);
|
|
979
|
+
if (up === dir) return resolve2(cwd);
|
|
980
|
+
dir = up;
|
|
981
|
+
}
|
|
982
|
+
}
|
|
983
|
+
async function projectKey(cwd) {
|
|
984
|
+
const root = await projectRoot(cwd);
|
|
985
|
+
return `${basename(root).slice(0, 100)}#${createHash2("sha256").update(root).digest("hex").slice(0, 8)}`;
|
|
986
|
+
}
|
|
987
|
+
async function findUp(dir, stop, names) {
|
|
988
|
+
let d = dir;
|
|
989
|
+
for (; ; ) {
|
|
990
|
+
for (const n of names) if (await read(join2(d, n)) !== null) return { path: join2(d, n), dir: d };
|
|
991
|
+
if (d === stop || dirname(d) === d) return null;
|
|
992
|
+
d = dirname(d);
|
|
993
|
+
}
|
|
994
|
+
}
|
|
995
|
+
var NPM_LOCKS = ["package-lock.json", "npm-shrinkwrap.json", "pnpm-lock.yaml", "yarn.lock", "bun.lock"];
|
|
996
|
+
async function npmResolver(dir, root, used) {
|
|
997
|
+
const lock = await findUp(dir, root, NPM_LOCKS);
|
|
998
|
+
if (!lock) return () => null;
|
|
999
|
+
const raw = await read(lock.path) ?? "";
|
|
1000
|
+
used.add(lock.path);
|
|
1001
|
+
const rel = relative2(lock.dir, dir) || ".";
|
|
1002
|
+
const file = basename(lock.path);
|
|
1003
|
+
if (file === "pnpm-lock.yaml") return pnpmLockResolver(raw, rel);
|
|
1004
|
+
if (file === "yarn.lock") return yarnLockResolver(raw);
|
|
1005
|
+
if (file === "bun.lock") return bunLockResolver(raw);
|
|
1006
|
+
return npmLockResolver(raw, rel);
|
|
1007
|
+
}
|
|
1008
|
+
async function pythonLock(dir, root, used) {
|
|
1009
|
+
const lock = await findUp(dir, root, ["poetry.lock", "uv.lock", "Pipfile.lock"]);
|
|
1010
|
+
if (!lock) return /* @__PURE__ */ new Map();
|
|
1011
|
+
used.add(lock.path);
|
|
1012
|
+
const raw = await read(lock.path) ?? "";
|
|
1013
|
+
return basename(lock.path) === "Pipfile.lock" ? pipfileLock(raw) : tomlPackages(raw);
|
|
1014
|
+
}
|
|
581
1015
|
async function detectStack(cwd = process.cwd()) {
|
|
582
|
-
const
|
|
1016
|
+
const base = resolve2(cwd);
|
|
1017
|
+
const root = await projectRoot(base);
|
|
1018
|
+
const level1 = await subdirs(base);
|
|
583
1019
|
const level2 = (await Promise.all(level1.map(subdirs))).flat();
|
|
584
|
-
const dirs = [
|
|
585
|
-
const parsers = [
|
|
586
|
-
["package.json", fromPackageJson],
|
|
587
|
-
["requirements.txt", fromRequirements],
|
|
588
|
-
["pyproject.toml", fromPyproject],
|
|
589
|
-
["go.mod", fromGoMod]
|
|
590
|
-
];
|
|
1020
|
+
const dirs = [base, ...level1, ...level2].slice(0, 400);
|
|
591
1021
|
const deps = /* @__PURE__ */ new Map();
|
|
592
1022
|
const manifests = [];
|
|
1023
|
+
const locks = /* @__PURE__ */ new Set();
|
|
1024
|
+
const add = (d) => {
|
|
1025
|
+
const key = `${d.runtime ? "runtime" : d.ecosystem}:${d.ecosystem === "pypi" ? pep503(d.name) : d.name.toLowerCase()}@${d.version ?? ""}`;
|
|
1026
|
+
if (!deps.has(key)) deps.set(key, d);
|
|
1027
|
+
};
|
|
1028
|
+
const rel = (p) => relative2(base, p) || basename(p);
|
|
593
1029
|
for (const dir of dirs) {
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
for (const
|
|
1030
|
+
const pkgRaw = await read(join2(dir, "package.json"));
|
|
1031
|
+
if (pkgRaw !== null) {
|
|
1032
|
+
manifests.push(rel(join2(dir, "package.json")));
|
|
1033
|
+
const resolveVersion = await npmResolver(dir, root, locks);
|
|
1034
|
+
for (const { name, spec } of fromPackageJson(pkgRaw)) {
|
|
1035
|
+
const locked = resolveVersion(name, spec);
|
|
1036
|
+
add({ name, ecosystem: "npm", ...locked ?? spec ? { version: (locked ?? spec).slice(0, 64) } : {} });
|
|
1037
|
+
}
|
|
1038
|
+
}
|
|
1039
|
+
for (const r of await runtimes(dir, pkgRaw)) add(r);
|
|
1040
|
+
const req = await read(join2(dir, "requirements.txt"));
|
|
1041
|
+
const pyproject = await read(join2(dir, "pyproject.toml"));
|
|
1042
|
+
if (req !== null || pyproject !== null) {
|
|
1043
|
+
const locked = await pythonLock(dir, root, locks);
|
|
1044
|
+
const pyDeps = [...req !== null ? fromRequirements(req) : [], ...pyproject !== null ? fromPyproject(pyproject) : []];
|
|
1045
|
+
if (req !== null) manifests.push(rel(join2(dir, "requirements.txt")));
|
|
1046
|
+
if (pyproject !== null) manifests.push(rel(join2(dir, "pyproject.toml")));
|
|
1047
|
+
for (const d of pyDeps) {
|
|
1048
|
+
const v = locked.get(pep503(d.name)) ?? d.version;
|
|
1049
|
+
add({ name: d.name, ecosystem: "pypi", ...v ? { version: v } : {} });
|
|
1050
|
+
}
|
|
1051
|
+
}
|
|
1052
|
+
const gomod = await read(join2(dir, "go.mod"));
|
|
1053
|
+
if (gomod !== null) {
|
|
1054
|
+
manifests.push(rel(join2(dir, "go.mod")));
|
|
1055
|
+
for (const d of fromGoMod(gomod)) add({ ...d, ecosystem: "go" });
|
|
1056
|
+
}
|
|
1057
|
+
const cargo = await read(join2(dir, "Cargo.toml"));
|
|
1058
|
+
if (cargo !== null) {
|
|
1059
|
+
manifests.push(rel(join2(dir, "Cargo.toml")));
|
|
1060
|
+
const lock = await findUp(dir, root, ["Cargo.lock"]);
|
|
1061
|
+
const locked = lock ? tomlPackages(await read(lock.path) ?? "", (n) => n.toLowerCase()) : /* @__PURE__ */ new Map();
|
|
1062
|
+
if (lock) locks.add(lock.path);
|
|
1063
|
+
for (const d of fromCargoToml(cargo)) {
|
|
1064
|
+
const v = locked.get(d.name.toLowerCase()) ?? d.version;
|
|
1065
|
+
add({ name: d.name, ecosystem: "cargo", ...v ? { version: v } : {} });
|
|
1066
|
+
}
|
|
599
1067
|
}
|
|
600
1068
|
}
|
|
601
|
-
|
|
1069
|
+
const all = [...deps.values()];
|
|
1070
|
+
const exactOf = (d) => d.ecosystem === "go" && !!d.version || isExactVersion(d.version);
|
|
1071
|
+
all.sort((a, b) => Number(exactOf(b)) - Number(exactOf(a)));
|
|
1072
|
+
const sent = all.slice(0, 300);
|
|
1073
|
+
const exact = sent.filter((d) => !d.runtime && exactOf(d)).length;
|
|
1074
|
+
return {
|
|
1075
|
+
deps: sent,
|
|
1076
|
+
manifests,
|
|
1077
|
+
lockfiles: [...locks].map((p) => relative2(base, p) || basename(p)),
|
|
1078
|
+
exact,
|
|
1079
|
+
ranges: sent.filter((d) => !d.runtime).length - exact
|
|
1080
|
+
};
|
|
602
1081
|
}
|
|
603
1082
|
async function installedSkillNames(skillDirs) {
|
|
604
1083
|
const names = /* @__PURE__ */ new Set();
|
|
@@ -610,13 +1089,298 @@ async function installedSkillNames(skillDirs) {
|
|
|
610
1089
|
return [...names];
|
|
611
1090
|
}
|
|
612
1091
|
|
|
1092
|
+
// src/destructive.ts
|
|
1093
|
+
import { realpathSync } from "node:fs";
|
|
1094
|
+
import { homedir as homedir2, tmpdir } from "node:os";
|
|
1095
|
+
import { isAbsolute, join as join3, normalize, resolve as resolve3, sep as sep2 } from "node:path";
|
|
1096
|
+
function split(line) {
|
|
1097
|
+
return line.split(/&&|\|\||[;\n]/).flatMap((part) => part.split(/(?<!\|)\|(?!\|)/).map((raw) => raw.trim())).filter(Boolean).map((raw) => ({ raw, words: (raw.match(/"[^"]*"|'[^']*'|\S+/g) ?? []).map((w) => w.replace(/^["']|["']$/g, "")) }));
|
|
1098
|
+
}
|
|
1099
|
+
function strip(words) {
|
|
1100
|
+
let w = words;
|
|
1101
|
+
while (w.length && /^(sudo|doas|env|time|nice|command|exec|\w+=\S*)$/.test(w[0])) w = w.slice(1);
|
|
1102
|
+
return w;
|
|
1103
|
+
}
|
|
1104
|
+
var SYSTEM_DIRS = ["/", "/bin", "/boot", "/dev", "/etc", "/lib", "/lib64", "/opt", "/proc", "/root", "/sbin", "/sys", "/usr", "/var", "/System", "/Library", "/Applications", "/Users", "/home", "/private", "/Volumes"];
|
|
1105
|
+
function expand(arg, ctx) {
|
|
1106
|
+
let out = arg.replace(/^~(?=$|\/)/, ctx.home);
|
|
1107
|
+
let unresolved = false;
|
|
1108
|
+
out = out.replace(/\$\{(\w+)(?::?[-=][^}]*)?\}|\$(\w+)/g, (m, a, b) => {
|
|
1109
|
+
const name = a ?? b;
|
|
1110
|
+
const v = name === "HOME" ? ctx.home : name === "PWD" ? ctx.cwd : ctx.env[name];
|
|
1111
|
+
if (v === void 0 || v === "") {
|
|
1112
|
+
if (/:?[-=]/.test(m)) return m.replace(/^\$\{\w+:?[-=]|\}$/g, "");
|
|
1113
|
+
unresolved = true;
|
|
1114
|
+
return "";
|
|
1115
|
+
}
|
|
1116
|
+
return v;
|
|
1117
|
+
});
|
|
1118
|
+
return unresolved ? null : out;
|
|
1119
|
+
}
|
|
1120
|
+
var within = (p, dir) => p === dir || p.startsWith(dir.endsWith(sep2) ? dir : dir + sep2);
|
|
1121
|
+
function real(p) {
|
|
1122
|
+
try {
|
|
1123
|
+
return realpathSync(p);
|
|
1124
|
+
} catch {
|
|
1125
|
+
return p;
|
|
1126
|
+
}
|
|
1127
|
+
}
|
|
1128
|
+
function judgeDelete(target, ctx, flags) {
|
|
1129
|
+
const expanded = expand(target, ctx);
|
|
1130
|
+
if (expanded === null) {
|
|
1131
|
+
return { decision: "deny", reason: `"${target}" contains a variable that is not set here, so it may expand to nothing \u2014 e.g. "rm -rf $DIR/" becomes "rm -rf /". Use the literal path.` };
|
|
1132
|
+
}
|
|
1133
|
+
if (/^\/?\*$|^~\/?\*$/.test(target) || expanded === "/*" || expanded === `${ctx.home}/*`) {
|
|
1134
|
+
return { decision: "deny", reason: `"rm ${flags} ${target}" deletes everything in ${expanded.startsWith(ctx.home) ? "the home directory" : "the filesystem root"}.` };
|
|
1135
|
+
}
|
|
1136
|
+
const abs = normalize(isAbsolute(expanded) ? expanded : resolve3(ctx.cwd, expanded)).replace(/\/\*$/, "").replace(/(.)\/+$/, "$1") || "/";
|
|
1137
|
+
const resolved = real(abs);
|
|
1138
|
+
for (const p of /* @__PURE__ */ new Set([abs, resolved])) {
|
|
1139
|
+
if (p === ctx.home || within(ctx.home, p)) return { decision: "deny", reason: `"rm ${flags} ${target}" would delete the home directory (${p}).` };
|
|
1140
|
+
if (SYSTEM_DIRS.some((d) => p === d)) return { decision: "deny", reason: `"rm ${flags} ${target}" would delete the system directory ${p}.` };
|
|
1141
|
+
}
|
|
1142
|
+
const tmp = [real(tmpdir()), "/tmp", "/private/tmp", "/var/folders"];
|
|
1143
|
+
const safe = (p) => within(p, ctx.project) || tmp.some((t) => within(p, t));
|
|
1144
|
+
if (!safe(abs) || !safe(resolved)) {
|
|
1145
|
+
const via = resolved !== abs ? ` (it resolves to ${resolved} through a link)` : "";
|
|
1146
|
+
return { decision: "ask", reason: `"rm ${flags} ${target}" deletes ${abs}${via}, outside the project ${ctx.project}. Confirm that this is intended.` };
|
|
1147
|
+
}
|
|
1148
|
+
return null;
|
|
1149
|
+
}
|
|
1150
|
+
var RULES = [
|
|
1151
|
+
// rm -r / -rf / --recursive
|
|
1152
|
+
(w, _raw, ctx) => {
|
|
1153
|
+
if (w[0]?.split("/").pop() !== "rm") return null;
|
|
1154
|
+
const flags = w.filter((x) => x.startsWith("-"));
|
|
1155
|
+
if (!flags.some((f) => /^-[a-zA-Z]*[rR]/.test(f) || f === "--recursive")) return null;
|
|
1156
|
+
const targets = w.slice(1).filter((x) => !x.startsWith("-"));
|
|
1157
|
+
let worst = null;
|
|
1158
|
+
for (const t of targets) {
|
|
1159
|
+
const v = judgeDelete(t, ctx, flags.join(" "));
|
|
1160
|
+
if (v?.decision === "deny") return v;
|
|
1161
|
+
worst ??= v;
|
|
1162
|
+
}
|
|
1163
|
+
return worst;
|
|
1164
|
+
},
|
|
1165
|
+
// find … -delete / -exec rm outside the project
|
|
1166
|
+
(w, _raw, ctx) => {
|
|
1167
|
+
if (w[0] !== "find" || !(w.includes("-delete") || w.includes("-exec") && w.includes("rm"))) return null;
|
|
1168
|
+
const start = w[1] && !w[1].startsWith("-") ? w[1] : ".";
|
|
1169
|
+
const v = judgeDelete(start, ctx, "(find -delete)");
|
|
1170
|
+
return v && { ...v, reason: v.reason.replace(/^"rm \(find -delete\) /, `"find ${start} \u2026 -delete" `) };
|
|
1171
|
+
},
|
|
1172
|
+
// disks and filesystems
|
|
1173
|
+
(w) => /^mkfs(\.\w+)?$/.test(w[0] ?? "") || w[0] === "dd" && w.some((x) => /^of=\/dev\//.test(x)) ? { decision: "deny", reason: `"${w.join(" ")}" overwrites a disk or partition.` } : null,
|
|
1174
|
+
(_w, raw) => />\s*\/dev\/(sd|nvme|disk|hd)\w*/.test(raw) ? { decision: "deny", reason: "Writing directly to a disk device." } : null,
|
|
1175
|
+
// recursive chmod/chown on / or ~
|
|
1176
|
+
(w, _raw, ctx) => {
|
|
1177
|
+
if (!["chmod", "chown"].includes(w[0] ?? "") || !w.some((x) => /^-[a-zA-Z]*R/.test(x))) return null;
|
|
1178
|
+
const t = w.slice(1).filter((x) => !x.startsWith("-")).slice(1);
|
|
1179
|
+
const hit = t.map((x) => expand(x, ctx) ?? "/").find((x) => SYSTEM_DIRS.includes(normalize(x)) || normalize(x) === ctx.home);
|
|
1180
|
+
return hit ? { decision: "deny", reason: `Recursive ${w[0]} on ${hit}.` } : null;
|
|
1181
|
+
},
|
|
1182
|
+
// git
|
|
1183
|
+
(w) => {
|
|
1184
|
+
if (w[0] !== "git") return null;
|
|
1185
|
+
const sub = w[1];
|
|
1186
|
+
if (sub === "push" && w.some((x) => /^(-f|--force|--force-with-lease(=.*)?|--mirror|--delete|-d)$/.test(x) || /^\+/.test(x) || /^:/.test(x))) {
|
|
1187
|
+
return { decision: "ask", reason: `"${w.join(" ")}" rewrites or deletes history on the remote. Confirm with the user first.` };
|
|
1188
|
+
}
|
|
1189
|
+
if (sub === "reset" && w.includes("--hard")) return { decision: "ask", reason: '"git reset --hard" throws away uncommitted changes.' };
|
|
1190
|
+
if (sub === "clean" && w.some((x) => /^-[a-zA-Z]*f/.test(x))) return { decision: "ask", reason: `"${w.join(" ")}" permanently deletes untracked files${w.some((x) => /x/.test(x) && x.startsWith("-")) ? ", including ignored ones like .env" : ""}.` };
|
|
1191
|
+
if ((sub === "checkout" || sub === "restore") && w.includes(".") && !w.includes("--staged")) return { decision: "ask", reason: `"${w.join(" ")}" discards all uncommitted changes.` };
|
|
1192
|
+
if (sub === "branch" && w.includes("-D")) return { decision: "ask", reason: `"${w.join(" ")}" deletes a branch even if it is not merged.` };
|
|
1193
|
+
if (sub === "add" && w.slice(2).some((x) => /(^|\/)\.env(\..+)?$|\.pem$|id_rsa|id_ed25519/.test(x) && !/\.example$|\.sample$|\.template$/.test(x))) {
|
|
1194
|
+
return { decision: "ask", reason: `"${w.join(" ")}" stages a file that usually holds secrets.` };
|
|
1195
|
+
}
|
|
1196
|
+
return null;
|
|
1197
|
+
},
|
|
1198
|
+
// databases
|
|
1199
|
+
(w, raw) => {
|
|
1200
|
+
const s = raw.toLowerCase();
|
|
1201
|
+
if (/\bprisma\b.*\bmigrate\s+reset\b/.test(s) || /\bprisma\b.*\bdb\s+push\b.*--(force-reset|accept-data-loss)/.test(s)) {
|
|
1202
|
+
return { decision: "ask", reason: `"${raw.trim()}" deletes data in the database ${/database_url=/.test(s) ? "named in DATABASE_URL" : "from DATABASE_URL"}. Confirm it is not a shared or production database.` };
|
|
1203
|
+
}
|
|
1204
|
+
if (/\b(drizzle-kit\s+drop|rails\s+db:(drop|reset)|rake\s+db:(drop|reset)|manage\.py\s+flush|sequelize\s+db:drop|typeorm\s+schema:drop|knex\s+migrate:rollback\s+--all)\b/.test(s)) {
|
|
1205
|
+
return { decision: "ask", reason: `"${raw.trim()}" drops or empties the database.` };
|
|
1206
|
+
}
|
|
1207
|
+
if (/\b(psql|mysql|mariadb|sqlite3|mongosh|mongo|redis-cli|clickhouse-client)\b/.test(s) && /\b(drop\s+(database|schema|table)|truncate\b|delete\s+from\s+\w+\s*(;|$|"|')|flushall|flushdb|dropdatabase\(\))/.test(s)) {
|
|
1208
|
+
return { decision: "ask", reason: `"${raw.trim().slice(0, 200)}" deletes database data. Confirm the target is not production.` };
|
|
1209
|
+
}
|
|
1210
|
+
return null;
|
|
1211
|
+
},
|
|
1212
|
+
// infrastructure and cloud
|
|
1213
|
+
(w, raw) => {
|
|
1214
|
+
const s = raw.toLowerCase();
|
|
1215
|
+
const hit = /\b(terraform|tofu|terragrunt)\s+(destroy|apply\b.*-destroy)/.test(s) && "tears down infrastructure" || /\bpulumi\s+(destroy|down)\b/.test(s) && "tears down infrastructure" || /\bcdk\s+destroy\b/.test(s) && "tears down infrastructure" || /\bkubectl\s+delete\b.*(\b(ns|namespace|namespaces|pv|pvc|node)\b|--all\b)/.test(s) && "deletes Kubernetes resources broadly" || /\bhelm\s+(uninstall|delete)\b/.test(s) && "removes a Helm release" || /\bdocker\s+(system\s+prune\b.*(-a|--all|--volumes)|volume\s+(rm|prune)\b|compose\s+down\b.*(-v|--volumes))/.test(s) && "deletes Docker volumes or all unused data" || /\baws\s+s3\s+(rm\b.*--recursive|rb\b)/.test(s) && "deletes S3 objects or a bucket" || /\baws\s+\S+\s+(delete|terminate)-/.test(s) && "deletes AWS resources" || /\bgcloud\b.*\b(delete)\b/.test(s) && "deletes Google Cloud resources" || /\baz\s+\S+.*\bdelete\b/.test(s) && "deletes Azure resources" || /\b(vercel|netlify|fly|flyctl|railway|heroku)\b.*\b(remove|rm|destroy|delete|apps:destroy)\b/.test(s) && "deletes a deployment or app";
|
|
1216
|
+
return hit ? { decision: "ask", reason: `"${raw.trim().slice(0, 200)}" ${hit}. Confirm with the user first.` } : null;
|
|
1217
|
+
}
|
|
1218
|
+
];
|
|
1219
|
+
function exfiltration(line) {
|
|
1220
|
+
const s = line.toLowerCase();
|
|
1221
|
+
const net = /\b(curl|wget|nc|ncat|netcat|socat|http|httpie|scp|rsync|ftp|telnet)\b/;
|
|
1222
|
+
const secret = /(\.env\b|\.ssh\/|id_rsa|id_ed25519|\.aws\/credentials|\.npmrc|\.netrc|\.pgpass|\.git-credentials|\.docker\/config\.json|\bprintenv\b|(^|[|;&\s])env(\s*[|;&]|\s*$))/;
|
|
1223
|
+
if (!net.test(s) || !secret.test(s)) return null;
|
|
1224
|
+
const piped = /\|\s*(curl|wget|nc|ncat|netcat|socat|http)\b/.test(s);
|
|
1225
|
+
const uploaded = /(\s-d|--data(-binary|-raw)?|\s-F|--form|\s-T|--upload-file)\s+\S*@\S*(\.env|\.ssh|id_rsa|credentials|\.npmrc|\.netrc)/.test(line) || /\b(scp|rsync)\b.*(\.env|\.ssh|id_rsa|credentials)/.test(s);
|
|
1226
|
+
return piped || uploaded ? { decision: "deny", reason: "This sends secrets (.env, SSH keys, cloud credentials or the environment) over the network." } : null;
|
|
1227
|
+
}
|
|
1228
|
+
function destructiveVerdict(line, opts = {}) {
|
|
1229
|
+
const home = normalize(opts.home ?? homedir2());
|
|
1230
|
+
const cwd = normalize(opts.cwd ?? process.cwd());
|
|
1231
|
+
const ctx = { cwd, home, project: normalize(opts.project ?? cwd), env: opts.env ?? process.env };
|
|
1232
|
+
if (ctx.project === home || SYSTEM_DIRS.includes(ctx.project)) ctx.project = join3(home, "\0no-project");
|
|
1233
|
+
const verdicts = [exfiltration(line), /:\(\)\s*\{\s*:\s*\|\s*:\s*&\s*\}\s*;\s*:/.test(line) ? { decision: "deny", reason: "Fork bomb." } : null];
|
|
1234
|
+
for (const { raw, words } of split(line)) {
|
|
1235
|
+
const w = strip(words);
|
|
1236
|
+
if (!w.length) continue;
|
|
1237
|
+
for (const rule of RULES) verdicts.push(rule(w, raw, ctx));
|
|
1238
|
+
}
|
|
1239
|
+
const hits = verdicts.filter((v) => !!v);
|
|
1240
|
+
if (!hits.length) return null;
|
|
1241
|
+
const deny = hits.filter((v) => v.decision === "deny");
|
|
1242
|
+
const pick = deny.length ? deny : hits;
|
|
1243
|
+
return { decision: deny.length ? "deny" : "ask", reason: [...new Set(pick.map((v) => v.reason))].join("\n") };
|
|
1244
|
+
}
|
|
1245
|
+
|
|
1246
|
+
// src/guard.ts
|
|
1247
|
+
function commands(line) {
|
|
1248
|
+
return line.split(/&&|\|\||[;|\n]/).map((c) => (c.match(/"[^"]*"|'[^']*'|\S+/g) ?? []).map((w) => w.replace(/^["']|["']$/g, ""))).filter((w) => w.length);
|
|
1249
|
+
}
|
|
1250
|
+
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"]);
|
|
1251
|
+
function args(words) {
|
|
1252
|
+
const out = [];
|
|
1253
|
+
for (let i = 0; i < words.length; i++) {
|
|
1254
|
+
const w = words[i];
|
|
1255
|
+
if (w.startsWith("-")) {
|
|
1256
|
+
if (VALUE_FLAGS.has(w)) i++;
|
|
1257
|
+
continue;
|
|
1258
|
+
}
|
|
1259
|
+
out.push(w);
|
|
1260
|
+
}
|
|
1261
|
+
return out;
|
|
1262
|
+
}
|
|
1263
|
+
var isLocalOrUrl = (s) => /^(\.{0,2}\/|~|file:|git[+:]|https?:|github:|link:|workspace:)/.test(s) || /\.(tgz|tar\.gz|whl|zip)$/.test(s);
|
|
1264
|
+
function npmSpec(s) {
|
|
1265
|
+
if (isLocalOrUrl(s) || /^[\w.-]+\/[\w.-]+$/.test(s)) return null;
|
|
1266
|
+
const m = s.match(/^(@[^/@\s]+\/[^@\s]+|[^@\s]+)(?:@(.+))?$/);
|
|
1267
|
+
if (!m || !/^(@[a-z0-9-~][a-z0-9-._~]*\/)?[a-z0-9-~][a-z0-9-._~]*$/i.test(m[1])) return null;
|
|
1268
|
+
const version = m[2] && /\d/.test(m[2]) ? m[2] : void 0;
|
|
1269
|
+
return { name: m[1], ecosystem: "npm", ...version ? { version } : {} };
|
|
1270
|
+
}
|
|
1271
|
+
function pySpec(s) {
|
|
1272
|
+
if (isLocalOrUrl(s)) return null;
|
|
1273
|
+
const m = s.match(/^([A-Za-z0-9][A-Za-z0-9._-]*)(?:\[[^\]]*\])?\s*(?:(==|~=|>=|<=|>|<|!=)\s*([^,;\s]+))?/);
|
|
1274
|
+
if (!m) return null;
|
|
1275
|
+
return { name: m[1], ecosystem: "pypi", ...m[3] ? { version: m[3] } : {} };
|
|
1276
|
+
}
|
|
1277
|
+
function cargoSpec(s) {
|
|
1278
|
+
const m = s.match(/^([A-Za-z0-9_-]+)(?:@(.+))?$/);
|
|
1279
|
+
return m ? { name: m[1], ecosystem: "cargo", ...m[2] ? { version: m[2] } : {} } : null;
|
|
1280
|
+
}
|
|
1281
|
+
function goSpec(s) {
|
|
1282
|
+
const m = s.match(/^([a-z0-9.-]+\.[a-z]{2,}\/[^@\s]+?)(?:\/\.\.\.)?(?:@(.+))?$/i);
|
|
1283
|
+
if (!m) return null;
|
|
1284
|
+
const version = m[2] && /^v\d/.test(m[2]) ? m[2] : void 0;
|
|
1285
|
+
return { name: m[1], ecosystem: "go", ...version ? { version } : {} };
|
|
1286
|
+
}
|
|
1287
|
+
function packagesInCommand(line) {
|
|
1288
|
+
const out = [];
|
|
1289
|
+
for (let words of commands(line)) {
|
|
1290
|
+
while (words.length && /^(sudo|env|\w+=\S*|time|nice|command)$/.test(words[0])) words = words.slice(1);
|
|
1291
|
+
const [tool, sub, ...rest] = words;
|
|
1292
|
+
if (!tool) continue;
|
|
1293
|
+
const bin = tool.split("/").pop();
|
|
1294
|
+
let specs = [];
|
|
1295
|
+
if (bin === "npm" && ["install", "i", "add", "in", "isntall"].includes(sub)) specs = args(rest).map(npmSpec);
|
|
1296
|
+
else if (bin === "pnpm" && ["add", "install", "i"].includes(sub)) specs = args(rest).map(npmSpec);
|
|
1297
|
+
else if (bin === "yarn" && sub === "add") specs = args(rest).map(npmSpec);
|
|
1298
|
+
else if (bin === "bun" && ["add", "install", "i"].includes(sub)) specs = args(rest).map(npmSpec);
|
|
1299
|
+
else if (["npx", "bunx"].includes(bin)) specs = [npmSpec(args([sub, ...rest].filter(Boolean))[0] ?? "")];
|
|
1300
|
+
else if ((bin === "pnpm" || bin === "yarn") && sub === "dlx") specs = [npmSpec(args(rest)[0] ?? "")];
|
|
1301
|
+
else if (/^pip3?$/.test(bin) && sub === "install") specs = args(rest).map(pySpec);
|
|
1302
|
+
else if (/^python3?$/.test(bin) && sub === "-m" && /^pip3?$/.test(rest[0] ?? "") && rest[1] === "install") specs = args(rest.slice(2)).map(pySpec);
|
|
1303
|
+
else if (bin === "uv" && sub === "add") specs = args(rest).map(pySpec);
|
|
1304
|
+
else if (bin === "uv" && sub === "pip" && rest[0] === "install") specs = args(rest.slice(1)).map(pySpec);
|
|
1305
|
+
else if (bin === "uv" && sub === "tool" && rest[0] === "install") specs = args(rest.slice(1)).map(pySpec);
|
|
1306
|
+
else if (bin === "uvx" || bin === "pipx" && sub === "run") specs = [pySpec(args(bin === "uvx" ? [sub, ...rest].filter(Boolean) : rest)[0] ?? "")];
|
|
1307
|
+
else if (bin === "pipx" && sub === "install") specs = args(rest).map(pySpec);
|
|
1308
|
+
else if (bin === "poetry" && sub === "add") specs = args(rest).map(pySpec);
|
|
1309
|
+
else if (bin === "cargo" && (sub === "add" || sub === "install")) specs = args(rest).map(cargoSpec);
|
|
1310
|
+
else if (bin === "go" && (sub === "get" || sub === "install")) specs = args(rest).map(goSpec);
|
|
1311
|
+
for (const s of specs) if (s) out.push(s);
|
|
1312
|
+
}
|
|
1313
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1314
|
+
return out.filter((p) => !seen.has(`${p.ecosystem}:${p.name}`) && !!seen.add(`${p.ecosystem}:${p.name}`)).slice(0, 25);
|
|
1315
|
+
}
|
|
1316
|
+
async function readStdin() {
|
|
1317
|
+
const chunks = [];
|
|
1318
|
+
for await (const c of process.stdin) chunks.push(c);
|
|
1319
|
+
return Buffer.concat(chunks).toString("utf8");
|
|
1320
|
+
}
|
|
1321
|
+
async function runGuard(opts) {
|
|
1322
|
+
let command = "";
|
|
1323
|
+
let cwd;
|
|
1324
|
+
try {
|
|
1325
|
+
const payload = JSON.parse(await readStdin());
|
|
1326
|
+
command = payload.tool_input?.command ?? "";
|
|
1327
|
+
cwd = payload.cwd;
|
|
1328
|
+
} catch {
|
|
1329
|
+
return 0;
|
|
1330
|
+
}
|
|
1331
|
+
const local = destructiveVerdict(command, { cwd, project: cwd });
|
|
1332
|
+
const packages = packagesInCommand(command);
|
|
1333
|
+
if (local?.decision === "deny" || !packages.length) return emit(local);
|
|
1334
|
+
if (!opts.key) {
|
|
1335
|
+
console.error("codexguild guard: CODEXGUILD_API_KEY is not set \u2014 packages were not checked.");
|
|
1336
|
+
return emit(local);
|
|
1337
|
+
}
|
|
1338
|
+
let results;
|
|
1339
|
+
try {
|
|
1340
|
+
const res = await fetch(`${opts.api}/packages/check`, {
|
|
1341
|
+
method: "POST",
|
|
1342
|
+
headers: { "Content-Type": "application/json", Authorization: `Bearer ${opts.key}` },
|
|
1343
|
+
body: JSON.stringify({ packages }),
|
|
1344
|
+
signal: AbortSignal.timeout(15e3)
|
|
1345
|
+
});
|
|
1346
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
1347
|
+
results = (await res.json()).results;
|
|
1348
|
+
} catch (e) {
|
|
1349
|
+
console.error(`codexguild guard: check failed (${e instanceof Error ? e.message : e}) \u2014 command not blocked.`);
|
|
1350
|
+
return emit(local);
|
|
1351
|
+
}
|
|
1352
|
+
return emit(mergeVerdicts(local, guardDecision(results)));
|
|
1353
|
+
}
|
|
1354
|
+
function mergeVerdicts(a, b) {
|
|
1355
|
+
if (!a || !b) return a ?? b;
|
|
1356
|
+
if (a.decision !== b.decision) return a.decision === "deny" ? a : b;
|
|
1357
|
+
return { decision: a.decision, reason: `${a.reason}
|
|
1358
|
+
${b.reason}` };
|
|
1359
|
+
}
|
|
1360
|
+
function emit(d) {
|
|
1361
|
+
if (d) process.stdout.write(JSON.stringify({ hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: d.decision, permissionDecisionReason: `CodexGuild guard: ${d.reason}` } }));
|
|
1362
|
+
return 0;
|
|
1363
|
+
}
|
|
1364
|
+
function guardDecision(results) {
|
|
1365
|
+
const flagged = results.filter((r) => r.verdict === "block" || r.verdict === "warn");
|
|
1366
|
+
if (!flagged.length) return null;
|
|
1367
|
+
const lines = flagged.map((r) => `${r.verdict === "block" ? "BLOCKED" : "Check"} ${r.ecosystem}:${r.name}${r.version ? `@${r.version}` : ""} \u2014 ${r.reasons.join(" ")}`);
|
|
1368
|
+
const blocked = flagged.some((r) => r.verdict === "block");
|
|
1369
|
+
return {
|
|
1370
|
+
decision: blocked ? "deny" : "ask",
|
|
1371
|
+
reason: `package check:
|
|
1372
|
+
${lines.join("\n")}${blocked ? "\nUse the correct package name, or ask the user before trying again." : ""}`
|
|
1373
|
+
};
|
|
1374
|
+
}
|
|
1375
|
+
|
|
613
1376
|
// src/index.ts
|
|
614
1377
|
var API = (process.env.CODEXGUILD_API_URL || "https://api.codexguild.com/v1").replace(/\/$/, "");
|
|
615
1378
|
var KEY = process.env.CODEXGUILD_API_KEY ?? "";
|
|
616
|
-
var VERSION = "1.
|
|
1379
|
+
var VERSION = "1.5.0";
|
|
1380
|
+
if (process.argv[2] === "guard") process.exit(await runGuard({ api: API, key: KEY }));
|
|
617
1381
|
var INSTRUCTION_FILES = ["CLAUDE.md", ".claude/CLAUDE.md", "AGENTS.md", "AGENTS.override.md", ".hermes.md", "HERMES.md", "GEMINI.md", ".cursorrules"];
|
|
618
1382
|
async function existingInstructionFiles(cwd) {
|
|
619
|
-
const present = await Promise.all(INSTRUCTION_FILES.map((f) => access(
|
|
1383
|
+
const present = await Promise.all(INSTRUCTION_FILES.map((f) => access(join4(cwd, f)).then(() => f, () => null)));
|
|
620
1384
|
return present.filter((f) => f !== null);
|
|
621
1385
|
}
|
|
622
1386
|
var remote = null;
|
|
@@ -646,7 +1410,8 @@ var SCAN_TOOL = {
|
|
|
646
1410
|
};
|
|
647
1411
|
var ENHANCED = {
|
|
648
1412
|
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.",
|
|
649
|
-
codexguild_advise: "Runs locally first: stack, installed skills and the instruction files present in the project are detected automatically; you only need harness."
|
|
1413
|
+
codexguild_advise: "Runs locally first: stack, installed skills and the instruction files present in the project are detected automatically; you only need harness.",
|
|
1414
|
+
codexguild_debug: "Runs locally first: the project's package versions are read from its lockfiles and sent with the error \u2014 you only need error (and stack)."
|
|
650
1415
|
};
|
|
651
1416
|
function enhance(t) {
|
|
652
1417
|
const note = ENHANCED[t.name];
|
|
@@ -662,47 +1427,141 @@ ${note}`,
|
|
|
662
1427
|
};
|
|
663
1428
|
}
|
|
664
1429
|
async function detected(name, cwd) {
|
|
665
|
-
|
|
1430
|
+
if (name === "codexguild_debug") {
|
|
1431
|
+
const { deps: deps2 } = await detectStack(cwd);
|
|
1432
|
+
return { args: { packages: deps2.slice(0, 300).map((d) => ({ name: d.name, ...d.version ? { version: d.version } : {}, ...d.ecosystem ? { ecosystem: d.ecosystem } : {} })) } };
|
|
1433
|
+
}
|
|
1434
|
+
const [{ deps, manifests, lockfiles, exact, ranges }, local, project] = await Promise.all([detectStack(cwd), scanLocalSkills(), projectKey(cwd)]);
|
|
666
1435
|
const installedSkills = await installedSkillNames(local.reports.map((r) => r.dir));
|
|
667
1436
|
if (name === "codexguild_advise") {
|
|
668
|
-
return { args: { stack: deps, installedSkills, existingInstructionFiles: await existingInstructionFiles(cwd) } };
|
|
1437
|
+
return { args: { stack: deps, installedSkills, existingInstructionFiles: await existingInstructionFiles(cwd), project } };
|
|
669
1438
|
}
|
|
670
1439
|
const flagged = local.reports.filter((x) => x.result.status === "flagged");
|
|
1440
|
+
const shown = (xs) => xs.length > 8 ? `${xs.slice(0, 8).join(", ")} +${xs.length - 8} more` : xs.join(", ");
|
|
671
1441
|
const summary = [
|
|
672
|
-
`
|
|
1442
|
+
`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)" : ""}.`,
|
|
673
1443
|
`Local skill scan: ${local.reports.length} skills \u2014 ${flagged.length} flagged, ${local.reports.filter((x) => x.result.status === "warn").length} to review.`,
|
|
674
1444
|
...flagged.slice(0, 10).map((x) => ` - FLAGGED ${x.dir}: ${x.result.findings[0]?.title ?? ""}`),
|
|
675
1445
|
...flagged.length ? [" Run codexguild_scan_skills for details and ask the user before using flagged skills."] : []
|
|
676
1446
|
].join("\n");
|
|
677
|
-
return { args: { stack: deps, installedSkills }, summary };
|
|
1447
|
+
return { args: { stack: deps, installedSkills, project }, summary };
|
|
1448
|
+
}
|
|
1449
|
+
var LINT_TOOL = {
|
|
1450
|
+
name: "codexguild_lint_instructions",
|
|
1451
|
+
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.",
|
|
1452
|
+
inputSchema: {
|
|
1453
|
+
type: "object",
|
|
1454
|
+
properties: { files: { type: "array", items: { type: "string" }, description: "Paths to lint instead of the instruction files found in the project root" } }
|
|
1455
|
+
}
|
|
1456
|
+
};
|
|
1457
|
+
async function lintLocal(cwd, files) {
|
|
1458
|
+
const targets = files?.length ? files : await existingInstructionFiles(cwd);
|
|
1459
|
+
if (!targets.length) return "No instruction files found (looked for CLAUDE.md, AGENTS.md, GEMINI.md, .cursorrules, \u2026).";
|
|
1460
|
+
const out = [];
|
|
1461
|
+
for (const f of targets) {
|
|
1462
|
+
const content = await readFile3(isAbsolute2(f) ? f : join4(cwd, f), "utf8").catch(() => null);
|
|
1463
|
+
if (content === null) {
|
|
1464
|
+
out.push(`## ${f}
|
|
1465
|
+
Could not read the file.`);
|
|
1466
|
+
continue;
|
|
1467
|
+
}
|
|
1468
|
+
const r = lintInstructions(content, f.split("/").pop());
|
|
1469
|
+
out.push(`## ${f} \u2014 ${r.score}/100 (${r.grade}) \xB7 ~${r.stats.approxTokens} tokens \xB7 ${r.stats.commands} commands`);
|
|
1470
|
+
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."]);
|
|
1471
|
+
}
|
|
1472
|
+
return out.join("\n");
|
|
678
1473
|
}
|
|
679
1474
|
var text = (t, isError = false) => ({ content: [{ type: "text", text: t }], ...isError ? { isError } : {} });
|
|
680
|
-
var server = new Server({ name: "codexguild", version: VERSION }, { capabilities: { tools: {} } });
|
|
1475
|
+
var server = new Server({ name: "codexguild", version: VERSION }, { capabilities: { tools: { listChanged: true } } });
|
|
1476
|
+
var WORKFLOWS_TOOL = {
|
|
1477
|
+
name: "codexguild_scan_workflows",
|
|
1478
|
+
description: "Security-review the GitHub Actions workflows in this project that run coding agents (claude-code-action, run-gemini-cli, codex, opencode, aider\u2026): triggers outside contributors control (pull_request_target, issue_comment, issues) combined with secrets, issue/PR text interpolated into prompts or shells (prompt injection), unrestricted agent tools, broad permissions, unpinned agent actions. Runs locally \u2014 nothing is uploaded.",
|
|
1479
|
+
inputSchema: {
|
|
1480
|
+
type: "object",
|
|
1481
|
+
properties: { files: { type: "array", items: { type: "string" }, description: "Workflow files to review instead of .github/workflows/*.yml" } }
|
|
1482
|
+
},
|
|
1483
|
+
annotations: { readOnlyHint: true, openWorldHint: false }
|
|
1484
|
+
};
|
|
1485
|
+
async function scanWorkflowsLocal(cwd, files) {
|
|
1486
|
+
const dir = join4(cwd, ".github", "workflows");
|
|
1487
|
+
const targets = files?.length ? files : (await readdir3(dir).catch(() => [])).filter((f) => /\.ya?ml$/.test(f)).map((f) => join4(dir, f));
|
|
1488
|
+
if (!targets.length) return "No workflows found in .github/workflows.";
|
|
1489
|
+
const out = [];
|
|
1490
|
+
for (const f of targets) {
|
|
1491
|
+
const content = await readFile3(isAbsolute2(f) ? f : join4(cwd, f), "utf8").catch(() => null);
|
|
1492
|
+
const name = f.startsWith(cwd + "/") ? f.slice(cwd.length + 1) : f;
|
|
1493
|
+
if (content === null) {
|
|
1494
|
+
out.push(`## ${name}
|
|
1495
|
+
Could not read the file.`);
|
|
1496
|
+
continue;
|
|
1497
|
+
}
|
|
1498
|
+
const r = lintWorkflow(content);
|
|
1499
|
+
if (!r.agents.length && !r.issues.length) continue;
|
|
1500
|
+
out.push(`## ${name} \u2014 ${r.score}/100 (${r.grade})${r.agents.length ? ` \xB7 runs ${r.agents.join(", ")}` : ""}${r.untrustedTriggers.length ? ` \xB7 untrusted triggers: ${r.untrustedTriggers.join(", ")}` : ""}`);
|
|
1501
|
+
out.push(...r.issues.length ? r.issues.map((i) => `- [${i.severity}]${i.line ? ` line ${i.line}` : ""} ${i.title}: ${i.detail}`) : ["- No issues."]);
|
|
1502
|
+
}
|
|
1503
|
+
return out.length ? out.join("\n") : `Reviewed ${targets.length} workflow(s): none runs a coding agent and no injection risks were found.`;
|
|
1504
|
+
}
|
|
1505
|
+
var LOCAL_TOOLS = [SCAN_TOOL, LINT_TOOL, WORKFLOWS_TOOL];
|
|
1506
|
+
var RETRY_MS = [1e3, 3e3];
|
|
1507
|
+
var RECONNECT_MS = 3e4;
|
|
1508
|
+
var reconnectTimer = null;
|
|
1509
|
+
async function remoteTools() {
|
|
1510
|
+
for (let attempt = 0; ; attempt++) {
|
|
1511
|
+
try {
|
|
1512
|
+
return (await (await remoteClient()).listTools()).tools;
|
|
1513
|
+
} catch (e) {
|
|
1514
|
+
if (!KEY || attempt >= RETRY_MS.length) throw e;
|
|
1515
|
+
await new Promise((r) => setTimeout(r, RETRY_MS[attempt]));
|
|
1516
|
+
}
|
|
1517
|
+
}
|
|
1518
|
+
}
|
|
1519
|
+
function scheduleReconnect() {
|
|
1520
|
+
if (reconnectTimer || !KEY) return;
|
|
1521
|
+
reconnectTimer = setInterval(async () => {
|
|
1522
|
+
try {
|
|
1523
|
+
await (await remoteClient()).listTools();
|
|
1524
|
+
clearInterval(reconnectTimer);
|
|
1525
|
+
reconnectTimer = null;
|
|
1526
|
+
console.error("CodexGuild: remote endpoint reachable again \u2014 tool list updated.");
|
|
1527
|
+
await server.sendToolListChanged();
|
|
1528
|
+
} catch {
|
|
1529
|
+
}
|
|
1530
|
+
}, RECONNECT_MS);
|
|
1531
|
+
reconnectTimer.unref();
|
|
1532
|
+
}
|
|
681
1533
|
server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
682
1534
|
try {
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
1535
|
+
return { tools: [...LOCAL_TOOLS, ...(await remoteTools()).map(enhance)] };
|
|
1536
|
+
} catch (e) {
|
|
1537
|
+
console.error(`CodexGuild: remote tools unavailable (${e instanceof Error ? e.message : String(e)}) \u2014 serving local tools only; retrying in the background.`);
|
|
1538
|
+
scheduleReconnect();
|
|
1539
|
+
return { tools: LOCAL_TOOLS };
|
|
687
1540
|
}
|
|
688
1541
|
});
|
|
689
1542
|
server.setRequestHandler(CallToolRequestSchema, async (req) => {
|
|
690
1543
|
const { name } = req.params;
|
|
691
|
-
const
|
|
1544
|
+
const args2 = { ...req.params.arguments ?? {} };
|
|
692
1545
|
try {
|
|
1546
|
+
if (name === WORKFLOWS_TOOL.name) {
|
|
1547
|
+
return text(await scanWorkflowsLocal(process.cwd(), Array.isArray(args2.files) ? args2.files.map(String) : void 0));
|
|
1548
|
+
}
|
|
1549
|
+
if (name === LINT_TOOL.name) {
|
|
1550
|
+
return text(await lintLocal(process.cwd(), Array.isArray(args2.files) ? args2.files.map(String) : void 0));
|
|
1551
|
+
}
|
|
693
1552
|
if (name === SCAN_TOOL.name) {
|
|
694
|
-
const paths = Array.isArray(
|
|
695
|
-
return text(formatLocalScan(await scanLocalSkills(paths),
|
|
1553
|
+
const paths = Array.isArray(args2.paths) ? args2.paths.map(String) : void 0;
|
|
1554
|
+
return text(formatLocalScan(await scanLocalSkills(paths), args2.verbose === true));
|
|
696
1555
|
}
|
|
697
1556
|
let summary;
|
|
698
1557
|
if (name in ENHANCED) {
|
|
699
|
-
const cwd = typeof
|
|
700
|
-
delete
|
|
1558
|
+
const cwd = typeof args2.cwd === "string" ? args2.cwd : process.cwd();
|
|
1559
|
+
delete args2.cwd;
|
|
701
1560
|
const d = await detected(name, cwd);
|
|
702
1561
|
summary = d.summary;
|
|
703
|
-
Object.assign(
|
|
1562
|
+
Object.assign(args2, { ...d.args, ...args2 });
|
|
704
1563
|
}
|
|
705
|
-
const r = await (await remoteClient()).callTool({ name, arguments:
|
|
1564
|
+
const r = await (await remoteClient()).callTool({ name, arguments: args2 });
|
|
706
1565
|
return summary ? { ...r, content: [{ type: "text", text: summary }, ...r.content ?? []] } : r;
|
|
707
1566
|
} catch (e) {
|
|
708
1567
|
return text(`CodexGuild error: ${e instanceof Error ? e.message : String(e)}`, true);
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@codexguild/mcp",
|
|
3
|
-
"version": "1.
|
|
4
|
-
"description": "CodexGuild MCP server (stdio)
|
|
3
|
+
"version": "1.5.0",
|
|
4
|
+
"description": "CodexGuild MCP server (stdio) \u2014 forwards to the remote CodexGuild MCP endpoint and adds local stack detection and an offline security audit of installed agent skills",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"mcp",
|
|
7
7
|
"model-context-protocol",
|
|
@@ -39,10 +39,11 @@
|
|
|
39
39
|
"build": "tsc -p tsconfig.json --noEmit && esbuild src/index.ts --bundle --platform=node --format=esm --target=node20 --packages=external --alias:@codexguild/scanner=../scanner/src/index.ts --outfile=dist/codexguild-mcp.js",
|
|
40
40
|
"start": "node dist/codexguild-mcp.js",
|
|
41
41
|
"dev": "tsx src/index.ts",
|
|
42
|
-
"prepublishOnly": "npm run build"
|
|
42
|
+
"prepublishOnly": "npm run build",
|
|
43
|
+
"test": "node --import tsx --test test/*.test.mjs"
|
|
43
44
|
},
|
|
44
45
|
"dependencies": {
|
|
45
|
-
"@modelcontextprotocol/sdk": "^1.
|
|
46
|
+
"@modelcontextprotocol/sdk": "^1.32.1"
|
|
46
47
|
},
|
|
47
48
|
"devDependencies": {
|
|
48
49
|
"@types/node": "^22.10.0",
|