codex-plugin-doctor 1.56.0 → 1.58.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.
Files changed (42) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +3 -2
  3. package/dist/compatibility/claude-desktop-install-preview.js +4 -4
  4. package/dist/compatibility/cline-install-preview.js +4 -4
  5. package/dist/compatibility/compatibility-matrix.js +13 -17
  6. package/dist/compatibility/cursor-install-preview.js +4 -4
  7. package/dist/compatibility/windsurf-install-preview.js +4 -4
  8. package/dist/core/doctor-size.d.ts +5 -1
  9. package/dist/core/doctor-size.js +77 -3
  10. package/dist/core/init-plugin.js +2 -2
  11. package/dist/core/inspector-bridge.js +8 -13
  12. package/dist/core/mcp-config-normalizer.d.ts +18 -0
  13. package/dist/core/mcp-config-normalizer.js +50 -0
  14. package/dist/core/package-path.d.ts +1 -0
  15. package/dist/core/package-path.js +25 -0
  16. package/dist/core/plugin-components.d.ts +7 -0
  17. package/dist/core/plugin-components.js +169 -0
  18. package/dist/core/plugin-hooks.d.ts +2 -0
  19. package/dist/core/plugin-hooks.js +190 -0
  20. package/dist/core/runtime-plan.js +97 -16
  21. package/dist/core/runtime-probe.js +3 -8
  22. package/dist/core/validate-plugin.js +39 -17
  23. package/dist/domain/types.d.ts +34 -0
  24. package/dist/domain/types.js +13 -1
  25. package/dist/mcp/generic-mcp-doctor.js +45 -29
  26. package/dist/rules/rule-catalog.js +130 -4
  27. package/dist/run-cli.js +32 -2
  28. package/dist/security/security-audit.d.ts +1 -0
  29. package/dist/security/security-audit.js +104 -7
  30. package/examples/README.md +94 -94
  31. package/examples/codex-doctor-generic-mcp/.mcp.json +10 -10
  32. package/examples/codex-doctor-generic-mcp/server.js +1 -1
  33. package/examples/codex-doctor-risky/.codex-plugin/plugin.json +7 -7
  34. package/examples/codex-doctor-risky/.mcp.json +12 -12
  35. package/examples/codex-doctor-risky/mock-server.js +1 -1
  36. package/examples/codex-doctor-runtime/.codex-plugin/plugin.json +8 -8
  37. package/examples/codex-doctor-runtime/.mcp.json +9 -9
  38. package/examples/codex-doctor-runtime/mock-server.js +237 -237
  39. package/examples/codex-doctor-runtime/skills/context-check/SKILL.md +7 -7
  40. package/examples/codex-doctor-starter/.codex-plugin/plugin.json +7 -7
  41. package/examples/codex-doctor-starter/skills/repo-scan/SKILL.md +7 -7
  42. package/package.json +1 -1
@@ -0,0 +1,169 @@
1
+ import { readFile, realpath, stat } from "node:fs/promises";
2
+ import path from "node:path";
3
+ const interfaceStringFields = [
4
+ "displayName",
5
+ "shortDescription",
6
+ "longDescription",
7
+ "developerName",
8
+ "category",
9
+ "websiteURL",
10
+ "privacyPolicyURL",
11
+ "termsOfServiceURL",
12
+ "brandColor"
13
+ ];
14
+ const interfaceUrlFields = new Set(["websiteURL", "privacyPolicyURL", "termsOfServiceURL"]);
15
+ const interfacePathFields = ["composerIcon", "logo"];
16
+ const interfaceStringArrayFields = ["capabilities", "defaultPrompt"];
17
+ function isPlainObject(value) {
18
+ return typeof value === "object" && value !== null && !Array.isArray(value);
19
+ }
20
+ function isHttpUrl(value) {
21
+ try {
22
+ const url = new URL(value);
23
+ return url.protocol === "http:" || url.protocol === "https:";
24
+ }
25
+ catch {
26
+ return false;
27
+ }
28
+ }
29
+ function relativePackagePath(rootPath, targetPath) {
30
+ return path.relative(rootPath, targetPath).split(path.sep).join("/");
31
+ }
32
+ function isWithinRoot(rootPath, candidatePath) {
33
+ const relativePath = path.relative(rootPath, candidatePath);
34
+ return relativePath === "" || (!relativePath.startsWith(`..${path.sep}`) && relativePath !== ".." && !path.isAbsolute(relativePath));
35
+ }
36
+ export async function resolveSafePackagePath(rootPath, value) {
37
+ if (!value.startsWith("./")) {
38
+ return null;
39
+ }
40
+ const resolvedPath = path.resolve(rootPath, value);
41
+ if (!isWithinRoot(rootPath, resolvedPath)) {
42
+ return null;
43
+ }
44
+ try {
45
+ const canonicalRoot = await realpath(rootPath);
46
+ const canonicalPath = await realpath(resolvedPath);
47
+ if (!isWithinRoot(canonicalRoot, canonicalPath)) {
48
+ return null;
49
+ }
50
+ }
51
+ catch {
52
+ // The path may be intentionally absent; callers that require it report that separately.
53
+ }
54
+ return { path: resolvedPath, packagePath: relativePackagePath(rootPath, resolvedPath) };
55
+ }
56
+ function invalidField(field, manifestPath) {
57
+ return failure("plugin.manifest.invalid_field", `The plugin manifest field \`${field}\` has an invalid value.`, "Malformed optional metadata cannot be interpreted reliably by Codex clients.", `Use the official type for \`${field}\` in .codex-plugin/plugin.json.`, { manifestPath, field });
58
+ }
59
+ function invalidPath(id, field, manifestPath) {
60
+ return failure(id, `The plugin manifest field \`${field}\` must reference a safe package-relative path.`, "Paths outside the plugin package can expose files that are not part of the plugin bundle.", `Use a \`./\` path that remains inside the plugin package for \`${field}\`.`, { manifestPath, field });
61
+ }
62
+ function failure(id, message, impact, suggestedFix, evidence) {
63
+ return { id, severity: "fail", message, impact, suggestedFix, evidence };
64
+ }
65
+ async function safePath(rootPath, manifestPath, field, value, id) {
66
+ if (typeof value !== "string") {
67
+ return invalidField(field, manifestPath);
68
+ }
69
+ return (await resolveSafePackagePath(rootPath, value)) ?? invalidPath(id, field, manifestPath);
70
+ }
71
+ function validateString(value, field, manifestPath, findings) {
72
+ if (value !== undefined && typeof value !== "string") {
73
+ findings.push(invalidField(field, manifestPath));
74
+ }
75
+ }
76
+ function validateHttpUrl(value, field, manifestPath, findings) {
77
+ if (value === undefined)
78
+ return;
79
+ if (typeof value !== "string" || !isHttpUrl(value)) {
80
+ findings.push(invalidField(field, manifestPath));
81
+ }
82
+ }
83
+ function validateStringArray(value, field, manifestPath, findings) {
84
+ if (value !== undefined && (!Array.isArray(value) || value.some((item) => typeof item !== "string"))) {
85
+ findings.push(invalidField(field, manifestPath));
86
+ }
87
+ }
88
+ function validateMetadata(manifest, manifestPath) {
89
+ const findings = [];
90
+ if (manifest.author !== undefined) {
91
+ if (!isPlainObject(manifest.author)) {
92
+ findings.push(invalidField("author", manifestPath));
93
+ }
94
+ else {
95
+ validateString(manifest.author.name, "author.name", manifestPath, findings);
96
+ validateString(manifest.author.email, "author.email", manifestPath, findings);
97
+ validateHttpUrl(manifest.author.url, "author.url", manifestPath, findings);
98
+ }
99
+ }
100
+ validateHttpUrl(manifest.homepage, "homepage", manifestPath, findings);
101
+ validateHttpUrl(manifest.repository, "repository", manifestPath, findings);
102
+ validateString(manifest.license, "license", manifestPath, findings);
103
+ validateStringArray(manifest.keywords, "keywords", manifestPath, findings);
104
+ if (manifest.interface === undefined)
105
+ return findings;
106
+ if (!isPlainObject(manifest.interface)) {
107
+ findings.push(invalidField("interface", manifestPath));
108
+ return findings;
109
+ }
110
+ for (const field of interfaceStringFields) {
111
+ const value = manifest.interface[field];
112
+ if (interfaceUrlFields.has(field)) {
113
+ validateHttpUrl(value, `interface.${field}`, manifestPath, findings);
114
+ }
115
+ else {
116
+ validateString(value, `interface.${field}`, manifestPath, findings);
117
+ }
118
+ }
119
+ for (const field of interfaceStringArrayFields) {
120
+ validateStringArray(manifest.interface[field], `interface.${field}`, manifestPath, findings);
121
+ }
122
+ return findings;
123
+ }
124
+ export async function validatePluginComponents(discoveredPackage) {
125
+ const { manifest, rootPath } = discoveredPackage;
126
+ const manifestPath = relativePackagePath(rootPath, discoveredPackage.manifestPath);
127
+ const findings = validateMetadata(manifest, manifestPath);
128
+ const componentPaths = [
129
+ ["skills", manifest.skills],
130
+ ["mcpServers", manifest.mcpServers]
131
+ ];
132
+ if (isPlainObject(manifest.interface)) {
133
+ for (const field of interfacePathFields)
134
+ componentPaths.push([`interface.${field}`, manifest.interface[field]]);
135
+ const screenshots = manifest.interface.screenshots;
136
+ if (screenshots !== undefined && (!Array.isArray(screenshots) || screenshots.some((item) => typeof item !== "string"))) {
137
+ findings.push(invalidField("interface.screenshots", manifestPath));
138
+ }
139
+ else if (Array.isArray(screenshots)) {
140
+ screenshots.forEach((screenshot, index) => componentPaths.push([`interface.screenshots[${index}]`, screenshot]));
141
+ }
142
+ }
143
+ for (const [field, value] of componentPaths) {
144
+ if (value === undefined)
145
+ continue;
146
+ const result = await safePath(rootPath, manifestPath, field, value, "plugin.manifest.invalid_path");
147
+ if ("id" in result)
148
+ findings.push(result);
149
+ }
150
+ if (manifest.apps === undefined)
151
+ return findings;
152
+ const appPath = await safePath(rootPath, manifestPath, "apps", manifest.apps, "plugin.app.invalid_path");
153
+ if ("id" in appPath)
154
+ return [...findings, appPath];
155
+ try {
156
+ if (!(await stat(appPath.path)).isFile())
157
+ throw new Error("not a regular file");
158
+ }
159
+ catch {
160
+ return [...findings, failure("plugin.app.missing_file", "The plugin manifest points to a missing .app.json file.", "Codex cannot load an app manifest that is absent or not a regular file.", "Create the referenced app manifest as a regular JSON file inside the plugin package.", { field: "apps", path: appPath.packagePath })];
161
+ }
162
+ try {
163
+ JSON.parse(await readFile(appPath.path, "utf8"));
164
+ }
165
+ catch {
166
+ findings.push(failure("plugin.app.invalid_json", "The referenced .app.json file is not valid JSON.", "Codex cannot parse the app manifest.", "Fix the JSON syntax in the referenced app manifest.", { field: "apps", path: appPath.packagePath }));
167
+ }
168
+ return findings;
169
+ }
@@ -0,0 +1,2 @@
1
+ import { type DiscoveredPackage, type Finding } from "../domain/types.js";
2
+ export declare function validatePluginHooks(discoveredPackage: DiscoveredPackage): Promise<Finding[]>;
@@ -0,0 +1,190 @@
1
+ import { readFile, stat } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { pluginHookEvents } from "../domain/types.js";
4
+ import { auditHookCommand } from "../security/security-audit.js";
5
+ import { resolveSafePackagePath } from "./plugin-components.js";
6
+ const ignoredMatcherEvents = new Set(["Stop", "UserPromptSubmit"]);
7
+ function isPlainObject(value) {
8
+ return typeof value === "object" && value !== null && !Array.isArray(value);
9
+ }
10
+ function packagePath(rootPath, targetPath) {
11
+ return path.relative(rootPath, targetPath).replace(/\\/g, "/") || ".";
12
+ }
13
+ function finding(severity, id, message, impact, suggestedFix, evidence) {
14
+ return { severity, id, message, impact, suggestedFix, evidence };
15
+ }
16
+ function invalidShape(sourcePath, field) {
17
+ return finding("fail", "plugin.hook.invalid_shape", "The plugin lifecycle hook configuration has an invalid shape.", "Codex cannot safely interpret malformed lifecycle hook metadata.", "Use the official hook configuration schema for this field.", { sourcePath, field });
18
+ }
19
+ function unsupportedEvent(sourcePath, event) {
20
+ return finding("fail", "plugin.hook.unsupported_event", `The plugin lifecycle hook event \`${event}\` is not supported.`, "Codex will not invoke hook events outside the official lifecycle event set.", "Use one of the official plugin lifecycle hook events.", { sourcePath, event });
21
+ }
22
+ function invalidHookPath(manifestPath) {
23
+ return finding("fail", "plugin.hook.invalid_path", "The plugin lifecycle hook source must be a safe package-relative path.", "Paths outside the plugin package can expose unreviewed files to hook configuration loading.", "Use a `./` hook configuration path that stays within the plugin package.", { sourcePath: manifestPath, field: "hooks" });
24
+ }
25
+ function warning(id, sourcePath, event, field) {
26
+ const messages = {
27
+ "plugin.hook.unsupported_handler": [
28
+ "A plugin lifecycle hook uses a handler type the host skips.",
29
+ "Prompt and agent lifecycle handlers are not executed by this host.",
30
+ "Use a command handler for behavior that must run in this host."
31
+ ],
32
+ "plugin.hook.async_unsupported": [
33
+ "A plugin lifecycle hook requests unsupported asynchronous execution.",
34
+ "The host does not support asynchronous lifecycle hook execution.",
35
+ "Remove `async: true` or run the command synchronously."
36
+ ],
37
+ "plugin.hook.matcher_ignored": [
38
+ "A plugin lifecycle hook matcher is ignored for this event.",
39
+ "This lifecycle event does not use matcher filtering.",
40
+ "Remove the matcher from this event group."
41
+ ]
42
+ };
43
+ const [message, impact, suggestedFix] = messages[id];
44
+ return finding("warn", id, message, impact, suggestedFix, { sourcePath, event, field });
45
+ }
46
+ function validateHandler(rootPath, value, sourcePath, event, groupIndex, handlerIndex) {
47
+ const field = `hooks.${event}[${groupIndex}].hooks[${handlerIndex}]`;
48
+ if (!isPlainObject(value) || typeof value.type !== "string") {
49
+ return [invalidShape(sourcePath, field)];
50
+ }
51
+ const findings = [];
52
+ if (value.type !== "command" && value.type !== "prompt" && value.type !== "agent") {
53
+ return [invalidShape(sourcePath, `${field}.type`)];
54
+ }
55
+ if (value.async !== undefined && typeof value.async !== "boolean") {
56
+ findings.push(invalidShape(sourcePath, `${field}.async`));
57
+ }
58
+ else if (value.async === true) {
59
+ findings.push(warning("plugin.hook.async_unsupported", sourcePath, event, `${field}.async`));
60
+ }
61
+ if (value.type === "prompt" || value.type === "agent") {
62
+ findings.push(warning("plugin.hook.unsupported_handler", sourcePath, event, `${field}.type`));
63
+ return findings;
64
+ }
65
+ if (typeof value.command !== "string") {
66
+ findings.push(invalidShape(sourcePath, `${field}.command`));
67
+ }
68
+ else {
69
+ findings.push(...auditHookCommand(rootPath, sourcePath, event, `${field}.command`, value.command));
70
+ }
71
+ if (value.commandWindows !== undefined) {
72
+ if (typeof value.commandWindows !== "string") {
73
+ findings.push(invalidShape(sourcePath, `${field}.commandWindows`));
74
+ }
75
+ else {
76
+ findings.push(...auditHookCommand(rootPath, sourcePath, event, `${field}.commandWindows`, value.commandWindows));
77
+ }
78
+ }
79
+ if (value.timeout !== undefined && (typeof value.timeout !== "number" || !Number.isFinite(value.timeout) || value.timeout <= 0)) {
80
+ findings.push(invalidShape(sourcePath, `${field}.timeout`));
81
+ }
82
+ if (event === "SessionEnd" && typeof value.timeout === "number" && value.timeout > 3) {
83
+ findings.push(invalidShape(sourcePath, `${field}.timeout`));
84
+ }
85
+ if (value.statusMessage !== undefined && typeof value.statusMessage !== "string") {
86
+ findings.push(invalidShape(sourcePath, `${field}.statusMessage`));
87
+ }
88
+ if (value.additionalContextLimit !== undefined && (typeof value.additionalContextLimit !== "number" ||
89
+ !Number.isFinite(value.additionalContextLimit) ||
90
+ value.additionalContextLimit < 0)) {
91
+ findings.push(invalidShape(sourcePath, `${field}.additionalContextLimit`));
92
+ }
93
+ return findings;
94
+ }
95
+ function validateConfig(rootPath, config, sourcePath) {
96
+ if (!isPlainObject(config))
97
+ return [invalidShape(sourcePath, "root")];
98
+ const findings = [];
99
+ if (config.description !== undefined && typeof config.description !== "string") {
100
+ findings.push(invalidShape(sourcePath, "description"));
101
+ }
102
+ if (!isPlainObject(config.hooks)) {
103
+ findings.push(invalidShape(sourcePath, "hooks"));
104
+ return findings;
105
+ }
106
+ for (const [event, groups] of Object.entries(config.hooks)) {
107
+ if (!pluginHookEvents.includes(event)) {
108
+ findings.push(unsupportedEvent(sourcePath, event));
109
+ continue;
110
+ }
111
+ if (!Array.isArray(groups)) {
112
+ findings.push(invalidShape(sourcePath, `hooks.${event}`));
113
+ continue;
114
+ }
115
+ groups.forEach((group, groupIndex) => {
116
+ const groupField = `hooks.${event}[${groupIndex}]`;
117
+ if (!isPlainObject(group)) {
118
+ findings.push(invalidShape(sourcePath, groupField));
119
+ return;
120
+ }
121
+ if (group.matcher !== undefined && typeof group.matcher !== "string") {
122
+ findings.push(invalidShape(sourcePath, `${groupField}.matcher`));
123
+ }
124
+ else if (typeof group.matcher === "string" && ignoredMatcherEvents.has(event)) {
125
+ findings.push(warning("plugin.hook.matcher_ignored", sourcePath, event, `${groupField}.matcher`));
126
+ }
127
+ if (!Array.isArray(group.hooks)) {
128
+ findings.push(invalidShape(sourcePath, `${groupField}.hooks`));
129
+ return;
130
+ }
131
+ group.hooks.forEach((handler, handlerIndex) => {
132
+ findings.push(...validateHandler(rootPath, handler, sourcePath, event, groupIndex, handlerIndex));
133
+ });
134
+ });
135
+ }
136
+ return findings;
137
+ }
138
+ async function validateHookFile(rootPath, manifestPath, source) {
139
+ const resolved = await resolveSafePackagePath(rootPath, source);
140
+ if (!resolved) {
141
+ return [invalidHookPath(manifestPath)];
142
+ }
143
+ try {
144
+ if (!(await stat(resolved.path)).isFile())
145
+ throw new Error("not a regular file");
146
+ }
147
+ catch {
148
+ return [finding("fail", "plugin.hook.missing_file", "The plugin lifecycle hook source file is missing.", "Codex cannot load a hook configuration file that is absent or not a regular file.", "Create the referenced hook configuration JSON file inside the plugin package.", { sourcePath: resolved.packagePath })];
149
+ }
150
+ try {
151
+ return validateConfig(rootPath, JSON.parse(await readFile(resolved.path, "utf8")), resolved.packagePath);
152
+ }
153
+ catch {
154
+ return [finding("fail", "plugin.hook.invalid_json", "The plugin lifecycle hook source is not valid JSON.", "Codex cannot parse the hook configuration.", "Fix the JSON syntax in the hook configuration file.", { sourcePath: resolved.packagePath })];
155
+ }
156
+ }
157
+ export async function validatePluginHooks(discoveredPackage) {
158
+ const { manifest, rootPath } = discoveredPackage;
159
+ const manifestPath = packagePath(rootPath, discoveredPackage.manifestPath);
160
+ const configuredHooks = manifest.hooks;
161
+ if (configuredHooks === undefined) {
162
+ const defaultPath = "./hooks/hooks.json";
163
+ const resolved = await resolveSafePackagePath(rootPath, defaultPath);
164
+ if (!resolved)
165
+ return [invalidHookPath(manifestPath)];
166
+ try {
167
+ if (!(await stat(resolved.path)).isFile())
168
+ return [];
169
+ }
170
+ catch {
171
+ return [];
172
+ }
173
+ return validateHookFile(rootPath, manifestPath, defaultPath);
174
+ }
175
+ if (typeof configuredHooks === "string") {
176
+ return validateHookFile(rootPath, manifestPath, configuredHooks);
177
+ }
178
+ if (Array.isArray(configuredHooks)) {
179
+ if (configuredHooks.length === 0)
180
+ return [];
181
+ if (configuredHooks.every((value) => typeof value === "string")) {
182
+ return (await Promise.all(configuredHooks.map((source) => validateHookFile(rootPath, manifestPath, source)))).flat();
183
+ }
184
+ if (configuredHooks.every(isPlainObject)) {
185
+ return configuredHooks.flatMap((config) => validateConfig(rootPath, config, manifestPath));
186
+ }
187
+ return [invalidShape(manifestPath, "hooks")];
188
+ }
189
+ return validateConfig(rootPath, configuredHooks, manifestPath);
190
+ }
@@ -2,10 +2,12 @@ import { createHash } from "node:crypto";
2
2
  import path from "node:path";
3
3
  import { packageVersion } from "../version.js";
4
4
  import { discoverPackage } from "./discover-package.js";
5
+ import { normalizeMcpConfig } from "./mcp-config-normalizer.js";
5
6
  import { readJsonFile } from "./read-json-file.js";
6
7
  import { buildSecurityAudit } from "../security/security-audit.js";
7
8
  import { DOCKER_RUNTIME_IMAGE } from "./runtime-sandbox.js";
8
9
  import { inspectRemoteMcpUrl } from "./remote-url-policy.js";
10
+ import { resolveContainedPackagePath } from "./package-path.js";
9
11
  function isPlainObject(value) {
10
12
  return typeof value === "object" && value !== null && !Array.isArray(value);
11
13
  }
@@ -68,7 +70,7 @@ function remoteNetworkClass(rawUrl) {
68
70
  }
69
71
  return "public_https";
70
72
  }
71
- function planDigestPayload(plan) {
73
+ function planDigestPayload(plan, rawServerArgs = {}) {
72
74
  return {
73
75
  schemaVersion: plan.schemaVersion,
74
76
  kind: "doctor.runtime.plan.digest.v1",
@@ -76,7 +78,10 @@ function planDigestPayload(plan) {
76
78
  execution: plan.execution,
77
79
  status: plan.status,
78
80
  summary: plan.summary,
79
- servers: plan.servers,
81
+ servers: plan.servers.map((server) => ({
82
+ ...server,
83
+ args: rawServerArgs[server.name] ?? server.args
84
+ })),
80
85
  findings: plan.findings.map((finding) => ({
81
86
  id: finding.id,
82
87
  severity: finding.severity,
@@ -84,13 +89,59 @@ function planDigestPayload(plan) {
84
89
  }))
85
90
  };
86
91
  }
87
- function buildRuntimePlanDigest(plan) {
88
- return sha256(stableStringify(planDigestPayload(plan)));
92
+ function buildRuntimePlanDigest(plan, rawServerArgs = {}) {
93
+ return sha256(stableStringify(planDigestPayload(plan, rawServerArgs)));
94
+ }
95
+ const secretRuntimeArgumentFlag = /^--?(?:token|api[-_]?key|apikey|password|secret|credential|authorization|bearer)$/i;
96
+ const secretRuntimeArgumentInline = /^(--?(?:token|api[-_]?key|apikey|password|secret|credential|authorization|bearer))[=:].*$/i;
97
+ const authorizationBearerHeader = /^authorization\s*:\s*bearer\s+\S+/i;
98
+ const inlineAuthorizationBearerHeader = /^(?:--header|-H)=authorization\s*:\s*bearer\s+\S+/i;
99
+ function redactRuntimeArguments(args) {
100
+ return args.map((arg, index) => {
101
+ const inlineMatch = arg.match(secretRuntimeArgumentInline);
102
+ if (inlineMatch) {
103
+ return `${inlineMatch[1]}=[REDACTED]`;
104
+ }
105
+ if (authorizationBearerHeader.test(arg)) {
106
+ return "[REDACTED]";
107
+ }
108
+ if (inlineAuthorizationBearerHeader.test(arg)) {
109
+ return "[REDACTED]";
110
+ }
111
+ if (index > 0 && secretRuntimeArgumentFlag.test(args[index - 1])) {
112
+ return "[REDACTED]";
113
+ }
114
+ return arg;
115
+ });
116
+ }
117
+ function buildFailedRuntimePlan(targetPath, generatedAt, execution, finding) {
118
+ const partialPlan = {
119
+ schemaVersion: "1.0.0",
120
+ kind: "doctor.runtime.plan",
121
+ version: packageVersion,
122
+ targetPath,
123
+ status: "fail",
124
+ exitCode: 1,
125
+ runtimeExecution: "not_started",
126
+ execution,
127
+ summary: {
128
+ serverCount: 0,
129
+ executableServerCount: 0,
130
+ highRiskServerCount: 0,
131
+ findings: { fail: 1, warn: 0, total: 1 }
132
+ },
133
+ servers: [],
134
+ findings: [finding]
135
+ };
136
+ return {
137
+ ...partialPlan,
138
+ generatedAt,
139
+ digest: buildRuntimePlanDigest(partialPlan)
140
+ };
89
141
  }
90
142
  export async function buildDoctorRuntimePlan(targetPath, generatedAt = new Date().toISOString(), options = {}) {
91
143
  const rootPath = path.resolve(targetPath);
92
144
  const discoveredPackage = await discoverPackage(rootPath);
93
- const security = await buildSecurityAudit(rootPath);
94
145
  const execution = options.sandbox === "docker"
95
146
  ? {
96
147
  backend: "docker",
@@ -99,7 +150,8 @@ export async function buildDoctorRuntimePlan(targetPath, generatedAt = new Date(
99
150
  packageMount: "read_only"
100
151
  }
101
152
  : { backend: "native", image: null, network: "host", packageMount: "host" };
102
- if (!discoveredPackage?.manifest.mcpServers) {
153
+ if (!discoveredPackage || !Object.hasOwn(discoveredPackage.manifest, "mcpServers")) {
154
+ const security = await buildSecurityAudit(rootPath);
103
155
  const partialPlan = {
104
156
  schemaVersion: "1.0.0",
105
157
  kind: "doctor.runtime.plan",
@@ -124,16 +176,43 @@ export async function buildDoctorRuntimePlan(targetPath, generatedAt = new Date(
124
176
  digest: buildRuntimePlanDigest(partialPlan)
125
177
  };
126
178
  }
179
+ const manifestMcpServers = discoveredPackage.manifest.mcpServers;
180
+ if (typeof manifestMcpServers !== "string" ||
181
+ !manifestMcpServers ||
182
+ !manifestMcpServers.startsWith("./")) {
183
+ return buildFailedRuntimePlan(discoveredPackage.rootPath, generatedAt, execution, {
184
+ id: typeof manifestMcpServers === "string"
185
+ ? "plugin.manifest.invalid_path"
186
+ : "plugin.manifest.invalid_field",
187
+ severity: "fail",
188
+ message: "The plugin manifest mcpServers field must be a non-empty package-relative path.",
189
+ impact: "Runtime planning cannot safely resolve malformed MCP server configuration metadata.",
190
+ suggestedFix: "Use a non-empty ./ path for mcpServers that remains inside the package.",
191
+ evidence: { manifestPath: ".codex-plugin/plugin.json", field: "mcpServers" }
192
+ });
193
+ }
194
+ const mcpConfigPath = await resolveContainedPackagePath(discoveredPackage.rootPath, manifestMcpServers);
195
+ if (!mcpConfigPath) {
196
+ return buildFailedRuntimePlan(discoveredPackage.rootPath, generatedAt, execution, {
197
+ id: "plugin.security.mcp_config_path",
198
+ severity: "fail",
199
+ message: "The MCP server config path resolves outside the package root.",
200
+ impact: "Runtime planning cannot safely inspect MCP server metadata outside the package.",
201
+ suggestedFix: "Use an MCP server config path that remains inside the package root.",
202
+ evidence: { manifestPath: ".codex-plugin/plugin.json", field: "mcpServers" }
203
+ });
204
+ }
205
+ const security = await buildSecurityAudit(rootPath);
127
206
  let parsedConfig;
128
207
  try {
129
- parsedConfig = await readJsonFile(path.resolve(discoveredPackage.rootPath, discoveredPackage.manifest.mcpServers));
208
+ parsedConfig = await readJsonFile(mcpConfigPath);
130
209
  }
131
210
  catch {
132
211
  parsedConfig = {};
133
212
  }
134
- const serverEntries = isPlainObject(parsedConfig) && isPlainObject(parsedConfig.mcpServers)
135
- ? Object.entries(parsedConfig.mcpServers)
136
- : [];
213
+ const normalizedConfig = normalizeMcpConfig(parsedConfig);
214
+ const serverEntries = normalizedConfig.ok ? Object.entries(normalizedConfig.servers) : [];
215
+ const rawServerArgs = {};
137
216
  const servers = serverEntries
138
217
  .filter((entry) => isPlainObject(entry[1]))
139
218
  .map(([serverName, serverConfig]) => {
@@ -142,13 +221,15 @@ export async function buildDoctorRuntimePlan(targetPath, generatedAt = new Date(
142
221
  const { riskLevel, riskReasons } = buildRisk(serverName, serverConfig, security.findings);
143
222
  const networkClass = url ? remoteNetworkClass(url) : undefined;
144
223
  const sanitizedUrl = url ? inspectRemoteMcpUrl(url).sanitizedUrl : null;
224
+ const args = Array.isArray(serverConfig.args)
225
+ ? serverConfig.args.filter((arg) => typeof arg === "string")
226
+ : [];
227
+ rawServerArgs[serverName] = args;
145
228
  return {
146
229
  name: serverName,
147
230
  transport: command ? "stdio" : "http",
148
231
  command,
149
- args: Array.isArray(serverConfig.args)
150
- ? serverConfig.args.filter((arg) => typeof arg === "string")
151
- : [],
232
+ args: redactRuntimeArguments(args),
152
233
  cwd: command ? normalizeCwd(discoveredPackage.rootPath, serverConfig.cwd) : null,
153
234
  url: sanitizedUrl,
154
235
  ...(networkClass ? { networkClass } : {}),
@@ -182,12 +263,12 @@ export async function buildDoctorRuntimePlan(targetPath, generatedAt = new Date(
182
263
  kind: "doctor.runtime.plan",
183
264
  version: packageVersion,
184
265
  targetPath: discoveredPackage.rootPath,
185
- status: highRiskServerCount > 0
266
+ status: !normalizedConfig.ok || highRiskServerCount > 0 || security.status === "fail"
186
267
  ? "fail"
187
268
  : security.status === "warn"
188
269
  ? "warn"
189
270
  : "pass",
190
- exitCode: (highRiskServerCount > 0 ? 1 : 0),
271
+ exitCode: (!normalizedConfig.ok || highRiskServerCount > 0 || security.status === "fail" ? 1 : 0),
191
272
  runtimeExecution: "not_started",
192
273
  execution,
193
274
  summary: {
@@ -202,7 +283,7 @@ export async function buildDoctorRuntimePlan(targetPath, generatedAt = new Date(
202
283
  return {
203
284
  ...partialPlan,
204
285
  generatedAt,
205
- digest: buildRuntimePlanDigest(partialPlan)
286
+ digest: buildRuntimePlanDigest(partialPlan, rawServerArgs)
206
287
  };
207
288
  }
208
289
  export function evaluateRuntimeApproval(plan, options) {
@@ -6,6 +6,7 @@ import readline from "node:readline";
6
6
  import { evaluateMcpConformance } from "./mcp-conformance.js";
7
7
  import { probeRemoteMcpServer } from "./remote-mcp-probe.js";
8
8
  import { buildRuntimeLaunch, DOCKER_RUNTIME_STARTUP_TIMEOUT_MS, RuntimeSandboxError } from "./runtime-sandbox.js";
9
+ import { normalizeMcpConfig } from "./mcp-config-normalizer.js";
9
10
  import { formatRequestTranscript as formatRequestTranscriptForLog, formatResponseTranscript as formatResponseTranscriptForLog } from "./runtime-transcript.js";
10
11
  const MCP_PROTOCOL_VERSION = "2025-11-25";
11
12
  const PROMPT_PROBE_PLACEHOLDER = "codex-plugin-doctor-probe";
@@ -262,14 +263,8 @@ async function loadMcpServers(mcpConfigPath) {
262
263
  catch {
263
264
  return null;
264
265
  }
265
- if (!isPlainObject(parsedConfig)) {
266
- return null;
267
- }
268
- const servers = parsedConfig.mcpServers;
269
- if (!isPlainObject(servers)) {
270
- return null;
271
- }
272
- return servers;
266
+ const normalizedConfig = normalizeMcpConfig(parsedConfig);
267
+ return normalizedConfig.ok ? normalizedConfig.servers : null;
273
268
  }
274
269
  function getCapabilities(message) {
275
270
  if (!isPlainObject(message.result)) {