codex-plugin-doctor 1.37.0 → 1.39.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
@@ -402,9 +402,9 @@ jobs:
402
402
  runs-on: ubuntu-latest
403
403
  steps:
404
404
  - uses: actions/checkout@v5
405
- - uses: Esquetta/CodexPluginDoctor@v1.37.0
405
+ - uses: Esquetta/CodexPluginDoctor@v1.39.0
406
406
  with:
407
- version: "1.37.0"
407
+ version: "1.39.0"
408
408
  path: .
409
409
  runtime: "true"
410
410
  policy: codex-publish
@@ -15,9 +15,13 @@ async function fileExists(filePath) {
15
15
  }
16
16
  async function runNpmAudit(cwd) {
17
17
  return new Promise((resolve, reject) => {
18
- const command = process.platform === "win32" ? process.env.ComSpec ?? "cmd.exe" : "npm";
18
+ const npmCliPath = process.env.npm_execpath ??
19
+ path.join(path.dirname(process.execPath), "node_modules", "npm", "bin", "npm-cli.js");
20
+ const command = process.platform === "win32"
21
+ ? process.execPath
22
+ : "npm";
19
23
  const args = process.platform === "win32"
20
- ? ["/d", "/s", "/c", "npm", "audit", "--json"]
24
+ ? [npmCliPath, "audit", "--json"]
21
25
  : ["audit", "--json"];
22
26
  execFile(command, args, { cwd, timeout: 120_000 }, (error, stdout, stderr) => {
23
27
  const parsed = (() => {
@@ -1,39 +1,77 @@
1
1
  import { chmod, mkdir, stat, writeFile } from "node:fs/promises";
2
2
  import path from "node:path";
3
- const preCommitHook = [
4
- "#!/usr/bin/env sh",
5
- "# Generated by codex-plugin-doctor init git-hooks",
6
- "# Validates the plugin package before every commit.",
7
- "",
8
- "echo \"Codex Plugin Doctor: running pre-commit validation...\"",
9
- "",
10
- "npx --yes codex-plugin-doctor check . --profile ci",
11
- "",
12
- "if [ $? -ne 0 ]; then",
13
- " echo \"\"",
14
- " echo \"Plugin validation failed. Commit blocked.\"",
15
- " echo \"Run 'codex-plugin-doctor check .' to see full diagnostics.\"",
16
- " exit 1",
17
- "fi",
18
- ""
19
- ].join("\n");
20
- const prePushHook = [
21
- "#!/usr/bin/env sh",
22
- "# Generated by codex-plugin-doctor init git-hooks",
23
- "# Validates the plugin package before every push.",
24
- "",
25
- "echo \"Codex Plugin Doctor: running pre-push validation...\"",
26
- "",
27
- "npx --yes codex-plugin-doctor check . --profile ci --runtime",
28
- "",
29
- "if [ $? -ne 0 ]; then",
30
- " echo \"\"",
31
- " echo \"Plugin validation failed. Push blocked.\"",
32
- " echo \"Run 'codex-plugin-doctor check . --runtime' to see full diagnostics.\"",
33
- " exit 1",
34
- "fi",
35
- ""
36
- ].join("\n");
3
+ const isWindows = process.platform === "win32";
4
+ const preCommitHook = isWindows
5
+ ? [
6
+ "@echo off",
7
+ "REM Generated by codex-plugin-doctor init git-hooks",
8
+ "REM Validates the plugin package before every commit.",
9
+ "",
10
+ "echo Codex Plugin Doctor: running pre-commit validation...",
11
+ "",
12
+ "call npx --yes codex-plugin-doctor check . --profile ci",
13
+ "",
14
+ "if %ERRORLEVEL% neq 0 (",
15
+ " echo.",
16
+ " echo Plugin validation failed. Commit blocked.",
17
+ " echo Run 'codex-plugin-doctor check .' to see full diagnostics.",
18
+ " exit /b 1",
19
+ ")",
20
+ ""
21
+ ].join("\r\n")
22
+ : [
23
+ "#!/usr/bin/env sh",
24
+ "# Generated by codex-plugin-doctor init git-hooks",
25
+ "# Validates the plugin package before every commit.",
26
+ "",
27
+ "echo \"Codex Plugin Doctor: running pre-commit validation...\"",
28
+ "",
29
+ "npx --yes codex-plugin-doctor check . --profile ci",
30
+ "",
31
+ "if [ $? -ne 0 ]; then",
32
+ " echo \"\"",
33
+ " echo \"Plugin validation failed. Commit blocked.\"",
34
+ " echo \"Run 'codex-plugin-doctor check .' to see full diagnostics.\"",
35
+ " exit 1",
36
+ "fi",
37
+ ""
38
+ ].join("\n");
39
+ const prePushHook = isWindows
40
+ ? [
41
+ "@echo off",
42
+ "REM Generated by codex-plugin-doctor init git-hooks",
43
+ "REM Validates the plugin package before every push.",
44
+ "",
45
+ "echo Codex Plugin Doctor: running pre-push validation...",
46
+ "",
47
+ "call npx --yes codex-plugin-doctor check . --profile ci --runtime",
48
+ "",
49
+ "if %ERRORLEVEL% neq 0 (",
50
+ " echo.",
51
+ " echo Plugin validation failed. Push blocked.",
52
+ " echo Run 'codex-plugin-doctor check . --runtime' to see full diagnostics.",
53
+ " exit /b 1",
54
+ ")",
55
+ ""
56
+ ].join("\r\n")
57
+ : [
58
+ "#!/usr/bin/env sh",
59
+ "# Generated by codex-plugin-doctor init git-hooks",
60
+ "# Validates the plugin package before every push.",
61
+ "",
62
+ "echo \"Codex Plugin Doctor: running pre-push validation...\"",
63
+ "",
64
+ "npx --yes codex-plugin-doctor check . --profile ci --runtime",
65
+ "",
66
+ "if [ $? -ne 0 ]; then",
67
+ " echo \"\"",
68
+ " echo \"Plugin validation failed. Push blocked.\"",
69
+ " echo \"Run 'codex-plugin-doctor check . --runtime' to see full diagnostics.\"",
70
+ " exit 1",
71
+ "fi",
72
+ ""
73
+ ].join("\n");
74
+ const hookExtension = isWindows ? ".bat" : "";
37
75
  async function fileExists(filePath) {
38
76
  try {
39
77
  await stat(filePath);
@@ -57,7 +95,7 @@ async function writeHook(hookPath, content, force) {
57
95
  export async function removeGitHooks(targetPath) {
58
96
  const rootPath = path.resolve(targetPath);
59
97
  const hooksDir = path.join(rootPath, ".git", "hooks");
60
- const hookNames = ["pre-commit", "pre-push"];
98
+ const hookNames = [`pre-commit${hookExtension}`, `pre-push${hookExtension}`, "pre-commit", "pre-push"];
61
99
  const removed = [];
62
100
  const skipped = [];
63
101
  for (const name of hookNames) {
@@ -82,8 +120,8 @@ export async function initGitHooks(targetPath, options = {}) {
82
120
  await mkdir(hooksDir, { recursive: true });
83
121
  const hookPaths = [];
84
122
  const preExisting = [];
85
- const preCommitPath = path.join(hooksDir, "pre-commit");
86
- const prePushPath = path.join(hooksDir, "pre-push");
123
+ const preCommitPath = path.join(hooksDir, `pre-commit${hookExtension}`);
124
+ const prePushPath = path.join(hooksDir, `pre-push${hookExtension}`);
87
125
  const wasPreCommitExisting = await writeHook(preCommitPath, preCommitHook, force);
88
126
  hookPaths.push(preCommitPath);
89
127
  if (wasPreCommitExisting) {
@@ -9,9 +9,11 @@ const execFileAsync = promisify(execFile);
9
9
  const gunzipAsync = promisify(gunzip);
10
10
  function npmCommand(args) {
11
11
  if (process.platform === "win32") {
12
+ const npmCliPath = process.env.npm_execpath ??
13
+ path.join(path.dirname(process.execPath), "node_modules", "npm", "bin", "npm-cli.js");
12
14
  return {
13
- command: process.env.ComSpec ?? "cmd.exe",
14
- args: ["/d", "/s", "/c", "npm", ...args]
15
+ command: process.execPath,
16
+ args: [npmCliPath, ...args]
15
17
  };
16
18
  }
17
19
  return { command: "npm", args };
@@ -260,6 +260,15 @@ export const ruleCatalog = [
260
260
  fix: "Use `127.0.0.1` or `localhost` in server URLs and transport configuration unless external access is explicitly required.",
261
261
  example: '{ "url": "http://127.0.0.1:3000/mcp" }'
262
262
  },
263
+ {
264
+ id: "plugin.skill.external_http_reference",
265
+ category: "skill",
266
+ defaultSeverity: "warn",
267
+ summary: "A packaged skill text references an external HTTP/HTTPS URL.",
268
+ why: "External URLs in skill instructions can lead to link rot, phishing risk, or unauthorized telemetry when an agent follows them.",
269
+ fix: "Replace the external URL with a local reference, or document the link only in the plugin README where reviewers can see it.",
270
+ example: "Replace `https://example.com/docs` with a local `docs/setup.md` reference."
271
+ },
263
272
  {
264
273
  id: "plugin.security.prompt_injection_text",
265
274
  category: "security",
package/dist/run-cli.js CHANGED
@@ -78,7 +78,7 @@ const defaultIo = {
78
78
  }
79
79
  };
80
80
  function printUsage(io) {
81
- io.writeStderr("Usage: codex-plugin-doctor check <path|--installed> [filter] [--policy codex-publish|mcp-strict|security] [--compat] [--json|--markdown|--badge-json|--badge-markdown] [--output <path>] [--history <path>] [--runtime] [--require-runtime-approval --runtime-approval-digest <digest>] [--verbose-runtime] [--explain] [--no-animations] [--ascii]\n codex-plugin-doctor audit --installed [filter] [--policy codex-publish|mcp-strict|security] [--security] [--compat] [--json] [--output <path>] [--cache] [--changed]\n codex-plugin-doctor audit deps <path> [--policy codex-publish|mcp-strict|security] [--recommend] [--json|--sarif] [--output <path>]\n codex-plugin-doctor mcp <path> [--json] [--output <path>]\n codex-plugin-doctor security <path> [--policy security] [--json|--scorecard]\n codex-plugin-doctor compat <path> [--all|--client <client>] [--json] [--scorecard] [--output <path>] [--install-preview|--apply --backup]\n codex-plugin-doctor suppress add <path> [--fingerprint <sha256> --reason <text> --expires-at YYYY-MM-DD] [--config <path>] [--json]\n codex-plugin-doctor suppress list <path> [--config <path>] [--json]\n codex-plugin-doctor suppress remove <path> [--fingerprint <sha256>|--index <n>] [--config <path>] [--json]\n codex-plugin-doctor fix <path> (--dry-run|--interactive --backup|--apply --backup)\n codex-plugin-doctor history <history.jsonl> [--json] [--fail-on-regression]\n codex-plugin-doctor watch <path> [--runtime] [--json] [--output <path>] [--debounce-ms <ms>] [--max-iterations <n>] [--fail-fast] [--accumulate-json <path>]\n codex-plugin-doctor doctor [npm <package>|contract|corpus|runtime-plan <path> [--json|--markdown] [--output <path>]|runtime-policy <path> [--json] [--output <path>]|review-bundle <path> --output <dir> --sign-key-env NAME [--json] [--allow-dirty] [--allow-untagged]|review-bundle verify <bundle-dir> --target <path> --sign-key-env NAME [--json] [--output <path>] [--failures-only]|review-bundle diff --before <dir> --after <dir> [--json]|attest <path> [--sign-key-env NAME]|attest verify <attestation.json> --target <path> --sign-key-env NAME|release-evidence <path> --sign-key-env NAME [--allow-dirty] [--allow-untagged] [--require-runtime-approval --runtime-approval-digest <digest>]|release-evidence verify <evidence.json> --target <path> --sign-key-env NAME|release-evidence asset <path> --tag <tag> --output <evidence.json> --sign-key-env NAME [--upload]|mcp <path>|inspector <path>|diff --before <path> --after <path>|recommend <path>|trust <path>|perf <path> [--max-total-ms <ms>] [--max-stage-ms stage=ms]|export --bundle <path>|snapshot|clients|--json|--update-check]\n codex-plugin-doctor init [path] [--template skill-only|mcp-stdio|mcp-http|full-runtime]\n codex-plugin-doctor init-ci [path]\n codex-plugin-doctor init-git-hooks [path] [--force] [--json]\n codex-plugin-doctor init-git-hooks [path] --remove [--json]\n codex-plugin-doctor self-test\n codex-plugin-doctor list --installed\n codex-plugin-doctor explain <finding-id>\n codex-plugin-doctor --version\n\nFirst run:\n codex-plugin-doctor doctor\n codex-plugin-doctor self-test\n codex-plugin-doctor init my-plugin\n codex-plugin-doctor check . --runtime --explain");
81
+ io.writeStderr("Usage: codex-plugin-doctor check <path|--installed> [filter] [--policy codex-publish|mcp-strict|security] [--compat] [--json|--markdown|--badge-json|--badge-markdown] [--output <path>] [--history <path>] [--runtime] [--require-runtime-approval --runtime-approval-digest <digest>] [--verbose-runtime] [--explain] [--no-animations] [--ascii] [--changed-since <ref>]\n codex-plugin-doctor audit --installed [filter] [--policy codex-publish|mcp-strict|security] [--security] [--compat] [--json] [--output <path>] [--cache] [--changed]\n codex-plugin-doctor audit deps <path> [--policy codex-publish|mcp-strict|security] [--recommend] [--json|--sarif] [--output <path>]\n codex-plugin-doctor mcp <path> [--json] [--output <path>]\n codex-plugin-doctor security <path> [--policy security] [--json|--scorecard]\n codex-plugin-doctor compat <path> [--all|--client <client>] [--json] [--scorecard] [--output <path>] [--install-preview|--apply --backup]\n codex-plugin-doctor suppress add <path> [--fingerprint <sha256> --reason <text> --expires-at YYYY-MM-DD] [--config <path>] [--json]\n codex-plugin-doctor suppress list <path> [--config <path>] [--json]\n codex-plugin-doctor suppress remove <path> [--fingerprint <sha256>|--index <n>] [--config <path>] [--json]\n codex-plugin-doctor fix <path> (--dry-run|--interactive --backup|--apply --backup)\n codex-plugin-doctor history <history.jsonl> [--json] [--fail-on-regression]\n codex-plugin-doctor watch <path> [--runtime] [--json] [--output <path>] [--debounce-ms <ms>] [--max-iterations <n>] [--fail-fast] [--accumulate-json <path>]\n codex-plugin-doctor doctor [npm <package>|contract|corpus|runtime-plan <path> [--json|--markdown] [--output <path>]|runtime-policy <path> [--json] [--output <path>]|review-bundle <path> --output <dir> --sign-key-env NAME [--json] [--allow-dirty] [--allow-untagged]|review-bundle verify <bundle-dir> --target <path> --sign-key-env NAME [--json] [--output <path>] [--failures-only]|review-bundle diff --before <dir> --after <dir> [--json]|attest <path> [--sign-key-env NAME]|attest verify <attestation.json> --target <path> --sign-key-env NAME|release-evidence <path> --sign-key-env NAME [--allow-dirty] [--allow-untagged] [--require-runtime-approval --runtime-approval-digest <digest>]|release-evidence verify <evidence.json> --target <path> --sign-key-env NAME|release-evidence asset <path> --tag <tag> --output <evidence.json> --sign-key-env NAME [--upload]|mcp <path>|inspector <path>|diff --before <path> --after <path>|recommend <path>|trust <path>|perf <path> [--max-total-ms <ms>] [--max-stage-ms stage=ms]|export --bundle <path>|snapshot|clients|--json|--update-check]\n codex-plugin-doctor init [path] [--template skill-only|mcp-stdio|mcp-http|full-runtime]\n codex-plugin-doctor init-ci [path]\n codex-plugin-doctor init-git-hooks [path] [--force] [--json]\n codex-plugin-doctor init-git-hooks [path] --remove [--json]\n codex-plugin-doctor self-test\n codex-plugin-doctor list --installed\n codex-plugin-doctor explain <finding-id>\n codex-plugin-doctor --version\n\nFirst run:\n codex-plugin-doctor doctor\n codex-plugin-doctor self-test\n codex-plugin-doctor init my-plugin\n codex-plugin-doctor check . --runtime --explain");
82
82
  io.writeStderr("Suppression governance: codex-plugin-doctor suppress list <path> [--fail-on-expired] [--fail-on-invalid] [--warn-expiring-within-days <days>]\n codex-plugin-doctor suppress prune <path> [--apply] [--json]");
83
83
  }
84
84
  const suppressUsageText = [
@@ -2254,6 +2254,8 @@ export async function runCli(args, io = defaultIo, options = {}) {
2254
2254
  const runtimeApprovalDigest = runtimeApprovalDigestIndex === -1
2255
2255
  ? null
2256
2256
  : normalizedFlags[runtimeApprovalDigestIndex + 1];
2257
+ const changedSinceIndex = normalizedFlags.indexOf("--changed-since");
2258
+ const changedSinceRef = changedSinceIndex === -1 ? null : normalizedFlags[changedSinceIndex + 1];
2257
2259
  if (outputIndex !== -1 && (!outputPath || outputPath.startsWith("--"))) {
2258
2260
  io.writeStderr("Missing path after --output.");
2259
2261
  return 2;
@@ -2282,6 +2284,14 @@ export async function runCli(args, io = defaultIo, options = {}) {
2282
2284
  io.writeStderr("Missing path after --history.");
2283
2285
  return 2;
2284
2286
  }
2287
+ if (changedSinceIndex !== -1 && (!changedSinceRef || changedSinceRef.startsWith("--"))) {
2288
+ io.writeStderr("Missing ref after --changed-since.");
2289
+ return 2;
2290
+ }
2291
+ if (changedSinceIndex !== -1 && checkInstalled) {
2292
+ io.writeStderr("--changed-since requires a single package target.");
2293
+ return 2;
2294
+ }
2285
2295
  if (runtimeApprovalDigestIndex !== -1 &&
2286
2296
  (!runtimeApprovalDigest || runtimeApprovalDigest.startsWith("--"))) {
2287
2297
  io.writeStderr("Missing digest after --runtime-approval-digest.");
@@ -2384,6 +2394,59 @@ export async function runCli(args, io = defaultIo, options = {}) {
2384
2394
  ? createLiveStatusRenderer(io, getSpinner(outputPolicy.style === "ascii" ? "ascii" : "doctor"))
2385
2395
  : null;
2386
2396
  renderer?.start("Validating package");
2397
+ if (changedSinceRef) {
2398
+ const { execFile } = await import("node:child_process");
2399
+ try {
2400
+ const gitOutput = await new Promise((resolve, reject) => {
2401
+ execFile("git", ["diff", "--name-only", `${changedSinceRef}...HEAD`], { cwd: path.resolve(targetPath) }, (error, stdout, stderr) => {
2402
+ if (error) {
2403
+ reject(new Error(stderr.trim() || error.message));
2404
+ return;
2405
+ }
2406
+ resolve(stdout);
2407
+ });
2408
+ });
2409
+ const changedFiles = gitOutput.split("\n").filter(Boolean);
2410
+ if (changedFiles.length === 0) {
2411
+ io.writeStdout("No changed files since the given ref.");
2412
+ return 0;
2413
+ }
2414
+ const changedDirs = new Set();
2415
+ for (const file of changedFiles) {
2416
+ const dir = path.dirname(file);
2417
+ changedDirs.add(dir);
2418
+ }
2419
+ let allPassed = true;
2420
+ let validatedCount = 0;
2421
+ for (const dir of changedDirs) {
2422
+ const pluginRoot = path.resolve(targetPath, dir);
2423
+ const manifestPath = path.join(pluginRoot, ".codex-plugin", "plugin.json");
2424
+ try {
2425
+ await (await import("node:fs/promises")).stat(manifestPath);
2426
+ }
2427
+ catch {
2428
+ continue;
2429
+ }
2430
+ const pluginResult = applyDoctorConfig(await runCheckImpl(pluginRoot, {
2431
+ runtime: effectiveRuntimeProbeEnabled
2432
+ }), applyPolicyToDoctorConfig(applyCheckProfile(await loadDoctorConfig(pluginRoot, configPath), checkProfile), policy));
2433
+ io.writeStdout(`\n${pluginResult.targetPath}: ${pluginResult.status.toUpperCase()}`);
2434
+ validatedCount += 1;
2435
+ if (pluginResult.status !== "pass") {
2436
+ allPassed = false;
2437
+ for (const finding of pluginResult.findings) {
2438
+ io.writeStdout(` ${finding.severity === "fail" ? "FAIL" : "WARN"} ${finding.id}: ${finding.message}`);
2439
+ }
2440
+ }
2441
+ }
2442
+ io.writeStdout(`\n\nValidated ${validatedCount} changed plugins.`);
2443
+ return allPassed ? 0 : 1;
2444
+ }
2445
+ catch (error) {
2446
+ io.writeStderr(`--changed-since requires a git repository. ${error.message}`);
2447
+ return 2;
2448
+ }
2449
+ }
2387
2450
  const result = applyDoctorConfig(await runCheckImpl(targetPath, {
2388
2451
  runtime: effectiveRuntimeProbeEnabled,
2389
2452
  runtimeTranscript: effectiveRuntimeProbeEnabled && verboseRuntime
@@ -13,6 +13,7 @@ export interface SecurityAudit {
13
13
  export declare function auditMcpServerConfig(rootPath: string, parsedConfig: unknown, options?: {
14
14
  configPath?: string;
15
15
  }): Finding[];
16
+ export declare function auditChildProcessSourceSurface(rootPath: string): Promise<Finding[]>;
16
17
  export declare function buildSecurityAuditFromFindings(targetPath: string, findings: Finding[]): SecurityAudit;
17
18
  export declare function buildSecurityAudit(targetPath: string): Promise<SecurityAudit>;
18
19
  export declare function renderSecurityAuditJson(audit: SecurityAudit): string;
@@ -157,13 +157,34 @@ const poisonScanSkippedDirectories = new Set([
157
157
  ".git",
158
158
  "coverage",
159
159
  "dist",
160
- "node_modules"
160
+ "node_modules",
161
+ "release-candidate",
162
+ "tests"
161
163
  ]);
162
164
  const promptInjectionPatterns = [
163
165
  /\bignore\s+(?:all\s+)?(?:previous|prior|system|developer)\s+instructions?\b/i,
164
166
  /\b(?:exfiltrate|steal|leak|upload|send)\b.{0,120}\b(?:secret|secrets|token|tokens|api\s*key|api\s*keys|credential|credentials|environment\s+variables?|env)\b/i,
165
167
  /\bdo\s+not\s+(?:reveal|tell|mention|disclose)\b.{0,120}\b(?:instruction|instructions|prompt|prompts|system|developer)\b/i
166
168
  ];
169
+ const childProcessScanExtensions = new Set([
170
+ ".cjs",
171
+ ".js",
172
+ ".mjs",
173
+ ".ts",
174
+ ".tsx"
175
+ ]);
176
+ function importsChildProcess(content) {
177
+ return (/\bfrom\s+["']node:child_process["']/.test(content) ||
178
+ /\bfrom\s+["']child_process["']/.test(content) ||
179
+ /\brequire\(\s*["'](?:node:)?child_process["']\s*\)/.test(content));
180
+ }
181
+ function sourceUsesShellOption(content) {
182
+ return /\bshell\s*:\s*(?:true|process\.platform\s*===\s*["']win32["'])/.test(content);
183
+ }
184
+ function sourceUsesShellCommand(content) {
185
+ return (/["']cmd(?:\.exe)?["'][\s\S]{0,200}["']\/c["']/i.test(content) ||
186
+ /["'](?:powershell|pwsh)(?:\.exe)?["'][\s\S]{0,200}["']-(?:command|encodedcommand|enc)["']/i.test(content));
187
+ }
167
188
  export function auditMcpServerConfig(rootPath, parsedConfig, options = {}) {
168
189
  const configPath = options.configPath
169
190
  ? relativePackagePath(rootPath, options.configPath)
@@ -233,6 +254,23 @@ export function auditMcpServerConfig(rootPath, parsedConfig, options = {}) {
233
254
  }
234
255
  return findings;
235
256
  }
257
+ const externalUrlPattern = /https?:\/\/[^\s`"'<>)]+/gi;
258
+ async function auditSkillExternalReferences(rootPath) {
259
+ const findings = [];
260
+ for (const filePath of await collectPromptPoisoningScanFiles(rootPath)) {
261
+ const content = await readFile(filePath, "utf8");
262
+ const matches = content.match(externalUrlPattern);
263
+ if (!matches) {
264
+ continue;
265
+ }
266
+ const uniqueUrls = [...new Set(matches)];
267
+ const relativeFilePath = path.relative(rootPath, filePath).replace(/\\/g, "/");
268
+ for (const url of uniqueUrls.slice(0, 5)) {
269
+ findings.push(buildFinding("warn", "plugin.skill.external_http_reference", `The packaged text file \`${relativeFilePath}\` references external URL \`${url}\`.`, "External URLs in skill instructions can lead to link rot, phishing risk, or unauthorized telemetry when an agent follows them.", "Replace the external URL with a local reference, or document the link only in the plugin README where reviewers can see it.", { filePath: relativeFilePath, url }));
270
+ }
271
+ }
272
+ return findings;
273
+ }
236
274
  async function collectPromptPoisoningScanFiles(rootPath, currentPath = rootPath) {
237
275
  const entries = await readdir(currentPath, { withFileTypes: true });
238
276
  const filePaths = [];
@@ -270,6 +308,45 @@ async function auditPromptPoisoningSurface(rootPath) {
270
308
  }
271
309
  return findings;
272
310
  }
311
+ async function collectChildProcessScanFiles(rootPath, currentPath = rootPath) {
312
+ const entries = await readdir(currentPath, { withFileTypes: true });
313
+ const filePaths = [];
314
+ for (const entry of entries) {
315
+ const entryPath = path.join(currentPath, entry.name);
316
+ if (entry.isDirectory()) {
317
+ if (poisonScanSkippedDirectories.has(entry.name)) {
318
+ continue;
319
+ }
320
+ filePaths.push(...(await collectChildProcessScanFiles(rootPath, entryPath)));
321
+ continue;
322
+ }
323
+ if (!entry.isFile() || !childProcessScanExtensions.has(path.extname(entry.name).toLowerCase())) {
324
+ continue;
325
+ }
326
+ const details = await stat(entryPath);
327
+ if (details.size <= 512 * 1024) {
328
+ filePaths.push(entryPath);
329
+ }
330
+ }
331
+ return filePaths;
332
+ }
333
+ export async function auditChildProcessSourceSurface(rootPath) {
334
+ const findings = [];
335
+ for (const filePath of await collectChildProcessScanFiles(rootPath)) {
336
+ const content = await readFile(filePath, "utf8");
337
+ if (!importsChildProcess(content)) {
338
+ continue;
339
+ }
340
+ const relativeFilePath = relativePackagePath(rootPath, filePath);
341
+ if (sourceUsesShellOption(content)) {
342
+ findings.push(buildFinding("fail", "plugin.security.child_process_shell", `The source file \`${relativeFilePath}\` enables child_process shell execution.`, "Shell execution expands command strings through a platform shell, which can turn quoting bugs or user-controlled args into command injection.", "Use `spawn` or `execFile` with an explicit executable and argument array instead of enabling `shell`.", { filePath: relativeFilePath }));
343
+ }
344
+ if (sourceUsesShellCommand(content)) {
345
+ findings.push(buildFinding("fail", "plugin.security.child_process_shell_command", `The source file \`${relativeFilePath}\` appears to launch a shell command wrapper.`, "Explicit shell wrappers such as `cmd /c` or `powershell -Command` make command execution harder to audit and increase injection risk.", "Launch the concrete executable directly, or use platform-specific executable names such as `npm.cmd` on Windows.", { filePath: relativeFilePath }));
346
+ }
347
+ }
348
+ return findings;
349
+ }
273
350
  async function auditMcpCommandSurface(discoveredPackage) {
274
351
  const { manifest, rootPath } = discoveredPackage;
275
352
  if (!manifest.mcpServers) {
@@ -343,7 +420,9 @@ export async function buildSecurityAudit(targetPath) {
343
420
  const findings = [
344
421
  ...validationSecurityFindings,
345
422
  ...(await auditMcpCommandSurface(discoveredPackage)),
346
- ...(await auditPromptPoisoningSurface(discoveredPackage.rootPath))
423
+ ...(await auditChildProcessSourceSurface(discoveredPackage.rootPath)),
424
+ ...(await auditPromptPoisoningSurface(discoveredPackage.rootPath)),
425
+ ...(await auditSkillExternalReferences(discoveredPackage.rootPath))
347
426
  ];
348
427
  return buildSecurityAuditFromFindings(discoveredPackage.rootPath, findings);
349
428
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codex-plugin-doctor",
3
- "version": "1.37.0",
3
+ "version": "1.39.0",
4
4
  "description": "CLI-first validator for Codex plugins, skills, and MCP package surfaces with runtime MCP protocol validation.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",