codex-plugin-doctor 1.46.0 → 1.47.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -79,6 +79,7 @@ Runtime MCP validation with `--runtime`:
79
79
  - runtime capability scorecard
80
80
  - redacted verbose transcript with `--verbose-runtime`
81
81
  - optional runtime approval gating with a precomputed `doctor runtime-plan` digest
82
+ - optional Docker isolation for local Node.js stdio servers with `--sandbox docker`
82
83
 
83
84
  Output formats:
84
85
 
@@ -195,6 +196,7 @@ codex-plugin-doctor doctor contract
195
196
  codex-plugin-doctor doctor contract --json --output output-contract.json
196
197
  codex-plugin-doctor doctor corpus
197
198
  codex-plugin-doctor doctor corpus --json --output validation-corpus.json
199
+ codex-plugin-doctor doctor corpus --manifest ../private-corpus/corpus.json --json
198
200
  codex-plugin-doctor doctor npm <published-plugin-package>
199
201
  codex-plugin-doctor doctor npm <published-plugin-package> --json --output npm-preinstall.json
200
202
  codex-plugin-doctor doctor attest .
@@ -216,8 +218,10 @@ codex-plugin-doctor doctor perf . --max-total-ms 2500 --max-stage-ms validation=
216
218
  codex-plugin-doctor doctor runtime-plan .
217
219
  codex-plugin-doctor doctor runtime-plan . --json --output runtime-plan.json
218
220
  codex-plugin-doctor doctor runtime-plan . --markdown --output runtime-plan.md
221
+ codex-plugin-doctor doctor runtime-plan . --sandbox docker --json
219
222
  codex-plugin-doctor doctor runtime-policy .
220
223
  codex-plugin-doctor doctor runtime-policy . --json --output runtime-policy.json
224
+ codex-plugin-doctor doctor runtime-policy . --sandbox docker --json
221
225
  codex-plugin-doctor doctor review-bundle . --output review-bundle --sign-key-env CODEX_PLUGIN_DOCTOR_SIGNING_KEY
222
226
  codex-plugin-doctor doctor review-bundle verify review-bundle --target . --sign-key-env CODEX_PLUGIN_DOCTOR_SIGNING_KEY
223
227
  codex-plugin-doctor doctor review-bundle diff --before old-review-bundle --after review-bundle
@@ -287,6 +291,13 @@ codex-plugin-doctor check . --ascii
287
291
  codex-plugin-doctor check . --no-animations
288
292
  codex-plugin-doctor check . --runtime
289
293
  codex-plugin-doctor check . --runtime --require-runtime-approval --runtime-approval-digest sha256:<approved-plan-digest>
294
+ codex-plugin-doctor check . --runtime --sandbox docker
295
+ codex-plugin-doctor check . --runtime --sandbox docker --require-runtime-approval --runtime-approval-digest sha256:<approved-docker-plan-digest>
296
+ codex-plugin-doctor release check .
297
+ codex-plugin-doctor release check . --json
298
+ codex-plugin-doctor release check . --runtime
299
+ codex-plugin-doctor release check . --runtime --require-runtime-approval --runtime-approval-digest sha256:<approved-plan-digest>
300
+ codex-plugin-doctor release check . --runtime --sandbox docker --require-runtime-approval --runtime-approval-digest sha256:<approved-docker-plan-digest>
290
301
  codex-plugin-doctor check . --config .codex-doctor.json
291
302
  codex-plugin-doctor baseline create . --output .codex-doctor-baseline.json
292
303
  codex-plugin-doctor check . --baseline .codex-doctor-baseline.json
@@ -300,6 +311,10 @@ codex-plugin-doctor fix . --apply --backup
300
311
  codex-plugin-doctor check . --json --runtime --verbose-runtime
301
312
  ```
302
313
 
314
+ Docker runtime mode supports local Node.js stdio MCP servers. It uses a digest-pinned image, no network, read-only container and package filesystems, an unprivileged user, dropped capabilities, bounded resources, and a limited writable `/tmp`. It fails closed without native fallback and rejects package roots containing commas. The Docker daemon, base image, and host kernel remain trusted boundaries. Generate approval digests with the same `--sandbox docker` backend used for execution. See [Runtime Approval And Sandboxing](./docs/security/runtime-approval-and-sandboxing.md).
315
+
316
+ External corpus evaluation is static and offline. `doctor corpus --manifest <corpus.json>` validates anonymized local snapshots using stable content digests and exact finding fingerprints without downloading dependencies or starting target servers. Reports omit manifest and package paths and fail on digest drift, missing expected findings, present disputed findings, or unreviewed findings. See [Real-World Validation](./docs/guides/real-world-validation.md).
317
+
303
318
  `self-test` runs the bundled runtime-complete sample through static validation, runtime MCP probes, and the compatibility scorecard. It is the fastest post-install check after `npm install -g codex-plugin-doctor`.
304
319
 
305
320
  `doctor` checks the local environment, including package version, platform, Node version, npm global prefix, Codex home, and Codex plugin cache visibility. The text output also includes recommended next commands for self-test, installed plugin discovery, runtime checks, compatibility scoring, and CI setup. `doctor contract` publishes the machine-readable output contract, including public JSON schema surfaces, stable-through-1.0 compatibility metadata, and a frozen rule catalog digest. Add `--json` for automation or `--output output-contract.json` to write the contract to disk. `doctor corpus` runs the bundled validation corpus against healthy runtime, risky security, starter skill, and generic MCP packages, then reports whether each case matched its expected outcome. Add `--json` for automation or `--output validation-corpus.json` to write the corpus report to disk. `doctor npm <package>` runs a preinstall scan by packing the npm package with scripts disabled, extracting the publish tarball, and running validation, security, trust, and recommendation checks against the shipped contents. The JSON report includes tarball metadata such as filename, path, integrity, shasum, sizes, file count, and extracted package root so automation can prove which archive was scanned. Use a published Codex plugin package as the target; scanning `codex-plugin-doctor` itself intentionally reports a missing plugin manifest because this CLI package is not a plugin package. Add `--json` for automation or `--output npm-preinstall.json` to write the report to disk. `doctor attest <path>` creates a local attestation with stable package/report digests, validation/security/compatibility/trust summary, and verification metadata. Add `--sign-key-env NAME` to attach a local HMAC-SHA256 signature without printing the secret, or `--json --output attestation.json` to write the artifact to disk. `doctor attest verify <attestation.json> --target <path> --sign-key-env NAME` recomputes the package fingerprint, report digest, and HMAC signature offline; verification intentionally treats `generatedAt`, `targetPath`, `verification`, and `signature.keyHint` as unsigned display metadata. `doctor runtime-plan <path>` creates a non-executing runtime plan that lists MCP server commands, safe probe methods, risk reasons, and a stable approval digest before any local server is started. Add `--markdown --output runtime-plan.md` to preserve a review-ready approval artifact with the execution boundary, checklist, servers, probes, and risk reasons. `doctor runtime-policy <path>` evaluates the same runtime plan and security signals, then recommends `allow`, `review`, `sandbox_recommended`, or `deny` before local MCP execution starts. `doctor review-bundle <path> --output <dir> --sign-key-env NAME` writes a signed review directory with runtime plan, runtime policy, attestation, release evidence, manifest, Markdown summary files, and SHA-256 file integrity digests. `doctor review-bundle verify <bundle-dir> --target <path> --sign-key-env NAME` verifies the bundle manifest, expected files, manifest integrity digests, runtime artifacts, signed attestation, and signed release evidence offline before a reviewer trusts the handoff. `doctor review-bundle diff --before <dir> --after <dir>` compares two review bundles and flags risk-increasing changes in status, runtime policy, release readiness, signatures, release evidence, and runtime plan digest. `check --runtime --require-runtime-approval --runtime-approval-digest <digest>` refuses to run runtime probes unless the current plan digest matches the approved digest. `doctor release-evidence <path> --sign-key-env NAME` creates one redacted release bundle with signed attestation, offline verification, corpus, performance, security, trust, package metadata, git release gates, and runtime approval status. Strict release evidence requires a clean tagged worktree; use `--allow-dirty` or `--allow-untagged` only for local rehearsal. `doctor release-evidence verify <evidence.json> --target <path> --sign-key-env NAME` verifies a shared release evidence artifact offline against an explicit package path; the artifact target path is treated as display metadata, not trusted input. `doctor release-evidence asset <path> --tag <tag> --output <evidence.json> --sign-key-env NAME` writes a signed release evidence file and prints the `gh release upload` command; add `--upload` to run the upload through GitHub CLI with `--clobber`. `doctor inspector <path>` builds a safe MCP Inspector launch command from a packaged `.mcp.json` file without starting the Inspector proxy automatically. Use `--server <name>` when the package contains multiple MCP server entries. `doctor diff --before <path> --after <path>` compares two package roots and reports new findings, resolved findings, trust score delta, and whether risk increased. `doctor recommend <path>` turns validation, security, and compatibility signals into a prioritized action plan with blocker, high, medium, and info actions. Add `--json` for automation or `--output recommendations.json` to write the report to disk. `doctor trust <path>` creates a local trust score from package lifecycle scripts, dependency specs, and MCP security findings. Use it before release when you want supply-chain risks summarized as one score. `doctor perf <path>` profiles the shared package analysis pipeline and reports per-stage durations for validation, config, security, compatibility, trust, recommendations, and total runtime. Add `--max-total-ms <ms>` or repeatable `--max-stage-ms stage=ms` to fail CI when a budget is exceeded. `doctor mcp <path>` exposes the generic MCP static health report under the doctor command family without starting local MCP servers. `doctor export --bundle <path>` creates a redacted operator handoff bundle that includes validation JSON, security scorecard data, compatibility matrix, recommendations, and trust score in one file. `doctor snapshot` creates a redacted diagnostics bundle with environment health, client config readiness, installed plugin metadata, and next commands. Add `--json` for machine-readable output or `--output doctor-snapshot.json` to write the bundle to disk. `doctor clients` reports local Codex, Claude Desktop, Cursor, Cline, and Windsurf config readiness. `doctor --update-check` compares the installed CLI version with the latest npm version and prints the upgrade command when a newer release is available.
@@ -406,9 +421,9 @@ jobs:
406
421
  runs-on: ubuntu-latest
407
422
  steps:
408
423
  - uses: actions/checkout@v5
409
- - uses: Esquetta/CodexPluginDoctor@v1.46.0
424
+ - uses: Esquetta/CodexPluginDoctor@v1.47.0
410
425
  with:
411
- version: "1.46.0"
426
+ version: "1.47.0"
412
427
  path: .
413
428
  runtime: "true"
414
429
  policy: codex-publish
@@ -435,7 +450,6 @@ docs/ Public architecture, guides, rules, security, and contribu
435
450
  examples/ Manual plugin packs for local CLI testing
436
451
  src/ CLI, validation logic, runtime probing, reports
437
452
  tests/ Fixture-based regression tests
438
- validation-sessions/ Real-world validation waves and tuning notes
439
453
  ```
440
454
 
441
455
  ## Validation Evidence
@@ -443,7 +457,6 @@ validation-sessions/ Real-world validation waves and tuning notes
443
457
  The validator is tuned against local fixtures and real marketplace-style plugin packages. See:
444
458
 
445
459
  - [Real-World Validation Workflow](./docs/guides/real-world-validation.md)
446
- - [Validation Sessions](./validation-sessions/README.md)
447
460
  - [Examples](./examples/README.md)
448
461
  - [Rule Catalog](./docs/rules/catalog.md)
449
462
 
@@ -463,7 +476,9 @@ npm run prepare-release
463
476
  npm run release-check
464
477
  ```
465
478
 
466
- `prepare-release` runs tests, builds the TypeScript output, and performs `npm pack --dry-run`. `release-check` adds release preflight checks for a clean git tree, existing npm versions, existing version tags, tests, build, pack dry-run, fresh temp install audit, and publish dry-run.
479
+ `prepare-release` runs tests, builds the TypeScript output, and performs `npm pack --dry-run`. `release-check` adds release preflight checks for a clean git tree, existing npm versions, existing version tags, package/lockfile/changelog version consistency, tests, build, pack dry-run, fresh temp install audit, and publish dry-run.
480
+
481
+ For plugin packages, `codex-plugin-doctor release check .` performs static readiness checks by default. Runtime probing executes package-local MCP servers and therefore requires an explicit `--runtime`; protected workflows can also require the approved runtime-plan digest.
467
482
 
468
483
  Related docs:
469
484
 
@@ -95,6 +95,7 @@ export interface VerifyDoctorAttestationOptions {
95
95
  signingKey: string;
96
96
  artifactPath?: string;
97
97
  }
98
+ export declare function buildPackageFingerprint(rootPath: string): Promise<PackageFingerprint>;
98
99
  export declare function buildDoctorAttestation(targetPath: string, options?: BuildDoctorAttestationOptions): Promise<DoctorAttestation>;
99
100
  export declare function verifyDoctorAttestation(artifactPath: string, targetPath: string, options: VerifyDoctorAttestationOptions): Promise<DoctorAttestationVerificationReport>;
100
101
  export declare function verifyDoctorAttestationObject(artifact: unknown, targetPath: string, options: VerifyDoctorAttestationOptions): Promise<DoctorAttestationVerificationReport>;
@@ -61,7 +61,7 @@ async function collectFileEntries(rootPath, currentPath = rootPath) {
61
61
  }));
62
62
  return fileEntries.flat().sort((left, right) => left.relativePath.localeCompare(right.relativePath));
63
63
  }
64
- async function buildPackageFingerprint(rootPath) {
64
+ export async function buildPackageFingerprint(rootPath) {
65
65
  const files = await collectFileEntries(rootPath);
66
66
  const bytes = files.reduce((total, file) => total + file.size, 0);
67
67
  const digest = sha256(stableStringify(files));
@@ -0,0 +1,88 @@
1
+ export type ExternalCorpusProfile = "healthy" | "broken" | "edge-case";
2
+ export type ExternalCorpusSourceType = "public-package" | "local-snapshot" | "derived-fixture";
3
+ export type ExternalCorpusMode = "codex-plugin" | "generic-mcp";
4
+ export type FindingReviewClassification = "true_positive" | "false_positive" | "unclear";
5
+ export interface ExternalCorpusFindingReview {
6
+ findingId: string;
7
+ fingerprint: string;
8
+ classification: FindingReviewClassification;
9
+ }
10
+ export interface ExternalCorpusTarget {
11
+ id: string;
12
+ profile: ExternalCorpusProfile;
13
+ sourceType: ExternalCorpusSourceType;
14
+ disclosure: "anonymized";
15
+ path: string;
16
+ mode: ExternalCorpusMode;
17
+ contentDigest: string;
18
+ expectedStatus: "pass" | "warn" | "fail";
19
+ reviews: ExternalCorpusFindingReview[];
20
+ }
21
+ export interface ExternalCorpusManifest {
22
+ schemaVersion: "1.0.0";
23
+ targets: ExternalCorpusTarget[];
24
+ }
25
+ export interface LoadedExternalCorpusTarget extends ExternalCorpusTarget {
26
+ resolvedPath: string;
27
+ }
28
+ export interface LoadedExternalCorpusManifest extends ExternalCorpusManifest {
29
+ targets: LoadedExternalCorpusTarget[];
30
+ }
31
+ export interface ExternalCorpusReportedFinding {
32
+ findingId: string;
33
+ fingerprint: string;
34
+ classification: FindingReviewClassification | "unreviewed";
35
+ }
36
+ export interface ExternalCorpusCaseResult {
37
+ id: string;
38
+ profile: ExternalCorpusProfile;
39
+ sourceType: ExternalCorpusSourceType;
40
+ disclosure: "anonymized";
41
+ mode: ExternalCorpusMode;
42
+ expected: {
43
+ status: ExternalCorpusTarget["expectedStatus"];
44
+ contentDigest: string;
45
+ findings: ExternalCorpusFindingReview[];
46
+ };
47
+ actual: {
48
+ status: ExternalCorpusTarget["expectedStatus"];
49
+ contentDigest: string;
50
+ findings: ExternalCorpusReportedFinding[];
51
+ };
52
+ digestMatched: boolean;
53
+ classificationCounts: ExternalCorpusClassificationCounts;
54
+ expectationMatched: boolean;
55
+ }
56
+ export interface ExternalCorpusClassificationCounts {
57
+ truePositive: number;
58
+ falsePositive: number;
59
+ unclear: number;
60
+ missingExpectedFinding: number;
61
+ }
62
+ export interface ExternalValidationCorpusReport {
63
+ schemaVersion: "1.0.0";
64
+ kind: "doctor.validation.corpus";
65
+ corpusType: "external";
66
+ generatedAt: string;
67
+ version: string;
68
+ summary: {
69
+ status: "pass" | "fail";
70
+ caseCount: number;
71
+ passedExpectations: number;
72
+ failedExpectations: number;
73
+ runtimeCases: 0;
74
+ classificationCounts: ExternalCorpusClassificationCounts;
75
+ };
76
+ cases: ExternalCorpusCaseResult[];
77
+ }
78
+ export interface BuildExternalValidationCorpusOptions {
79
+ environment?: {
80
+ env?: NodeJS.ProcessEnv;
81
+ platform?: NodeJS.Platform;
82
+ };
83
+ }
84
+ export declare class ExternalCorpusManifestError extends Error {
85
+ constructor(message: string);
86
+ }
87
+ export declare function loadExternalCorpusManifest(manifestPath: string): Promise<LoadedExternalCorpusManifest>;
88
+ export declare function buildExternalValidationCorpusReport(manifestPath: string, options?: BuildExternalValidationCorpusOptions): Promise<ExternalValidationCorpusReport>;
@@ -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 = path.join(rootPath, ".git", "hooks");
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 import("node:fs/promises").then((m) => m.readFile(hookPath, "utf8"));
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 = path.join(rootPath, ".git", "hooks");
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,12 @@ 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
+ },
98
104
  {
99
105
  id: "doctor.recommendations.json",
100
106
  command: "codex-plugin-doctor doctor recommend <path> --json",
@@ -115,13 +121,13 @@ const publicSchemaDefinitions = [
115
121
  id: "doctor.runtime.plan.json",
116
122
  command: "codex-plugin-doctor doctor runtime-plan <path> --json",
117
123
  outputKind: "doctor.runtime.plan",
118
- required: ["schemaVersion", "kind", "generatedAt", "version", "targetPath", "status", "exitCode", "runtimeExecution", "digest", "summary", "servers", "findings"]
124
+ required: ["schemaVersion", "kind", "generatedAt", "version", "targetPath", "status", "exitCode", "runtimeExecution", "execution", "digest", "summary", "servers", "findings"]
119
125
  },
120
126
  {
121
127
  id: "doctor.runtime.policy.json",
122
128
  command: "codex-plugin-doctor doctor runtime-policy <path> --json",
123
129
  outputKind: "doctor.runtime.policy",
124
- required: ["schemaVersion", "kind", "generatedAt", "version", "targetPath", "status", "exitCode", "runtimeExecution", "planDigest", "recommendation", "summary", "servers"]
130
+ required: ["schemaVersion", "kind", "generatedAt", "version", "targetPath", "status", "exitCode", "runtimeExecution", "execution", "planDigest", "recommendation", "summary", "servers"]
125
131
  },
126
132
  {
127
133
  id: "doctor.review.bundle.json",
@@ -1,6 +1,8 @@
1
+ import type { CheckOptions, CheckResult, RuntimeSandboxMode } from "../domain/types.js";
1
2
  export interface ReleaseCheckReport {
2
3
  targetPath: string;
3
4
  status: "pass" | "fail";
5
+ runtimeProbeEnabled?: boolean;
4
6
  checks: {
5
7
  validation: {
6
8
  status: string;
@@ -24,12 +26,26 @@ export interface ReleaseCheckReport {
24
26
  status: string;
25
27
  score: number;
26
28
  };
29
+ metadata?: ReleaseMetadataCheck;
27
30
  };
28
31
  ready: boolean;
29
32
  }
30
- export declare function buildReleaseCheck(targetPath: string, options?: {
33
+ export interface ReleaseMetadataCheck {
34
+ status: "pass" | "fail" | "skipped";
35
+ packageVersion: string | null;
36
+ lockfileVersion: string | null;
37
+ changelogVersion: boolean | null;
38
+ findings: string[];
39
+ }
40
+ export interface BuildReleaseCheckOptions {
31
41
  env?: Record<string, string | undefined>;
32
42
  platform?: NodeJS.Platform;
33
- }): Promise<ReleaseCheckReport>;
43
+ runtime?: boolean;
44
+ runtimeSandbox?: RuntimeSandboxMode;
45
+ runCheck?: (targetPath: string, options: CheckOptions) => Promise<CheckResult>;
46
+ }
47
+ export declare function buildReleaseMetadataCheck(targetPath: string): Promise<ReleaseMetadataCheck>;
48
+ export declare function assertReleaseMetadataSync(targetPath: string): Promise<void>;
49
+ export declare function buildReleaseCheck(targetPath: string, options?: BuildReleaseCheckOptions): Promise<ReleaseCheckReport>;
34
50
  export declare function renderReleaseCheck(report: ReleaseCheckReport): string;
35
51
  export declare function renderReleaseCheckJson(report: ReleaseCheckReport): string;