@codexguild/mcp 1.4.0 → 1.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +27 -7
- package/dist/codexguild-mcp.js +579 -104
- package/package.json +6 -5
package/README.md
CHANGED
|
@@ -1,22 +1,26 @@
|
|
|
1
1
|
# @codexguild/mcp
|
|
2
2
|
|
|
3
|
-
Local MCP server for [CodexGuild](https://codexguild.com) —
|
|
3
|
+
Local MCP server for [CodexGuild](https://codexguild.com) — tuning for AI coding agents: freshness, error lookup, package checks and scanned skills.
|
|
4
4
|
|
|
5
|
-
It forwards every tool of the remote CodexGuild MCP endpoint (sync, freshness,
|
|
6
|
-
|
|
5
|
+
It forwards every tool of the remote CodexGuild MCP endpoint (sync, freshness, error lookup, package checks, security-scanned
|
|
6
|
+
skills and MCP servers, knowledge base) 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
|
|
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
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
|
+
| `codexguild_scan_code` | Finds APIs that are **deprecated or removed in the versions you run** in your own code — file, line and the replacement (e.g. `app.mount` in hono 4.13 → `mount()` from `hono/mount`). Only package names and versions are sent; the code is searched locally. Runs automatically after `codexguild_sync`. |
|
|
13
|
+
| `codexguild_upgrade_plan` | Fills in the installed version from your lockfile: ask for a package and get the releases up to the target, possibly breaking ones, removed APIs with replacements and the steps in order. |
|
|
12
14
|
| `codexguild_advise` | Detects the instruction files in your project (`CLAUDE.md`, `AGENTS.md`, …) before proposing setup changes. Proposals always need your approval. |
|
|
15
|
+
| `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. |
|
|
16
|
+
| `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
17
|
|
|
14
18
|
If your harness can't start local processes, use the remote endpoint `https://api.codexguild.com/v1/mcp` directly
|
|
15
19
|
(see [MCP docs](https://codexguild.com/docs/mcp)).
|
|
16
20
|
|
|
17
21
|
## Setup
|
|
18
22
|
|
|
19
|
-
1. Create an agent key in the [CodexGuild dashboard](https://codexguild.com/dashboard
|
|
23
|
+
1. Create an agent key in the [CodexGuild dashboard](https://codexguild.com/dashboard/agents) (`cgk_…`).
|
|
20
24
|
2. Add the server to your harness:
|
|
21
25
|
|
|
22
26
|
```json
|
|
@@ -39,18 +43,34 @@ claude mcp add codexguild --scope user -e CODEXGUILD_API_KEY=cgk_... -- npx -y @
|
|
|
39
43
|
|
|
40
44
|
Per-harness snippets (Codex, Hermes, OpenCode, Cursor, Gemini CLI, …): <https://codexguild.com/connect>.
|
|
41
45
|
|
|
46
|
+
## Guard hook (Claude Code PreToolUse)
|
|
47
|
+
|
|
48
|
+
`codexguild-mcp guard` reads every Bash command before it runs:
|
|
49
|
+
|
|
50
|
+
- **Destructive commands** — checked locally, no key or network: denies `rm -rf` of `/`, your home or system
|
|
51
|
+
directories, deletes through an unset variable (`rm -rf $DIR/`), disk writes and secrets sent over the network; asks
|
|
52
|
+
before deletes outside the project, force pushes, `git reset --hard`, database resets, `terraform destroy`, volume and
|
|
53
|
+
cloud-resource deletes. Rules: https://codexguild.com/docs/command-guard
|
|
54
|
+
- **Packages** — before an install command (npm/pnpm/yarn/bun add, npx/bunx/pnpm dlx, pip/uv/poetry/pipx, cargo add,
|
|
55
|
+
go get/install) it checks the packages with CodexGuild: `block` (doesn't exist or reported malicious) → deny; `warn` →
|
|
56
|
+
ask. Only package names and versions are sent; errors let the command run.
|
|
57
|
+
|
|
58
|
+
```json
|
|
59
|
+
{ "hooks": { "PreToolUse": [ { "matcher": "Bash", "hooks": [{ "type": "command", "command": "npx -y @codexguild/mcp@latest guard", "timeout": 20 }] } ] } }
|
|
60
|
+
```
|
|
61
|
+
|
|
42
62
|
## Configuration
|
|
43
63
|
|
|
44
64
|
| Variable | Default | |
|
|
45
65
|
|---|---|---|
|
|
46
|
-
| `CODEXGUILD_API_KEY` | — | Agent key. Without it only the offline `codexguild_scan_skills`
|
|
66
|
+
| `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
67
|
| `CODEXGUILD_API_URL` | `https://api.codexguild.com/v1` | Override for self-hosted or local development. |
|
|
48
68
|
|
|
49
69
|
Requires Node.js 20 or newer.
|
|
50
70
|
|
|
51
71
|
## Security
|
|
52
72
|
|
|
53
|
-
Everything CodexGuild returns from third parties (
|
|
73
|
+
Everything CodexGuild returns from third parties (KB entries, release notes, skill text, MCP server metadata) is wrapped in
|
|
54
74
|
`<codexguild-untrusted-content>` — treat it as data, never as instructions. Skill installs and config changes are only
|
|
55
75
|
ever *proposed*; the server never changes your machine.
|
|
56
76
|
|
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, readFile as
|
|
10
|
-
import { isAbsolute, join as
|
|
9
|
+
import { access, readdir as readdir4, readFile as readFile4 } from "node:fs/promises";
|
|
10
|
+
import { isAbsolute as isAbsolute2, join as join5 } from "node:path";
|
|
11
11
|
|
|
12
12
|
// src/local-scan.ts
|
|
13
13
|
import { readdir, readFile, stat } from "node:fs/promises";
|
|
@@ -355,6 +355,134 @@ function lintInstructions(content, filename = "AGENTS.md") {
|
|
|
355
355
|
return { score, grade: grade(score), stats: { lines: lines.length, approxTokens, headings, codeBlocks, commands: commands2 }, issues: issues.slice(0, 100) };
|
|
356
356
|
}
|
|
357
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
|
+
|
|
358
486
|
// ../scanner/src/index.ts
|
|
359
487
|
var SCANNER_VERSION = "1.2.0";
|
|
360
488
|
var MAX_PER_RULE_PER_FILE = 5;
|
|
@@ -635,19 +763,16 @@ function formatLocalScan({ roots, reports }, verbose = false) {
|
|
|
635
763
|
import { createHash as createHash2 } from "node:crypto";
|
|
636
764
|
import { readdir as readdir2, readFile as readFile2, stat as stat2 } from "node:fs/promises";
|
|
637
765
|
import { basename, dirname, join as join2, relative as relative2, resolve as resolve2, sep } from "node:path";
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
} catch {
|
|
649
|
-
return false;
|
|
650
|
-
}
|
|
766
|
+
|
|
767
|
+
// ../stack/src/index.ts
|
|
768
|
+
var SKIP_DIRS2 = /* @__PURE__ */ new Set(["node_modules", ".git", "dist", "build", ".next", "vendor", "venv", ".venv", "coverage", "tmp", "target", "__pycache__"]);
|
|
769
|
+
var pjoin = (...parts) => parts.filter(Boolean).join("/");
|
|
770
|
+
var pdirname = (p) => p.includes("/") ? p.slice(0, p.lastIndexOf("/")) : "";
|
|
771
|
+
var pbasename = (p) => p.slice(p.lastIndexOf("/") + 1);
|
|
772
|
+
function prelative(from, to) {
|
|
773
|
+
if (from === to) return ".";
|
|
774
|
+
if (!from) return to;
|
|
775
|
+
return to.startsWith(`${from}/`) ? to.slice(from.length + 1) : to;
|
|
651
776
|
}
|
|
652
777
|
function isExactVersion(v) {
|
|
653
778
|
return !!v && /^v?\d+\.\d+\.\d+(?:[-+.][0-9A-Za-z.-]+)?$/.test(v.trim());
|
|
@@ -677,7 +802,7 @@ function npmLockResolver(raw, rel) {
|
|
|
677
802
|
} catch {
|
|
678
803
|
return () => null;
|
|
679
804
|
}
|
|
680
|
-
const prefix = rel && rel !== "." ? `${rel.split(
|
|
805
|
+
const prefix = rel && rel !== "." ? `${rel.split("\\").join("/")}/` : "";
|
|
681
806
|
return (name) => {
|
|
682
807
|
const p = lock.packages;
|
|
683
808
|
if (p) {
|
|
@@ -689,7 +814,7 @@ function npmLockResolver(raw, rel) {
|
|
|
689
814
|
};
|
|
690
815
|
}
|
|
691
816
|
function pnpmLockResolver(raw, rel) {
|
|
692
|
-
const importer = !rel || rel === "." ? "." : rel.split(
|
|
817
|
+
const importer = !rel || rel === "." ? "." : rel.split("\\").join("/");
|
|
693
818
|
const out = /* @__PURE__ */ new Map();
|
|
694
819
|
const clean = (v) => unquote(v).replace(/\(.*$/, "").replace(/_.*$/, "");
|
|
695
820
|
let inImporters = false;
|
|
@@ -787,12 +912,12 @@ function fromPyproject(raw) {
|
|
|
787
912
|
}
|
|
788
913
|
return deps;
|
|
789
914
|
}
|
|
790
|
-
function tomlPackages(raw,
|
|
915
|
+
function tomlPackages(raw, normalize2 = pep503) {
|
|
791
916
|
const out = /* @__PURE__ */ new Map();
|
|
792
917
|
for (const block of raw.split(/^\[\[package\]\]\s*$/m).slice(1)) {
|
|
793
918
|
const name = block.match(/^name\s*=\s*"([^"]+)"/m)?.[1];
|
|
794
919
|
const version = block.match(/^version\s*=\s*"([^"]+)"/m)?.[1];
|
|
795
|
-
if (name && version && !out.has(
|
|
920
|
+
if (name && version && !out.has(normalize2(name))) out.set(normalize2(name), version);
|
|
796
921
|
}
|
|
797
922
|
return out;
|
|
798
923
|
}
|
|
@@ -824,72 +949,54 @@ function fromCargoToml(raw) {
|
|
|
824
949
|
}
|
|
825
950
|
return deps;
|
|
826
951
|
}
|
|
827
|
-
async function runtimes(dir, pkgRaw) {
|
|
952
|
+
async function runtimes(reader, dir, pkgRaw) {
|
|
828
953
|
const out = [];
|
|
829
|
-
const node = (await read(
|
|
830
|
-
const tool = await read(
|
|
954
|
+
const node = (await reader.read(pjoin(dir, ".nvmrc")))?.trim() || (await reader.read(pjoin(dir, ".node-version")))?.trim();
|
|
955
|
+
const tool = await reader.read(pjoin(dir, ".tool-versions"));
|
|
831
956
|
const toolNode = tool?.match(/^(?:nodejs|node)\s+(\S+)/m)?.[1];
|
|
832
957
|
const nodeVersion = node || toolNode || (pkgRaw ? nodeEngine(pkgRaw) : null);
|
|
833
958
|
if (nodeVersion && /\d/.test(nodeVersion)) out.push({ name: "node", version: nodeVersion.replace(/^v/, "").slice(0, 64), runtime: true });
|
|
834
|
-
const py = (await read(
|
|
959
|
+
const py = (await reader.read(pjoin(dir, ".python-version")))?.trim().split("\n")[0] || tool?.match(/^python\s+(\S+)/m)?.[1];
|
|
835
960
|
if (py && /^\d/.test(py)) out.push({ name: "python", version: py.slice(0, 64), runtime: true });
|
|
836
961
|
return out;
|
|
837
962
|
}
|
|
838
|
-
|
|
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) {
|
|
963
|
+
async function findUp(reader, dir, names) {
|
|
860
964
|
let d = dir;
|
|
861
965
|
for (; ; ) {
|
|
862
|
-
for (const n of names)
|
|
863
|
-
|
|
864
|
-
|
|
966
|
+
for (const n of names) {
|
|
967
|
+
const raw = await reader.read(pjoin(d, n));
|
|
968
|
+
if (raw !== null) return { path: pjoin(d, n), dir: d, raw };
|
|
969
|
+
}
|
|
970
|
+
if (!d) return null;
|
|
971
|
+
d = pdirname(d);
|
|
865
972
|
}
|
|
866
973
|
}
|
|
867
974
|
var NPM_LOCKS = ["package-lock.json", "npm-shrinkwrap.json", "pnpm-lock.yaml", "yarn.lock", "bun.lock"];
|
|
868
|
-
async function npmResolver(
|
|
869
|
-
const lock = await findUp(
|
|
975
|
+
async function npmResolver(reader, dir, used) {
|
|
976
|
+
const lock = await findUp(reader, dir, NPM_LOCKS);
|
|
870
977
|
if (!lock) return () => null;
|
|
871
|
-
const raw = await read(lock.path) ?? "";
|
|
872
978
|
used.add(lock.path);
|
|
873
|
-
const rel =
|
|
874
|
-
const file =
|
|
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(
|
|
881
|
-
const lock = await findUp(
|
|
979
|
+
const rel = prelative(lock.dir, dir);
|
|
980
|
+
const file = pbasename(lock.path);
|
|
981
|
+
if (file === "pnpm-lock.yaml") return pnpmLockResolver(lock.raw, rel);
|
|
982
|
+
if (file === "yarn.lock") return yarnLockResolver(lock.raw);
|
|
983
|
+
if (file === "bun.lock") return bunLockResolver(lock.raw);
|
|
984
|
+
return npmLockResolver(lock.raw, rel);
|
|
985
|
+
}
|
|
986
|
+
async function pythonLock(reader, dir, used) {
|
|
987
|
+
const lock = await findUp(reader, dir, ["poetry.lock", "uv.lock", "Pipfile.lock"]);
|
|
882
988
|
if (!lock) return /* @__PURE__ */ new Map();
|
|
883
989
|
used.add(lock.path);
|
|
884
|
-
|
|
885
|
-
return basename(lock.path) === "Pipfile.lock" ? pipfileLock(raw) : tomlPackages(raw);
|
|
990
|
+
return pbasename(lock.path) === "Pipfile.lock" ? pipfileLock(lock.raw) : tomlPackages(lock.raw);
|
|
886
991
|
}
|
|
887
|
-
async function
|
|
888
|
-
const
|
|
889
|
-
const
|
|
890
|
-
const
|
|
891
|
-
|
|
892
|
-
|
|
992
|
+
async function stackDirs(reader, base = "") {
|
|
993
|
+
const keep = (dirs) => dirs.filter((d) => !pbasename(d).startsWith(".") && !SKIP_DIRS2.has(pbasename(d)));
|
|
994
|
+
const level1 = keep(await reader.subdirs(base));
|
|
995
|
+
const level2 = keep((await Promise.all(level1.map((d) => reader.subdirs(d)))).flat());
|
|
996
|
+
return [base, ...level1, ...level2].slice(0, 400);
|
|
997
|
+
}
|
|
998
|
+
async function detectStack(reader, base = "") {
|
|
999
|
+
const dirs = await stackDirs(reader, base);
|
|
893
1000
|
const deps = /* @__PURE__ */ new Map();
|
|
894
1001
|
const manifests = [];
|
|
895
1002
|
const locks = /* @__PURE__ */ new Set();
|
|
@@ -897,40 +1004,40 @@ async function detectStack(cwd = process.cwd()) {
|
|
|
897
1004
|
const key = `${d.runtime ? "runtime" : d.ecosystem}:${d.ecosystem === "pypi" ? pep503(d.name) : d.name.toLowerCase()}@${d.version ?? ""}`;
|
|
898
1005
|
if (!deps.has(key)) deps.set(key, d);
|
|
899
1006
|
};
|
|
900
|
-
const rel = (p) =>
|
|
1007
|
+
const rel = (p) => (base ? prelative(base, p) : p) || pbasename(p);
|
|
901
1008
|
for (const dir of dirs) {
|
|
902
|
-
const pkgRaw = await read(
|
|
1009
|
+
const pkgRaw = await reader.read(pjoin(dir, "package.json"));
|
|
903
1010
|
if (pkgRaw !== null) {
|
|
904
|
-
manifests.push(rel(
|
|
905
|
-
const resolveVersion = await npmResolver(
|
|
1011
|
+
manifests.push(rel(pjoin(dir, "package.json")));
|
|
1012
|
+
const resolveVersion = await npmResolver(reader, dir, locks);
|
|
906
1013
|
for (const { name, spec } of fromPackageJson(pkgRaw)) {
|
|
907
1014
|
const locked = resolveVersion(name, spec);
|
|
908
1015
|
add({ name, ecosystem: "npm", ...locked ?? spec ? { version: (locked ?? spec).slice(0, 64) } : {} });
|
|
909
1016
|
}
|
|
910
1017
|
}
|
|
911
|
-
for (const r of await runtimes(dir, pkgRaw)) add(r);
|
|
912
|
-
const req = await read(
|
|
913
|
-
const pyproject = await read(
|
|
1018
|
+
for (const r of await runtimes(reader, dir, pkgRaw)) add(r);
|
|
1019
|
+
const req = await reader.read(pjoin(dir, "requirements.txt"));
|
|
1020
|
+
const pyproject = await reader.read(pjoin(dir, "pyproject.toml"));
|
|
914
1021
|
if (req !== null || pyproject !== null) {
|
|
915
|
-
const locked = await pythonLock(
|
|
1022
|
+
const locked = await pythonLock(reader, dir, locks);
|
|
916
1023
|
const pyDeps = [...req !== null ? fromRequirements(req) : [], ...pyproject !== null ? fromPyproject(pyproject) : []];
|
|
917
|
-
if (req !== null) manifests.push(rel(
|
|
918
|
-
if (pyproject !== null) manifests.push(rel(
|
|
1024
|
+
if (req !== null) manifests.push(rel(pjoin(dir, "requirements.txt")));
|
|
1025
|
+
if (pyproject !== null) manifests.push(rel(pjoin(dir, "pyproject.toml")));
|
|
919
1026
|
for (const d of pyDeps) {
|
|
920
1027
|
const v = locked.get(pep503(d.name)) ?? d.version;
|
|
921
1028
|
add({ name: d.name, ecosystem: "pypi", ...v ? { version: v } : {} });
|
|
922
1029
|
}
|
|
923
1030
|
}
|
|
924
|
-
const gomod = await read(
|
|
1031
|
+
const gomod = await reader.read(pjoin(dir, "go.mod"));
|
|
925
1032
|
if (gomod !== null) {
|
|
926
|
-
manifests.push(rel(
|
|
1033
|
+
manifests.push(rel(pjoin(dir, "go.mod")));
|
|
927
1034
|
for (const d of fromGoMod(gomod)) add({ ...d, ecosystem: "go" });
|
|
928
1035
|
}
|
|
929
|
-
const cargo = await read(
|
|
1036
|
+
const cargo = await reader.read(pjoin(dir, "Cargo.toml"));
|
|
930
1037
|
if (cargo !== null) {
|
|
931
|
-
manifests.push(rel(
|
|
932
|
-
const lock = await findUp(
|
|
933
|
-
const locked = lock ? tomlPackages(
|
|
1038
|
+
manifests.push(rel(pjoin(dir, "Cargo.toml")));
|
|
1039
|
+
const lock = await findUp(reader, dir, ["Cargo.lock"]);
|
|
1040
|
+
const locked = lock ? tomlPackages(lock.raw, (n) => n.toLowerCase()) : /* @__PURE__ */ new Map();
|
|
934
1041
|
if (lock) locks.add(lock.path);
|
|
935
1042
|
for (const d of fromCargoToml(cargo)) {
|
|
936
1043
|
const v = locked.get(d.name.toLowerCase()) ?? d.version;
|
|
@@ -946,11 +1053,58 @@ async function detectStack(cwd = process.cwd()) {
|
|
|
946
1053
|
return {
|
|
947
1054
|
deps: sent,
|
|
948
1055
|
manifests,
|
|
949
|
-
lockfiles: [...locks].map((p) =>
|
|
1056
|
+
lockfiles: [...locks].map((p) => rel(p)),
|
|
950
1057
|
exact,
|
|
951
1058
|
ranges: sent.filter((d) => !d.runtime).length - exact
|
|
952
1059
|
};
|
|
953
1060
|
}
|
|
1061
|
+
|
|
1062
|
+
// src/stack-detect.ts
|
|
1063
|
+
async function read(path) {
|
|
1064
|
+
try {
|
|
1065
|
+
return await readFile2(path, "utf8");
|
|
1066
|
+
} catch {
|
|
1067
|
+
return null;
|
|
1068
|
+
}
|
|
1069
|
+
}
|
|
1070
|
+
async function isDir(path) {
|
|
1071
|
+
try {
|
|
1072
|
+
return (await stat2(path)).isDirectory();
|
|
1073
|
+
} catch {
|
|
1074
|
+
return false;
|
|
1075
|
+
}
|
|
1076
|
+
}
|
|
1077
|
+
async function projectRoot(cwd) {
|
|
1078
|
+
let dir = resolve2(cwd);
|
|
1079
|
+
for (; ; ) {
|
|
1080
|
+
if (await isDir(join2(dir, ".git")) || await read(join2(dir, ".git")) !== null) return dir;
|
|
1081
|
+
const up = dirname(dir);
|
|
1082
|
+
if (up === dir) return resolve2(cwd);
|
|
1083
|
+
dir = up;
|
|
1084
|
+
}
|
|
1085
|
+
}
|
|
1086
|
+
async function projectKey(cwd) {
|
|
1087
|
+
const root = await projectRoot(cwd);
|
|
1088
|
+
return `${basename(root).slice(0, 100)}#${createHash2("sha256").update(root).digest("hex").slice(0, 8)}`;
|
|
1089
|
+
}
|
|
1090
|
+
function fsReader(root) {
|
|
1091
|
+
const abs = (p) => p ? join2(root, ...p.split("/")) : root;
|
|
1092
|
+
return {
|
|
1093
|
+
read: (p) => read(abs(p)),
|
|
1094
|
+
subdirs: async (d) => {
|
|
1095
|
+
try {
|
|
1096
|
+
return (await readdir2(abs(d), { withFileTypes: true })).filter((e) => e.isDirectory()).map((e) => d ? `${d}/${e.name}` : e.name);
|
|
1097
|
+
} catch {
|
|
1098
|
+
return [];
|
|
1099
|
+
}
|
|
1100
|
+
}
|
|
1101
|
+
};
|
|
1102
|
+
}
|
|
1103
|
+
async function detectStack2(cwd = process.cwd()) {
|
|
1104
|
+
const base = resolve2(cwd);
|
|
1105
|
+
const root = await projectRoot(base);
|
|
1106
|
+
return detectStack(fsReader(root), relative2(root, base).split(sep).join("/"));
|
|
1107
|
+
}
|
|
954
1108
|
async function installedSkillNames(skillDirs) {
|
|
955
1109
|
const names = /* @__PURE__ */ new Set();
|
|
956
1110
|
for (const dir of skillDirs) {
|
|
@@ -961,6 +1115,160 @@ async function installedSkillNames(skillDirs) {
|
|
|
961
1115
|
return [...names];
|
|
962
1116
|
}
|
|
963
1117
|
|
|
1118
|
+
// src/destructive.ts
|
|
1119
|
+
import { realpathSync } from "node:fs";
|
|
1120
|
+
import { homedir as homedir2, tmpdir } from "node:os";
|
|
1121
|
+
import { isAbsolute, join as join3, normalize, resolve as resolve3, sep as sep2 } from "node:path";
|
|
1122
|
+
function split(line) {
|
|
1123
|
+
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, "")) }));
|
|
1124
|
+
}
|
|
1125
|
+
function strip(words) {
|
|
1126
|
+
let w = words;
|
|
1127
|
+
while (w.length && /^(sudo|doas|env|time|nice|command|exec|\w+=\S*)$/.test(w[0])) w = w.slice(1);
|
|
1128
|
+
return w;
|
|
1129
|
+
}
|
|
1130
|
+
var SYSTEM_DIRS = ["/", "/bin", "/boot", "/dev", "/etc", "/lib", "/lib64", "/opt", "/proc", "/root", "/sbin", "/sys", "/usr", "/var", "/System", "/Library", "/Applications", "/Users", "/home", "/private", "/Volumes"];
|
|
1131
|
+
function expand(arg, ctx) {
|
|
1132
|
+
let out = arg.replace(/^~(?=$|\/)/, ctx.home);
|
|
1133
|
+
let unresolved = false;
|
|
1134
|
+
out = out.replace(/\$\{(\w+)(?::?[-=][^}]*)?\}|\$(\w+)/g, (m, a, b) => {
|
|
1135
|
+
const name = a ?? b;
|
|
1136
|
+
const v = name === "HOME" ? ctx.home : name === "PWD" ? ctx.cwd : ctx.env[name];
|
|
1137
|
+
if (v === void 0 || v === "") {
|
|
1138
|
+
if (/:?[-=]/.test(m)) return m.replace(/^\$\{\w+:?[-=]|\}$/g, "");
|
|
1139
|
+
unresolved = true;
|
|
1140
|
+
return "";
|
|
1141
|
+
}
|
|
1142
|
+
return v;
|
|
1143
|
+
});
|
|
1144
|
+
return unresolved ? null : out;
|
|
1145
|
+
}
|
|
1146
|
+
var within = (p, dir) => p === dir || p.startsWith(dir.endsWith(sep2) ? dir : dir + sep2);
|
|
1147
|
+
function real(p) {
|
|
1148
|
+
try {
|
|
1149
|
+
return realpathSync(p);
|
|
1150
|
+
} catch {
|
|
1151
|
+
return p;
|
|
1152
|
+
}
|
|
1153
|
+
}
|
|
1154
|
+
function judgeDelete(target, ctx, flags) {
|
|
1155
|
+
const expanded = expand(target, ctx);
|
|
1156
|
+
if (expanded === null) {
|
|
1157
|
+
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.` };
|
|
1158
|
+
}
|
|
1159
|
+
if (/^\/?\*$|^~\/?\*$/.test(target) || expanded === "/*" || expanded === `${ctx.home}/*`) {
|
|
1160
|
+
return { decision: "deny", reason: `"rm ${flags} ${target}" deletes everything in ${expanded.startsWith(ctx.home) ? "the home directory" : "the filesystem root"}.` };
|
|
1161
|
+
}
|
|
1162
|
+
const abs = normalize(isAbsolute(expanded) ? expanded : resolve3(ctx.cwd, expanded)).replace(/\/\*$/, "").replace(/(.)\/+$/, "$1") || "/";
|
|
1163
|
+
const resolved = real(abs);
|
|
1164
|
+
for (const p of /* @__PURE__ */ new Set([abs, resolved])) {
|
|
1165
|
+
if (p === ctx.home || within(ctx.home, p)) return { decision: "deny", reason: `"rm ${flags} ${target}" would delete the home directory (${p}).` };
|
|
1166
|
+
if (SYSTEM_DIRS.some((d) => p === d)) return { decision: "deny", reason: `"rm ${flags} ${target}" would delete the system directory ${p}.` };
|
|
1167
|
+
}
|
|
1168
|
+
const tmp = [real(tmpdir()), "/tmp", "/private/tmp", "/var/folders"];
|
|
1169
|
+
const safe = (p) => within(p, ctx.project) || tmp.some((t) => within(p, t));
|
|
1170
|
+
if (!safe(abs) || !safe(resolved)) {
|
|
1171
|
+
const via = resolved !== abs ? ` (it resolves to ${resolved} through a link)` : "";
|
|
1172
|
+
return { decision: "ask", reason: `"rm ${flags} ${target}" deletes ${abs}${via}, outside the project ${ctx.project}. Confirm that this is intended.` };
|
|
1173
|
+
}
|
|
1174
|
+
return null;
|
|
1175
|
+
}
|
|
1176
|
+
var RULES = [
|
|
1177
|
+
// rm -r / -rf / --recursive
|
|
1178
|
+
(w, _raw, ctx) => {
|
|
1179
|
+
if (w[0]?.split("/").pop() !== "rm") return null;
|
|
1180
|
+
const flags = w.filter((x) => x.startsWith("-"));
|
|
1181
|
+
if (!flags.some((f) => /^-[a-zA-Z]*[rR]/.test(f) || f === "--recursive")) return null;
|
|
1182
|
+
const targets = w.slice(1).filter((x) => !x.startsWith("-"));
|
|
1183
|
+
let worst = null;
|
|
1184
|
+
for (const t of targets) {
|
|
1185
|
+
const v = judgeDelete(t, ctx, flags.join(" "));
|
|
1186
|
+
if (v?.decision === "deny") return v;
|
|
1187
|
+
worst ??= v;
|
|
1188
|
+
}
|
|
1189
|
+
return worst;
|
|
1190
|
+
},
|
|
1191
|
+
// find … -delete / -exec rm outside the project
|
|
1192
|
+
(w, _raw, ctx) => {
|
|
1193
|
+
if (w[0] !== "find" || !(w.includes("-delete") || w.includes("-exec") && w.includes("rm"))) return null;
|
|
1194
|
+
const start = w[1] && !w[1].startsWith("-") ? w[1] : ".";
|
|
1195
|
+
const v = judgeDelete(start, ctx, "(find -delete)");
|
|
1196
|
+
return v && { ...v, reason: v.reason.replace(/^"rm \(find -delete\) /, `"find ${start} \u2026 -delete" `) };
|
|
1197
|
+
},
|
|
1198
|
+
// disks and filesystems
|
|
1199
|
+
(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,
|
|
1200
|
+
(_w, raw) => />\s*\/dev\/(sd|nvme|disk|hd)\w*/.test(raw) ? { decision: "deny", reason: "Writing directly to a disk device." } : null,
|
|
1201
|
+
// recursive chmod/chown on / or ~
|
|
1202
|
+
(w, _raw, ctx) => {
|
|
1203
|
+
if (!["chmod", "chown"].includes(w[0] ?? "") || !w.some((x) => /^-[a-zA-Z]*R/.test(x))) return null;
|
|
1204
|
+
const t = w.slice(1).filter((x) => !x.startsWith("-")).slice(1);
|
|
1205
|
+
const hit = t.map((x) => expand(x, ctx) ?? "/").find((x) => SYSTEM_DIRS.includes(normalize(x)) || normalize(x) === ctx.home);
|
|
1206
|
+
return hit ? { decision: "deny", reason: `Recursive ${w[0]} on ${hit}.` } : null;
|
|
1207
|
+
},
|
|
1208
|
+
// git
|
|
1209
|
+
(w) => {
|
|
1210
|
+
if (w[0] !== "git") return null;
|
|
1211
|
+
const sub = w[1];
|
|
1212
|
+
if (sub === "push" && w.some((x) => /^(-f|--force|--force-with-lease(=.*)?|--mirror|--delete|-d)$/.test(x) || /^\+/.test(x) || /^:/.test(x))) {
|
|
1213
|
+
return { decision: "ask", reason: `"${w.join(" ")}" rewrites or deletes history on the remote. Confirm with the user first.` };
|
|
1214
|
+
}
|
|
1215
|
+
if (sub === "reset" && w.includes("--hard")) return { decision: "ask", reason: '"git reset --hard" throws away uncommitted changes.' };
|
|
1216
|
+
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" : ""}.` };
|
|
1217
|
+
if ((sub === "checkout" || sub === "restore") && w.includes(".") && !w.includes("--staged")) return { decision: "ask", reason: `"${w.join(" ")}" discards all uncommitted changes.` };
|
|
1218
|
+
if (sub === "branch" && w.includes("-D")) return { decision: "ask", reason: `"${w.join(" ")}" deletes a branch even if it is not merged.` };
|
|
1219
|
+
if (sub === "add" && w.slice(2).some((x) => /(^|\/)\.env(\..+)?$|\.pem$|id_rsa|id_ed25519/.test(x) && !/\.example$|\.sample$|\.template$/.test(x))) {
|
|
1220
|
+
return { decision: "ask", reason: `"${w.join(" ")}" stages a file that usually holds secrets.` };
|
|
1221
|
+
}
|
|
1222
|
+
return null;
|
|
1223
|
+
},
|
|
1224
|
+
// databases
|
|
1225
|
+
(w, raw) => {
|
|
1226
|
+
const s = raw.toLowerCase();
|
|
1227
|
+
if (/\bprisma\b.*\bmigrate\s+reset\b/.test(s) || /\bprisma\b.*\bdb\s+push\b.*--(force-reset|accept-data-loss)/.test(s)) {
|
|
1228
|
+
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.` };
|
|
1229
|
+
}
|
|
1230
|
+
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)) {
|
|
1231
|
+
return { decision: "ask", reason: `"${raw.trim()}" drops or empties the database.` };
|
|
1232
|
+
}
|
|
1233
|
+
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)) {
|
|
1234
|
+
return { decision: "ask", reason: `"${raw.trim().slice(0, 200)}" deletes database data. Confirm the target is not production.` };
|
|
1235
|
+
}
|
|
1236
|
+
return null;
|
|
1237
|
+
},
|
|
1238
|
+
// infrastructure and cloud
|
|
1239
|
+
(w, raw) => {
|
|
1240
|
+
const s = raw.toLowerCase();
|
|
1241
|
+
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";
|
|
1242
|
+
return hit ? { decision: "ask", reason: `"${raw.trim().slice(0, 200)}" ${hit}. Confirm with the user first.` } : null;
|
|
1243
|
+
}
|
|
1244
|
+
];
|
|
1245
|
+
function exfiltration(line) {
|
|
1246
|
+
const s = line.toLowerCase();
|
|
1247
|
+
const net = /\b(curl|wget|nc|ncat|netcat|socat|http|httpie|scp|rsync|ftp|telnet)\b/;
|
|
1248
|
+
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*$))/;
|
|
1249
|
+
if (!net.test(s) || !secret.test(s)) return null;
|
|
1250
|
+
const piped = /\|\s*(curl|wget|nc|ncat|netcat|socat|http)\b/.test(s);
|
|
1251
|
+
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);
|
|
1252
|
+
return piped || uploaded ? { decision: "deny", reason: "This sends secrets (.env, SSH keys, cloud credentials or the environment) over the network." } : null;
|
|
1253
|
+
}
|
|
1254
|
+
function destructiveVerdict(line, opts = {}) {
|
|
1255
|
+
const home = normalize(opts.home ?? homedir2());
|
|
1256
|
+
const cwd = normalize(opts.cwd ?? process.cwd());
|
|
1257
|
+
const ctx = { cwd, home, project: normalize(opts.project ?? cwd), env: opts.env ?? process.env };
|
|
1258
|
+
if (ctx.project === home || SYSTEM_DIRS.includes(ctx.project)) ctx.project = join3(home, "\0no-project");
|
|
1259
|
+
const verdicts = [exfiltration(line), /:\(\)\s*\{\s*:\s*\|\s*:\s*&\s*\}\s*;\s*:/.test(line) ? { decision: "deny", reason: "Fork bomb." } : null];
|
|
1260
|
+
for (const { raw, words } of split(line)) {
|
|
1261
|
+
const w = strip(words);
|
|
1262
|
+
if (!w.length) continue;
|
|
1263
|
+
for (const rule of RULES) verdicts.push(rule(w, raw, ctx));
|
|
1264
|
+
}
|
|
1265
|
+
const hits = verdicts.filter((v) => !!v);
|
|
1266
|
+
if (!hits.length) return null;
|
|
1267
|
+
const deny = hits.filter((v) => v.decision === "deny");
|
|
1268
|
+
const pick = deny.length ? deny : hits;
|
|
1269
|
+
return { decision: deny.length ? "deny" : "ask", reason: [...new Set(pick.map((v) => v.reason))].join("\n") };
|
|
1270
|
+
}
|
|
1271
|
+
|
|
964
1272
|
// src/guard.ts
|
|
965
1273
|
function commands(line) {
|
|
966
1274
|
return line.split(/&&|\|\||[;|\n]/).map((c) => (c.match(/"[^"]*"|'[^']*'|\S+/g) ?? []).map((w) => w.replace(/^["']|["']$/g, ""))).filter((w) => w.length);
|
|
@@ -1038,17 +1346,20 @@ async function readStdin() {
|
|
|
1038
1346
|
}
|
|
1039
1347
|
async function runGuard(opts) {
|
|
1040
1348
|
let command = "";
|
|
1349
|
+
let cwd;
|
|
1041
1350
|
try {
|
|
1042
1351
|
const payload = JSON.parse(await readStdin());
|
|
1043
1352
|
command = payload.tool_input?.command ?? "";
|
|
1353
|
+
cwd = payload.cwd;
|
|
1044
1354
|
} catch {
|
|
1045
1355
|
return 0;
|
|
1046
1356
|
}
|
|
1357
|
+
const local = destructiveVerdict(command, { cwd, project: cwd });
|
|
1047
1358
|
const packages = packagesInCommand(command);
|
|
1048
|
-
if (!packages.length) return
|
|
1359
|
+
if (local?.decision === "deny" || !packages.length) return emit(local);
|
|
1049
1360
|
if (!opts.key) {
|
|
1050
1361
|
console.error("codexguild guard: CODEXGUILD_API_KEY is not set \u2014 packages were not checked.");
|
|
1051
|
-
return
|
|
1362
|
+
return emit(local);
|
|
1052
1363
|
}
|
|
1053
1364
|
let results;
|
|
1054
1365
|
try {
|
|
@@ -1062,12 +1373,18 @@ async function runGuard(opts) {
|
|
|
1062
1373
|
results = (await res.json()).results;
|
|
1063
1374
|
} catch (e) {
|
|
1064
1375
|
console.error(`codexguild guard: check failed (${e instanceof Error ? e.message : e}) \u2014 command not blocked.`);
|
|
1065
|
-
return
|
|
1066
|
-
}
|
|
1067
|
-
const decision = guardDecision(results);
|
|
1068
|
-
if (decision) {
|
|
1069
|
-
process.stdout.write(JSON.stringify({ hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: decision.decision, permissionDecisionReason: decision.reason } }));
|
|
1376
|
+
return emit(local);
|
|
1070
1377
|
}
|
|
1378
|
+
return emit(mergeVerdicts(local, guardDecision(results)));
|
|
1379
|
+
}
|
|
1380
|
+
function mergeVerdicts(a, b) {
|
|
1381
|
+
if (!a || !b) return a ?? b;
|
|
1382
|
+
if (a.decision !== b.decision) return a.decision === "deny" ? a : b;
|
|
1383
|
+
return { decision: a.decision, reason: `${a.reason}
|
|
1384
|
+
${b.reason}` };
|
|
1385
|
+
}
|
|
1386
|
+
function emit(d) {
|
|
1387
|
+
if (d) process.stdout.write(JSON.stringify({ hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: d.decision, permissionDecisionReason: `CodexGuild guard: ${d.reason}` } }));
|
|
1071
1388
|
return 0;
|
|
1072
1389
|
}
|
|
1073
1390
|
function guardDecision(results) {
|
|
@@ -1077,19 +1394,112 @@ function guardDecision(results) {
|
|
|
1077
1394
|
const blocked = flagged.some((r) => r.verdict === "block");
|
|
1078
1395
|
return {
|
|
1079
1396
|
decision: blocked ? "deny" : "ask",
|
|
1080
|
-
reason: `
|
|
1397
|
+
reason: `package check:
|
|
1081
1398
|
${lines.join("\n")}${blocked ? "\nUse the correct package name, or ask the user before trying again." : ""}`
|
|
1082
1399
|
};
|
|
1083
1400
|
}
|
|
1084
1401
|
|
|
1402
|
+
// src/code-scan.ts
|
|
1403
|
+
import { readdir as readdir3, readFile as readFile3, stat as stat3 } from "node:fs/promises";
|
|
1404
|
+
import { extname, join as join4, relative as relative3 } from "node:path";
|
|
1405
|
+
var SOURCE_EXT = /* @__PURE__ */ new Set([".js", ".jsx", ".ts", ".tsx", ".mjs", ".cjs", ".mts", ".cts", ".vue", ".svelte", ".astro", ".py", ".go", ".rs"]);
|
|
1406
|
+
var LIMITS = { files: 5e3, fileBytes: 1024 * 1024, hits: 60 };
|
|
1407
|
+
var escape = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
1408
|
+
function patternsFor(apiRaw) {
|
|
1409
|
+
const api = apiRaw.trim().replace(/\(\)$/, "").replace(/^['"`]|['"`]$/g, "");
|
|
1410
|
+
if (!api || /\s/.test(api) || api.length < 4 || /^[-@/]/.test(api)) return [];
|
|
1411
|
+
const distinctive = (s) => s.length >= 6 && (/[a-z][A-Z]/.test(s) || s.includes("_") || /^[A-Z][a-z]/.test(s) || s.length >= 10);
|
|
1412
|
+
const out = [];
|
|
1413
|
+
if (api.includes(".")) {
|
|
1414
|
+
out.push(new RegExp(`(?<![\\w$])${escape(api)}(?![\\w$])`));
|
|
1415
|
+
const last = api.slice(api.lastIndexOf(".") + 1);
|
|
1416
|
+
if (distinctive(last)) out.push(new RegExp(`\\.${escape(last)}\\s*\\(`));
|
|
1417
|
+
} else if (distinctive(api)) {
|
|
1418
|
+
out.push(new RegExp(`(?<![\\w$])${escape(api)}(?![\\w$])`));
|
|
1419
|
+
}
|
|
1420
|
+
return out;
|
|
1421
|
+
}
|
|
1422
|
+
async function sourceFiles(root) {
|
|
1423
|
+
const out = [];
|
|
1424
|
+
const walk = async (dir, depth) => {
|
|
1425
|
+
if (out.length >= LIMITS.files || depth > 12) return;
|
|
1426
|
+
const entries = await readdir3(dir, { withFileTypes: true }).catch(() => []);
|
|
1427
|
+
for (const e of entries) {
|
|
1428
|
+
if (out.length >= LIMITS.files) return;
|
|
1429
|
+
if (e.isDirectory()) {
|
|
1430
|
+
if (e.name.startsWith(".") || SKIP_DIRS2.has(e.name)) continue;
|
|
1431
|
+
await walk(join4(dir, e.name), depth + 1);
|
|
1432
|
+
} else if (e.isFile() && SOURCE_EXT.has(extname(e.name)) && !/\.(min|d)\.[cm]?[jt]s$/.test(e.name)) {
|
|
1433
|
+
out.push(join4(dir, e.name));
|
|
1434
|
+
}
|
|
1435
|
+
}
|
|
1436
|
+
};
|
|
1437
|
+
await walk(root, 0);
|
|
1438
|
+
return out;
|
|
1439
|
+
}
|
|
1440
|
+
async function scanCode(root, deps) {
|
|
1441
|
+
const targets = deps.flatMap((dep) => dep.apis.flatMap((api) => patternsFor(api).map((re) => ({ dep, api, re }))));
|
|
1442
|
+
if (!targets.length) return { hits: [], files: 0 };
|
|
1443
|
+
const files = await sourceFiles(root);
|
|
1444
|
+
const hits = [];
|
|
1445
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1446
|
+
for (const f of files) {
|
|
1447
|
+
if (hits.length >= LIMITS.hits) break;
|
|
1448
|
+
const size = (await stat3(f).catch(() => null))?.size ?? 0;
|
|
1449
|
+
if (!size || size > LIMITS.fileBytes) continue;
|
|
1450
|
+
const lines = (await readFile3(f, "utf8").catch(() => "")).split("\n");
|
|
1451
|
+
lines.forEach((text2, i) => {
|
|
1452
|
+
for (const t of targets) {
|
|
1453
|
+
if (hits.length >= LIMITS.hits || !t.re.test(text2)) continue;
|
|
1454
|
+
const key = `${f}:${i}:${t.dep.package}:${t.api}`;
|
|
1455
|
+
if (seen.has(key)) continue;
|
|
1456
|
+
seen.add(key);
|
|
1457
|
+
hits.push({ dep: t.dep, api: t.api, file: relative3(root, f), line: i + 1, snippet: text2.trim().slice(0, 140) });
|
|
1458
|
+
}
|
|
1459
|
+
});
|
|
1460
|
+
}
|
|
1461
|
+
return { hits, files: files.length };
|
|
1462
|
+
}
|
|
1463
|
+
async function applicableDeprecations(api, key, deps) {
|
|
1464
|
+
const packages = deps.filter((d) => d.version && !d.runtime).slice(0, 300).map((d) => ({ name: d.name, version: d.version, ...d.ecosystem ? { ecosystem: d.ecosystem } : {} }));
|
|
1465
|
+
if (!packages.length || !key) return [];
|
|
1466
|
+
const res = await fetch(`${api}/freshness/deprecations/check`, {
|
|
1467
|
+
method: "POST",
|
|
1468
|
+
headers: { "content-type": "application/json", "x-codexguild-key": key },
|
|
1469
|
+
body: JSON.stringify({ packages }),
|
|
1470
|
+
signal: AbortSignal.timeout(15e3)
|
|
1471
|
+
});
|
|
1472
|
+
if (!res.ok) throw new Error(`deprecation check failed (${res.status})`);
|
|
1473
|
+
return (await res.json()).items;
|
|
1474
|
+
}
|
|
1475
|
+
function formatCodeScan(hits, files, checked) {
|
|
1476
|
+
if (!hits.length) return `Code check: ${files} source files searched for ${checked} deprecated/removed APIs that apply to your installed versions \u2014 none used.`;
|
|
1477
|
+
const groups = /* @__PURE__ */ new Map();
|
|
1478
|
+
for (const h of hits) {
|
|
1479
|
+
const k = `${h.dep.package}|${h.api}|${h.dep.versionTag}`;
|
|
1480
|
+
groups.set(k, [...groups.get(k) ?? [], h]);
|
|
1481
|
+
}
|
|
1482
|
+
const sorted = [...groups.values()].sort((a, b) => Number(b[0].dep.kind === "removed") - Number(a[0].dep.kind === "removed"));
|
|
1483
|
+
const out = [`## Your code uses ${groups.size} API(s) that are deprecated or removed in the versions you run`];
|
|
1484
|
+
for (const g of sorted) {
|
|
1485
|
+
const d = g[0].dep;
|
|
1486
|
+
out.push(`- ${d.kind === "removed" ? "REMOVED" : "DEPRECATED"} \`${g[0].api}\` \u2014 ${d.package} ${d.installed} (since ${d.versionTag ?? "?"})${d.replacement ? ` \u2192 use \`${d.replacement}\`` : ""}${d.url ? ` ${d.url}` : ""}`);
|
|
1487
|
+
out.push(` ${d.text}`);
|
|
1488
|
+
for (const h of g.slice(0, 5)) out.push(` - ${h.file}:${h.line} ${h.snippet}`);
|
|
1489
|
+
if (g.length > 5) out.push(` - +${g.length - 5} more`);
|
|
1490
|
+
}
|
|
1491
|
+
out.push("Fix these before they break (removed APIs fail at runtime). Read the linked release notes for the exact migration.");
|
|
1492
|
+
return out.join("\n");
|
|
1493
|
+
}
|
|
1494
|
+
|
|
1085
1495
|
// src/index.ts
|
|
1086
1496
|
var API = (process.env.CODEXGUILD_API_URL || "https://api.codexguild.com/v1").replace(/\/$/, "");
|
|
1087
1497
|
var KEY = process.env.CODEXGUILD_API_KEY ?? "";
|
|
1088
|
-
var VERSION = "1.
|
|
1498
|
+
var VERSION = "1.6.0";
|
|
1089
1499
|
if (process.argv[2] === "guard") process.exit(await runGuard({ api: API, key: KEY }));
|
|
1090
1500
|
var INSTRUCTION_FILES = ["CLAUDE.md", ".claude/CLAUDE.md", "AGENTS.md", "AGENTS.override.md", ".hermes.md", "HERMES.md", "GEMINI.md", ".cursorrules"];
|
|
1091
1501
|
async function existingInstructionFiles(cwd) {
|
|
1092
|
-
const present = await Promise.all(INSTRUCTION_FILES.map((f) => access(
|
|
1502
|
+
const present = await Promise.all(INSTRUCTION_FILES.map((f) => access(join5(cwd, f)).then(() => f, () => null)));
|
|
1093
1503
|
return present.filter((f) => f !== null);
|
|
1094
1504
|
}
|
|
1095
1505
|
var remote = null;
|
|
@@ -1119,7 +1529,9 @@ var SCAN_TOOL = {
|
|
|
1119
1529
|
};
|
|
1120
1530
|
var ENHANCED = {
|
|
1121
1531
|
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."
|
|
1532
|
+
codexguild_advise: "Runs locally first: stack, installed skills and the instruction files present in the project are detected automatically; you only need harness.",
|
|
1533
|
+
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).",
|
|
1534
|
+
codexguild_upgrade_plan: "Runs locally first: `from` (and the ecosystem) is read from the lockfile when you only give `package`."
|
|
1123
1535
|
};
|
|
1124
1536
|
function enhance(t) {
|
|
1125
1537
|
const note = ENHANCED[t.name];
|
|
@@ -1130,12 +1542,24 @@ function enhance(t) {
|
|
|
1130
1542
|
${note}`,
|
|
1131
1543
|
inputSchema: {
|
|
1132
1544
|
...t.inputSchema,
|
|
1133
|
-
properties: { ...t.inputSchema.properties, cwd: { type: "string", description: "Project directory (defaults to the current directory)" } }
|
|
1545
|
+
properties: { ...t.inputSchema.properties, cwd: { type: "string", description: "Project directory (defaults to the current directory)" } },
|
|
1546
|
+
// Filled from the lockfile here, so the agent need not pass it.
|
|
1547
|
+
...t.name === "codexguild_upgrade_plan" && Array.isArray(t.inputSchema.required) ? { required: t.inputSchema.required.filter((r) => r !== "from") } : {}
|
|
1134
1548
|
}
|
|
1135
1549
|
};
|
|
1136
1550
|
}
|
|
1137
|
-
async function detected(name, cwd) {
|
|
1138
|
-
|
|
1551
|
+
async function detected(name, cwd, explicit = {}) {
|
|
1552
|
+
if (name === "codexguild_upgrade_plan") {
|
|
1553
|
+
const pkg = typeof explicit.package === "string" ? explicit.package : "";
|
|
1554
|
+
const { deps: deps2 } = await detectStack2(cwd);
|
|
1555
|
+
const d = deps2.find((x) => !x.runtime && x.name === pkg && x.version);
|
|
1556
|
+
return { args: d ? { from: d.version, ...d.ecosystem ? { ecosystem: d.ecosystem } : {} } : {} };
|
|
1557
|
+
}
|
|
1558
|
+
if (name === "codexguild_debug") {
|
|
1559
|
+
const { deps: deps2 } = await detectStack2(cwd);
|
|
1560
|
+
return { args: { packages: deps2.slice(0, 300).map((d) => ({ name: d.name, ...d.version ? { version: d.version } : {}, ...d.ecosystem ? { ecosystem: d.ecosystem } : {} })) } };
|
|
1561
|
+
}
|
|
1562
|
+
const [{ deps, manifests, lockfiles, exact, ranges }, local, project] = await Promise.all([detectStack2(cwd), scanLocalSkills(), projectKey(cwd)]);
|
|
1139
1563
|
const installedSkills = await installedSkillNames(local.reports.map((r) => r.dir));
|
|
1140
1564
|
if (name === "codexguild_advise") {
|
|
1141
1565
|
return { args: { stack: deps, installedSkills, existingInstructionFiles: await existingInstructionFiles(cwd), project } };
|
|
@@ -1163,7 +1587,7 @@ async function lintLocal(cwd, files) {
|
|
|
1163
1587
|
if (!targets.length) return "No instruction files found (looked for CLAUDE.md, AGENTS.md, GEMINI.md, .cursorrules, \u2026).";
|
|
1164
1588
|
const out = [];
|
|
1165
1589
|
for (const f of targets) {
|
|
1166
|
-
const content = await
|
|
1590
|
+
const content = await readFile4(isAbsolute2(f) ? f : join5(cwd, f), "utf8").catch(() => null);
|
|
1167
1591
|
if (content === null) {
|
|
1168
1592
|
out.push(`## ${f}
|
|
1169
1593
|
Could not read the file.`);
|
|
@@ -1177,7 +1601,48 @@ Could not read the file.`);
|
|
|
1177
1601
|
}
|
|
1178
1602
|
var text = (t, isError = false) => ({ content: [{ type: "text", text: t }], ...isError ? { isError } : {} });
|
|
1179
1603
|
var server = new Server({ name: "codexguild", version: VERSION }, { capabilities: { tools: { listChanged: true } } });
|
|
1180
|
-
var
|
|
1604
|
+
var WORKFLOWS_TOOL = {
|
|
1605
|
+
name: "codexguild_scan_workflows",
|
|
1606
|
+
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.",
|
|
1607
|
+
inputSchema: {
|
|
1608
|
+
type: "object",
|
|
1609
|
+
properties: { files: { type: "array", items: { type: "string" }, description: "Workflow files to review instead of .github/workflows/*.yml" } }
|
|
1610
|
+
},
|
|
1611
|
+
annotations: { readOnlyHint: true, openWorldHint: false }
|
|
1612
|
+
};
|
|
1613
|
+
async function scanWorkflowsLocal(cwd, files) {
|
|
1614
|
+
const dir = join5(cwd, ".github", "workflows");
|
|
1615
|
+
const targets = files?.length ? files : (await readdir4(dir).catch(() => [])).filter((f) => /\.ya?ml$/.test(f)).map((f) => join5(dir, f));
|
|
1616
|
+
if (!targets.length) return "No workflows found in .github/workflows.";
|
|
1617
|
+
const out = [];
|
|
1618
|
+
for (const f of targets) {
|
|
1619
|
+
const content = await readFile4(isAbsolute2(f) ? f : join5(cwd, f), "utf8").catch(() => null);
|
|
1620
|
+
const name = f.startsWith(cwd + "/") ? f.slice(cwd.length + 1) : f;
|
|
1621
|
+
if (content === null) {
|
|
1622
|
+
out.push(`## ${name}
|
|
1623
|
+
Could not read the file.`);
|
|
1624
|
+
continue;
|
|
1625
|
+
}
|
|
1626
|
+
const r = lintWorkflow(content);
|
|
1627
|
+
if (!r.agents.length && !r.issues.length) continue;
|
|
1628
|
+
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(", ")}` : ""}`);
|
|
1629
|
+
out.push(...r.issues.length ? r.issues.map((i) => `- [${i.severity}]${i.line ? ` line ${i.line}` : ""} ${i.title}: ${i.detail}`) : ["- No issues."]);
|
|
1630
|
+
}
|
|
1631
|
+
return out.length ? out.join("\n") : `Reviewed ${targets.length} workflow(s): none runs a coding agent and no injection risks were found.`;
|
|
1632
|
+
}
|
|
1633
|
+
var CODE_TOOL = {
|
|
1634
|
+
name: "codexguild_scan_code",
|
|
1635
|
+
description: "Find deprecated and removed APIs in this project's code for the exact versions it runs (from the lockfiles): e.g. `app.mount` deprecated in hono 4.13, with the replacement and where it is used (file:line). Only package names and versions are sent to CodexGuild; the code is searched locally and never uploaded. codexguild_sync runs this automatically.",
|
|
1636
|
+
inputSchema: { type: "object", properties: { cwd: { type: "string", description: "Project directory (defaults to the current directory)" } } },
|
|
1637
|
+
annotations: { readOnlyHint: true, openWorldHint: true }
|
|
1638
|
+
};
|
|
1639
|
+
async function codeScanLocal(cwd) {
|
|
1640
|
+
const { deps } = await detectStack2(cwd);
|
|
1641
|
+
const applicable = await applicableDeprecations(API, KEY, deps);
|
|
1642
|
+
const { hits, files } = await scanCode(cwd, applicable);
|
|
1643
|
+
return formatCodeScan(hits, files, applicable.length);
|
|
1644
|
+
}
|
|
1645
|
+
var LOCAL_TOOLS = [SCAN_TOOL, LINT_TOOL, WORKFLOWS_TOOL, CODE_TOOL];
|
|
1181
1646
|
var RETRY_MS = [1e3, 3e3];
|
|
1182
1647
|
var RECONNECT_MS = 3e4;
|
|
1183
1648
|
var reconnectTimer = null;
|
|
@@ -1218,23 +1683,33 @@ server.setRequestHandler(CallToolRequestSchema, async (req) => {
|
|
|
1218
1683
|
const { name } = req.params;
|
|
1219
1684
|
const args2 = { ...req.params.arguments ?? {} };
|
|
1220
1685
|
try {
|
|
1686
|
+
if (name === WORKFLOWS_TOOL.name) {
|
|
1687
|
+
return text(await scanWorkflowsLocal(process.cwd(), Array.isArray(args2.files) ? args2.files.map(String) : void 0));
|
|
1688
|
+
}
|
|
1221
1689
|
if (name === LINT_TOOL.name) {
|
|
1222
1690
|
return text(await lintLocal(process.cwd(), Array.isArray(args2.files) ? args2.files.map(String) : void 0));
|
|
1223
1691
|
}
|
|
1692
|
+
if (name === CODE_TOOL.name) {
|
|
1693
|
+
return text(await codeScanLocal(typeof args2.cwd === "string" ? args2.cwd : process.cwd()));
|
|
1694
|
+
}
|
|
1224
1695
|
if (name === SCAN_TOOL.name) {
|
|
1225
1696
|
const paths = Array.isArray(args2.paths) ? args2.paths.map(String) : void 0;
|
|
1226
1697
|
return text(formatLocalScan(await scanLocalSkills(paths), args2.verbose === true));
|
|
1227
1698
|
}
|
|
1228
1699
|
let summary;
|
|
1700
|
+
let cwd = process.cwd();
|
|
1229
1701
|
if (name in ENHANCED) {
|
|
1230
|
-
|
|
1702
|
+
cwd = typeof args2.cwd === "string" ? args2.cwd : process.cwd();
|
|
1231
1703
|
delete args2.cwd;
|
|
1232
|
-
const d = await detected(name, cwd);
|
|
1704
|
+
const d = await detected(name, cwd, args2);
|
|
1233
1705
|
summary = d.summary;
|
|
1234
1706
|
Object.assign(args2, { ...d.args, ...args2 });
|
|
1235
1707
|
}
|
|
1236
1708
|
const r = await (await remoteClient()).callTool({ name, arguments: args2 });
|
|
1237
|
-
|
|
1709
|
+
const codeNote = name === "codexguild_sync" && !r.isError ? await codeScanLocal(cwd).catch(() => null) : null;
|
|
1710
|
+
const extra = [...summary ? [{ type: "text", text: summary }] : []];
|
|
1711
|
+
const tail = codeNote && !codeNote.startsWith("Code check:") ? [{ type: "text", text: codeNote }] : [];
|
|
1712
|
+
return extra.length || tail.length ? { ...r, content: [...extra, ...r.content ?? [], ...tail] } : r;
|
|
1238
1713
|
} catch (e) {
|
|
1239
1714
|
return text(`CodexGuild error: ${e instanceof Error ? e.message : String(e)}`, true);
|
|
1240
1715
|
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@codexguild/mcp",
|
|
3
|
-
"version": "1.
|
|
4
|
-
"description": "CodexGuild MCP server (stdio) \u2014
|
|
3
|
+
"version": "1.6.0",
|
|
4
|
+
"description": "CodexGuild MCP server (stdio) \u2014 boosts your coding agent: remote CodexGuild tools plus local stack detection from lockfiles, deprecated APIs found in your own code, and offline security audits of skills, instructions and agent workflows",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"mcp",
|
|
7
7
|
"model-context-protocol",
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
],
|
|
16
16
|
"homepage": "https://codexguild.com/docs/mcp",
|
|
17
17
|
"bugs": {
|
|
18
|
-
"
|
|
18
|
+
"email": "info@codexguild.com"
|
|
19
19
|
},
|
|
20
20
|
"repository": {
|
|
21
21
|
"type": "git",
|
|
@@ -36,7 +36,7 @@
|
|
|
36
36
|
"node": ">=20"
|
|
37
37
|
},
|
|
38
38
|
"scripts": {
|
|
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",
|
|
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 --alias:@codexguild/stack=../stack/src/index.ts --outfile=dist/codexguild-mcp.js",
|
|
40
40
|
"start": "node dist/codexguild-mcp.js",
|
|
41
41
|
"dev": "tsx src/index.ts",
|
|
42
42
|
"prepublishOnly": "npm run build",
|
|
@@ -50,6 +50,7 @@
|
|
|
50
50
|
"esbuild": "^0.28.2",
|
|
51
51
|
"tsx": "^4.19.2",
|
|
52
52
|
"typescript": "^5.7.0",
|
|
53
|
-
"@codexguild/scanner": "0.1.0"
|
|
53
|
+
"@codexguild/scanner": "0.1.0",
|
|
54
|
+
"@codexguild/stack": "0.1.0"
|
|
54
55
|
}
|
|
55
56
|
}
|