codex-plugin-doctor 1.46.1 → 1.48.0

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