@codexguild/mcp 1.4.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 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 every skill installed in `~/.claude/skills`, `~/.claude/plugins`, `~/.codex/skills`, `~/.agents/skills`, `~/.hermes/skills`, `~/.config/opencode/skills`, `~/.cursor/skills`, `~/.openclaude/skills` **offline**, with the same scanner CodexGuild runs server-side (pipe-to-shell, secret exfiltration, prompt injection, hidden unicode, config tampering…). Nothing is uploaded. |
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
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#agents) (`cgk_…`).
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` works. |
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.
@@ -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 readFile3 } from "node:fs/promises";
10
- import { isAbsolute, join as join3 } from "node:path";
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";
@@ -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;
@@ -787,12 +915,12 @@ function fromPyproject(raw) {
787
915
  }
788
916
  return deps;
789
917
  }
790
- function tomlPackages(raw, normalize = pep503) {
918
+ function tomlPackages(raw, normalize2 = pep503) {
791
919
  const out = /* @__PURE__ */ new Map();
792
920
  for (const block of raw.split(/^\[\[package\]\]\s*$/m).slice(1)) {
793
921
  const name = block.match(/^name\s*=\s*"([^"]+)"/m)?.[1];
794
922
  const version = block.match(/^version\s*=\s*"([^"]+)"/m)?.[1];
795
- if (name && version && !out.has(normalize(name))) out.set(normalize(name), version);
923
+ if (name && version && !out.has(normalize2(name))) out.set(normalize2(name), version);
796
924
  }
797
925
  return out;
798
926
  }
@@ -961,6 +1089,160 @@ async function installedSkillNames(skillDirs) {
961
1089
  return [...names];
962
1090
  }
963
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
+
964
1246
  // src/guard.ts
965
1247
  function commands(line) {
966
1248
  return line.split(/&&|\|\||[;|\n]/).map((c) => (c.match(/"[^"]*"|'[^']*'|\S+/g) ?? []).map((w) => w.replace(/^["']|["']$/g, ""))).filter((w) => w.length);
@@ -1038,17 +1320,20 @@ async function readStdin() {
1038
1320
  }
1039
1321
  async function runGuard(opts) {
1040
1322
  let command = "";
1323
+ let cwd;
1041
1324
  try {
1042
1325
  const payload = JSON.parse(await readStdin());
1043
1326
  command = payload.tool_input?.command ?? "";
1327
+ cwd = payload.cwd;
1044
1328
  } catch {
1045
1329
  return 0;
1046
1330
  }
1331
+ const local = destructiveVerdict(command, { cwd, project: cwd });
1047
1332
  const packages = packagesInCommand(command);
1048
- if (!packages.length) return 0;
1333
+ if (local?.decision === "deny" || !packages.length) return emit(local);
1049
1334
  if (!opts.key) {
1050
1335
  console.error("codexguild guard: CODEXGUILD_API_KEY is not set \u2014 packages were not checked.");
1051
- return 0;
1336
+ return emit(local);
1052
1337
  }
1053
1338
  let results;
1054
1339
  try {
@@ -1062,12 +1347,18 @@ async function runGuard(opts) {
1062
1347
  results = (await res.json()).results;
1063
1348
  } catch (e) {
1064
1349
  console.error(`codexguild guard: check failed (${e instanceof Error ? e.message : e}) \u2014 command not blocked.`);
1065
- return 0;
1066
- }
1067
- const decision = guardDecision(results);
1068
- if (decision) {
1069
- process.stdout.write(JSON.stringify({ hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: decision.decision, permissionDecisionReason: decision.reason } }));
1350
+ return emit(local);
1070
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}` } }));
1071
1362
  return 0;
1072
1363
  }
1073
1364
  function guardDecision(results) {
@@ -1077,7 +1368,7 @@ function guardDecision(results) {
1077
1368
  const blocked = flagged.some((r) => r.verdict === "block");
1078
1369
  return {
1079
1370
  decision: blocked ? "deny" : "ask",
1080
- reason: `CodexGuild package check:
1371
+ reason: `package check:
1081
1372
  ${lines.join("\n")}${blocked ? "\nUse the correct package name, or ask the user before trying again." : ""}`
1082
1373
  };
1083
1374
  }
@@ -1085,11 +1376,11 @@ ${lines.join("\n")}${blocked ? "\nUse the correct package name, or ask the user
1085
1376
  // src/index.ts
1086
1377
  var API = (process.env.CODEXGUILD_API_URL || "https://api.codexguild.com/v1").replace(/\/$/, "");
1087
1378
  var KEY = process.env.CODEXGUILD_API_KEY ?? "";
1088
- var VERSION = "1.4.0";
1379
+ var VERSION = "1.5.0";
1089
1380
  if (process.argv[2] === "guard") process.exit(await runGuard({ api: API, key: KEY }));
1090
1381
  var INSTRUCTION_FILES = ["CLAUDE.md", ".claude/CLAUDE.md", "AGENTS.md", "AGENTS.override.md", ".hermes.md", "HERMES.md", "GEMINI.md", ".cursorrules"];
1091
1382
  async function existingInstructionFiles(cwd) {
1092
- const present = await Promise.all(INSTRUCTION_FILES.map((f) => access(join3(cwd, f)).then(() => f, () => null)));
1383
+ const present = await Promise.all(INSTRUCTION_FILES.map((f) => access(join4(cwd, f)).then(() => f, () => null)));
1093
1384
  return present.filter((f) => f !== null);
1094
1385
  }
1095
1386
  var remote = null;
@@ -1119,7 +1410,8 @@ var SCAN_TOOL = {
1119
1410
  };
1120
1411
  var ENHANCED = {
1121
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.",
1122
- 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)."
1123
1415
  };
1124
1416
  function enhance(t) {
1125
1417
  const note = ENHANCED[t.name];
@@ -1135,6 +1427,10 @@ ${note}`,
1135
1427
  };
1136
1428
  }
1137
1429
  async function detected(name, cwd) {
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
+ }
1138
1434
  const [{ deps, manifests, lockfiles, exact, ranges }, local, project] = await Promise.all([detectStack(cwd), scanLocalSkills(), projectKey(cwd)]);
1139
1435
  const installedSkills = await installedSkillNames(local.reports.map((r) => r.dir));
1140
1436
  if (name === "codexguild_advise") {
@@ -1163,7 +1459,7 @@ async function lintLocal(cwd, files) {
1163
1459
  if (!targets.length) return "No instruction files found (looked for CLAUDE.md, AGENTS.md, GEMINI.md, .cursorrules, \u2026).";
1164
1460
  const out = [];
1165
1461
  for (const f of targets) {
1166
- const content = await readFile3(isAbsolute(f) ? f : join3(cwd, f), "utf8").catch(() => null);
1462
+ const content = await readFile3(isAbsolute2(f) ? f : join4(cwd, f), "utf8").catch(() => null);
1167
1463
  if (content === null) {
1168
1464
  out.push(`## ${f}
1169
1465
  Could not read the file.`);
@@ -1177,7 +1473,36 @@ Could not read the file.`);
1177
1473
  }
1178
1474
  var text = (t, isError = false) => ({ content: [{ type: "text", text: t }], ...isError ? { isError } : {} });
1179
1475
  var server = new Server({ name: "codexguild", version: VERSION }, { capabilities: { tools: { listChanged: true } } });
1180
- var LOCAL_TOOLS = [SCAN_TOOL, LINT_TOOL];
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];
1181
1506
  var RETRY_MS = [1e3, 3e3];
1182
1507
  var RECONNECT_MS = 3e4;
1183
1508
  var reconnectTimer = null;
@@ -1218,6 +1543,9 @@ server.setRequestHandler(CallToolRequestSchema, async (req) => {
1218
1543
  const { name } = req.params;
1219
1544
  const args2 = { ...req.params.arguments ?? {} };
1220
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
+ }
1221
1549
  if (name === LINT_TOOL.name) {
1222
1550
  return text(await lintLocal(process.cwd(), Array.isArray(args2.files) ? args2.files.map(String) : void 0));
1223
1551
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@codexguild/mcp",
3
- "version": "1.4.0",
3
+ "version": "1.5.0",
4
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",