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 +20 -5
- package/dist/core/attestation.d.ts +1 -0
- package/dist/core/attestation.js +1 -1
- 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 +8 -2
- package/dist/core/release-check.d.ts +18 -2
- package/dist/core/release-check.js +90 -3
- 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 -1
- package/dist/index.js +2 -1
- 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.js +151 -20
- package/package.json +1 -1
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.
|
|
424
|
+
- uses: Esquetta/CodexPluginDoctor@v1.47.0
|
|
410
425
|
with:
|
|
411
|
-
version: "1.
|
|
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>;
|
package/dist/core/attestation.js
CHANGED
|
@@ -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 =
|
|
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,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
|
|
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
|
-
|
|
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;
|