codex-plugin-doctor 1.46.1 → 1.48.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 +27 -14
- package/dist/core/attestation.d.ts +1 -0
- package/dist/core/attestation.js +1 -1
- package/dist/core/corpus-quality-metrics.d.ts +125 -0
- package/dist/core/corpus-quality-metrics.js +433 -0
- package/dist/core/external-validation-corpus.d.ts +88 -0
- package/dist/core/external-validation-corpus.js +271 -0
- package/dist/core/init-git-hooks.js +43 -4
- package/dist/core/output-contract.js +25 -2
- package/dist/core/release-check.d.ts +2 -1
- package/dist/core/release-check.js +2 -1
- package/dist/core/release-evidence.d.ts +5 -2
- package/dist/core/release-evidence.js +12 -2
- package/dist/core/runtime-plan.d.ts +5 -1
- package/dist/core/runtime-plan.js +13 -1
- package/dist/core/runtime-policy.d.ts +5 -1
- package/dist/core/runtime-policy.js +3 -2
- package/dist/core/runtime-probe.d.ts +2 -1
- package/dist/core/runtime-probe.js +100 -18
- package/dist/core/runtime-sandbox.d.ts +21 -0
- package/dist/core/runtime-sandbox.js +68 -0
- package/dist/core/validate-plugin.js +19 -8
- package/dist/core/validation-corpus.d.ts +4 -2
- package/dist/core/validation-corpus.js +12 -1
- package/dist/domain/types.d.ts +11 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/reporting/render-json-report.js +3 -0
- package/dist/reporting/render-markdown-report.js +3 -0
- package/dist/reporting/render-text-report.js +3 -0
- package/dist/run-cli.d.ts +2 -0
- package/dist/run-cli.js +214 -19
- package/package.json +1 -1
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
import { stat } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { buildGenericMcpDoctor } from "../mcp/generic-mcp-doctor.js";
|
|
4
|
+
import { packageVersion } from "../version.js";
|
|
5
|
+
import { buildPackageFingerprint } from "./attestation.js";
|
|
6
|
+
import { readJsonFile } from "./read-json-file.js";
|
|
7
|
+
import { validatePlugin } from "./validate-plugin.js";
|
|
8
|
+
export class ExternalCorpusManifestError extends Error {
|
|
9
|
+
constructor(message) {
|
|
10
|
+
super(message);
|
|
11
|
+
this.name = "ExternalCorpusManifestError";
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
const topLevelKeys = new Set(["schemaVersion", "targets"]);
|
|
15
|
+
const targetKeys = new Set([
|
|
16
|
+
"id",
|
|
17
|
+
"profile",
|
|
18
|
+
"sourceType",
|
|
19
|
+
"disclosure",
|
|
20
|
+
"path",
|
|
21
|
+
"mode",
|
|
22
|
+
"contentDigest",
|
|
23
|
+
"expectedStatus",
|
|
24
|
+
"reviews"
|
|
25
|
+
]);
|
|
26
|
+
const reviewKeys = new Set(["findingId", "fingerprint", "classification"]);
|
|
27
|
+
const profiles = new Set(["healthy", "broken", "edge-case"]);
|
|
28
|
+
const sourceTypes = new Set([
|
|
29
|
+
"public-package",
|
|
30
|
+
"local-snapshot",
|
|
31
|
+
"derived-fixture"
|
|
32
|
+
]);
|
|
33
|
+
const modes = new Set(["codex-plugin", "generic-mcp"]);
|
|
34
|
+
const statuses = new Set(["pass", "warn", "fail"]);
|
|
35
|
+
const classifications = new Set([
|
|
36
|
+
"true_positive",
|
|
37
|
+
"false_positive",
|
|
38
|
+
"unclear"
|
|
39
|
+
]);
|
|
40
|
+
const sha256Digest = /^sha256:[a-f0-9]{64}$/;
|
|
41
|
+
const findingFingerprint = /^[a-f0-9]{64}$/;
|
|
42
|
+
function isRecord(value) {
|
|
43
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
44
|
+
}
|
|
45
|
+
function rejectUnknownKeys(value, allowed, label) {
|
|
46
|
+
const unknown = Object.keys(value).filter((key) => !allowed.has(key));
|
|
47
|
+
if (unknown.length > 0) {
|
|
48
|
+
throw new ExternalCorpusManifestError(`${label} contains unsupported field${unknown.length === 1 ? "" : "s"}: ${unknown.join(", ")}.`);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
function requireString(value, label) {
|
|
52
|
+
if (typeof value !== "string" || value.length === 0) {
|
|
53
|
+
throw new ExternalCorpusManifestError(`${label} must be a non-empty string.`);
|
|
54
|
+
}
|
|
55
|
+
return value;
|
|
56
|
+
}
|
|
57
|
+
function parseReview(value, targetId, index) {
|
|
58
|
+
if (!isRecord(value)) {
|
|
59
|
+
throw new ExternalCorpusManifestError(`Corpus target ${targetId} review ${index} must be an object.`);
|
|
60
|
+
}
|
|
61
|
+
rejectUnknownKeys(value, reviewKeys, `Corpus target ${targetId} review ${index}`);
|
|
62
|
+
const findingId = requireString(value.findingId, `Corpus target ${targetId} review ${index} findingId`);
|
|
63
|
+
const fingerprint = requireString(value.fingerprint, `Corpus target ${targetId} review ${index} fingerprint`);
|
|
64
|
+
const classification = value.classification;
|
|
65
|
+
if (typeof classification !== "string" || !classifications.has(classification)) {
|
|
66
|
+
throw new ExternalCorpusManifestError(`Corpus target ${targetId} review ${index} has an unsupported classification.`);
|
|
67
|
+
}
|
|
68
|
+
if (!findingFingerprint.test(fingerprint)) {
|
|
69
|
+
throw new ExternalCorpusManifestError(`Corpus target ${targetId} review ${index} fingerprint must be a sha256 fingerprint.`);
|
|
70
|
+
}
|
|
71
|
+
return {
|
|
72
|
+
findingId,
|
|
73
|
+
fingerprint,
|
|
74
|
+
classification: classification
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
async function parseTarget(value, manifestDirectory, index) {
|
|
78
|
+
if (!isRecord(value)) {
|
|
79
|
+
throw new ExternalCorpusManifestError(`Corpus target ${index} must be an object.`);
|
|
80
|
+
}
|
|
81
|
+
rejectUnknownKeys(value, targetKeys, `Corpus target ${index}`);
|
|
82
|
+
const id = requireString(value.id, `Corpus target ${index} id`);
|
|
83
|
+
const targetPath = requireString(value.path, `Corpus target ${id} path`);
|
|
84
|
+
if (path.posix.isAbsolute(targetPath) || path.win32.isAbsolute(targetPath)) {
|
|
85
|
+
throw new ExternalCorpusManifestError("Corpus target path must be relative to the manifest file.");
|
|
86
|
+
}
|
|
87
|
+
if (typeof value.profile !== "string" || !profiles.has(value.profile)) {
|
|
88
|
+
throw new ExternalCorpusManifestError(`Corpus target ${id} has an unsupported profile.`);
|
|
89
|
+
}
|
|
90
|
+
if (typeof value.sourceType !== "string" || !sourceTypes.has(value.sourceType)) {
|
|
91
|
+
throw new ExternalCorpusManifestError(`Corpus target ${id} has an unsupported sourceType.`);
|
|
92
|
+
}
|
|
93
|
+
if (value.disclosure !== "anonymized") {
|
|
94
|
+
throw new ExternalCorpusManifestError(`Corpus target ${id} disclosure must be anonymized.`);
|
|
95
|
+
}
|
|
96
|
+
if (typeof value.mode !== "string" || !modes.has(value.mode)) {
|
|
97
|
+
throw new ExternalCorpusManifestError(`Corpus target ${id} has an unsupported mode.`);
|
|
98
|
+
}
|
|
99
|
+
if (typeof value.expectedStatus !== "string" || !statuses.has(value.expectedStatus)) {
|
|
100
|
+
throw new ExternalCorpusManifestError(`Corpus target ${id} has an unsupported expectedStatus.`);
|
|
101
|
+
}
|
|
102
|
+
if (typeof value.contentDigest !== "string" || !sha256Digest.test(value.contentDigest)) {
|
|
103
|
+
throw new ExternalCorpusManifestError(`Corpus target ${id} contentDigest must be a sha256 digest.`);
|
|
104
|
+
}
|
|
105
|
+
if (!Array.isArray(value.reviews)) {
|
|
106
|
+
throw new ExternalCorpusManifestError(`Corpus target ${id} reviews must be an array.`);
|
|
107
|
+
}
|
|
108
|
+
const reviews = value.reviews.map((review, reviewIndex) => parseReview(review, id, reviewIndex));
|
|
109
|
+
const reviewKeySet = new Set();
|
|
110
|
+
for (const review of reviews) {
|
|
111
|
+
const key = `${review.findingId}:${review.fingerprint}`;
|
|
112
|
+
if (reviewKeySet.has(key)) {
|
|
113
|
+
throw new ExternalCorpusManifestError(`Corpus target ${id} contains a duplicate finding review.`);
|
|
114
|
+
}
|
|
115
|
+
reviewKeySet.add(key);
|
|
116
|
+
}
|
|
117
|
+
const resolvedPath = path.resolve(manifestDirectory, targetPath);
|
|
118
|
+
try {
|
|
119
|
+
if (!(await stat(resolvedPath)).isDirectory()) {
|
|
120
|
+
throw new Error("not a directory");
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
catch {
|
|
124
|
+
throw new ExternalCorpusManifestError(`Corpus target ${id} directory does not exist.`);
|
|
125
|
+
}
|
|
126
|
+
return {
|
|
127
|
+
id,
|
|
128
|
+
profile: value.profile,
|
|
129
|
+
sourceType: value.sourceType,
|
|
130
|
+
disclosure: "anonymized",
|
|
131
|
+
path: targetPath,
|
|
132
|
+
mode: value.mode,
|
|
133
|
+
contentDigest: value.contentDigest,
|
|
134
|
+
expectedStatus: value.expectedStatus,
|
|
135
|
+
reviews,
|
|
136
|
+
resolvedPath
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
export async function loadExternalCorpusManifest(manifestPath) {
|
|
140
|
+
let value;
|
|
141
|
+
try {
|
|
142
|
+
value = await readJsonFile(path.resolve(manifestPath));
|
|
143
|
+
}
|
|
144
|
+
catch {
|
|
145
|
+
throw new ExternalCorpusManifestError("Corpus manifest could not be read as JSON.");
|
|
146
|
+
}
|
|
147
|
+
if (!isRecord(value)) {
|
|
148
|
+
throw new ExternalCorpusManifestError("Corpus manifest must be an object.");
|
|
149
|
+
}
|
|
150
|
+
rejectUnknownKeys(value, topLevelKeys, "Corpus manifest");
|
|
151
|
+
if (value.schemaVersion !== "1.0.0") {
|
|
152
|
+
throw new ExternalCorpusManifestError("Unsupported corpus manifest schemaVersion.");
|
|
153
|
+
}
|
|
154
|
+
if (!Array.isArray(value.targets) || value.targets.length === 0) {
|
|
155
|
+
throw new ExternalCorpusManifestError("Corpus manifest targets must be a non-empty array.");
|
|
156
|
+
}
|
|
157
|
+
const manifestDirectory = path.dirname(path.resolve(manifestPath));
|
|
158
|
+
const targets = await Promise.all(value.targets.map((target, index) => parseTarget(target, manifestDirectory, index)));
|
|
159
|
+
const seenIds = new Set();
|
|
160
|
+
for (const target of targets) {
|
|
161
|
+
if (seenIds.has(target.id)) {
|
|
162
|
+
throw new ExternalCorpusManifestError(`Corpus target id ${target.id} is duplicated.`);
|
|
163
|
+
}
|
|
164
|
+
seenIds.add(target.id);
|
|
165
|
+
}
|
|
166
|
+
return { schemaVersion: "1.0.0", targets };
|
|
167
|
+
}
|
|
168
|
+
function findingKey(findingId, fingerprint) {
|
|
169
|
+
return `${findingId}:${fingerprint}`;
|
|
170
|
+
}
|
|
171
|
+
function emptyClassificationCounts() {
|
|
172
|
+
return {
|
|
173
|
+
truePositive: 0,
|
|
174
|
+
falsePositive: 0,
|
|
175
|
+
unclear: 0,
|
|
176
|
+
missingExpectedFinding: 0
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
function addClassificationCounts(total, value) {
|
|
180
|
+
return {
|
|
181
|
+
truePositive: total.truePositive + value.truePositive,
|
|
182
|
+
falsePositive: total.falsePositive + value.falsePositive,
|
|
183
|
+
unclear: total.unclear + value.unclear,
|
|
184
|
+
missingExpectedFinding: total.missingExpectedFinding + value.missingExpectedFinding
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
async function evaluateExternalCorpusTarget(target, options) {
|
|
188
|
+
const report = target.mode === "generic-mcp"
|
|
189
|
+
? await buildGenericMcpDoctor(target.resolvedPath, options.environment)
|
|
190
|
+
: await validatePlugin(target.resolvedPath);
|
|
191
|
+
const packageFingerprint = await buildPackageFingerprint(target.resolvedPath);
|
|
192
|
+
const findings = report.findings.map((finding) => {
|
|
193
|
+
if (!finding.fingerprint) {
|
|
194
|
+
throw new ExternalCorpusManifestError(`Corpus target ${target.id} produced a finding without a fingerprint.`);
|
|
195
|
+
}
|
|
196
|
+
return {
|
|
197
|
+
findingId: finding.id,
|
|
198
|
+
fingerprint: finding.fingerprint
|
|
199
|
+
};
|
|
200
|
+
});
|
|
201
|
+
const reviewsByKey = new Map(target.reviews.map((review) => [
|
|
202
|
+
findingKey(review.findingId, review.fingerprint),
|
|
203
|
+
review
|
|
204
|
+
]));
|
|
205
|
+
const actualKeys = new Set(findings.map((finding) => findingKey(finding.findingId, finding.fingerprint)));
|
|
206
|
+
const missingExpectedFinding = target.reviews.filter((review) => review.classification === "true_positive" &&
|
|
207
|
+
!actualKeys.has(findingKey(review.findingId, review.fingerprint))).length;
|
|
208
|
+
const classifiedFindings = findings
|
|
209
|
+
.map((finding) => ({
|
|
210
|
+
...finding,
|
|
211
|
+
classification: reviewsByKey.get(findingKey(finding.findingId, finding.fingerprint))?.classification ?? "unreviewed"
|
|
212
|
+
}))
|
|
213
|
+
.sort((left, right) => findingKey(left.findingId, left.fingerprint)
|
|
214
|
+
.localeCompare(findingKey(right.findingId, right.fingerprint)));
|
|
215
|
+
const classificationCounts = {
|
|
216
|
+
truePositive: classifiedFindings.filter((finding) => finding.classification === "true_positive").length,
|
|
217
|
+
falsePositive: classifiedFindings.filter((finding) => finding.classification === "false_positive").length,
|
|
218
|
+
unclear: classifiedFindings.filter((finding) => finding.classification === "unclear").length,
|
|
219
|
+
missingExpectedFinding
|
|
220
|
+
};
|
|
221
|
+
const digestMatched = packageFingerprint.digest === target.contentDigest;
|
|
222
|
+
const expectationMatched = digestMatched &&
|
|
223
|
+
report.status === target.expectedStatus &&
|
|
224
|
+
classificationCounts.falsePositive === 0 &&
|
|
225
|
+
classificationCounts.unclear === 0 &&
|
|
226
|
+
classificationCounts.missingExpectedFinding === 0 &&
|
|
227
|
+
classifiedFindings.every((finding) => finding.classification !== "unreviewed");
|
|
228
|
+
return {
|
|
229
|
+
id: target.id,
|
|
230
|
+
profile: target.profile,
|
|
231
|
+
sourceType: target.sourceType,
|
|
232
|
+
disclosure: target.disclosure,
|
|
233
|
+
mode: target.mode,
|
|
234
|
+
expected: {
|
|
235
|
+
status: target.expectedStatus,
|
|
236
|
+
contentDigest: target.contentDigest,
|
|
237
|
+
findings: [...target.reviews].sort((left, right) => findingKey(left.findingId, left.fingerprint)
|
|
238
|
+
.localeCompare(findingKey(right.findingId, right.fingerprint)))
|
|
239
|
+
},
|
|
240
|
+
actual: {
|
|
241
|
+
status: report.status,
|
|
242
|
+
contentDigest: packageFingerprint.digest,
|
|
243
|
+
findings: classifiedFindings
|
|
244
|
+
},
|
|
245
|
+
digestMatched,
|
|
246
|
+
classificationCounts,
|
|
247
|
+
expectationMatched
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
export async function buildExternalValidationCorpusReport(manifestPath, options = {}) {
|
|
251
|
+
const manifest = await loadExternalCorpusManifest(manifestPath);
|
|
252
|
+
const cases = await Promise.all(manifest.targets.map((target) => evaluateExternalCorpusTarget(target, options)));
|
|
253
|
+
const failedExpectations = cases.filter((result) => !result.expectationMatched).length;
|
|
254
|
+
const classificationCounts = cases.reduce((total, result) => addClassificationCounts(total, result.classificationCounts), emptyClassificationCounts());
|
|
255
|
+
return {
|
|
256
|
+
schemaVersion: "1.0.0",
|
|
257
|
+
kind: "doctor.validation.corpus",
|
|
258
|
+
corpusType: "external",
|
|
259
|
+
generatedAt: new Date().toISOString(),
|
|
260
|
+
version: packageVersion,
|
|
261
|
+
summary: {
|
|
262
|
+
status: failedExpectations === 0 ? "pass" : "fail",
|
|
263
|
+
caseCount: cases.length,
|
|
264
|
+
passedExpectations: cases.length - failedExpectations,
|
|
265
|
+
failedExpectations,
|
|
266
|
+
runtimeCases: 0,
|
|
267
|
+
classificationCounts
|
|
268
|
+
},
|
|
269
|
+
cases
|
|
270
|
+
};
|
|
271
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { chmod, mkdir, stat, writeFile } from "node:fs/promises";
|
|
1
|
+
import { chmod, mkdir, readFile, stat, writeFile } from "node:fs/promises";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
const isWindows = process.platform === "win32";
|
|
4
4
|
const preCommitHook = isWindows
|
|
@@ -81,6 +81,45 @@ async function fileExists(filePath) {
|
|
|
81
81
|
return false;
|
|
82
82
|
}
|
|
83
83
|
}
|
|
84
|
+
async function resolveHooksDirectory(rootPath) {
|
|
85
|
+
const dotGitPath = path.join(rootPath, ".git");
|
|
86
|
+
let dotGitStat;
|
|
87
|
+
try {
|
|
88
|
+
dotGitStat = await stat(dotGitPath);
|
|
89
|
+
}
|
|
90
|
+
catch (error) {
|
|
91
|
+
if (error.code === "ENOENT") {
|
|
92
|
+
return path.join(dotGitPath, "hooks");
|
|
93
|
+
}
|
|
94
|
+
throw error;
|
|
95
|
+
}
|
|
96
|
+
if (dotGitStat.isDirectory()) {
|
|
97
|
+
return path.join(dotGitPath, "hooks");
|
|
98
|
+
}
|
|
99
|
+
let gitFile;
|
|
100
|
+
try {
|
|
101
|
+
gitFile = (await readFile(dotGitPath, "utf8")).trim();
|
|
102
|
+
}
|
|
103
|
+
catch {
|
|
104
|
+
throw new Error("Invalid linked-worktree .git file.");
|
|
105
|
+
}
|
|
106
|
+
const match = /^gitdir:\s*(.+)$/i.exec(gitFile);
|
|
107
|
+
if (!match) {
|
|
108
|
+
throw new Error("Invalid linked-worktree .git file.");
|
|
109
|
+
}
|
|
110
|
+
const worktreeGitDir = path.resolve(rootPath, match[1]);
|
|
111
|
+
let commonDirValue;
|
|
112
|
+
try {
|
|
113
|
+
commonDirValue = (await readFile(path.join(worktreeGitDir, "commondir"), "utf8")).trim();
|
|
114
|
+
}
|
|
115
|
+
catch {
|
|
116
|
+
throw new Error("Invalid linked-worktree commondir file.");
|
|
117
|
+
}
|
|
118
|
+
if (!commonDirValue) {
|
|
119
|
+
throw new Error("Invalid linked-worktree commondir file.");
|
|
120
|
+
}
|
|
121
|
+
return path.join(path.resolve(worktreeGitDir, commonDirValue), "hooks");
|
|
122
|
+
}
|
|
84
123
|
async function writeHook(hookPath, content, force) {
|
|
85
124
|
const exists = await fileExists(hookPath);
|
|
86
125
|
if (exists && !force) {
|
|
@@ -94,14 +133,14 @@ async function writeHook(hookPath, content, force) {
|
|
|
94
133
|
}
|
|
95
134
|
export async function removeGitHooks(targetPath) {
|
|
96
135
|
const rootPath = path.resolve(targetPath);
|
|
97
|
-
const hooksDir =
|
|
136
|
+
const hooksDir = await resolveHooksDirectory(rootPath);
|
|
98
137
|
const hookNames = Array.from(new Set([`pre-commit${hookExtension}`, `pre-push${hookExtension}`, "pre-commit", "pre-push"]));
|
|
99
138
|
const removed = [];
|
|
100
139
|
const skipped = [];
|
|
101
140
|
for (const name of hookNames) {
|
|
102
141
|
const hookPath = path.join(hooksDir, name);
|
|
103
142
|
if (await fileExists(hookPath)) {
|
|
104
|
-
const content = await
|
|
143
|
+
const content = await readFile(hookPath, "utf8");
|
|
105
144
|
if (content.includes("Generated by codex-plugin-doctor init git-hooks")) {
|
|
106
145
|
await import("node:fs/promises").then((m) => m.rm(hookPath));
|
|
107
146
|
removed.push(hookPath);
|
|
@@ -115,7 +154,7 @@ export async function removeGitHooks(targetPath) {
|
|
|
115
154
|
}
|
|
116
155
|
export async function initGitHooks(targetPath, options = {}) {
|
|
117
156
|
const rootPath = path.resolve(targetPath);
|
|
118
|
-
const hooksDir =
|
|
157
|
+
const hooksDir = await resolveHooksDirectory(rootPath);
|
|
119
158
|
const force = options.force ?? false;
|
|
120
159
|
await mkdir(hooksDir, { recursive: true });
|
|
121
160
|
const hookPaths = [];
|
|
@@ -95,6 +95,29 @@ const publicSchemaDefinitions = [
|
|
|
95
95
|
outputKind: "doctor.validation.corpus",
|
|
96
96
|
required: ["schemaVersion", "kind", "generatedAt", "version", "summary", "cases"]
|
|
97
97
|
},
|
|
98
|
+
{
|
|
99
|
+
id: "doctor.validation.corpus.external.json",
|
|
100
|
+
command: "codex-plugin-doctor doctor corpus --manifest <corpus.json> --json",
|
|
101
|
+
outputKind: "doctor.validation.corpus",
|
|
102
|
+
required: ["schemaVersion", "kind", "corpusType", "generatedAt", "version", "summary", "cases"]
|
|
103
|
+
},
|
|
104
|
+
{
|
|
105
|
+
id: "doctor.validation.corpus.metrics.json",
|
|
106
|
+
command: "codex-plugin-doctor doctor corpus metrics --manifest <corpus.json> --json",
|
|
107
|
+
outputKind: "doctor.validation.corpus.metrics",
|
|
108
|
+
required: [
|
|
109
|
+
"schemaVersion",
|
|
110
|
+
"kind",
|
|
111
|
+
"generatedAt",
|
|
112
|
+
"version",
|
|
113
|
+
"status",
|
|
114
|
+
"exitCode",
|
|
115
|
+
"summary",
|
|
116
|
+
"thresholds",
|
|
117
|
+
"thresholdChecks",
|
|
118
|
+
"targets"
|
|
119
|
+
]
|
|
120
|
+
},
|
|
98
121
|
{
|
|
99
122
|
id: "doctor.recommendations.json",
|
|
100
123
|
command: "codex-plugin-doctor doctor recommend <path> --json",
|
|
@@ -115,13 +138,13 @@ const publicSchemaDefinitions = [
|
|
|
115
138
|
id: "doctor.runtime.plan.json",
|
|
116
139
|
command: "codex-plugin-doctor doctor runtime-plan <path> --json",
|
|
117
140
|
outputKind: "doctor.runtime.plan",
|
|
118
|
-
required: ["schemaVersion", "kind", "generatedAt", "version", "targetPath", "status", "exitCode", "runtimeExecution", "digest", "summary", "servers", "findings"]
|
|
141
|
+
required: ["schemaVersion", "kind", "generatedAt", "version", "targetPath", "status", "exitCode", "runtimeExecution", "execution", "digest", "summary", "servers", "findings"]
|
|
119
142
|
},
|
|
120
143
|
{
|
|
121
144
|
id: "doctor.runtime.policy.json",
|
|
122
145
|
command: "codex-plugin-doctor doctor runtime-policy <path> --json",
|
|
123
146
|
outputKind: "doctor.runtime.policy",
|
|
124
|
-
required: ["schemaVersion", "kind", "generatedAt", "version", "targetPath", "status", "exitCode", "runtimeExecution", "planDigest", "recommendation", "summary", "servers"]
|
|
147
|
+
required: ["schemaVersion", "kind", "generatedAt", "version", "targetPath", "status", "exitCode", "runtimeExecution", "execution", "planDigest", "recommendation", "summary", "servers"]
|
|
125
148
|
},
|
|
126
149
|
{
|
|
127
150
|
id: "doctor.review.bundle.json",
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { CheckOptions, CheckResult } from "../domain/types.js";
|
|
1
|
+
import type { CheckOptions, CheckResult, RuntimeSandboxMode } from "../domain/types.js";
|
|
2
2
|
export interface ReleaseCheckReport {
|
|
3
3
|
targetPath: string;
|
|
4
4
|
status: "pass" | "fail";
|
|
@@ -41,6 +41,7 @@ export interface BuildReleaseCheckOptions {
|
|
|
41
41
|
env?: Record<string, string | undefined>;
|
|
42
42
|
platform?: NodeJS.Platform;
|
|
43
43
|
runtime?: boolean;
|
|
44
|
+
runtimeSandbox?: RuntimeSandboxMode;
|
|
44
45
|
runCheck?: (targetPath: string, options: CheckOptions) => Promise<CheckResult>;
|
|
45
46
|
}
|
|
46
47
|
export declare function buildReleaseMetadataCheck(targetPath: string): Promise<ReleaseMetadataCheck>;
|
|
@@ -105,7 +105,8 @@ export async function buildReleaseCheck(targetPath, options = {}) {
|
|
|
105
105
|
const platform = options.platform ?? process.platform;
|
|
106
106
|
const runtimeProbeEnabled = options.runtime ?? false;
|
|
107
107
|
const validationResult = await (options.runCheck ?? validatePlugin)(resolvedPath, {
|
|
108
|
-
runtime: runtimeProbeEnabled
|
|
108
|
+
runtime: runtimeProbeEnabled,
|
|
109
|
+
...(options.runtimeSandbox ? { runtimeSandbox: options.runtimeSandbox } : {})
|
|
109
110
|
});
|
|
110
111
|
const securityResult = await buildSecurityAudit(resolvedPath);
|
|
111
112
|
const depResult = await buildDepAudit(resolvedPath);
|
|
@@ -5,7 +5,7 @@ import { type SecurityAudit } from "../security/security-audit.js";
|
|
|
5
5
|
import { type TrustScoreReport } from "../security/trust-score.js";
|
|
6
6
|
import { type RuntimeApprovalReport } from "./runtime-plan.js";
|
|
7
7
|
import type { CompatibilityEnvironment } from "../compatibility/compatibility-matrix.js";
|
|
8
|
-
import type { CheckResult } from "../domain/types.js";
|
|
8
|
+
import type { CheckOptions, CheckResult, RuntimeExecutionEvidence, RuntimeSandboxMode } from "../domain/types.js";
|
|
9
9
|
type EvidenceStatus = "pass" | "warn" | "fail";
|
|
10
10
|
export interface DoctorReleaseEvidenceSignature {
|
|
11
11
|
status: "signed";
|
|
@@ -52,6 +52,7 @@ export interface DoctorReleaseEvidenceReport {
|
|
|
52
52
|
}>;
|
|
53
53
|
};
|
|
54
54
|
runtimeApproval: RuntimeApprovalReport;
|
|
55
|
+
execution?: RuntimeExecutionEvidence;
|
|
55
56
|
package: DoctorReleaseEvidencePackageMetadata;
|
|
56
57
|
git: DoctorReleaseEvidenceGitMetadata;
|
|
57
58
|
attestation: DoctorAttestation;
|
|
@@ -109,8 +110,10 @@ export interface BuildDoctorReleaseEvidenceOptions {
|
|
|
109
110
|
allowUntagged?: boolean;
|
|
110
111
|
requireRuntimeApproval?: boolean;
|
|
111
112
|
runtimeApprovalDigest?: string | null;
|
|
113
|
+
runtime?: boolean;
|
|
114
|
+
sandbox?: RuntimeSandboxMode;
|
|
112
115
|
environment?: CompatibilityEnvironment;
|
|
113
|
-
runCheck?: (targetPath: string) => Promise<CheckResult>;
|
|
116
|
+
runCheck?: (targetPath: string, options?: CheckOptions) => Promise<CheckResult>;
|
|
114
117
|
performanceThresholds?: DoctorPerformanceThresholdOptions;
|
|
115
118
|
}
|
|
116
119
|
export declare function buildDoctorReleaseEvidenceReport(targetPath: string, options: BuildDoctorReleaseEvidenceOptions): Promise<DoctorReleaseEvidenceReport>;
|
|
@@ -10,6 +10,7 @@ import { readJsonFile } from "./read-json-file.js";
|
|
|
10
10
|
import { buildSecurityAudit } from "../security/security-audit.js";
|
|
11
11
|
import { buildTrustScore } from "../security/trust-score.js";
|
|
12
12
|
import { buildDoctorRuntimePlan, evaluateRuntimeApproval, runtimeApprovalPassed } from "./runtime-plan.js";
|
|
13
|
+
import { validatePlugin } from "./validate-plugin.js";
|
|
13
14
|
import { packageVersion } from "../version.js";
|
|
14
15
|
const execFileAsync = promisify(execFile);
|
|
15
16
|
function isPlainObject(value) {
|
|
@@ -152,6 +153,7 @@ function buildReleaseEvidenceSigningPayload(report) {
|
|
|
152
153
|
git: report.git,
|
|
153
154
|
releaseGates: report.releaseGates,
|
|
154
155
|
runtimeApproval: report.runtimeApproval,
|
|
156
|
+
...(report.execution ? { execution: report.execution } : {}),
|
|
155
157
|
attestation: {
|
|
156
158
|
version: report.attestation.version,
|
|
157
159
|
subject: report.attestation.subject,
|
|
@@ -193,6 +195,13 @@ function signReleaseEvidence(report, signingKey, keyHint) {
|
|
|
193
195
|
export async function buildDoctorReleaseEvidenceReport(targetPath, options) {
|
|
194
196
|
const rootPath = path.resolve(targetPath);
|
|
195
197
|
const security = await buildSecurityAudit(rootPath);
|
|
198
|
+
const runCheck = options.runCheck ?? validatePlugin;
|
|
199
|
+
const checkOptions = options.runtime
|
|
200
|
+
? {
|
|
201
|
+
runtime: true,
|
|
202
|
+
...(options.sandbox ? { runtimeSandbox: options.sandbox } : {})
|
|
203
|
+
}
|
|
204
|
+
: {};
|
|
196
205
|
const [attestation, corpus, performance, trust, packageMetadata, git, runtimePlan] = await Promise.all([
|
|
197
206
|
buildDoctorAttestation(rootPath, {
|
|
198
207
|
signingKey: options.signingKey,
|
|
@@ -202,13 +211,13 @@ export async function buildDoctorReleaseEvidenceReport(targetPath, options) {
|
|
|
202
211
|
buildDoctorValidationCorpusReport({ environment: options.environment }),
|
|
203
212
|
buildDoctorPerformanceReport(rootPath, {
|
|
204
213
|
environment: options.environment,
|
|
205
|
-
runCheck:
|
|
214
|
+
runCheck: (pathToCheck) => runCheck(pathToCheck, checkOptions),
|
|
206
215
|
thresholds: options.performanceThresholds
|
|
207
216
|
}),
|
|
208
217
|
buildTrustScore(rootPath, { securityAudit: security }),
|
|
209
218
|
readPackageMetadata(rootPath),
|
|
210
219
|
readGitMetadata(rootPath),
|
|
211
|
-
buildDoctorRuntimePlan(rootPath)
|
|
220
|
+
buildDoctorRuntimePlan(rootPath, new Date().toISOString(), options.sandbox ? { sandbox: options.sandbox } : {})
|
|
212
221
|
]);
|
|
213
222
|
const normalizedPackageMetadata = {
|
|
214
223
|
name: packageMetadata.name ?? attestation.subject.name,
|
|
@@ -244,6 +253,7 @@ export async function buildDoctorReleaseEvidenceReport(targetPath, options) {
|
|
|
244
253
|
git,
|
|
245
254
|
releaseGates,
|
|
246
255
|
runtimeApproval,
|
|
256
|
+
...(options.runtime ? { execution: runtimePlan.execution } : {}),
|
|
247
257
|
attestation,
|
|
248
258
|
attestationVerification,
|
|
249
259
|
corpus,
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { type SecurityAudit } from "../security/security-audit.js";
|
|
2
2
|
import type { Finding } from "../domain/types.js";
|
|
3
|
+
import type { RuntimeExecutionEvidence, RuntimeSandboxMode } from "../domain/types.js";
|
|
3
4
|
type RuntimePlanStatus = "pass" | "warn" | "fail";
|
|
4
5
|
type RuntimePlanRiskLevel = "low" | "medium" | "high";
|
|
5
6
|
type RuntimePlanTransport = "stdio" | "http";
|
|
@@ -23,6 +24,7 @@ export interface DoctorRuntimePlan {
|
|
|
23
24
|
status: RuntimePlanStatus;
|
|
24
25
|
exitCode: 0 | 1;
|
|
25
26
|
runtimeExecution: "not_started";
|
|
27
|
+
execution: RuntimeExecutionEvidence;
|
|
26
28
|
digest: string;
|
|
27
29
|
summary: {
|
|
28
30
|
serverCount: number;
|
|
@@ -40,7 +42,9 @@ export interface RuntimeApprovalReport {
|
|
|
40
42
|
approvedDigest: string | null;
|
|
41
43
|
message: string;
|
|
42
44
|
}
|
|
43
|
-
export declare function buildDoctorRuntimePlan(targetPath: string, generatedAt?: string
|
|
45
|
+
export declare function buildDoctorRuntimePlan(targetPath: string, generatedAt?: string, options?: {
|
|
46
|
+
sandbox?: RuntimeSandboxMode;
|
|
47
|
+
}): Promise<DoctorRuntimePlan>;
|
|
44
48
|
export declare function evaluateRuntimeApproval(plan: DoctorRuntimePlan, options: {
|
|
45
49
|
required: boolean;
|
|
46
50
|
approvedDigest?: string | null;
|
|
@@ -4,6 +4,7 @@ import { packageVersion } from "../version.js";
|
|
|
4
4
|
import { discoverPackage } from "./discover-package.js";
|
|
5
5
|
import { readJsonFile } from "./read-json-file.js";
|
|
6
6
|
import { buildSecurityAudit } from "../security/security-audit.js";
|
|
7
|
+
import { DOCKER_RUNTIME_IMAGE } from "./runtime-sandbox.js";
|
|
7
8
|
function isPlainObject(value) {
|
|
8
9
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
9
10
|
}
|
|
@@ -51,6 +52,7 @@ function planDigestPayload(plan) {
|
|
|
51
52
|
schemaVersion: plan.schemaVersion,
|
|
52
53
|
kind: "doctor.runtime.plan.digest.v1",
|
|
53
54
|
version: plan.version,
|
|
55
|
+
execution: plan.execution,
|
|
54
56
|
status: plan.status,
|
|
55
57
|
summary: plan.summary,
|
|
56
58
|
servers: plan.servers,
|
|
@@ -64,10 +66,18 @@ function planDigestPayload(plan) {
|
|
|
64
66
|
function buildRuntimePlanDigest(plan) {
|
|
65
67
|
return sha256(stableStringify(planDigestPayload(plan)));
|
|
66
68
|
}
|
|
67
|
-
export async function buildDoctorRuntimePlan(targetPath, generatedAt = new Date().toISOString()) {
|
|
69
|
+
export async function buildDoctorRuntimePlan(targetPath, generatedAt = new Date().toISOString(), options = {}) {
|
|
68
70
|
const rootPath = path.resolve(targetPath);
|
|
69
71
|
const discoveredPackage = await discoverPackage(rootPath);
|
|
70
72
|
const security = await buildSecurityAudit(rootPath);
|
|
73
|
+
const execution = options.sandbox === "docker"
|
|
74
|
+
? {
|
|
75
|
+
backend: "docker",
|
|
76
|
+
image: DOCKER_RUNTIME_IMAGE,
|
|
77
|
+
network: "none",
|
|
78
|
+
packageMount: "read_only"
|
|
79
|
+
}
|
|
80
|
+
: { backend: "native", image: null, network: "host", packageMount: "host" };
|
|
71
81
|
if (!discoveredPackage?.manifest.mcpServers) {
|
|
72
82
|
const partialPlan = {
|
|
73
83
|
schemaVersion: "1.0.0",
|
|
@@ -77,6 +87,7 @@ export async function buildDoctorRuntimePlan(targetPath, generatedAt = new Date(
|
|
|
77
87
|
status: security.status,
|
|
78
88
|
exitCode: (security.status === "fail" ? 1 : 0),
|
|
79
89
|
runtimeExecution: "not_started",
|
|
90
|
+
execution,
|
|
80
91
|
summary: {
|
|
81
92
|
serverCount: 0,
|
|
82
93
|
executableServerCount: 0,
|
|
@@ -146,6 +157,7 @@ export async function buildDoctorRuntimePlan(targetPath, generatedAt = new Date(
|
|
|
146
157
|
: "pass",
|
|
147
158
|
exitCode: (highRiskServerCount > 0 ? 1 : 0),
|
|
148
159
|
runtimeExecution: "not_started",
|
|
160
|
+
execution,
|
|
149
161
|
summary: {
|
|
150
162
|
serverCount: servers.length,
|
|
151
163
|
executableServerCount: servers.filter((server) => server.command).length,
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { RuntimeExecutionEvidence, RuntimeSandboxMode } from "../domain/types.js";
|
|
1
2
|
import { type DoctorRuntimePlan, type RuntimePlanServer } from "./runtime-plan.js";
|
|
2
3
|
export type RuntimePolicyDecision = "allow" | "review" | "sandbox_recommended" | "deny";
|
|
3
4
|
export interface RuntimePolicyRecommendation {
|
|
@@ -14,6 +15,7 @@ export interface DoctorRuntimePolicyReport {
|
|
|
14
15
|
status: "pass" | "warn" | "fail";
|
|
15
16
|
exitCode: 0 | 1;
|
|
16
17
|
runtimeExecution: "not_started";
|
|
18
|
+
execution: RuntimeExecutionEvidence;
|
|
17
19
|
planDigest: string;
|
|
18
20
|
recommendation: RuntimePolicyRecommendation;
|
|
19
21
|
summary: {
|
|
@@ -29,6 +31,8 @@ export interface DoctorRuntimePolicyReport {
|
|
|
29
31
|
reasons: string[];
|
|
30
32
|
}>;
|
|
31
33
|
}
|
|
32
|
-
export declare function buildDoctorRuntimePolicyReport(targetPath: string, generatedAt?: string
|
|
34
|
+
export declare function buildDoctorRuntimePolicyReport(targetPath: string, generatedAt?: string, options?: {
|
|
35
|
+
sandbox?: RuntimeSandboxMode;
|
|
36
|
+
}): Promise<DoctorRuntimePolicyReport>;
|
|
33
37
|
export declare function renderDoctorRuntimePolicyJson(report: DoctorRuntimePolicyReport): string;
|
|
34
38
|
export declare function renderDoctorRuntimePolicy(report: DoctorRuntimePolicyReport): string;
|
|
@@ -91,8 +91,8 @@ function buildRecommendation(plan, decision, reasons) {
|
|
|
91
91
|
]
|
|
92
92
|
};
|
|
93
93
|
}
|
|
94
|
-
export async function buildDoctorRuntimePolicyReport(targetPath, generatedAt = new Date().toISOString()) {
|
|
95
|
-
const plan = await buildDoctorRuntimePlan(targetPath, generatedAt);
|
|
94
|
+
export async function buildDoctorRuntimePolicyReport(targetPath, generatedAt = new Date().toISOString(), options = {}) {
|
|
95
|
+
const plan = await buildDoctorRuntimePlan(targetPath, generatedAt, options);
|
|
96
96
|
const servers = plan.servers.map((server) => ({
|
|
97
97
|
name: server.name,
|
|
98
98
|
decision: serverDecision(server),
|
|
@@ -118,6 +118,7 @@ export async function buildDoctorRuntimePolicyReport(targetPath, generatedAt = n
|
|
|
118
118
|
status,
|
|
119
119
|
exitCode: status === "fail" ? 1 : 0,
|
|
120
120
|
runtimeExecution: "not_started",
|
|
121
|
+
execution: plan.execution,
|
|
121
122
|
planDigest: plan.digest,
|
|
122
123
|
recommendation,
|
|
123
124
|
summary: {
|
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import type { DiscoveredPackage, RuntimeProbeResult } from "../domain/types.js";
|
|
1
|
+
import type { DiscoveredPackage, RuntimeProbeResult, RuntimeSandboxMode } from "../domain/types.js";
|
|
2
2
|
export declare function probeRuntime(discoveredPackage: DiscoveredPackage, options?: {
|
|
3
3
|
startupTimeoutMs?: number;
|
|
4
|
+
sandbox?: RuntimeSandboxMode;
|
|
4
5
|
transcript?: (line: string) => void;
|
|
5
6
|
}): Promise<RuntimeProbeResult>;
|