@codexguild/mcp 1.1.0 → 1.5.0

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