codex-plugin-doctor 1.11.0 → 1.13.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
@@ -358,9 +358,9 @@ jobs:
358
358
  runs-on: ubuntu-latest
359
359
  steps:
360
360
  - uses: actions/checkout@v5
361
- - uses: Esquetta/CodexPluginDoctor@v1.11.0
361
+ - uses: Esquetta/CodexPluginDoctor@v1.13.0
362
362
  with:
363
- version: "1.11.0"
363
+ version: "1.13.0"
364
364
  path: .
365
365
  runtime: "true"
366
366
  policy: codex-publish
@@ -1,5 +1,5 @@
1
1
  import { createHash } from "node:crypto";
2
- import { mkdir, readFile, stat, writeFile } from "node:fs/promises";
2
+ import { mkdir, readFile, realpath, stat, writeFile } from "node:fs/promises";
3
3
  import path from "node:path";
4
4
  import { buildDoctorAttestation, renderDoctorAttestationJson, verifyDoctorAttestation } from "./attestation.js";
5
5
  import { buildDoctorReleaseEvidenceReport, renderDoctorReleaseEvidenceJson, verifyDoctorReleaseEvidence } from "./release-evidence.js";
@@ -10,14 +10,60 @@ import { readJsonFile } from "./read-json-file.js";
10
10
  function isPlainObject(value) {
11
11
  return typeof value === "object" && value !== null && !Array.isArray(value);
12
12
  }
13
+ function hasExactKeys(value, keys) {
14
+ const actualKeys = Object.keys(value);
15
+ return actualKeys.length === keys.length && keys.every((key) => actualKeys.includes(key));
16
+ }
17
+ function isStatus(value) {
18
+ return value === "pass" || value === "warn" || value === "fail";
19
+ }
20
+ function isRuntimePolicyDecision(value) {
21
+ return value === "allow" ||
22
+ value === "review" ||
23
+ value === "sandbox_recommended" ||
24
+ value === "deny";
25
+ }
26
+ function isBundleFileMap(value) {
27
+ const expectedKeys = Object.keys(relativeBundleFiles());
28
+ return isPlainObject(value) &&
29
+ hasExactKeys(value, expectedKeys) &&
30
+ expectedKeys.every((key) => typeof value[key] === "string");
31
+ }
32
+ function isIntegrityEntry(value) {
33
+ if (!isPlainObject(value)) {
34
+ return false;
35
+ }
36
+ const bytes = value.bytes;
37
+ return typeof value.path === "string" &&
38
+ typeof value.digest === "string" &&
39
+ /^sha256:[a-f0-9]{64}$/.test(value.digest) &&
40
+ typeof bytes === "number" &&
41
+ Number.isSafeInteger(bytes) &&
42
+ bytes >= 0;
43
+ }
44
+ function isManifestIntegrity(value) {
45
+ return isPlainObject(value) &&
46
+ value.algorithm === "sha256" &&
47
+ isPlainObject(value.files) &&
48
+ Object.values(value.files).every(isIntegrityEntry);
49
+ }
13
50
  function isDoctorReviewBundleManifest(value) {
14
51
  return isPlainObject(value) &&
15
52
  value.schemaVersion === "1.0.0" &&
16
53
  value.kind === "doctor.review.bundle" &&
54
+ typeof value.generatedAt === "string" &&
55
+ typeof value.version === "string" &&
17
56
  typeof value.targetPath === "string" &&
18
57
  typeof value.outputDirectory === "string" &&
58
+ isStatus(value.status) &&
59
+ (value.exitCode === 0 || value.exitCode === 1) &&
19
60
  isPlainObject(value.summary) &&
20
- isPlainObject(value.files);
61
+ isRuntimePolicyDecision(value.summary.runtimePolicy) &&
62
+ typeof value.summary.releaseReady === "boolean" &&
63
+ isStatus(value.summary.attestation) &&
64
+ (value.summary.releaseEvidence === "pass" || value.summary.releaseEvidence === "fail") &&
65
+ isBundleFileMap(value.files) &&
66
+ (value.integrity === undefined || isManifestIntegrity(value.integrity));
21
67
  }
22
68
  function statusRank(status) {
23
69
  return status === "fail" ? 2 : status === "warn" ? 1 : 0;
@@ -50,6 +96,21 @@ function isPathInsideDirectory(candidatePath, directoryPath) {
50
96
  const relativePath = path.relative(directoryPath, candidatePath);
51
97
  return relativePath === "" || (!relativePath.startsWith("..") && !path.isAbsolute(relativePath));
52
98
  }
99
+ async function resolveBundleArtifactPath(bundleDirectory, relativePath) {
100
+ const resolvedBundleDirectory = path.resolve(bundleDirectory);
101
+ const artifactPath = path.resolve(resolvedBundleDirectory, relativePath);
102
+ if (!isPathInsideDirectory(artifactPath, resolvedBundleDirectory)) {
103
+ throw new Error("Bundle artifact path resolves outside the bundle directory.");
104
+ }
105
+ const [canonicalBundleDirectory, canonicalArtifactPath] = await Promise.all([
106
+ realpath(resolvedBundleDirectory),
107
+ realpath(artifactPath)
108
+ ]);
109
+ if (!isPathInsideDirectory(canonicalArtifactPath, canonicalBundleDirectory)) {
110
+ throw new Error("Bundle artifact canonical path resolves outside the bundle directory.");
111
+ }
112
+ return canonicalArtifactPath;
113
+ }
53
114
  function bundleStatus(runtimePolicy, releaseEvidence) {
54
115
  if (runtimePolicy.status === "fail" || releaseEvidence.status === "fail") {
55
116
  return "fail";
@@ -171,7 +232,7 @@ export function renderDoctorReviewBundleJson(bundle) {
171
232
  return JSON.stringify(bundle.manifest, null, 2);
172
233
  }
173
234
  async function readBundleJsonFile(bundleDirectory, relativePath) {
174
- return readJsonFile(path.join(bundleDirectory, relativePath));
235
+ return readJsonFile(await resolveBundleArtifactPath(bundleDirectory, relativePath));
175
236
  }
176
237
  async function readBundleDiffSnapshot(bundleDirectory) {
177
238
  const manifestArtifact = await readBundleJsonFile(bundleDirectory, "manifest.json");
@@ -348,15 +409,7 @@ export async function verifyDoctorReviewBundle(bundleDirectory, options) {
348
409
  const files = manifest?.files ?? relativeBundleFiles();
349
410
  for (const [fileKey, relativePath] of Object.entries(files)) {
350
411
  try {
351
- const artifactPath = path.resolve(resolvedBundleDirectory, relativePath);
352
- if (!isPathInsideDirectory(artifactPath, resolvedBundleDirectory)) {
353
- checks.push({
354
- id: `review_bundle.file.${fileKey}`,
355
- status: "fail",
356
- message: `${relativePath} resolves outside the review bundle directory.`
357
- });
358
- continue;
359
- }
412
+ const artifactPath = await resolveBundleArtifactPath(resolvedBundleDirectory, relativePath);
360
413
  const fileStat = await stat(artifactPath);
361
414
  checks.push({
362
415
  id: `review_bundle.file.${fileKey}`,
@@ -427,8 +480,8 @@ export async function verifyDoctorReviewBundle(bundleDirectory, options) {
427
480
  });
428
481
  continue;
429
482
  }
430
- const integrityPath = path.resolve(resolvedBundleDirectory, expected.path);
431
- if (!isPathInsideDirectory(integrityPath, resolvedBundleDirectory)) {
483
+ const resolvedIntegrityPath = path.resolve(resolvedBundleDirectory, expected.path);
484
+ if (!isPathInsideDirectory(resolvedIntegrityPath, resolvedBundleDirectory)) {
432
485
  integrityStatus = "fail";
433
486
  checks.push({
434
487
  id: `review_bundle.integrity.${fileKey}.path`,
@@ -437,6 +490,7 @@ export async function verifyDoctorReviewBundle(bundleDirectory, options) {
437
490
  });
438
491
  continue;
439
492
  }
493
+ const integrityPath = await resolveBundleArtifactPath(resolvedBundleDirectory, expected.path);
440
494
  const content = await readFile(integrityPath);
441
495
  const digest = sha256(content);
442
496
  const matches = digest === expected.digest && content.byteLength === expected.bytes;
@@ -505,7 +559,8 @@ export async function verifyDoctorReviewBundle(bundleDirectory, options) {
505
559
  });
506
560
  }
507
561
  try {
508
- attestation = await verifyDoctorAttestation(path.join(resolvedBundleDirectory, files.attestationJson), targetPath, { signingKey: options.signingKey });
562
+ const attestationPath = await resolveBundleArtifactPath(resolvedBundleDirectory, files.attestationJson);
563
+ attestation = await verifyDoctorAttestation(attestationPath, targetPath, { signingKey: options.signingKey });
509
564
  checks.push({
510
565
  id: "review_bundle.attestation",
511
566
  status: attestation.status,
@@ -522,7 +577,8 @@ export async function verifyDoctorReviewBundle(bundleDirectory, options) {
522
577
  });
523
578
  }
524
579
  try {
525
- releaseEvidence = await verifyDoctorReleaseEvidence(path.join(resolvedBundleDirectory, files.releaseEvidenceJson), {
580
+ const releaseEvidencePath = await resolveBundleArtifactPath(resolvedBundleDirectory, files.releaseEvidenceJson);
581
+ releaseEvidence = await verifyDoctorReleaseEvidence(releaseEvidencePath, {
526
582
  signingKey: options.signingKey,
527
583
  targetPath
528
584
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codex-plugin-doctor",
3
- "version": "1.11.0",
3
+ "version": "1.13.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",