codex-plugin-doctor 1.57.0 → 1.59.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/LICENSE +21 -21
- package/README.md +20 -6
- package/dist/compatibility/claude-desktop-install-preview.js +4 -4
- package/dist/compatibility/cline-install-preview.js +4 -4
- package/dist/compatibility/compatibility-matrix.js +13 -17
- package/dist/compatibility/cursor-install-preview.js +4 -4
- package/dist/compatibility/windsurf-install-preview.js +4 -4
- package/dist/core/doctor-size.js +16 -1
- package/dist/core/init-plugin.js +2 -2
- package/dist/core/inspector-bridge.js +8 -13
- package/dist/core/mcp-config-normalizer.d.ts +18 -0
- package/dist/core/mcp-config-normalizer.js +50 -0
- package/dist/core/output-contract.js +76 -0
- package/dist/core/package-path.d.ts +1 -0
- package/dist/core/package-path.js +25 -0
- package/dist/core/plugin-components.d.ts +7 -0
- package/dist/core/plugin-components.js +169 -0
- package/dist/core/plugin-hooks.d.ts +2 -0
- package/dist/core/plugin-hooks.js +190 -0
- package/dist/core/runtime-plan.js +97 -16
- package/dist/core/runtime-probe.js +3 -8
- package/dist/core/shell-completion.js +29 -0
- package/dist/core/submission-assets.d.ts +6 -0
- package/dist/core/submission-assets.js +430 -0
- package/dist/core/submission-preflight.d.ts +34 -0
- package/dist/core/submission-preflight.js +289 -0
- package/dist/core/submission-ruleset.d.ts +56 -0
- package/dist/core/submission-ruleset.js +38 -0
- package/dist/core/submission-skill-metadata.d.ts +9 -0
- package/dist/core/submission-skill-metadata.js +301 -0
- package/dist/core/validate-plugin.js +39 -17
- package/dist/domain/types.d.ts +34 -0
- package/dist/domain/types.js +13 -1
- package/dist/index.d.ts +5 -0
- package/dist/index.js +5 -0
- package/dist/mcp/generic-mcp-doctor.js +45 -29
- package/dist/reporting/render-submission-report.d.ts +5 -0
- package/dist/reporting/render-submission-report.js +68 -0
- package/dist/rules/rule-catalog.js +130 -4
- package/dist/run-cli.js +96 -0
- package/dist/security/security-audit.d.ts +1 -0
- package/dist/security/security-audit.js +104 -7
- package/examples/README.md +94 -94
- package/examples/codex-doctor-generic-mcp/.mcp.json +10 -10
- package/examples/codex-doctor-generic-mcp/server.js +1 -1
- package/examples/codex-doctor-risky/.codex-plugin/plugin.json +7 -7
- package/examples/codex-doctor-risky/.mcp.json +12 -12
- package/examples/codex-doctor-risky/mock-server.js +1 -1
- package/examples/codex-doctor-runtime/.codex-plugin/plugin.json +8 -8
- package/examples/codex-doctor-runtime/.mcp.json +9 -9
- package/examples/codex-doctor-runtime/mock-server.js +237 -237
- package/examples/codex-doctor-runtime/skills/context-check/SKILL.md +7 -7
- package/examples/codex-doctor-starter/.codex-plugin/plugin.json +7 -7
- package/examples/codex-doctor-starter/skills/repo-scan/SKILL.md +7 -7
- package/package.json +5 -1
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { realpath, stat } from "node:fs/promises";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { buildCompatibilityMatrix, readMcpConfigPath } from "../compatibility/compatibility-matrix.js";
|
|
4
|
+
import { normalizeMcpConfig } from "../core/mcp-config-normalizer.js";
|
|
4
5
|
import { readJsonFile } from "../core/read-json-file.js";
|
|
5
6
|
import { probeRuntimeConfig, remoteReliabilityGatePassed } from "../core/runtime-probe.js";
|
|
6
7
|
import { formatFindingFingerprintLine, withFindingFingerprints } from "../reporting/finding-fingerprint.js";
|
|
@@ -15,9 +16,6 @@ function buildFinding(severity, id, message, impact, suggestedFix, evidence) {
|
|
|
15
16
|
...(evidence ? { evidence } : {})
|
|
16
17
|
};
|
|
17
18
|
}
|
|
18
|
-
function isPlainObject(value) {
|
|
19
|
-
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
20
|
-
}
|
|
21
19
|
async function fileExists(targetPath) {
|
|
22
20
|
try {
|
|
23
21
|
const details = await stat(targetPath);
|
|
@@ -32,6 +30,13 @@ function isPathWithinRoot(rootPath, candidatePath) {
|
|
|
32
30
|
return (relativePath === "" ||
|
|
33
31
|
(!relativePath.startsWith("..") && !path.isAbsolute(relativePath)));
|
|
34
32
|
}
|
|
33
|
+
function isMcpConfigPathOutsideRootError(error) {
|
|
34
|
+
return error instanceof Error && (error.message === "MCP config path resolves outside the package root." ||
|
|
35
|
+
error.message === "Manifest MCP config path resolves outside the package root.");
|
|
36
|
+
}
|
|
37
|
+
function buildMcpConfigPathOutsideRootFinding(configPath) {
|
|
38
|
+
return buildFinding("fail", "mcp.config.path_outside_root", "The MCP config path resolves outside the target root.", "A package that reads MCP configuration outside its root is harder to audit and can depend on unreviewed local files.", "Keep `.mcp.json` or the manifest `mcpServers` reference inside the package root.", { configPath, field: "configPath" });
|
|
39
|
+
}
|
|
35
40
|
function buildStaticMcpFindings(configPath, parsedConfig) {
|
|
36
41
|
if (!configPath) {
|
|
37
42
|
return {
|
|
@@ -41,29 +46,24 @@ function buildStaticMcpFindings(configPath, parsedConfig) {
|
|
|
41
46
|
]
|
|
42
47
|
};
|
|
43
48
|
}
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
const servers = parsedConfig.mcpServers;
|
|
53
|
-
if (!isPlainObject(servers) || Object.keys(servers).length === 0) {
|
|
49
|
+
const normalizedConfig = normalizeMcpConfig(parsedConfig);
|
|
50
|
+
if (!normalizedConfig.ok) {
|
|
51
|
+
if (normalizedConfig.field === "server" && normalizedConfig.invalidServerNames) {
|
|
52
|
+
return {
|
|
53
|
+
serverCount: 0,
|
|
54
|
+
findings: normalizedConfig.invalidServerNames.map((serverName) => buildFinding("fail", "mcp.server.invalid", `The MCP server \`${serverName}\` must be configured as an object.`, "MCP clients cannot interpret a server entry unless it is represented as an object with server options.", `Change the \`${serverName}\` entry in \`${configPath}\` to an object.`, { configPath, serverName, field: "server" }))
|
|
55
|
+
};
|
|
56
|
+
}
|
|
54
57
|
return {
|
|
55
58
|
serverCount: 0,
|
|
56
59
|
findings: [
|
|
57
|
-
buildFinding("fail", "mcp.config.invalid_shape", "The MCP config must contain a
|
|
60
|
+
buildFinding("fail", "mcp.config.invalid_shape", "The MCP config must contain a valid MCP server map.", "Without server entries, MCP clients cannot discover any package capabilities.", `Use a direct server map, \`mcp_servers\`, or \`mcpServers\` in \`${configPath}\`.`, { configPath, field: normalizedConfig.field })
|
|
58
61
|
]
|
|
59
62
|
};
|
|
60
63
|
}
|
|
64
|
+
const servers = normalizedConfig.servers;
|
|
61
65
|
const findings = [];
|
|
62
66
|
for (const [serverName, serverConfig] of Object.entries(servers)) {
|
|
63
|
-
if (!isPlainObject(serverConfig)) {
|
|
64
|
-
findings.push(buildFinding("fail", "mcp.server.invalid", `The MCP server \`${serverName}\` must be configured as an object.`, "MCP clients cannot interpret a server entry unless it is represented as an object with server options.", `Change the \`${serverName}\` entry in \`${configPath}\` to an object.`, { configPath, serverName, field: "server" }));
|
|
65
|
-
continue;
|
|
66
|
-
}
|
|
67
67
|
const command = serverConfig.command;
|
|
68
68
|
const url = serverConfig.url;
|
|
69
69
|
if (typeof command !== "string" && typeof url !== "string") {
|
|
@@ -90,7 +90,18 @@ function mergeReportStatus(findings, security) {
|
|
|
90
90
|
export async function buildGenericMcpDoctor(targetPath, environment = {}, options = {}) {
|
|
91
91
|
const rootPath = path.resolve(targetPath);
|
|
92
92
|
const compatibility = await buildCompatibilityMatrix(rootPath, environment);
|
|
93
|
-
|
|
93
|
+
let mcpConfigPath;
|
|
94
|
+
let mcpConfigPathOutsideRoot = false;
|
|
95
|
+
try {
|
|
96
|
+
mcpConfigPath = await readMcpConfigPath(rootPath);
|
|
97
|
+
}
|
|
98
|
+
catch (error) {
|
|
99
|
+
if (!isMcpConfigPathOutsideRootError(error)) {
|
|
100
|
+
throw error;
|
|
101
|
+
}
|
|
102
|
+
mcpConfigPath = null;
|
|
103
|
+
mcpConfigPathOutsideRoot = true;
|
|
104
|
+
}
|
|
94
105
|
const canonicalRootPath = await realpath(rootPath).catch(() => null);
|
|
95
106
|
const canonicalMcpConfigPath = mcpConfigPath
|
|
96
107
|
? await realpath(mcpConfigPath).catch(() => null)
|
|
@@ -98,18 +109,17 @@ export async function buildGenericMcpDoctor(targetPath, environment = {}, option
|
|
|
98
109
|
let parsedConfig = null;
|
|
99
110
|
let staticFindings = [];
|
|
100
111
|
let serverCount = 0;
|
|
101
|
-
if (
|
|
112
|
+
if (mcpConfigPathOutsideRoot) {
|
|
113
|
+
staticFindings = [buildMcpConfigPathOutsideRootFinding(".mcp.json")];
|
|
114
|
+
}
|
|
115
|
+
else if (!mcpConfigPath || !(await fileExists(mcpConfigPath))) {
|
|
102
116
|
staticFindings = buildStaticMcpFindings(null, null).findings;
|
|
103
117
|
}
|
|
104
118
|
else if (!canonicalRootPath ||
|
|
105
119
|
!canonicalMcpConfigPath ||
|
|
106
120
|
!isPathWithinRoot(canonicalRootPath, canonicalMcpConfigPath)) {
|
|
107
121
|
staticFindings = [
|
|
108
|
-
|
|
109
|
-
configPath: path.relative(rootPath, mcpConfigPath).replaceAll("\\", "/"),
|
|
110
|
-
resolvedPath: canonicalMcpConfigPath ?? mcpConfigPath,
|
|
111
|
-
field: "configPath"
|
|
112
|
-
})
|
|
122
|
+
buildMcpConfigPathOutsideRootFinding(path.relative(rootPath, mcpConfigPath).replaceAll("\\", "/"))
|
|
113
123
|
];
|
|
114
124
|
}
|
|
115
125
|
else {
|
|
@@ -128,9 +138,12 @@ export async function buildGenericMcpDoctor(targetPath, environment = {}, option
|
|
|
128
138
|
];
|
|
129
139
|
}
|
|
130
140
|
}
|
|
131
|
-
const security = buildSecurityAuditFromFindings(rootPath,
|
|
132
|
-
|
|
133
|
-
|
|
141
|
+
const security = buildSecurityAuditFromFindings(rootPath, [
|
|
142
|
+
...staticFindings.filter((finding) => finding.id === "mcp.config.path_outside_root"),
|
|
143
|
+
...(mcpConfigPath && parsedConfig !== null
|
|
144
|
+
? auditMcpServerConfig(rootPath, parsedConfig, { configPath: mcpConfigPath })
|
|
145
|
+
: [])
|
|
146
|
+
]);
|
|
134
147
|
const runtimeResult = options.runtime &&
|
|
135
148
|
mcpConfigPath !== null &&
|
|
136
149
|
parsedConfig !== null &&
|
|
@@ -156,7 +169,10 @@ export async function buildGenericMcpDoctor(targetPath, environment = {}, option
|
|
|
156
169
|
exitCode: status === "fail" ? 1 : 0,
|
|
157
170
|
mcpConfigPath,
|
|
158
171
|
serverCount,
|
|
159
|
-
findings: [
|
|
172
|
+
findings: [
|
|
173
|
+
...fingerprintedFindings,
|
|
174
|
+
...security.findings.filter((finding) => !fingerprintedFindings.some((staticFinding) => staticFinding.fingerprint === finding.fingerprint))
|
|
175
|
+
],
|
|
160
176
|
security,
|
|
161
177
|
compatibility,
|
|
162
178
|
...(runtimeResult ? { runtimeScorecard: runtimeResult.scorecard } : {}),
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type { SubmissionPreflightReport } from "../core/submission-preflight.js";
|
|
2
|
+
export declare function renderSubmissionPreflightJson(report: SubmissionPreflightReport): string;
|
|
3
|
+
export declare function renderSubmissionPreflightText(report: SubmissionPreflightReport): string;
|
|
4
|
+
export declare function renderSubmissionPreflightMarkdown(report: SubmissionPreflightReport): string;
|
|
5
|
+
export declare function submissionPreflightExitCode(report: SubmissionPreflightReport, requireReady: boolean): 0 | 1;
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
function upper(value) {
|
|
2
|
+
return value.replace(/[-_]/gu, " ").toUpperCase();
|
|
3
|
+
}
|
|
4
|
+
function findingsByCheck(report) {
|
|
5
|
+
const findings = new Map(report.findings.map((finding) => [finding.id, finding]));
|
|
6
|
+
return report.checks.map((check) => ({
|
|
7
|
+
check,
|
|
8
|
+
findings: check.findingIds.flatMap((id) => {
|
|
9
|
+
const finding = findings.get(id);
|
|
10
|
+
return finding ? [finding] : [];
|
|
11
|
+
})
|
|
12
|
+
}));
|
|
13
|
+
}
|
|
14
|
+
function escapeMarkdown(value) {
|
|
15
|
+
return value.replace(/[\\`*_{}\[\]()<>#+\-.!|]/gu, "\\$&");
|
|
16
|
+
}
|
|
17
|
+
function textSummary(report) {
|
|
18
|
+
return [
|
|
19
|
+
`Ruleset: ${report.rulesetVersion}`,
|
|
20
|
+
`Target: ${report.targetType}`,
|
|
21
|
+
`Automatic status: ${upper(report.status)}`,
|
|
22
|
+
`Readiness: ${upper(report.readiness)}`,
|
|
23
|
+
`Summary: ${report.summary.passed} passed, ${report.summary.warnings} warnings, ${report.summary.blockers} blockers, ${report.summary.manualChecks} manual checks`,
|
|
24
|
+
"Manual review is required; automatic checks do not complete directory review."
|
|
25
|
+
];
|
|
26
|
+
}
|
|
27
|
+
export function renderSubmissionPreflightJson(report) {
|
|
28
|
+
return `${JSON.stringify(report, null, 2)}\n`;
|
|
29
|
+
}
|
|
30
|
+
export function renderSubmissionPreflightText(report) {
|
|
31
|
+
const lines = ["Submission preflight", "====================", ...textSummary(report), "", "Findings by check"];
|
|
32
|
+
for (const { check, findings } of findingsByCheck(report)) {
|
|
33
|
+
lines.push(`${check.id} (${upper(check.status)})`);
|
|
34
|
+
lines.push(...(findings.length === 0
|
|
35
|
+
? [" None"]
|
|
36
|
+
: findings.map((finding) => ` ${upper(finding.severity)} ${finding.id}: ${finding.message}`)));
|
|
37
|
+
}
|
|
38
|
+
lines.push("", "Manual checklist");
|
|
39
|
+
lines.push(...report.manualChecklist.map((item) => ` ${upper(item.state)} ${item.id}: ${item.label}`));
|
|
40
|
+
return `${lines.join("\n")}\n`;
|
|
41
|
+
}
|
|
42
|
+
export function renderSubmissionPreflightMarkdown(report) {
|
|
43
|
+
const lines = [
|
|
44
|
+
"# Submission preflight",
|
|
45
|
+
"",
|
|
46
|
+
`- Ruleset: ${escapeMarkdown(report.rulesetVersion)}`,
|
|
47
|
+
`- Target: ${escapeMarkdown(report.targetType)}`,
|
|
48
|
+
`- Automatic status: ${escapeMarkdown(upper(report.status))}`,
|
|
49
|
+
`- Readiness: ${escapeMarkdown(upper(report.readiness))}`,
|
|
50
|
+
`- Summary: ${report.summary.passed} passed, ${report.summary.warnings} warnings, ${report.summary.blockers} blockers, ${report.summary.manualChecks} manual checks`,
|
|
51
|
+
"",
|
|
52
|
+
"Manual review is required; automatic checks do not complete directory review.",
|
|
53
|
+
"",
|
|
54
|
+
"## Findings by check"
|
|
55
|
+
];
|
|
56
|
+
for (const { check, findings } of findingsByCheck(report)) {
|
|
57
|
+
lines.push("", `### ${escapeMarkdown(check.id)} (${escapeMarkdown(upper(check.status))})`);
|
|
58
|
+
lines.push(...(findings.length === 0
|
|
59
|
+
? ["- None"]
|
|
60
|
+
: findings.map((finding) => `- **${escapeMarkdown(upper(finding.severity))}** ${escapeMarkdown(finding.id)}: ${escapeMarkdown(finding.message)}`)));
|
|
61
|
+
}
|
|
62
|
+
lines.push("", "## Manual checklist");
|
|
63
|
+
lines.push(...report.manualChecklist.map((item) => `- ${escapeMarkdown(upper(item.state))}: ${escapeMarkdown(item.id)} — ${escapeMarkdown(item.label)}`));
|
|
64
|
+
return `${lines.join("\n")}\n`;
|
|
65
|
+
}
|
|
66
|
+
export function submissionPreflightExitCode(report, requireReady) {
|
|
67
|
+
return requireReady && report.status === "fail" ? 1 : 0;
|
|
68
|
+
}
|
|
@@ -53,6 +53,123 @@ export const ruleCatalog = [
|
|
|
53
53
|
fix: "Add a short, specific `description` field to `.codex-plugin/plugin.json`.",
|
|
54
54
|
example: '{ "description": "Validates GitHub PR automation workflows before release." }'
|
|
55
55
|
},
|
|
56
|
+
{
|
|
57
|
+
id: "plugin.manifest.invalid_field",
|
|
58
|
+
category: "package",
|
|
59
|
+
defaultSeverity: "fail",
|
|
60
|
+
summary: "A plugin manifest optional field is invalid.",
|
|
61
|
+
why: "Malformed optional metadata cannot be interpreted reliably by Codex clients.",
|
|
62
|
+
fix: "Use the official type for the affected field in `.codex-plugin/plugin.json`.",
|
|
63
|
+
example: '{ "keywords": ["validation", "mcp"] }'
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
id: "plugin.manifest.invalid_path",
|
|
67
|
+
category: "package",
|
|
68
|
+
defaultSeverity: "fail",
|
|
69
|
+
summary: "A plugin manifest path is not a safe package-relative path.",
|
|
70
|
+
why: "Paths outside the plugin package can expose files that are not part of the plugin bundle.",
|
|
71
|
+
fix: "Use a `./` path that remains inside the plugin package.",
|
|
72
|
+
example: '{ "skills": "./skills" }'
|
|
73
|
+
},
|
|
74
|
+
{
|
|
75
|
+
id: "plugin.app.missing_file",
|
|
76
|
+
category: "package",
|
|
77
|
+
defaultSeverity: "fail",
|
|
78
|
+
summary: "The plugin manifest points to a missing .app.json file.",
|
|
79
|
+
why: "Codex cannot load an app manifest that is absent or not a regular file.",
|
|
80
|
+
fix: "Create the referenced app manifest as a regular JSON file inside the plugin package.",
|
|
81
|
+
example: '{ "apps": "./apps/example.app.json" }'
|
|
82
|
+
},
|
|
83
|
+
{
|
|
84
|
+
id: "plugin.app.invalid_json",
|
|
85
|
+
category: "package",
|
|
86
|
+
defaultSeverity: "fail",
|
|
87
|
+
summary: "The referenced .app.json file is not valid JSON.",
|
|
88
|
+
why: "Codex cannot parse the app manifest.",
|
|
89
|
+
fix: "Fix the JSON syntax in the referenced app manifest.",
|
|
90
|
+
example: "Use syntactically valid JSON in the referenced .app.json file."
|
|
91
|
+
},
|
|
92
|
+
{
|
|
93
|
+
id: "plugin.app.invalid_path",
|
|
94
|
+
category: "package",
|
|
95
|
+
defaultSeverity: "fail",
|
|
96
|
+
summary: "A plugin app path is not a safe package-relative path.",
|
|
97
|
+
why: "Paths outside the plugin package can expose files that are not part of the plugin bundle.",
|
|
98
|
+
fix: "Use an `./` app path that remains inside the plugin package.",
|
|
99
|
+
example: '{ "apps": "./apps/example.app.json" }'
|
|
100
|
+
},
|
|
101
|
+
{
|
|
102
|
+
id: "plugin.hook.missing_file",
|
|
103
|
+
category: "package",
|
|
104
|
+
defaultSeverity: "fail",
|
|
105
|
+
summary: "The plugin lifecycle hook source file is missing.",
|
|
106
|
+
why: "Codex cannot load a hook configuration file that is absent or not a regular file.",
|
|
107
|
+
fix: "Create the referenced hook configuration JSON file inside the plugin package.",
|
|
108
|
+
example: '{ "hooks": "./hooks/hooks.json" }'
|
|
109
|
+
},
|
|
110
|
+
{
|
|
111
|
+
id: "plugin.hook.invalid_json",
|
|
112
|
+
category: "package",
|
|
113
|
+
defaultSeverity: "fail",
|
|
114
|
+
summary: "The plugin lifecycle hook source is not valid JSON.",
|
|
115
|
+
why: "Codex cannot parse the hook configuration.",
|
|
116
|
+
fix: "Fix the JSON syntax in the hook configuration file.",
|
|
117
|
+
example: "Use syntactically valid JSON in the hook configuration file."
|
|
118
|
+
},
|
|
119
|
+
{
|
|
120
|
+
id: "plugin.hook.invalid_shape",
|
|
121
|
+
category: "package",
|
|
122
|
+
defaultSeverity: "fail",
|
|
123
|
+
summary: "The plugin lifecycle hook configuration has an invalid shape.",
|
|
124
|
+
why: "Codex cannot safely interpret malformed lifecycle hook metadata.",
|
|
125
|
+
fix: "Use the official hook configuration schema for the affected field.",
|
|
126
|
+
example: '{ "hooks": { "PreToolUse": [{ "hooks": [{ "type": "command", "command": "node hooks/check.js" }] }] } }'
|
|
127
|
+
},
|
|
128
|
+
{
|
|
129
|
+
id: "plugin.hook.invalid_path",
|
|
130
|
+
category: "package",
|
|
131
|
+
defaultSeverity: "fail",
|
|
132
|
+
summary: "The plugin lifecycle hook source is not a safe package-relative path.",
|
|
133
|
+
why: "Paths outside the plugin package can expose unreviewed files to hook configuration loading.",
|
|
134
|
+
fix: "Use a `./` hook configuration path that stays within the plugin package.",
|
|
135
|
+
example: '{ "hooks": "./hooks/hooks.json" }'
|
|
136
|
+
},
|
|
137
|
+
{
|
|
138
|
+
id: "plugin.hook.unsupported_event",
|
|
139
|
+
category: "package",
|
|
140
|
+
defaultSeverity: "fail",
|
|
141
|
+
summary: "A plugin lifecycle hook event is not supported.",
|
|
142
|
+
why: "Codex will not invoke hook events outside the official lifecycle event set.",
|
|
143
|
+
fix: "Use one of the official plugin lifecycle hook events.",
|
|
144
|
+
example: '{ "hooks": { "SessionStart": [] } }'
|
|
145
|
+
},
|
|
146
|
+
{
|
|
147
|
+
id: "plugin.hook.unsupported_handler",
|
|
148
|
+
category: "package",
|
|
149
|
+
defaultSeverity: "warn",
|
|
150
|
+
summary: "A plugin lifecycle hook uses a handler type the host skips.",
|
|
151
|
+
why: "Prompt and agent lifecycle handlers are not executed by this host.",
|
|
152
|
+
fix: "Use a command handler for behavior that must run in this host.",
|
|
153
|
+
example: '{ "type": "command", "command": "node hooks/check.js" }'
|
|
154
|
+
},
|
|
155
|
+
{
|
|
156
|
+
id: "plugin.hook.async_unsupported",
|
|
157
|
+
category: "package",
|
|
158
|
+
defaultSeverity: "warn",
|
|
159
|
+
summary: "A plugin lifecycle hook requests unsupported asynchronous execution.",
|
|
160
|
+
why: "The host does not support asynchronous lifecycle hook execution.",
|
|
161
|
+
fix: "Remove `async: true` or run the command synchronously.",
|
|
162
|
+
example: '{ "type": "command", "command": "node hooks/check.js" }'
|
|
163
|
+
},
|
|
164
|
+
{
|
|
165
|
+
id: "plugin.hook.matcher_ignored",
|
|
166
|
+
category: "package",
|
|
167
|
+
defaultSeverity: "warn",
|
|
168
|
+
summary: "A plugin lifecycle hook matcher is ignored for this event.",
|
|
169
|
+
why: "This lifecycle event does not use matcher filtering.",
|
|
170
|
+
fix: "Remove the matcher from this event group.",
|
|
171
|
+
example: '{ "hooks": { "Stop": [{ "hooks": [] }] } }'
|
|
172
|
+
},
|
|
56
173
|
{
|
|
57
174
|
id: "plugin.heuristic.description.too_long",
|
|
58
175
|
category: "package",
|
|
@@ -138,10 +255,19 @@ export const ruleCatalog = [
|
|
|
138
255
|
id: "plugin.mcp.invalid_shape",
|
|
139
256
|
category: "mcp",
|
|
140
257
|
defaultSeverity: "fail",
|
|
141
|
-
summary: "The `.mcp.json` file does not
|
|
142
|
-
why: "Codex
|
|
143
|
-
fix: "
|
|
144
|
-
example: '{ "
|
|
258
|
+
summary: "The `.mcp.json` file does not contain one non-empty supported server map.",
|
|
259
|
+
why: "Codex accepts exactly a direct server map, an `mcp_servers` wrapper, or the legacy `mcpServers` wrapper.",
|
|
260
|
+
fix: "Use exactly one non-empty direct server map, `mcp_servers`, or legacy `mcpServers` wrapper.",
|
|
261
|
+
example: '{ "doctor": { "command": "node", "args": ["server.js"] } }'
|
|
262
|
+
},
|
|
263
|
+
{
|
|
264
|
+
id: "plugin.mcp.ambiguous_shape",
|
|
265
|
+
category: "mcp",
|
|
266
|
+
defaultSeverity: "fail",
|
|
267
|
+
summary: "The `.mcp.json` file uses an ambiguous MCP config layout.",
|
|
268
|
+
why: "Codex cannot safely choose between multiple wrapper layouts when they appear in one configuration file.",
|
|
269
|
+
fix: "Use exactly one supported layout: a direct server map, `mcp_servers`, or `mcpServers`.",
|
|
270
|
+
example: '{ "weather": { "command": "node", "args": ["server.js"] } }'
|
|
145
271
|
},
|
|
146
272
|
{
|
|
147
273
|
id: "plugin.mcp.server.invalid",
|
package/dist/run-cli.js
CHANGED
|
@@ -19,6 +19,7 @@ import { buildDoctorRecommendations, renderDoctorRecommendations, renderDoctorRe
|
|
|
19
19
|
import { buildDoctorExportBundle, renderDoctorExportBundle, renderDoctorExportBundleJson } from "./core/doctor-export-bundle.js";
|
|
20
20
|
import { buildDoctorAttestation, renderDoctorAttestation, renderDoctorAttestationJson, renderDoctorAttestationVerification, renderDoctorAttestationVerificationJson, verifyDoctorAttestation } from "./core/attestation.js";
|
|
21
21
|
import { buildDoctorOutputContract, renderDoctorOutputContract, renderDoctorOutputContractJson } from "./core/output-contract.js";
|
|
22
|
+
import { buildSubmissionPreflight } from "./core/submission-preflight.js";
|
|
22
23
|
import { buildDoctorValidationCorpusReport, renderDoctorValidationCorpusJson, renderDoctorValidationCorpusReport } from "./core/validation-corpus.js";
|
|
23
24
|
import { buildExternalValidationCorpusReport, ExternalCorpusManifestError } from "./core/external-validation-corpus.js";
|
|
24
25
|
import { buildCorpusQualityMetricsDiffReport, buildCorpusQualityMetricsReport, CorpusMetricsDiffError, CorpusMetricsManifestError, renderCorpusQualityMetricsDiffJson, renderCorpusQualityMetricsDiffMarkdown, renderCorpusQualityMetricsDiffText, renderCorpusQualityMetricsJson, renderCorpusQualityMetricsMarkdown, renderCorpusQualityMetricsText } from "./core/corpus-quality-metrics.js";
|
|
@@ -57,6 +58,7 @@ import { renderSuppressionList, renderSuppressionListJson, renderSuppressionMuta
|
|
|
57
58
|
import { renderRuleExplanation } from "./reporting/render-rule-explanation.js";
|
|
58
59
|
import { renderSarifReport } from "./reporting/render-sarif-report.js";
|
|
59
60
|
import { renderTextReport } from "./reporting/render-text-report.js";
|
|
61
|
+
import { renderSubmissionPreflightJson, renderSubmissionPreflightMarkdown, renderSubmissionPreflightText, submissionPreflightExitCode } from "./reporting/render-submission-report.js";
|
|
60
62
|
import { applyPolicyToDepAudit, applyPolicyToDoctorConfig, applyPolicyToSecurityAudit, parsePolicyPack, policyEnablesRuntime, policyFailsOnWarnings, policyPackNames } from "./policy/policy-packs.js";
|
|
61
63
|
import { findRuleDefinition } from "./rules/rule-catalog.js";
|
|
62
64
|
import { buildSecurityAudit, renderSecurityAuditJson, renderSecurityScorecard } from "./security/security-audit.js";
|
|
@@ -86,6 +88,13 @@ const defaultIo = {
|
|
|
86
88
|
}
|
|
87
89
|
}
|
|
88
90
|
};
|
|
91
|
+
function writeExactStdout(io, message) {
|
|
92
|
+
if (io === defaultIo) {
|
|
93
|
+
process.stdout.write(message);
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
io.writeStdout(message);
|
|
97
|
+
}
|
|
89
98
|
class CliUsageError extends Error {
|
|
90
99
|
}
|
|
91
100
|
function parseRuntimeSandbox(flags, options = { requireRuntime: true }) {
|
|
@@ -177,6 +186,7 @@ function printUsage(io) {
|
|
|
177
186
|
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]");
|
|
178
187
|
io.writeStderr("Baseline gating: codex-plugin-doctor baseline create <path> --output <path> [--runtime]\n codex-plugin-doctor check <path> --baseline <path>");
|
|
179
188
|
io.writeStderr("Remote MCP runtime flags (check, mcp, release check, and doctor release-evidence): --runtime --allow-network [--allow-local-network] [--allow-session-lifecycle] [--require-remote-reliability]\n --allow-session-lifecycle requires --runtime --allow-network and can terminate a remote session.\n --require-remote-reliability requires --runtime --allow-network, blocks non-pass reliability, and does not grant network consent.");
|
|
189
|
+
io.writeStderr(" codex-plugin-doctor doctor submission <path> [--json|--markdown] [--output <path>] [--require-ready]");
|
|
180
190
|
}
|
|
181
191
|
const suppressUsageText = [
|
|
182
192
|
"Usage:",
|
|
@@ -970,6 +980,74 @@ function parseSelectedFixActionIndexes(answer, actionCount) {
|
|
|
970
980
|
? actionIndexes
|
|
971
981
|
: null;
|
|
972
982
|
}
|
|
983
|
+
function parseSubmissionCommandArgs(args) {
|
|
984
|
+
let targetPath = null;
|
|
985
|
+
let jsonOutput = false;
|
|
986
|
+
let markdownOutput = false;
|
|
987
|
+
let outputPath = null;
|
|
988
|
+
let requireReady = false;
|
|
989
|
+
let optionsEnded = false;
|
|
990
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
991
|
+
const argument = args[index];
|
|
992
|
+
if (argument === "--" && !optionsEnded) {
|
|
993
|
+
optionsEnded = true;
|
|
994
|
+
}
|
|
995
|
+
else if (optionsEnded) {
|
|
996
|
+
if (targetPath === null) {
|
|
997
|
+
targetPath = argument;
|
|
998
|
+
}
|
|
999
|
+
else {
|
|
1000
|
+
return new CliUsageError(`Unexpected submission argument: ${argument}.`);
|
|
1001
|
+
}
|
|
1002
|
+
}
|
|
1003
|
+
else if (argument === "--json") {
|
|
1004
|
+
if (jsonOutput)
|
|
1005
|
+
return new CliUsageError("Duplicate submission flag: --json.");
|
|
1006
|
+
jsonOutput = true;
|
|
1007
|
+
}
|
|
1008
|
+
else if (argument === "--markdown") {
|
|
1009
|
+
if (markdownOutput)
|
|
1010
|
+
return new CliUsageError("Duplicate submission flag: --markdown.");
|
|
1011
|
+
markdownOutput = true;
|
|
1012
|
+
}
|
|
1013
|
+
else if (argument === "--require-ready") {
|
|
1014
|
+
if (requireReady)
|
|
1015
|
+
return new CliUsageError("Duplicate submission flag: --require-ready.");
|
|
1016
|
+
requireReady = true;
|
|
1017
|
+
}
|
|
1018
|
+
else if (argument === "--output") {
|
|
1019
|
+
if (outputPath !== null)
|
|
1020
|
+
return new CliUsageError("Duplicate submission flag: --output.");
|
|
1021
|
+
const value = args[index + 1];
|
|
1022
|
+
if (!value || value.startsWith("--"))
|
|
1023
|
+
return new CliUsageError("Missing path after --output.");
|
|
1024
|
+
outputPath = value;
|
|
1025
|
+
index += 1;
|
|
1026
|
+
}
|
|
1027
|
+
else if (argument.startsWith("--output=")) {
|
|
1028
|
+
if (outputPath !== null)
|
|
1029
|
+
return new CliUsageError("Duplicate submission flag: --output.");
|
|
1030
|
+
const value = argument.slice("--output=".length);
|
|
1031
|
+
if (!value)
|
|
1032
|
+
return new CliUsageError("Missing path after --output.");
|
|
1033
|
+
outputPath = value;
|
|
1034
|
+
}
|
|
1035
|
+
else if (argument.startsWith("--")) {
|
|
1036
|
+
return new CliUsageError(`Unknown submission flag: ${argument}.`);
|
|
1037
|
+
}
|
|
1038
|
+
else if (targetPath === null) {
|
|
1039
|
+
targetPath = argument;
|
|
1040
|
+
}
|
|
1041
|
+
else {
|
|
1042
|
+
return new CliUsageError(`Unexpected submission argument: ${argument}.`);
|
|
1043
|
+
}
|
|
1044
|
+
}
|
|
1045
|
+
if (targetPath === null)
|
|
1046
|
+
return new CliUsageError("Missing target path for submission.");
|
|
1047
|
+
if (jsonOutput && markdownOutput)
|
|
1048
|
+
return new CliUsageError("Use either --json or --markdown, not both.");
|
|
1049
|
+
return { targetPath, jsonOutput, markdownOutput, outputPath, requireReady };
|
|
1050
|
+
}
|
|
973
1051
|
export async function runCli(args, io = defaultIo, options = {}) {
|
|
974
1052
|
const [command, maybePath, ...remainingArgs] = args;
|
|
975
1053
|
const runtimeSandboxSupported = (command === "check" &&
|
|
@@ -1079,6 +1157,24 @@ export async function runCli(args, io = defaultIo, options = {}) {
|
|
|
1079
1157
|
: renderDoctorOutputContract(contract, { outputPath }));
|
|
1080
1158
|
return 0;
|
|
1081
1159
|
}
|
|
1160
|
+
if (maybePath === "submission") {
|
|
1161
|
+
const parsedSubmissionArgs = parseSubmissionCommandArgs(remainingArgs);
|
|
1162
|
+
if (parsedSubmissionArgs instanceof CliUsageError) {
|
|
1163
|
+
io.writeStderr(parsedSubmissionArgs.message);
|
|
1164
|
+
return 2;
|
|
1165
|
+
}
|
|
1166
|
+
const report = await buildSubmissionPreflight(parsedSubmissionArgs.targetPath);
|
|
1167
|
+
const renderedReport = parsedSubmissionArgs.jsonOutput
|
|
1168
|
+
? renderSubmissionPreflightJson(report)
|
|
1169
|
+
: parsedSubmissionArgs.markdownOutput
|
|
1170
|
+
? renderSubmissionPreflightMarkdown(report)
|
|
1171
|
+
: renderSubmissionPreflightText(report);
|
|
1172
|
+
if (parsedSubmissionArgs.outputPath) {
|
|
1173
|
+
await writeFile(parsedSubmissionArgs.outputPath, renderedReport, "utf8");
|
|
1174
|
+
}
|
|
1175
|
+
writeExactStdout(io, renderedReport);
|
|
1176
|
+
return submissionPreflightExitCode(report, parsedSubmissionArgs.requireReady);
|
|
1177
|
+
}
|
|
1082
1178
|
if (maybePath === "mcp") {
|
|
1083
1179
|
const targetPath = remainingArgs[0] && !remainingArgs[0].startsWith("--")
|
|
1084
1180
|
? remainingArgs[0]
|
|
@@ -10,6 +10,7 @@ export interface SecurityAudit {
|
|
|
10
10
|
};
|
|
11
11
|
findings: Finding[];
|
|
12
12
|
}
|
|
13
|
+
export declare function auditHookCommand(rootPath: string, sourcePath: string, event: string, field: string, command: string): Finding[];
|
|
13
14
|
export declare function auditMcpServerConfig(rootPath: string, parsedConfig: unknown, options?: {
|
|
14
15
|
configPath?: string;
|
|
15
16
|
}): Finding[];
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { readFile, readdir, stat } from "node:fs/promises";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { discoverPackage } from "../core/discover-package.js";
|
|
4
|
+
import { normalizeMcpConfig } from "../core/mcp-config-normalizer.js";
|
|
4
5
|
import { readJsonFile } from "../core/read-json-file.js";
|
|
5
6
|
import { inspectRemoteMcpUrl } from "../core/remote-url-policy.js";
|
|
6
7
|
import { validatePlugin } from "../core/validate-plugin.js";
|
|
@@ -58,6 +59,101 @@ function containsPipeInstaller(args) {
|
|
|
58
59
|
/\b(iwr|irm|invoke-webrequest|invoke-restmethod)\b[^|]*\|\s*(iex|invoke-expression)\b/.test(joinedArgs) ||
|
|
59
60
|
/\binvoke-expression\b/.test(joinedArgs));
|
|
60
61
|
}
|
|
62
|
+
function relativeSourcePath(rootPath, sourcePath) {
|
|
63
|
+
return relativePackagePath(rootPath, path.resolve(rootPath, sourcePath));
|
|
64
|
+
}
|
|
65
|
+
export function auditHookCommand(rootPath, sourcePath, event, field, command) {
|
|
66
|
+
const evidence = { sourcePath: relativeSourcePath(rootPath, sourcePath), event, field };
|
|
67
|
+
const findings = [];
|
|
68
|
+
if (/(?:^|\s)[/-]enc(?:odedcommand)?(?=\s|$)/i.test(command)) {
|
|
69
|
+
findings.push(buildFinding("fail", "plugin.security.encoded_command", "A plugin lifecycle hook uses an encoded shell command flag.", "Encoded command payloads hide the executed script from reviewers and increase supply-chain risk.", "Replace encoded shell payloads with a checked-in script or readable direct command.", evidence));
|
|
70
|
+
}
|
|
71
|
+
if (containsHookRemotePipeInstaller(command)) {
|
|
72
|
+
findings.push(buildFinding("fail", "plugin.security.remote_pipe_install", "A plugin lifecycle hook appears to pipe remote content into a shell.", "Download-and-execute patterns can run unreviewed remote code when a host invokes the hook.", "Pin dependencies through the package manager or use a reviewed local script instead of piping remote content to a shell.", evidence));
|
|
73
|
+
}
|
|
74
|
+
if (/^\s*(?:cmd(?:\.exe)?\s+\/c|(?:powershell|pwsh)(?:\.exe)?\s+-(?:command|c)\b)/i.test(command)) {
|
|
75
|
+
findings.push(buildFinding("warn", "plugin.security.command_shell_wrapper", "A plugin lifecycle hook starts through a shell wrapper.", "Shell wrappers expand quoting, pipes, aliases, and platform-specific behavior, which makes the execution path harder to audit.", "Prefer a concrete executable or checked-in script with explicit arguments.", evidence));
|
|
76
|
+
}
|
|
77
|
+
return findings;
|
|
78
|
+
}
|
|
79
|
+
function containsHookRemotePipeInstaller(command) {
|
|
80
|
+
const firstPipeIndex = command.indexOf("|");
|
|
81
|
+
if (firstPipeIndex === -1)
|
|
82
|
+
return false;
|
|
83
|
+
const leftHandSide = command.slice(0, firstPipeIndex);
|
|
84
|
+
const rightHandSide = command.slice(firstPipeIndex + 1).trim();
|
|
85
|
+
const interpreter = /^(?:\/(?:[^/\s|]+\/)*(?:sh|bash)\b|(?:sh|bash)\b|(?:powershell|pwsh)(?:\.exe)?\s+-(?:command|c)\s+-\s*(?:$|[;&|])|(?:iex|invoke-expression)\b)/i;
|
|
86
|
+
return isInvokedHookDownloader(leftHandSide) && interpreter.test(rightHandSide);
|
|
87
|
+
}
|
|
88
|
+
function isInvokedHookDownloader(command) {
|
|
89
|
+
const tokens = command.trim().split(/\s+/).filter(Boolean);
|
|
90
|
+
let index = 0;
|
|
91
|
+
while (index < tokens.length) {
|
|
92
|
+
const token = tokens[index].toLowerCase();
|
|
93
|
+
if (token === "env") {
|
|
94
|
+
index += 1;
|
|
95
|
+
while (index < tokens.length) {
|
|
96
|
+
const envToken = tokens[index];
|
|
97
|
+
if (envToken === "--") {
|
|
98
|
+
index += 1;
|
|
99
|
+
break;
|
|
100
|
+
}
|
|
101
|
+
if (envToken === "-i" || envToken === "--ignore-environment") {
|
|
102
|
+
index += 1;
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
if (envToken === "-u" || envToken === "--unset") {
|
|
106
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(tokens[index + 1] ?? "")) {
|
|
107
|
+
return false;
|
|
108
|
+
}
|
|
109
|
+
index += 2;
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
if (/^--unset=[A-Za-z_][A-Za-z0-9_]*$/.test(envToken)) {
|
|
113
|
+
index += 1;
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
if (envToken === "-C" || envToken === "--chdir") {
|
|
117
|
+
if (!tokens[index + 1] || tokens[index + 1].startsWith("-")) {
|
|
118
|
+
return false;
|
|
119
|
+
}
|
|
120
|
+
index += 2;
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
if (/^--chdir=\S+$/.test(envToken)) {
|
|
124
|
+
index += 1;
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
if (/^[A-Za-z_][A-Za-z0-9_]*=\S*$/.test(envToken)) {
|
|
128
|
+
index += 1;
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
break;
|
|
132
|
+
}
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
if (token === "command") {
|
|
136
|
+
index += 1;
|
|
137
|
+
if (tokens[index] === "-p" || tokens[index]?.toLowerCase() === "--default-search-path") {
|
|
138
|
+
index += 1;
|
|
139
|
+
}
|
|
140
|
+
if (tokens[index] === "--") {
|
|
141
|
+
index += 1;
|
|
142
|
+
}
|
|
143
|
+
continue;
|
|
144
|
+
}
|
|
145
|
+
break;
|
|
146
|
+
}
|
|
147
|
+
const downloader = normalizeCommandName(tokens[index] ?? "");
|
|
148
|
+
if (new Set(["curl", "wget", "iwr", "irm", "invoke-webrequest", "invoke-restmethod"]).has(downloader)) {
|
|
149
|
+
return true;
|
|
150
|
+
}
|
|
151
|
+
const shell = normalizeCommandName(tokens[index] ?? "");
|
|
152
|
+
return ((shell === "powershell" || shell === "pwsh") &&
|
|
153
|
+
/^-(?:command|c)$/i.test(tokens[index + 1] ?? "") &&
|
|
154
|
+
new Set(["curl", "wget", "iwr", "irm", "invoke-webrequest", "invoke-restmethod"])
|
|
155
|
+
.has(normalizeCommandName(tokens[index + 2] ?? "")));
|
|
156
|
+
}
|
|
61
157
|
const pathLikeArgFlags = new Set([
|
|
62
158
|
"--config",
|
|
63
159
|
"--config-path",
|
|
@@ -200,16 +296,14 @@ export function auditMcpServerConfig(rootPath, parsedConfig, options = {}) {
|
|
|
200
296
|
const configPath = options.configPath
|
|
201
297
|
? relativePackagePath(rootPath, options.configPath)
|
|
202
298
|
: ".mcp.json";
|
|
203
|
-
|
|
299
|
+
const normalizedConfig = normalizeMcpConfig(parsedConfig);
|
|
300
|
+
if (!normalizedConfig.ok) {
|
|
204
301
|
return [
|
|
205
|
-
buildFinding("fail", "plugin.security.audit_unavailable", "The MCP security audit could not find a valid
|
|
302
|
+
buildFinding("fail", "plugin.security.audit_unavailable", "The MCP security audit could not find a valid MCP server map.", "Without server entries, the audit cannot evaluate command execution or remote transport risk.", "Use a direct server map, `mcp_servers`, or `mcpServers`.", { configPath })
|
|
206
303
|
];
|
|
207
304
|
}
|
|
208
305
|
const findings = [];
|
|
209
|
-
for (const [serverName, serverConfig] of Object.entries(
|
|
210
|
-
if (!isPlainObject(serverConfig)) {
|
|
211
|
-
continue;
|
|
212
|
-
}
|
|
306
|
+
for (const [serverName, serverConfig] of Object.entries(normalizedConfig.servers)) {
|
|
213
307
|
const command = serverConfig.command;
|
|
214
308
|
const args = serverConfig.args;
|
|
215
309
|
const cwd = serverConfig.cwd;
|
|
@@ -395,7 +489,10 @@ async function auditMcpCommandSurface(discoveredPackage) {
|
|
|
395
489
|
function dedupeFindings(findings) {
|
|
396
490
|
const seen = new Set();
|
|
397
491
|
return findings.filter((finding) => {
|
|
398
|
-
const
|
|
492
|
+
const hookLocation = finding.evidence?.sourcePath && finding.evidence.event && finding.evidence.field
|
|
493
|
+
? `${finding.evidence.sourcePath}\n${finding.evidence.event}\n${finding.evidence.field}`
|
|
494
|
+
: "";
|
|
495
|
+
const key = `${finding.id}\n${finding.message}\n${hookLocation}`;
|
|
399
496
|
if (seen.has(key)) {
|
|
400
497
|
return false;
|
|
401
498
|
}
|