codex-plugin-doctor 1.0.3 → 1.2.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
@@ -64,7 +64,7 @@ Security scorecard with `security`:
64
64
  - MCP server `cwd` paths that escape the package root
65
65
  - plain HTTP remote transport warnings
66
66
 
67
- Runtime MCP validation with `--runtime`:
67
+ Runtime MCP validation with `--runtime`:
68
68
 
69
69
  - `initialize`
70
70
  - `notifications/initialized`
@@ -76,8 +76,9 @@ Runtime MCP validation with `--runtime`:
76
76
  - `prompts/list`
77
77
  - `prompts/get`
78
78
  - paginated list responses
79
- - runtime capability scorecard
80
- - redacted verbose transcript with `--verbose-runtime`
79
+ - runtime capability scorecard
80
+ - redacted verbose transcript with `--verbose-runtime`
81
+ - optional runtime approval gating with a precomputed `doctor runtime-plan` digest
81
82
 
82
83
  Output formats:
83
84
 
@@ -212,6 +213,8 @@ codex-plugin-doctor doctor trust . --json --output trust-score.json
212
213
  codex-plugin-doctor doctor perf .
213
214
  codex-plugin-doctor doctor perf . --json --output perf.json
214
215
  codex-plugin-doctor doctor perf . --max-total-ms 2500 --max-stage-ms validation=500
216
+ codex-plugin-doctor doctor runtime-plan .
217
+ codex-plugin-doctor doctor runtime-plan . --json --output runtime-plan.json
215
218
  codex-plugin-doctor doctor mcp .
216
219
  codex-plugin-doctor doctor mcp . --json --output mcp-healthcheck.json
217
220
  codex-plugin-doctor doctor export --bundle .
@@ -267,9 +270,10 @@ codex-plugin-doctor check . --badge-json --output doctor-badge.json
267
270
  codex-plugin-doctor check . --badge-markdown
268
271
  codex-plugin-doctor check . --sarif --output results.sarif
269
272
  codex-plugin-doctor check . --ascii
270
- codex-plugin-doctor check . --no-animations
271
- codex-plugin-doctor check . --runtime
272
- codex-plugin-doctor check . --config .codex-doctor.json
273
+ codex-plugin-doctor check . --no-animations
274
+ codex-plugin-doctor check . --runtime
275
+ codex-plugin-doctor check . --runtime --require-runtime-approval --runtime-approval-digest sha256:<approved-plan-digest>
276
+ codex-plugin-doctor check . --config .codex-doctor.json
273
277
  codex-plugin-doctor check . --history validation-history.jsonl
274
278
  codex-plugin-doctor history validation-history.jsonl
275
279
  codex-plugin-doctor history validation-history.jsonl --json
@@ -282,7 +286,7 @@ codex-plugin-doctor check . --json --runtime --verbose-runtime
282
286
 
283
287
  `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`.
284
288
 
285
- `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. 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 release-evidence <path> --sign-key-env NAME` creates one redacted release bundle with signed attestation, offline verification, corpus, performance, security, trust, package metadata, and git release gates. Strict release evidence requires a clean tagged worktree; use `--allow-dirty` or `--allow-untagged` only for local rehearsal. `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.
289
+ `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. 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. `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.
286
290
 
287
291
  `audit --installed` runs a local ecosystem audit against every discovered Codex plugin in the installed plugin cache. Add `--security` to include security scorecards, `--compat` to include the all-client compatibility matrix, and `--json --output local-audit.json` when you want a shareable machine-readable report. Add `--cache` to reuse unchanged plugin results between runs; add `--changed` to only report plugins whose fingerprint changed since the last cached audit. Use `--cache-file path/to/audit-cache.json` when CI or scripted runs need an explicit cache location.
288
292
 
@@ -348,9 +352,9 @@ jobs:
348
352
  runs-on: ubuntu-latest
349
353
  steps:
350
354
  - uses: actions/checkout@v5
351
- - uses: Esquetta/CodexPluginDoctor@v1.0.3
355
+ - uses: Esquetta/CodexPluginDoctor@v1.2.0
352
356
  with:
353
- version: "1.0.3"
357
+ version: "1.2.0"
354
358
  path: .
355
359
  runtime: "true"
356
360
  policy: codex-publish
@@ -71,6 +71,12 @@ const publicSchemaDefinitions = [
71
71
  outputKind: "doctor.perf",
72
72
  required: ["schemaVersion", "kind", "generatedAt", "targetPath", "status", "exitCode", "summary", "stages", "thresholds"]
73
73
  },
74
+ {
75
+ id: "doctor.runtime.plan.json",
76
+ command: "codex-plugin-doctor doctor runtime-plan <path> --json",
77
+ outputKind: "doctor.runtime.plan",
78
+ required: ["schemaVersion", "kind", "generatedAt", "version", "targetPath", "status", "exitCode", "runtimeExecution", "digest", "summary", "servers", "findings"]
79
+ },
74
80
  {
75
81
  id: "doctor.export.bundle.json",
76
82
  command: "codex-plugin-doctor doctor export --bundle <path> --json",
@@ -93,7 +99,19 @@ const publicSchemaDefinitions = [
93
99
  id: "doctor.release.evidence.json",
94
100
  command: "codex-plugin-doctor doctor release-evidence <path> --json",
95
101
  outputKind: "doctor.release.evidence",
96
- required: ["schemaVersion", "kind", "generatedAt", "version", "targetPath", "status", "exitCode", "releaseReady", "summary", "package", "git", "releaseGates", "attestation", "attestationVerification", "corpus", "performance", "security", "trust"]
102
+ required: ["schemaVersion", "kind", "generatedAt", "version", "targetPath", "status", "exitCode", "releaseReady", "summary", "package", "git", "releaseGates", "runtimeApproval", "attestation", "attestationVerification", "corpus", "performance", "security", "trust", "evidenceSignature"]
103
+ },
104
+ {
105
+ id: "doctor.release.evidence.verification.json",
106
+ command: "codex-plugin-doctor doctor release-evidence verify <evidence.json> --target <path> --json",
107
+ outputKind: "doctor.release.evidence.verification",
108
+ required: ["schemaVersion", "kind", "generatedAt", "artifactPath", "targetPath", "status", "exitCode", "summary", "checks", "attestation"]
109
+ },
110
+ {
111
+ id: "doctor.release.evidence.asset.json",
112
+ command: "codex-plugin-doctor doctor release-evidence asset <path> --tag <tag> --output <evidence.json> --json",
113
+ outputKind: "doctor.release.evidence.asset",
114
+ required: ["schemaVersion", "kind", "generatedAt", "version", "targetPath", "tag", "artifactPath", "status", "exitCode", "uploaded", "uploadCommand", "releaseEvidence"]
97
115
  },
98
116
  {
99
117
  id: "doctor.npm.json",
@@ -3,9 +3,17 @@ import { type DoctorPerformanceReport, type DoctorPerformanceThresholdOptions }
3
3
  import { type DoctorValidationCorpusReport } from "./validation-corpus.js";
4
4
  import { type SecurityAudit } from "../security/security-audit.js";
5
5
  import { type TrustScoreReport } from "../security/trust-score.js";
6
+ import { type RuntimeApprovalReport } from "./runtime-plan.js";
6
7
  import type { CompatibilityEnvironment } from "../compatibility/compatibility-matrix.js";
7
8
  import type { CheckResult } from "../domain/types.js";
8
9
  type EvidenceStatus = "pass" | "warn" | "fail";
10
+ export interface DoctorReleaseEvidenceSignature {
11
+ status: "signed";
12
+ algorithm: "hmac-sha256";
13
+ digest: string;
14
+ payloadDigest: string;
15
+ keyHint: string;
16
+ }
9
17
  export interface DoctorReleaseEvidencePackageMetadata {
10
18
  name: string | null;
11
19
  version: string | null;
@@ -31,6 +39,7 @@ export interface DoctorReleaseEvidenceReport {
31
39
  corpus: EvidenceStatus;
32
40
  performance: EvidenceStatus;
33
41
  releaseGates: EvidenceStatus;
42
+ runtimeApproval: EvidenceStatus;
34
43
  security: EvidenceStatus;
35
44
  trust: EvidenceStatus;
36
45
  };
@@ -42,6 +51,7 @@ export interface DoctorReleaseEvidenceReport {
42
51
  message: string;
43
52
  }>;
44
53
  };
54
+ runtimeApproval: RuntimeApprovalReport;
45
55
  package: DoctorReleaseEvidencePackageMetadata;
46
56
  git: DoctorReleaseEvidenceGitMetadata;
47
57
  attestation: DoctorAttestation;
@@ -50,17 +60,75 @@ export interface DoctorReleaseEvidenceReport {
50
60
  performance: DoctorPerformanceReport;
51
61
  security: Pick<SecurityAudit, "status" | "score" | "findingCounts">;
52
62
  trust: Pick<TrustScoreReport, "status" | "score" | "findingCounts">;
63
+ evidenceSignature: DoctorReleaseEvidenceSignature;
64
+ }
65
+ export interface DoctorReleaseEvidenceVerificationReport {
66
+ schemaVersion: "1.0.0";
67
+ kind: "doctor.release.evidence.verification";
68
+ generatedAt: string;
69
+ artifactPath: string;
70
+ targetPath: string | null;
71
+ status: "pass" | "fail";
72
+ exitCode: 0 | 1;
73
+ summary: {
74
+ artifact: EvidenceStatus;
75
+ attestation: EvidenceStatus;
76
+ evidenceSignature: EvidenceStatus;
77
+ releaseReady: EvidenceStatus;
78
+ releaseGates: EvidenceStatus;
79
+ };
80
+ checks: Array<{
81
+ id: string;
82
+ status: EvidenceStatus;
83
+ message: string;
84
+ }>;
85
+ attestation: DoctorAttestationVerificationReport | null;
86
+ }
87
+ export interface DoctorReleaseEvidenceAssetReport {
88
+ schemaVersion: "1.0.0";
89
+ kind: "doctor.release.evidence.asset";
90
+ generatedAt: string;
91
+ version: string;
92
+ targetPath: string;
93
+ tag: string;
94
+ artifactPath: string;
95
+ status: "pass" | "fail";
96
+ exitCode: 0 | 1;
97
+ uploaded: boolean;
98
+ uploadCommand: string[];
99
+ releaseEvidence: {
100
+ status: "pass" | "fail";
101
+ releaseReady: boolean;
102
+ evidenceSignature: "signed";
103
+ };
53
104
  }
54
105
  export interface BuildDoctorReleaseEvidenceOptions {
55
106
  signingKey: string;
56
107
  signingKeyEnv: string;
57
108
  allowDirty?: boolean;
58
109
  allowUntagged?: boolean;
110
+ requireRuntimeApproval?: boolean;
111
+ runtimeApprovalDigest?: string | null;
59
112
  environment?: CompatibilityEnvironment;
60
113
  runCheck?: (targetPath: string) => Promise<CheckResult>;
61
114
  performanceThresholds?: DoctorPerformanceThresholdOptions;
62
115
  }
63
116
  export declare function buildDoctorReleaseEvidenceReport(targetPath: string, options: BuildDoctorReleaseEvidenceOptions): Promise<DoctorReleaseEvidenceReport>;
64
117
  export declare function renderDoctorReleaseEvidenceJson(report: DoctorReleaseEvidenceReport): string;
118
+ export declare function verifyDoctorReleaseEvidence(artifactPath: string, options: {
119
+ signingKey: string;
120
+ targetPath: string;
121
+ }): Promise<DoctorReleaseEvidenceVerificationReport>;
122
+ export declare function renderDoctorReleaseEvidenceVerificationJson(report: DoctorReleaseEvidenceVerificationReport): string;
123
+ export declare function renderDoctorReleaseEvidenceVerification(report: DoctorReleaseEvidenceVerificationReport, options?: {
124
+ outputPath?: string | null;
125
+ }): string;
126
+ export declare function buildDoctorReleaseEvidenceAssetReport(evidence: DoctorReleaseEvidenceReport, options: {
127
+ tag: string;
128
+ artifactPath: string;
129
+ uploaded: boolean;
130
+ }): DoctorReleaseEvidenceAssetReport;
131
+ export declare function renderDoctorReleaseEvidenceAssetJson(report: DoctorReleaseEvidenceAssetReport): string;
132
+ export declare function renderDoctorReleaseEvidenceAsset(report: DoctorReleaseEvidenceAssetReport): string;
65
133
  export declare function renderDoctorReleaseEvidence(report: DoctorReleaseEvidenceReport): string;
66
134
  export {};
@@ -1,4 +1,5 @@
1
1
  import { execFile } from "node:child_process";
2
+ import { createHash, createHmac, timingSafeEqual } from "node:crypto";
2
3
  import { promisify } from "node:util";
3
4
  import path from "node:path";
4
5
  import { buildDoctorAttestation, verifyDoctorAttestationObject } from "./attestation.js";
@@ -8,8 +9,43 @@ import { redactValue } from "./doctor-export-bundle.js";
8
9
  import { readJsonFile } from "./read-json-file.js";
9
10
  import { buildSecurityAudit } from "../security/security-audit.js";
10
11
  import { buildTrustScore } from "../security/trust-score.js";
12
+ import { buildDoctorRuntimePlan, evaluateRuntimeApproval, runtimeApprovalPassed } from "./runtime-plan.js";
11
13
  import { packageVersion } from "../version.js";
12
14
  const execFileAsync = promisify(execFile);
15
+ function isPlainObject(value) {
16
+ return typeof value === "object" && value !== null && !Array.isArray(value);
17
+ }
18
+ function stableStringify(value) {
19
+ if (Array.isArray(value)) {
20
+ return `[${value.map((item) => stableStringify(item)).join(",")}]`;
21
+ }
22
+ if (isPlainObject(value)) {
23
+ return `{${Object.keys(value)
24
+ .sort()
25
+ .map((key) => `${JSON.stringify(key)}:${stableStringify(value[key])}`)
26
+ .join(",")}}`;
27
+ }
28
+ return JSON.stringify(value);
29
+ }
30
+ function sha256(value) {
31
+ return `sha256:${createHash("sha256").update(value).digest("hex")}`;
32
+ }
33
+ function digestMatches(expected, actual) {
34
+ const expectedBuffer = Buffer.from(expected);
35
+ const actualBuffer = Buffer.from(actual);
36
+ return expectedBuffer.length === actualBuffer.length &&
37
+ timingSafeEqual(expectedBuffer, actualBuffer);
38
+ }
39
+ function isDoctorReleaseEvidenceReport(value) {
40
+ return isPlainObject(value) &&
41
+ value.schemaVersion === "1.0.0" &&
42
+ value.kind === "doctor.release.evidence" &&
43
+ typeof value.targetPath === "string" &&
44
+ isPlainObject(value.summary) &&
45
+ isPlainObject(value.releaseGates) &&
46
+ isPlainObject(value.attestation) &&
47
+ isPlainObject(value.evidenceSignature);
48
+ }
13
49
  function toEvidenceStatus(status) {
14
50
  return status;
15
51
  }
@@ -67,6 +103,7 @@ function releaseReady(report) {
67
103
  report.corpus.summary.status === "pass" &&
68
104
  report.performance.status === "pass" &&
69
105
  report.releaseGates.status === "pass" &&
106
+ runtimeApprovalPassed(report.runtimeApproval) &&
70
107
  report.security.status !== "fail" &&
71
108
  report.trust.status !== "fail";
72
109
  }
@@ -103,10 +140,60 @@ function buildReleaseGateReport(git, options) {
103
140
  checks
104
141
  };
105
142
  }
143
+ function buildReleaseEvidenceSigningPayload(report) {
144
+ return {
145
+ schemaVersion: report.schemaVersion,
146
+ kind: "doctor.release.evidence.signature.v1",
147
+ version: report.version,
148
+ status: report.status,
149
+ releaseReady: report.releaseReady,
150
+ summary: report.summary,
151
+ package: report.package,
152
+ git: report.git,
153
+ releaseGates: report.releaseGates,
154
+ runtimeApproval: report.runtimeApproval,
155
+ attestation: {
156
+ version: report.attestation.version,
157
+ subject: report.attestation.subject,
158
+ packageFingerprint: report.attestation.packageFingerprint,
159
+ reportDigest: report.attestation.reportDigest,
160
+ summary: report.attestation.summary,
161
+ signature: report.attestation.signature
162
+ },
163
+ attestationVerification: {
164
+ status: report.attestationVerification.status,
165
+ summary: report.attestationVerification.summary
166
+ },
167
+ corpus: {
168
+ status: report.corpus.summary.status,
169
+ summary: report.corpus.summary
170
+ },
171
+ performance: {
172
+ status: report.performance.status,
173
+ summary: report.performance.summary,
174
+ thresholds: report.performance.thresholds
175
+ },
176
+ security: report.security,
177
+ trust: report.trust
178
+ };
179
+ }
180
+ function signReleaseEvidence(report, signingKey, keyHint) {
181
+ const redactedReport = redactValue(report);
182
+ const serializedPayload = stableStringify(buildReleaseEvidenceSigningPayload(redactedReport));
183
+ return {
184
+ status: "signed",
185
+ algorithm: "hmac-sha256",
186
+ digest: `sha256:${createHmac("sha256", signingKey)
187
+ .update(serializedPayload)
188
+ .digest("hex")}`,
189
+ payloadDigest: sha256(serializedPayload),
190
+ keyHint
191
+ };
192
+ }
106
193
  export async function buildDoctorReleaseEvidenceReport(targetPath, options) {
107
194
  const rootPath = path.resolve(targetPath);
108
195
  const security = await buildSecurityAudit(rootPath);
109
- const [attestation, corpus, performance, trust, packageMetadata, git] = await Promise.all([
196
+ const [attestation, corpus, performance, trust, packageMetadata, git, runtimePlan] = await Promise.all([
110
197
  buildDoctorAttestation(rootPath, {
111
198
  signingKey: options.signingKey,
112
199
  signingKeyHint: `env:${options.signingKeyEnv}`,
@@ -120,7 +207,8 @@ export async function buildDoctorReleaseEvidenceReport(targetPath, options) {
120
207
  }),
121
208
  buildTrustScore(rootPath, { securityAudit: security }),
122
209
  readPackageMetadata(rootPath),
123
- readGitMetadata(rootPath)
210
+ readGitMetadata(rootPath),
211
+ buildDoctorRuntimePlan(rootPath)
124
212
  ]);
125
213
  const normalizedPackageMetadata = {
126
214
  name: packageMetadata.name ?? attestation.subject.name,
@@ -132,6 +220,10 @@ export async function buildDoctorReleaseEvidenceReport(targetPath, options) {
132
220
  artifactPath: "inline:doctor.release-evidence.attestation"
133
221
  });
134
222
  const releaseGates = buildReleaseGateReport(git, options);
223
+ const runtimeApproval = evaluateRuntimeApproval(runtimePlan, {
224
+ required: options.requireRuntimeApproval ?? false,
225
+ approvedDigest: options.runtimeApprovalDigest
226
+ });
135
227
  const partialReport = {
136
228
  schemaVersion: "1.0.0",
137
229
  kind: "doctor.release.evidence",
@@ -144,12 +236,14 @@ export async function buildDoctorReleaseEvidenceReport(targetPath, options) {
144
236
  corpus: corpus.summary.status,
145
237
  performance: performance.status,
146
238
  releaseGates: releaseGates.status,
239
+ runtimeApproval: runtimeApprovalPassed(runtimeApproval) ? "pass" : "fail",
147
240
  security: toEvidenceStatus(security.status),
148
241
  trust: toEvidenceStatus(trust.status)
149
242
  },
150
243
  package: normalizedPackageMetadata,
151
244
  git,
152
245
  releaseGates,
246
+ runtimeApproval,
153
247
  attestation,
154
248
  attestationVerification,
155
249
  corpus,
@@ -166,16 +260,191 @@ export async function buildDoctorReleaseEvidenceReport(targetPath, options) {
166
260
  }
167
261
  };
168
262
  const ready = releaseReady(partialReport);
169
- return {
263
+ const report = {
170
264
  ...partialReport,
171
- status: ready ? "pass" : "fail",
172
- exitCode: ready ? 0 : 1,
265
+ status: (ready ? "pass" : "fail"),
266
+ exitCode: (ready ? 0 : 1),
173
267
  releaseReady: ready
174
268
  };
269
+ return {
270
+ ...report,
271
+ evidenceSignature: signReleaseEvidence(report, options.signingKey, `env:${options.signingKeyEnv}`)
272
+ };
175
273
  }
176
274
  export function renderDoctorReleaseEvidenceJson(report) {
177
275
  return JSON.stringify(redactValue(report), null, 2);
178
276
  }
277
+ export async function verifyDoctorReleaseEvidence(artifactPath, options) {
278
+ const resolvedArtifactPath = path.resolve(artifactPath);
279
+ const artifact = await readJsonFile(resolvedArtifactPath);
280
+ if (!isDoctorReleaseEvidenceReport(artifact)) {
281
+ return {
282
+ schemaVersion: "1.0.0",
283
+ kind: "doctor.release.evidence.verification",
284
+ generatedAt: new Date().toISOString(),
285
+ artifactPath: resolvedArtifactPath,
286
+ targetPath: options.targetPath ? path.resolve(options.targetPath) : null,
287
+ status: "fail",
288
+ exitCode: 1,
289
+ summary: {
290
+ artifact: "fail",
291
+ attestation: "fail",
292
+ evidenceSignature: "fail",
293
+ releaseReady: "fail",
294
+ releaseGates: "fail"
295
+ },
296
+ checks: [
297
+ {
298
+ id: "release_evidence.artifact.invalid",
299
+ status: "fail",
300
+ message: "The release evidence artifact is not a valid doctor release evidence bundle."
301
+ }
302
+ ],
303
+ attestation: null
304
+ };
305
+ }
306
+ const targetPath = path.resolve(options.targetPath);
307
+ const attestation = await verifyDoctorAttestationObject(artifact.attestation, targetPath, {
308
+ signingKey: options.signingKey,
309
+ artifactPath: `${resolvedArtifactPath}#attestation`
310
+ });
311
+ const unsignedArtifact = { ...artifact };
312
+ delete unsignedArtifact.evidenceSignature;
313
+ const expectedEvidenceSignature = signReleaseEvidence(unsignedArtifact, options.signingKey, "verification");
314
+ const signatureStatus = artifact.evidenceSignature.status === "signed" &&
315
+ artifact.evidenceSignature.algorithm === "hmac-sha256" &&
316
+ digestMatches(expectedEvidenceSignature.payloadDigest, artifact.evidenceSignature.payloadDigest) &&
317
+ digestMatches(expectedEvidenceSignature.digest, artifact.evidenceSignature.digest)
318
+ ? "pass"
319
+ : "fail";
320
+ const checks = [
321
+ {
322
+ id: "release_evidence.artifact.valid",
323
+ status: "pass",
324
+ message: "The release evidence artifact has the expected schema and kind."
325
+ },
326
+ {
327
+ id: "release_evidence.signature",
328
+ status: signatureStatus,
329
+ message: signatureStatus === "pass"
330
+ ? "The release evidence signature matches the canonical release evidence payload."
331
+ : "The release evidence signature does not match the canonical release evidence payload."
332
+ },
333
+ {
334
+ id: "release_evidence.release_ready",
335
+ status: artifact.releaseReady && artifact.status === "pass" ? "pass" : "fail",
336
+ message: artifact.releaseReady && artifact.status === "pass"
337
+ ? "The release evidence bundle reports releaseReady=true and status=pass."
338
+ : "The release evidence bundle was not release-ready when it was created."
339
+ },
340
+ {
341
+ id: "release_evidence.release_gates",
342
+ status: artifact.releaseGates.status === "pass" &&
343
+ artifact.releaseGates.checks.every((check) => check.status === "pass")
344
+ ? "pass"
345
+ : "fail",
346
+ message: artifact.releaseGates.status === "pass"
347
+ ? "All recorded release gates passed."
348
+ : "One or more recorded release gates failed."
349
+ },
350
+ {
351
+ id: "release_evidence.attestation",
352
+ status: attestation.status,
353
+ message: attestation.status === "pass"
354
+ ? "The embedded signed attestation verifies against the target package."
355
+ : "The embedded signed attestation does not verify against the target package."
356
+ }
357
+ ];
358
+ const failedChecks = checks.filter((check) => check.status === "fail");
359
+ return {
360
+ schemaVersion: "1.0.0",
361
+ kind: "doctor.release.evidence.verification",
362
+ generatedAt: new Date().toISOString(),
363
+ artifactPath: resolvedArtifactPath,
364
+ targetPath,
365
+ status: failedChecks.length === 0 ? "pass" : "fail",
366
+ exitCode: failedChecks.length === 0 ? 0 : 1,
367
+ summary: {
368
+ artifact: "pass",
369
+ attestation: attestation.status,
370
+ evidenceSignature: signatureStatus,
371
+ releaseReady: checks.find((check) => check.id === "release_evidence.release_ready")?.status ?? "fail",
372
+ releaseGates: checks.find((check) => check.id === "release_evidence.release_gates")?.status ?? "fail"
373
+ },
374
+ checks,
375
+ attestation
376
+ };
377
+ }
378
+ export function renderDoctorReleaseEvidenceVerificationJson(report) {
379
+ return JSON.stringify(redactValue(report), null, 2);
380
+ }
381
+ export function renderDoctorReleaseEvidenceVerification(report, options = {}) {
382
+ const lines = [
383
+ "Doctor Release Evidence Verification",
384
+ "====================================",
385
+ `Artifact: ${report.artifactPath}`,
386
+ `Target: ${report.targetPath ?? "unknown"}`,
387
+ `Status: ${report.status.toUpperCase()}`,
388
+ `Attestation: ${report.summary.attestation.toUpperCase()}`,
389
+ `Release ready: ${report.summary.releaseReady.toUpperCase()}`,
390
+ `Release gates: ${report.summary.releaseGates.toUpperCase()}`
391
+ ];
392
+ if (options.outputPath) {
393
+ lines.push(`Output: ${options.outputPath}`);
394
+ }
395
+ lines.push("", "Checks", "------");
396
+ for (const check of report.checks) {
397
+ lines.push(`${check.status === "pass" ? "PASS" : "FAIL"} ${check.id}`);
398
+ lines.push(` ${check.message}`);
399
+ }
400
+ return lines.join("\n");
401
+ }
402
+ export function buildDoctorReleaseEvidenceAssetReport(evidence, options) {
403
+ const artifactPath = path.resolve(options.artifactPath);
404
+ const status = evidence.status === "pass" && evidence.releaseReady
405
+ ? "pass"
406
+ : "fail";
407
+ return {
408
+ schemaVersion: "1.0.0",
409
+ kind: "doctor.release.evidence.asset",
410
+ generatedAt: new Date().toISOString(),
411
+ version: packageVersion,
412
+ targetPath: evidence.targetPath,
413
+ tag: options.tag,
414
+ artifactPath,
415
+ status,
416
+ exitCode: status === "pass" ? 0 : 1,
417
+ uploaded: options.uploaded,
418
+ uploadCommand: [
419
+ "gh",
420
+ "release",
421
+ "upload",
422
+ options.tag,
423
+ artifactPath,
424
+ "--clobber"
425
+ ],
426
+ releaseEvidence: {
427
+ status: evidence.status,
428
+ releaseReady: evidence.releaseReady,
429
+ evidenceSignature: evidence.evidenceSignature.status
430
+ }
431
+ };
432
+ }
433
+ export function renderDoctorReleaseEvidenceAssetJson(report) {
434
+ return JSON.stringify(redactValue(report), null, 2);
435
+ }
436
+ export function renderDoctorReleaseEvidenceAsset(report) {
437
+ return [
438
+ "Doctor Release Evidence Asset",
439
+ "=============================",
440
+ `Target: ${report.targetPath}`,
441
+ `Tag: ${report.tag}`,
442
+ `Artifact: ${report.artifactPath}`,
443
+ `Status: ${report.status.toUpperCase()}`,
444
+ `Uploaded: ${report.uploaded ? "yes" : "no"}`,
445
+ `Upload command: ${report.uploadCommand.join(" ")}`
446
+ ].join("\n");
447
+ }
179
448
  export function renderDoctorReleaseEvidence(report) {
180
449
  return [
181
450
  "Doctor Release Evidence",
@@ -188,6 +457,7 @@ export function renderDoctorReleaseEvidence(report) {
188
457
  `Corpus: ${report.summary.corpus.toUpperCase()}`,
189
458
  `Performance: ${report.summary.performance.toUpperCase()}`,
190
459
  `Release gates: ${report.summary.releaseGates.toUpperCase()}`,
460
+ `Runtime approval: ${report.summary.runtimeApproval.toUpperCase()} (${report.runtimeApproval.status})`,
191
461
  `Security: ${report.summary.security.toUpperCase()} (${report.security.score}/100)`,
192
462
  `Trust: ${report.summary.trust.toUpperCase()} (${report.trust.score}/100)`,
193
463
  `Git commit: ${report.git.commit ?? "unknown"}`,
@@ -0,0 +1,51 @@
1
+ import { type SecurityAudit } from "../security/security-audit.js";
2
+ import type { Finding } from "../domain/types.js";
3
+ type RuntimePlanStatus = "pass" | "warn" | "fail";
4
+ type RuntimePlanRiskLevel = "low" | "medium" | "high";
5
+ type RuntimePlanTransport = "stdio" | "http";
6
+ export interface RuntimePlanServer {
7
+ name: string;
8
+ transport: RuntimePlanTransport;
9
+ command: string | null;
10
+ args: string[];
11
+ cwd: string | null;
12
+ url: string | null;
13
+ probeMethods: string[];
14
+ riskLevel: RuntimePlanRiskLevel;
15
+ riskReasons: string[];
16
+ }
17
+ export interface DoctorRuntimePlan {
18
+ schemaVersion: "1.0.0";
19
+ kind: "doctor.runtime.plan";
20
+ generatedAt: string;
21
+ version: string;
22
+ targetPath: string;
23
+ status: RuntimePlanStatus;
24
+ exitCode: 0 | 1;
25
+ runtimeExecution: "not_started";
26
+ digest: string;
27
+ summary: {
28
+ serverCount: number;
29
+ executableServerCount: number;
30
+ highRiskServerCount: number;
31
+ findings: SecurityAudit["findingCounts"];
32
+ };
33
+ servers: RuntimePlanServer[];
34
+ findings: Finding[];
35
+ }
36
+ export interface RuntimeApprovalReport {
37
+ required: boolean;
38
+ status: "approved" | "missing" | "mismatch" | "not_required";
39
+ planDigest: string;
40
+ approvedDigest: string | null;
41
+ message: string;
42
+ }
43
+ export declare function buildDoctorRuntimePlan(targetPath: string, generatedAt?: string): Promise<DoctorRuntimePlan>;
44
+ export declare function evaluateRuntimeApproval(plan: DoctorRuntimePlan, options: {
45
+ required: boolean;
46
+ approvedDigest?: string | null;
47
+ }): RuntimeApprovalReport;
48
+ export declare function runtimeApprovalPassed(approval: RuntimeApprovalReport): boolean;
49
+ export declare function renderDoctorRuntimePlanJson(plan: DoctorRuntimePlan): string;
50
+ export declare function renderDoctorRuntimePlan(plan: DoctorRuntimePlan): string;
51
+ export {};
@@ -0,0 +1,232 @@
1
+ import { createHash } from "node:crypto";
2
+ import path from "node:path";
3
+ import { packageVersion } from "../version.js";
4
+ import { discoverPackage } from "./discover-package.js";
5
+ import { readJsonFile } from "./read-json-file.js";
6
+ import { buildSecurityAudit } from "../security/security-audit.js";
7
+ function isPlainObject(value) {
8
+ return typeof value === "object" && value !== null && !Array.isArray(value);
9
+ }
10
+ function stableStringify(value) {
11
+ if (Array.isArray(value)) {
12
+ return `[${value.map((item) => stableStringify(item)).join(",")}]`;
13
+ }
14
+ if (isPlainObject(value)) {
15
+ return `{${Object.keys(value)
16
+ .sort()
17
+ .map((key) => `${JSON.stringify(key)}:${stableStringify(value[key])}`)
18
+ .join(",")}}`;
19
+ }
20
+ return JSON.stringify(value);
21
+ }
22
+ function sha256(value) {
23
+ return `sha256:${createHash("sha256").update(value).digest("hex")}`;
24
+ }
25
+ function normalizeCwd(rootPath, cwd) {
26
+ if (typeof cwd !== "string") {
27
+ return ".";
28
+ }
29
+ const resolvedCwd = path.resolve(rootPath, cwd);
30
+ const relativeCwd = path.relative(rootPath, resolvedCwd).replace(/\\/g, "/");
31
+ return relativeCwd.length === 0 ? "." : relativeCwd;
32
+ }
33
+ function buildRisk(serverName, serverConfig, findings) {
34
+ const matchingFindings = findings.filter((finding) => finding.message.includes(`\`${serverName}\``));
35
+ const riskReasons = matchingFindings.map((finding) => finding.id);
36
+ const hasFail = matchingFindings.some((finding) => finding.severity === "fail");
37
+ const hasWarn = matchingFindings.some((finding) => finding.severity === "warn");
38
+ if (typeof serverConfig.command === "string") {
39
+ riskReasons.push("runtime.executes_local_command");
40
+ }
41
+ if (typeof serverConfig.url === "string") {
42
+ riskReasons.push("runtime.connects_remote_server");
43
+ }
44
+ return {
45
+ riskLevel: hasFail ? "high" : hasWarn ? "medium" : "low",
46
+ riskReasons: [...new Set(riskReasons)]
47
+ };
48
+ }
49
+ function planDigestPayload(plan) {
50
+ return {
51
+ schemaVersion: plan.schemaVersion,
52
+ kind: "doctor.runtime.plan.digest.v1",
53
+ version: plan.version,
54
+ status: plan.status,
55
+ summary: plan.summary,
56
+ servers: plan.servers,
57
+ findings: plan.findings.map((finding) => ({
58
+ id: finding.id,
59
+ severity: finding.severity,
60
+ message: finding.message
61
+ }))
62
+ };
63
+ }
64
+ function buildRuntimePlanDigest(plan) {
65
+ return sha256(stableStringify(planDigestPayload(plan)));
66
+ }
67
+ export async function buildDoctorRuntimePlan(targetPath, generatedAt = new Date().toISOString()) {
68
+ const rootPath = path.resolve(targetPath);
69
+ const discoveredPackage = await discoverPackage(rootPath);
70
+ const security = await buildSecurityAudit(rootPath);
71
+ if (!discoveredPackage?.manifest.mcpServers) {
72
+ const partialPlan = {
73
+ schemaVersion: "1.0.0",
74
+ kind: "doctor.runtime.plan",
75
+ version: packageVersion,
76
+ targetPath: rootPath,
77
+ status: security.status,
78
+ exitCode: (security.status === "fail" ? 1 : 0),
79
+ runtimeExecution: "not_started",
80
+ summary: {
81
+ serverCount: 0,
82
+ executableServerCount: 0,
83
+ highRiskServerCount: 0,
84
+ findings: security.findingCounts
85
+ },
86
+ servers: [],
87
+ findings: security.findings
88
+ };
89
+ return {
90
+ ...partialPlan,
91
+ generatedAt,
92
+ digest: buildRuntimePlanDigest(partialPlan)
93
+ };
94
+ }
95
+ let parsedConfig;
96
+ try {
97
+ parsedConfig = await readJsonFile(path.resolve(discoveredPackage.rootPath, discoveredPackage.manifest.mcpServers));
98
+ }
99
+ catch {
100
+ parsedConfig = {};
101
+ }
102
+ const serverEntries = isPlainObject(parsedConfig) && isPlainObject(parsedConfig.mcpServers)
103
+ ? Object.entries(parsedConfig.mcpServers)
104
+ : [];
105
+ const servers = serverEntries
106
+ .filter((entry) => isPlainObject(entry[1]))
107
+ .map(([serverName, serverConfig]) => {
108
+ const command = typeof serverConfig.command === "string" ? serverConfig.command : null;
109
+ const url = typeof serverConfig.url === "string" ? serverConfig.url : null;
110
+ const { riskLevel, riskReasons } = buildRisk(serverName, serverConfig, security.findings);
111
+ return {
112
+ name: serverName,
113
+ transport: command ? "stdio" : "http",
114
+ command,
115
+ args: Array.isArray(serverConfig.args)
116
+ ? serverConfig.args.filter((arg) => typeof arg === "string")
117
+ : [],
118
+ cwd: command ? normalizeCwd(discoveredPackage.rootPath, serverConfig.cwd) : null,
119
+ url,
120
+ probeMethods: command
121
+ ? [
122
+ "initialize",
123
+ "tools/list",
124
+ "tools/call:safe-only",
125
+ "resources/list",
126
+ "resources/read:first-resource-only",
127
+ "resources/templates/list",
128
+ "prompts/list",
129
+ "prompts/get:first-prompt-only"
130
+ ]
131
+ : [],
132
+ riskLevel,
133
+ riskReasons
134
+ };
135
+ });
136
+ const highRiskServerCount = servers.filter((server) => server.riskLevel === "high").length;
137
+ const partialPlan = {
138
+ schemaVersion: "1.0.0",
139
+ kind: "doctor.runtime.plan",
140
+ version: packageVersion,
141
+ targetPath: discoveredPackage.rootPath,
142
+ status: highRiskServerCount > 0
143
+ ? "fail"
144
+ : security.status === "warn"
145
+ ? "warn"
146
+ : "pass",
147
+ exitCode: (highRiskServerCount > 0 ? 1 : 0),
148
+ runtimeExecution: "not_started",
149
+ summary: {
150
+ serverCount: servers.length,
151
+ executableServerCount: servers.filter((server) => server.command).length,
152
+ highRiskServerCount,
153
+ findings: security.findingCounts
154
+ },
155
+ servers,
156
+ findings: security.findings
157
+ };
158
+ return {
159
+ ...partialPlan,
160
+ generatedAt,
161
+ digest: buildRuntimePlanDigest(partialPlan)
162
+ };
163
+ }
164
+ export function evaluateRuntimeApproval(plan, options) {
165
+ if (!options.required) {
166
+ return {
167
+ required: false,
168
+ status: "not_required",
169
+ planDigest: plan.digest,
170
+ approvedDigest: options.approvedDigest ?? null,
171
+ message: "Runtime approval was not required for this run."
172
+ };
173
+ }
174
+ if (!options.approvedDigest) {
175
+ return {
176
+ required: true,
177
+ status: "missing",
178
+ planDigest: plan.digest,
179
+ approvedDigest: null,
180
+ message: "Runtime approval was required, but no approved plan digest was provided."
181
+ };
182
+ }
183
+ if (options.approvedDigest !== plan.digest) {
184
+ return {
185
+ required: true,
186
+ status: "mismatch",
187
+ planDigest: plan.digest,
188
+ approvedDigest: options.approvedDigest,
189
+ message: "Runtime approval digest does not match the current runtime plan."
190
+ };
191
+ }
192
+ return {
193
+ required: true,
194
+ status: "approved",
195
+ planDigest: plan.digest,
196
+ approvedDigest: options.approvedDigest,
197
+ message: "Runtime approval digest matches the current runtime plan."
198
+ };
199
+ }
200
+ export function runtimeApprovalPassed(approval) {
201
+ return approval.status === "approved" || approval.status === "not_required";
202
+ }
203
+ export function renderDoctorRuntimePlanJson(plan) {
204
+ return JSON.stringify(plan, null, 2);
205
+ }
206
+ export function renderDoctorRuntimePlan(plan) {
207
+ const lines = [
208
+ "Doctor Runtime Plan",
209
+ "===================",
210
+ `Target: ${plan.targetPath}`,
211
+ `Status: ${plan.status.toUpperCase()}`,
212
+ `Runtime execution: ${plan.runtimeExecution}`,
213
+ `Digest: ${plan.digest}`,
214
+ `Servers: ${plan.summary.serverCount}`,
215
+ `Executable servers: ${plan.summary.executableServerCount}`,
216
+ `High-risk servers: ${plan.summary.highRiskServerCount}`
217
+ ];
218
+ if (plan.servers.length === 0) {
219
+ lines.push("", "No MCP runtime servers found.");
220
+ return lines.join("\n");
221
+ }
222
+ lines.push("", "Servers", "-------");
223
+ for (const server of plan.servers) {
224
+ lines.push(`${server.riskLevel.toUpperCase()} ${server.name}`);
225
+ lines.push(` Transport: ${server.transport}`);
226
+ lines.push(` Command: ${server.command ?? server.url ?? "not executable by runtime probe"}`);
227
+ lines.push(` Cwd: ${server.cwd ?? "n/a"}`);
228
+ lines.push(` Probes: ${server.probeMethods.length > 0 ? server.probeMethods.join(", ") : "none"}`);
229
+ lines.push(` Risk reasons: ${server.riskReasons.length > 0 ? server.riskReasons.join(", ") : "none"}`);
230
+ }
231
+ return lines.join("\n");
232
+ }
package/dist/index.d.ts CHANGED
@@ -9,7 +9,8 @@ export { buildDoctorOutputContract, renderDoctorOutputContract, renderDoctorOutp
9
9
  export { buildDoctorValidationCorpusReport, renderDoctorValidationCorpusJson, renderDoctorValidationCorpusReport, type BuildDoctorValidationCorpusOptions, type DoctorValidationCorpusReport, type ValidationCorpusCaseDefinition, type ValidationCorpusCaseResult } from "./core/validation-corpus.js";
10
10
  export { buildDoctorExportBundleFromAnalysis, buildDoctorRecommendationsFromAnalysis, buildPackageAnalysis, type PackageAnalysis, type PackageAnalysisOptions, type PackageAnalysisStage, type PackageAnalysisTiming } from "./core/package-analysis.js";
11
11
  export { buildDoctorPerformanceReport, renderDoctorPerformanceReport, renderDoctorPerformanceReportJson, type BuildDoctorPerformanceReportOptions, type DoctorPerformanceReport, type DoctorPerformanceStage, type DoctorPerformanceStageName } from "./core/performance-report.js";
12
- export { buildDoctorReleaseEvidenceReport, renderDoctorReleaseEvidence, renderDoctorReleaseEvidenceJson, type BuildDoctorReleaseEvidenceOptions, type DoctorReleaseEvidenceGitMetadata, type DoctorReleaseEvidencePackageMetadata, type DoctorReleaseEvidenceReport } from "./core/release-evidence.js";
12
+ export { buildDoctorRuntimePlan, evaluateRuntimeApproval, renderDoctorRuntimePlan, renderDoctorRuntimePlanJson, runtimeApprovalPassed, type DoctorRuntimePlan, type RuntimeApprovalReport, type RuntimePlanServer } from "./core/runtime-plan.js";
13
+ export { buildDoctorReleaseEvidenceAssetReport, buildDoctorReleaseEvidenceReport, renderDoctorReleaseEvidenceAsset, renderDoctorReleaseEvidenceAssetJson, renderDoctorReleaseEvidence, renderDoctorReleaseEvidenceJson, renderDoctorReleaseEvidenceVerification, renderDoctorReleaseEvidenceVerificationJson, verifyDoctorReleaseEvidence, type BuildDoctorReleaseEvidenceOptions, type DoctorReleaseEvidenceAssetReport, type DoctorReleaseEvidenceGitMetadata, type DoctorReleaseEvidencePackageMetadata, type DoctorReleaseEvidenceReport, type DoctorReleaseEvidenceVerificationReport } from "./core/release-evidence.js";
13
14
  export { buildDoctorNpmPackageReport, renderDoctorNpmPackageReport, renderDoctorNpmPackageReportJson, type BuildDoctorNpmPackageReportOptions, type DoctorNpmPackageReport } from "./core/npm-package-doctor.js";
14
15
  export { buildDoctorRiskDiffReport, renderDoctorRiskDiffReport, renderDoctorRiskDiffReportJson, type BuildDoctorRiskDiffReportOptions, type DoctorRiskDiffReport, type RiskDiffFinding, type RiskFindingCategory } from "./core/risk-diff.js";
15
16
  export { buildDoctorInspectorReport, renderDoctorInspectorReport, renderDoctorInspectorReportJson, type BuildDoctorInspectorReportOptions, type DoctorInspectorReport } from "./core/inspector-bridge.js";
package/dist/index.js CHANGED
@@ -9,7 +9,8 @@ export { buildDoctorOutputContract, renderDoctorOutputContract, renderDoctorOutp
9
9
  export { buildDoctorValidationCorpusReport, renderDoctorValidationCorpusJson, renderDoctorValidationCorpusReport } from "./core/validation-corpus.js";
10
10
  export { buildDoctorExportBundleFromAnalysis, buildDoctorRecommendationsFromAnalysis, buildPackageAnalysis } from "./core/package-analysis.js";
11
11
  export { buildDoctorPerformanceReport, renderDoctorPerformanceReport, renderDoctorPerformanceReportJson } from "./core/performance-report.js";
12
- export { buildDoctorReleaseEvidenceReport, renderDoctorReleaseEvidence, renderDoctorReleaseEvidenceJson } from "./core/release-evidence.js";
12
+ export { buildDoctorRuntimePlan, evaluateRuntimeApproval, renderDoctorRuntimePlan, renderDoctorRuntimePlanJson, runtimeApprovalPassed } from "./core/runtime-plan.js";
13
+ export { buildDoctorReleaseEvidenceAssetReport, buildDoctorReleaseEvidenceReport, renderDoctorReleaseEvidenceAsset, renderDoctorReleaseEvidenceAssetJson, renderDoctorReleaseEvidence, renderDoctorReleaseEvidenceJson, renderDoctorReleaseEvidenceVerification, renderDoctorReleaseEvidenceVerificationJson, verifyDoctorReleaseEvidence } from "./core/release-evidence.js";
13
14
  export { buildDoctorNpmPackageReport, renderDoctorNpmPackageReport, renderDoctorNpmPackageReportJson } from "./core/npm-package-doctor.js";
14
15
  export { buildDoctorRiskDiffReport, renderDoctorRiskDiffReport, renderDoctorRiskDiffReportJson } from "./core/risk-diff.js";
15
16
  export { buildDoctorInspectorReport, renderDoctorInspectorReport, renderDoctorInspectorReportJson } from "./core/inspector-bridge.js";
package/dist/run-cli.d.ts CHANGED
@@ -13,6 +13,7 @@ export interface CliTerminalContext {
13
13
  export interface RunCliOptions {
14
14
  terminalContext?: CliTerminalContext;
15
15
  runCheckImpl?: typeof runCheck;
16
+ releaseAssetUploadImpl?: (args: string[]) => Promise<void>;
16
17
  resolveLatestVersion?: () => Promise<string>;
17
18
  }
18
19
  export declare function runCli(args: string[], io?: CliIo, options?: RunCliOptions): Promise<number>;
package/dist/run-cli.js CHANGED
@@ -20,7 +20,8 @@ import { buildDoctorAttestation, renderDoctorAttestation, renderDoctorAttestatio
20
20
  import { buildDoctorOutputContract, renderDoctorOutputContract, renderDoctorOutputContractJson } from "./core/output-contract.js";
21
21
  import { buildDoctorValidationCorpusReport, renderDoctorValidationCorpusJson, renderDoctorValidationCorpusReport } from "./core/validation-corpus.js";
22
22
  import { buildDoctorPerformanceReport, renderDoctorPerformanceReport, renderDoctorPerformanceReportJson } from "./core/performance-report.js";
23
- import { buildDoctorReleaseEvidenceReport, renderDoctorReleaseEvidence, renderDoctorReleaseEvidenceJson } from "./core/release-evidence.js";
23
+ import { buildDoctorRuntimePlan, evaluateRuntimeApproval, renderDoctorRuntimePlan, renderDoctorRuntimePlanJson, runtimeApprovalPassed } from "./core/runtime-plan.js";
24
+ import { buildDoctorReleaseEvidenceAssetReport, buildDoctorReleaseEvidenceReport, renderDoctorReleaseEvidenceAsset, renderDoctorReleaseEvidenceAssetJson, renderDoctorReleaseEvidence, renderDoctorReleaseEvidenceJson, renderDoctorReleaseEvidenceVerification, renderDoctorReleaseEvidenceVerificationJson, verifyDoctorReleaseEvidence } from "./core/release-evidence.js";
24
25
  import { buildDoctorNpmPackageReport, renderDoctorNpmPackageReport, renderDoctorNpmPackageReportJson } from "./core/npm-package-doctor.js";
25
26
  import { buildDoctorRiskDiffReport, renderDoctorRiskDiffReport, renderDoctorRiskDiffReportJson } from "./core/risk-diff.js";
26
27
  import { buildDoctorInspectorReport, renderDoctorInspectorReport, renderDoctorInspectorReportJson } from "./core/inspector-bridge.js";
@@ -70,7 +71,7 @@ const defaultIo = {
70
71
  }
71
72
  };
72
73
  function printUsage(io) {
73
- io.writeStderr("Usage: codex-plugin-doctor check <path|--installed> [filter] [--policy codex-publish|mcp-strict|security] [--compat] [--json|--markdown|--badge-json|--badge-markdown] [--output <path>] [--history <path>] [--runtime] [--verbose-runtime] [--explain] [--no-animations] [--ascii]\n codex-plugin-doctor audit --installed [filter] [--policy codex-publish|mcp-strict|security] [--security] [--compat] [--json] [--output <path>] [--cache] [--changed]\n codex-plugin-doctor mcp <path> [--json] [--output <path>]\n codex-plugin-doctor security <path> [--policy security] [--json|--scorecard]\n codex-plugin-doctor compat <path> [--all|--client <client>] [--json] [--scorecard] [--output <path>] [--install-preview|--apply --backup]\n codex-plugin-doctor fix <path> (--dry-run|--interactive --backup|--apply --backup)\n codex-plugin-doctor history <history.jsonl> [--json] [--fail-on-regression]\n codex-plugin-doctor doctor [npm <package>|contract|corpus|attest <path> [--sign-key-env NAME]|attest verify <attestation.json> --target <path> --sign-key-env NAME|release-evidence <path> --sign-key-env NAME [--allow-dirty] [--allow-untagged]|mcp <path>|inspector <path>|diff --before <path> --after <path>|recommend <path>|trust <path>|perf <path> [--max-total-ms <ms>] [--max-stage-ms stage=ms]|export --bundle <path>|snapshot|clients|--json|--update-check]\n codex-plugin-doctor init [path] [--template skill-only|mcp-stdio|mcp-http|full-runtime]\n codex-plugin-doctor init-ci [path]\n codex-plugin-doctor self-test\n codex-plugin-doctor list --installed\n codex-plugin-doctor explain <finding-id>\n codex-plugin-doctor --version\n\nFirst run:\n codex-plugin-doctor doctor\n codex-plugin-doctor self-test\n codex-plugin-doctor init my-plugin\n codex-plugin-doctor check . --runtime --explain");
74
+ io.writeStderr("Usage: codex-plugin-doctor check <path|--installed> [filter] [--policy codex-publish|mcp-strict|security] [--compat] [--json|--markdown|--badge-json|--badge-markdown] [--output <path>] [--history <path>] [--runtime] [--require-runtime-approval --runtime-approval-digest <digest>] [--verbose-runtime] [--explain] [--no-animations] [--ascii]\n codex-plugin-doctor audit --installed [filter] [--policy codex-publish|mcp-strict|security] [--security] [--compat] [--json] [--output <path>] [--cache] [--changed]\n codex-plugin-doctor mcp <path> [--json] [--output <path>]\n codex-plugin-doctor security <path> [--policy security] [--json|--scorecard]\n codex-plugin-doctor compat <path> [--all|--client <client>] [--json] [--scorecard] [--output <path>] [--install-preview|--apply --backup]\n codex-plugin-doctor fix <path> (--dry-run|--interactive --backup|--apply --backup)\n codex-plugin-doctor history <history.jsonl> [--json] [--fail-on-regression]\n codex-plugin-doctor doctor [npm <package>|contract|corpus|runtime-plan <path>|attest <path> [--sign-key-env NAME]|attest verify <attestation.json> --target <path> --sign-key-env NAME|release-evidence <path> --sign-key-env NAME [--allow-dirty] [--allow-untagged] [--require-runtime-approval --runtime-approval-digest <digest>]|release-evidence verify <evidence.json> --target <path> --sign-key-env NAME|release-evidence asset <path> --tag <tag> --output <evidence.json> --sign-key-env NAME [--upload]|mcp <path>|inspector <path>|diff --before <path> --after <path>|recommend <path>|trust <path>|perf <path> [--max-total-ms <ms>] [--max-stage-ms stage=ms]|export --bundle <path>|snapshot|clients|--json|--update-check]\n codex-plugin-doctor init [path] [--template skill-only|mcp-stdio|mcp-http|full-runtime]\n codex-plugin-doctor init-ci [path]\n codex-plugin-doctor self-test\n codex-plugin-doctor list --installed\n codex-plugin-doctor explain <finding-id>\n codex-plugin-doctor --version\n\nFirst run:\n codex-plugin-doctor doctor\n codex-plugin-doctor self-test\n codex-plugin-doctor init my-plugin\n codex-plugin-doctor check . --runtime --explain");
74
75
  }
75
76
  const performanceStageNames = new Set([
76
77
  "validation",
@@ -219,6 +220,17 @@ async function resolveLatestNpmVersion() {
219
220
  });
220
221
  });
221
222
  }
223
+ async function uploadGitHubReleaseAsset(args) {
224
+ return new Promise((resolve, reject) => {
225
+ execFile("gh", args, { shell: process.platform === "win32" }, (error, _stdout, stderr) => {
226
+ if (error) {
227
+ reject(new Error(stderr.trim() || error.message));
228
+ return;
229
+ }
230
+ resolve();
231
+ });
232
+ });
233
+ }
222
234
  function renderUpdateCheck(latestVersion) {
223
235
  const updateAvailable = latestVersion !== packageVersion;
224
236
  return [
@@ -340,7 +352,195 @@ export async function runCli(args, io = defaultIo, options = {}) {
340
352
  io.writeStdout(renderedReport);
341
353
  return report.exitCode;
342
354
  }
355
+ if (maybePath === "runtime-plan") {
356
+ const targetPath = remainingArgs[0] && !remainingArgs[0].startsWith("--")
357
+ ? remainingArgs[0]
358
+ : null;
359
+ const runtimePlanFlags = targetPath ? remainingArgs.slice(1) : remainingArgs;
360
+ const jsonOutput = runtimePlanFlags.includes("--json");
361
+ const outputIndex = runtimePlanFlags.indexOf("--output");
362
+ const outputPath = outputIndex === -1 ? null : runtimePlanFlags[outputIndex + 1];
363
+ if (!targetPath) {
364
+ io.writeStderr("Missing target path for runtime plan.");
365
+ return 2;
366
+ }
367
+ if (outputIndex !== -1 && (!outputPath || outputPath.startsWith("--"))) {
368
+ io.writeStderr("Missing path after --output.");
369
+ return 2;
370
+ }
371
+ const plan = await buildDoctorRuntimePlan(targetPath);
372
+ const renderedPlan = jsonOutput
373
+ ? renderDoctorRuntimePlanJson(plan)
374
+ : renderDoctorRuntimePlan(plan);
375
+ if (outputPath) {
376
+ await writeFile(outputPath, renderDoctorRuntimePlanJson(plan), "utf8");
377
+ }
378
+ io.writeStdout(renderedPlan);
379
+ return plan.exitCode;
380
+ }
343
381
  if (maybePath === "release-evidence") {
382
+ if (remainingArgs[0] === "asset") {
383
+ const targetPath = remainingArgs[1] && !remainingArgs[1].startsWith("--")
384
+ ? remainingArgs[1]
385
+ : null;
386
+ const assetFlags = targetPath ? remainingArgs.slice(2) : remainingArgs.slice(1);
387
+ const jsonOutput = assetFlags.includes("--json");
388
+ const upload = assetFlags.includes("--upload");
389
+ const outputIndex = assetFlags.indexOf("--output");
390
+ const outputPath = outputIndex === -1 ? null : assetFlags[outputIndex + 1];
391
+ const tagIndex = assetFlags.indexOf("--tag");
392
+ const tag = tagIndex === -1 ? null : assetFlags[tagIndex + 1];
393
+ const signKeyIndex = assetFlags.indexOf("--sign-key");
394
+ const signKeyEnvIndex = assetFlags.indexOf("--sign-key-env");
395
+ const signKeyEnv = signKeyEnvIndex === -1 ? null : assetFlags[signKeyEnvIndex + 1];
396
+ const allowDirty = assetFlags.includes("--allow-dirty");
397
+ const allowUntagged = assetFlags.includes("--allow-untagged");
398
+ const requireRuntimeApproval = assetFlags.includes("--require-runtime-approval");
399
+ const runtimeApprovalDigestIndex = assetFlags.indexOf("--runtime-approval-digest");
400
+ const runtimeApprovalDigest = runtimeApprovalDigestIndex === -1
401
+ ? null
402
+ : assetFlags[runtimeApprovalDigestIndex + 1];
403
+ if (!targetPath) {
404
+ io.writeStderr("Missing target path for release evidence asset.");
405
+ return 2;
406
+ }
407
+ if (tagIndex === -1) {
408
+ io.writeStderr("Missing release tag. Use --tag <tag>.");
409
+ return 2;
410
+ }
411
+ if (!tag || tag.startsWith("--")) {
412
+ io.writeStderr("Missing release tag after --tag.");
413
+ return 2;
414
+ }
415
+ if (outputIndex === -1) {
416
+ io.writeStderr("Missing output path. Use --output <path>.");
417
+ return 2;
418
+ }
419
+ if (!outputPath || outputPath.startsWith("--")) {
420
+ io.writeStderr("Missing path after --output.");
421
+ return 2;
422
+ }
423
+ if (signKeyIndex !== -1) {
424
+ io.writeStderr("Use --sign-key-env for release evidence assets; inline signing keys are not supported.");
425
+ return 2;
426
+ }
427
+ if (signKeyEnvIndex === -1) {
428
+ io.writeStderr("Missing signing key. Use --sign-key-env <name>.");
429
+ return 2;
430
+ }
431
+ if (!signKeyEnv || signKeyEnv.startsWith("--")) {
432
+ io.writeStderr("Missing environment variable name after --sign-key-env.");
433
+ return 2;
434
+ }
435
+ if (runtimeApprovalDigestIndex !== -1 &&
436
+ (!runtimeApprovalDigest || runtimeApprovalDigest.startsWith("--"))) {
437
+ io.writeStderr("Missing digest after --runtime-approval-digest.");
438
+ return 2;
439
+ }
440
+ const signingKey = terminalContext.env[signKeyEnv];
441
+ if (!signingKey) {
442
+ io.writeStderr(`Environment variable ${signKeyEnv} is not set.`);
443
+ return 2;
444
+ }
445
+ const parsedThresholds = parsePerformanceThresholds(assetFlags);
446
+ if (typeof parsedThresholds === "string") {
447
+ io.writeStderr(parsedThresholds);
448
+ return 2;
449
+ }
450
+ const resolvedOutputPath = path.resolve(outputPath);
451
+ const evidence = await buildDoctorReleaseEvidenceReport(targetPath, {
452
+ signingKey,
453
+ signingKeyEnv: signKeyEnv,
454
+ allowDirty,
455
+ allowUntagged,
456
+ requireRuntimeApproval,
457
+ runtimeApprovalDigest,
458
+ environment: {
459
+ env: terminalContext.env,
460
+ platform: terminalContext.platform
461
+ },
462
+ runCheck: options.runCheckImpl
463
+ ? (pathToCheck) => options.runCheckImpl(pathToCheck)
464
+ : undefined,
465
+ performanceThresholds: parsedThresholds.thresholds
466
+ });
467
+ await writeFile(resolvedOutputPath, renderDoctorReleaseEvidenceJson(evidence), "utf8");
468
+ let uploaded = false;
469
+ const uploadArgs = ["release", "upload", tag, resolvedOutputPath, "--clobber"];
470
+ if (upload && evidence.status === "pass" && evidence.releaseReady) {
471
+ const uploadImpl = options.releaseAssetUploadImpl ?? uploadGitHubReleaseAsset;
472
+ await uploadImpl(uploadArgs);
473
+ uploaded = true;
474
+ }
475
+ const report = buildDoctorReleaseEvidenceAssetReport(evidence, {
476
+ tag,
477
+ artifactPath: resolvedOutputPath,
478
+ uploaded
479
+ });
480
+ const reportJson = renderDoctorReleaseEvidenceAssetJson(report);
481
+ io.writeStdout(jsonOutput ? reportJson : renderDoctorReleaseEvidenceAsset(report));
482
+ return report.exitCode;
483
+ }
484
+ if (remainingArgs[0] === "verify") {
485
+ const artifactPath = remainingArgs[1] && !remainingArgs[1].startsWith("--")
486
+ ? remainingArgs[1]
487
+ : null;
488
+ const verifyFlags = artifactPath ? remainingArgs.slice(2) : remainingArgs.slice(1);
489
+ const jsonOutput = verifyFlags.includes("--json");
490
+ const outputIndex = verifyFlags.indexOf("--output");
491
+ const outputPath = outputIndex === -1 ? null : verifyFlags[outputIndex + 1];
492
+ const targetIndex = verifyFlags.indexOf("--target");
493
+ const targetPath = targetIndex === -1 ? null : verifyFlags[targetIndex + 1];
494
+ const signKeyIndex = verifyFlags.indexOf("--sign-key");
495
+ const signKeyEnvIndex = verifyFlags.indexOf("--sign-key-env");
496
+ const signKeyEnv = signKeyEnvIndex === -1 ? null : verifyFlags[signKeyEnvIndex + 1];
497
+ if (!artifactPath) {
498
+ io.writeStderr("Missing release evidence artifact path.");
499
+ return 2;
500
+ }
501
+ if (outputIndex !== -1 && (!outputPath || outputPath.startsWith("--"))) {
502
+ io.writeStderr("Missing path after --output.");
503
+ return 2;
504
+ }
505
+ if (targetIndex !== -1 && (!targetPath || targetPath.startsWith("--"))) {
506
+ io.writeStderr("Missing path after --target.");
507
+ return 2;
508
+ }
509
+ if (targetIndex === -1) {
510
+ io.writeStderr("Missing target path. Use --target <path>.");
511
+ return 2;
512
+ }
513
+ if (signKeyIndex !== -1) {
514
+ io.writeStderr("Use --sign-key-env for release evidence verification; inline signing keys are not supported.");
515
+ return 2;
516
+ }
517
+ if (signKeyEnvIndex === -1) {
518
+ io.writeStderr("Missing signing key. Use --sign-key-env <name>.");
519
+ return 2;
520
+ }
521
+ if (!signKeyEnv || signKeyEnv.startsWith("--")) {
522
+ io.writeStderr("Missing environment variable name after --sign-key-env.");
523
+ return 2;
524
+ }
525
+ const signingKey = terminalContext.env[signKeyEnv];
526
+ if (!signingKey) {
527
+ io.writeStderr(`Environment variable ${signKeyEnv} is not set.`);
528
+ return 2;
529
+ }
530
+ const report = await verifyDoctorReleaseEvidence(artifactPath, {
531
+ signingKey,
532
+ targetPath: targetPath
533
+ });
534
+ const reportJson = renderDoctorReleaseEvidenceVerificationJson(report);
535
+ const renderedReport = jsonOutput
536
+ ? reportJson
537
+ : renderDoctorReleaseEvidenceVerification(report, { outputPath });
538
+ if (outputPath) {
539
+ await writeFile(outputPath, reportJson, "utf8");
540
+ }
541
+ io.writeStdout(renderedReport);
542
+ return report.exitCode;
543
+ }
344
544
  const targetPath = remainingArgs[0] && !remainingArgs[0].startsWith("--")
345
545
  ? remainingArgs[0]
346
546
  : ".";
@@ -355,6 +555,11 @@ export async function runCli(args, io = defaultIo, options = {}) {
355
555
  const signKeyEnv = signKeyEnvIndex === -1 ? null : evidenceFlags[signKeyEnvIndex + 1];
356
556
  const allowDirty = evidenceFlags.includes("--allow-dirty");
357
557
  const allowUntagged = evidenceFlags.includes("--allow-untagged");
558
+ const requireRuntimeApproval = evidenceFlags.includes("--require-runtime-approval");
559
+ const runtimeApprovalDigestIndex = evidenceFlags.indexOf("--runtime-approval-digest");
560
+ const runtimeApprovalDigest = runtimeApprovalDigestIndex === -1
561
+ ? null
562
+ : evidenceFlags[runtimeApprovalDigestIndex + 1];
358
563
  if (outputIndex !== -1 && (!outputPath || outputPath.startsWith("--"))) {
359
564
  io.writeStderr("Missing path after --output.");
360
565
  return 2;
@@ -371,6 +576,11 @@ export async function runCli(args, io = defaultIo, options = {}) {
371
576
  io.writeStderr("Missing environment variable name after --sign-key-env.");
372
577
  return 2;
373
578
  }
579
+ if (runtimeApprovalDigestIndex !== -1 &&
580
+ (!runtimeApprovalDigest || runtimeApprovalDigest.startsWith("--"))) {
581
+ io.writeStderr("Missing digest after --runtime-approval-digest.");
582
+ return 2;
583
+ }
374
584
  const signingKey = terminalContext.env[signKeyEnv];
375
585
  if (!signingKey) {
376
586
  io.writeStderr(`Environment variable ${signKeyEnv} is not set.`);
@@ -386,6 +596,8 @@ export async function runCli(args, io = defaultIo, options = {}) {
386
596
  signingKeyEnv: signKeyEnv,
387
597
  allowDirty,
388
598
  allowUntagged,
599
+ requireRuntimeApproval,
600
+ runtimeApprovalDigest,
389
601
  environment: {
390
602
  env: terminalContext.env,
391
603
  platform: terminalContext.platform
@@ -1167,6 +1379,11 @@ export async function runCli(args, io = defaultIo, options = {}) {
1167
1379
  const policy = parsePolicyPack(policyName);
1168
1380
  const historyIndex = normalizedFlags.indexOf("--history");
1169
1381
  const historyPath = historyIndex === -1 ? null : normalizedFlags[historyIndex + 1];
1382
+ const requireRuntimeApproval = normalizedFlags.includes("--require-runtime-approval");
1383
+ const runtimeApprovalDigestIndex = normalizedFlags.indexOf("--runtime-approval-digest");
1384
+ const runtimeApprovalDigest = runtimeApprovalDigestIndex === -1
1385
+ ? null
1386
+ : normalizedFlags[runtimeApprovalDigestIndex + 1];
1170
1387
  if (outputIndex !== -1 && (!outputPath || outputPath.startsWith("--"))) {
1171
1388
  io.writeStderr("Missing path after --output.");
1172
1389
  return 2;
@@ -1195,6 +1412,11 @@ export async function runCli(args, io = defaultIo, options = {}) {
1195
1412
  io.writeStderr("Missing path after --history.");
1196
1413
  return 2;
1197
1414
  }
1415
+ if (runtimeApprovalDigestIndex !== -1 &&
1416
+ (!runtimeApprovalDigest || runtimeApprovalDigest.startsWith("--"))) {
1417
+ io.writeStderr("Missing digest after --runtime-approval-digest.");
1418
+ return 2;
1419
+ }
1198
1420
  if (checkInstalled && (badgeJsonOutput || badgeMarkdownOutput)) {
1199
1421
  io.writeStderr("Badge output requires a single package target.");
1200
1422
  return 2;
@@ -1206,6 +1428,14 @@ export async function runCli(args, io = defaultIo, options = {}) {
1206
1428
  const effectiveRuntimeProbeEnabled = runtimeProbeEnabled ||
1207
1429
  checkProfile === "publish" ||
1208
1430
  policyEnablesRuntime(policy);
1431
+ if (requireRuntimeApproval && !effectiveRuntimeProbeEnabled) {
1432
+ io.writeStderr("Runtime approval requires runtime probing. Add --runtime, --profile publish, or a runtime-enabled policy.");
1433
+ return 2;
1434
+ }
1435
+ if (checkInstalled && requireRuntimeApproval) {
1436
+ io.writeStderr("Runtime approval gating requires a single package target, not --installed.");
1437
+ return 2;
1438
+ }
1209
1439
  const outputPolicy = determineOutputPolicy({
1210
1440
  jsonOutput: jsonOutput || badgeJsonOutput,
1211
1441
  markdownOutput: markdownOutput || badgeMarkdownOutput,
@@ -1217,6 +1447,17 @@ export async function runCli(args, io = defaultIo, options = {}) {
1217
1447
  env: terminalContext.env
1218
1448
  });
1219
1449
  const runCheckImpl = options.runCheckImpl ?? runCheck;
1450
+ if (!checkInstalled && effectiveRuntimeProbeEnabled && requireRuntimeApproval) {
1451
+ const runtimePlan = await buildDoctorRuntimePlan(targetPath);
1452
+ const approval = evaluateRuntimeApproval(runtimePlan, {
1453
+ required: true,
1454
+ approvedDigest: runtimeApprovalDigest
1455
+ });
1456
+ if (!runtimeApprovalPassed(approval)) {
1457
+ io.writeStderr(`${approval.message}\nCurrent runtime plan digest: ${runtimePlan.digest}`);
1458
+ return 1;
1459
+ }
1460
+ }
1220
1461
  if (checkInstalled) {
1221
1462
  const installedPlugins = filterInstalledPlugins(await discoverInstalledPlugins({ env: terminalContext.env }), installedFilter);
1222
1463
  if (installedPlugins.length === 0) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codex-plugin-doctor",
3
- "version": "1.0.3",
3
+ "version": "1.2.0",
4
4
  "description": "CLI-first validator for Codex plugins, skills, and MCP package surfaces with runtime MCP protocol validation.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",