diff --git a/CHANGELOG.md b/CHANGELOG.md index e214353..7255dc5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,23 @@ All notable changes to `codex-plugin-doctor` are documented here. This changelog groups the shipped work into product-level release blocks instead of repeating every low-level git diff in isolation. +## [1.59.0] - 2026-08-17 + +### Added + +- added offline `doctor submission ` reports in text, JSON, and Markdown, with advisory defaults and `--require-ready` as the strict automatic blocker gate +- added automatic public-directory listing and classification, root `.app.json` boundary checks, bounded branding asset validation, and safe skill and `openai.yaml` identity and metadata validation +- added opt-in GitHub Action submission inputs, artifacts, and step-summary output while preserving existing defaults + +### Changed + +- kept portal-only review items explicit: automatic passing results still require manual review and do not submit a package or claim directory acceptance + +### Security + +- bounded raster, SVG, and YAML parsing; canonical containment; no process execution or network access; and redacted evidence with absolute Action paths +- added `yaml` and `fast-xml-parser` parsing dependencies without package scripts; includes the transitive `nanoid` remediation + ## [1.58.0] - 2026-08-11 ### Added diff --git a/README.md b/README.md index 6f04fe9..dc50c68 100644 --- a/README.md +++ b/README.md @@ -134,6 +134,19 @@ version is an immutable collision and must be replaced by a version bump. The command is advisory: it never authenticates, publishes, or changes npm or Registry records. See [MCP Registry Publication Preflight](./docs/architecture/mcp-registry-publication-preflight.md). +### Public Directory Submission Preflight + +Check a plugin package for deterministic public-directory submission issues without contacting a portal: + +```bash +codex-plugin-doctor doctor submission +codex-plugin-doctor doctor submission --json +codex-plugin-doctor doctor submission --markdown +codex-plugin-doctor doctor submission --require-ready +``` + +The preflight is offline and non-executing: it does not submit a package, make network requests, start MCP servers, verify domains, or handle OAuth credentials. Its automatic `status` is `pass` or `fail`; a passing automatic result remains `manual_review_required` until portal-only review is complete. It never claims directory acceptance. See [Public Directory Submission Preflight](./docs/architecture/public-directory-submission-preflight.md). + Output formats: - human text output @@ -480,9 +493,9 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v5 - - uses: Esquetta/CodexPluginDoctor@v1.58.0 + - uses: Esquetta/CodexPluginDoctor@v1.59.0 with: - version: "1.58.0" + version: "1.59.0" path: . runtime: "true" policy: codex-publish @@ -494,7 +507,7 @@ jobs: review-bundle-verify: "true" ``` -The action writes `codex-plugin-doctor-summary.md`, `codex-plugin-doctor-report.json`, `codex-plugin-doctor-action-manifest.json`, optional `codex-plugin-doctor.sarif`, optional validation corpus and quality metrics reports, optional `output-contract.json`, and optional signed `review-bundle/` files to `codex-plugin-doctor-reports`, appends the Markdown report to the GitHub Actions step summary, uploads the report directory as an artifact, and then returns the real validation exit code. Review bundle generation requires a signing key environment variable such as `CODEX_PLUGIN_DOCTOR_SIGNING_KEY`. For runtime probing, SARIF output, corpus quality regression gates, corpus and contract artifacts, review bundle artifacts, installed plugin cache checks, CI policy presets, and pinned release examples, see [GitHub Action Usage](./docs/guides/github-action.md). +The action writes `codex-plugin-doctor-summary.md`, `codex-plugin-doctor-report.json`, `codex-plugin-doctor-action-manifest.json`, optional `codex-plugin-doctor.sarif`, optional validation corpus and quality metrics reports, optional `output-contract.json`, and optional signed `review-bundle/` files to `codex-plugin-doctor-reports`, appends the Markdown report to the GitHub Actions step summary, uploads the report directory as an artifact, and then returns the real validation exit code. Set `submission: "true"` to add offline submission preflight reports; set `require-submission-ready: "true"` only with that opt-in to make automatic blockers fail the job. Review bundle generation requires a signing key environment variable such as `CODEX_PLUGIN_DOCTOR_SIGNING_KEY`. For runtime probing, SARIF output, corpus quality regression gates, corpus and contract artifacts, review bundle artifacts, installed plugin cache checks, CI policy presets, and pinned release examples, see [GitHub Action Usage](./docs/guides/github-action.md). To self-test this repository after cloning it: diff --git a/action.yml b/action.yml index 07ca8db..8c58361 100644 --- a/action.yml +++ b/action.yml @@ -38,6 +38,14 @@ inputs: description: Fail unless the configured local Registry metadata receives a pass result. required: false default: "false" + submission: + description: Generate offline public directory submission preflight JSON and Markdown reports. + required: false + default: "false" + require-submission-ready: + description: Fail unless the offline submission preflight is automatically ready; this does not replace manual review. + required: false + default: "false" installed: description: Validate plugins from the local Codex plugin cache. required: false @@ -169,6 +177,12 @@ outputs: registry-report-path: description: Path to the MCP Registry readiness JSON report when registry-metadata is configured. value: ${{ steps.run-doctor.outputs.registry-report-path }} + submission-json-path: + description: Path to the offline submission preflight JSON report when submission is enabled. + value: ${{ steps.run-doctor.outputs.submission-json-path }} + submission-summary-path: + description: Path to the offline submission preflight Markdown report when submission is enabled. + value: ${{ steps.run-doctor.outputs.submission-summary-path }} review-bundle-path: description: Path to the generated review bundle directory when review-bundle is enabled. value: ${{ steps.run-doctor.outputs.review-bundle-path }} @@ -193,6 +207,8 @@ runs: REQUIRE_REMOTE_RELIABILITY_INPUT: ${{ inputs['require-remote-reliability'] }} REGISTRY_METADATA_INPUT: ${{ inputs['registry-metadata'] }} REQUIRE_REGISTRY_READINESS_INPUT: ${{ inputs['require-registry-readiness'] }} + SUBMISSION_INPUT: ${{ inputs.submission }} + REQUIRE_SUBMISSION_READY_INPUT: ${{ inputs['require-submission-ready'] }} CORPUS_METRICS_MANIFEST_INPUT: ${{ inputs['corpus-metrics-manifest'] }} CORPUS_METRICS_BASELINE_INPUT: ${{ inputs['corpus-metrics-baseline'] }} CORPUS_METRICS_FAIL_ON_REGRESSION_INPUT: ${{ inputs['corpus-metrics-fail-on-regression'] }} @@ -209,10 +225,16 @@ runs: output_contract_path="$report_dir/output-contract.json" action_manifest_path="$report_dir/codex-plugin-doctor-action-manifest.json" registry_report_path="$report_dir/mcp-registry-readiness.json" + submission_json_path="$report_dir/codex-plugin-doctor-submission.json" + submission_summary_path="$report_dir/codex-plugin-doctor-submission.md" review_bundle_path="$report_dir/${{ inputs['review-bundle-dir'] }}" review_bundle_verification_path="$report_dir/review-bundle-verification.json" status_file="${RUNNER_TEMP:-.}/codex-plugin-doctor-status" + submission_state_file="${RUNNER_TEMP:-.}/codex-plugin-doctor-submission-ran" status=0 + submission_ran=false + submission_json_output="" + submission_summary_output="" doctor_version="$(codex-plugin-doctor --version)" mkdir -p "$report_dir" @@ -348,6 +370,24 @@ runs: run_doctor "MCP Registry readiness" "${registry_args[@]}" fi + if [[ "$REQUIRE_SUBMISSION_READY_INPUT" == "true" && "$SUBMISSION_INPUT" != "true" ]]; then + echo "require-submission-ready requires submission." >&2 + record_status 2 + elif [[ "$SUBMISSION_INPUT" == "true" && "${{ inputs.installed }}" == "true" ]]; then + echo "Submission preflight requires a single package path, not installed-cache mode." >&2 + record_status 2 + elif [[ "$SUBMISSION_INPUT" == "true" ]]; then + submission_ran=true + submission_args=(doctor submission "${{ inputs.path }}" --json --output "$submission_json_path") + if [[ "$REQUIRE_SUBMISSION_READY_INPUT" == "true" ]]; then + submission_args+=(--require-ready) + fi + run_doctor "submission preflight" "${submission_args[@]}" + run_doctor "submission summary" doctor submission "${{ inputs.path }}" --markdown --output "$submission_summary_path" + submission_json_output="$submission_json_path" + submission_summary_output="$submission_summary_path" + fi + if [[ "${{ inputs['review-bundle'] }}" == "true" ]]; then signing_key_env="${{ inputs['signing-key-env'] }}" @@ -395,6 +435,7 @@ runs: export CODEX_PLUGIN_DOCTOR_ACTION_CORPUS_METRICS_DIFF="$([[ -n "$CORPUS_METRICS_BASELINE_INPUT" ]] && echo true || echo false)" export CODEX_PLUGIN_DOCTOR_ACTION_CONTRACT="${{ inputs.contract }}" export CODEX_PLUGIN_DOCTOR_ACTION_REGISTRY="$([[ -n "$REGISTRY_METADATA_INPUT" ]] && echo true || echo false)" + export CODEX_PLUGIN_DOCTOR_ACTION_SUBMISSION="$submission_ran" export CODEX_PLUGIN_DOCTOR_ACTION_REVIEW_BUNDLE="${{ inputs['review-bundle'] }}" export CODEX_PLUGIN_DOCTOR_ACTION_REVIEW_BUNDLE_VERIFY="${{ inputs['review-bundle-verify'] }}" export CODEX_PLUGIN_DOCTOR_ACTION_SUMMARY_PATH="$summary_path" @@ -405,12 +446,19 @@ runs: export CODEX_PLUGIN_DOCTOR_ACTION_CORPUS_METRICS_DIFF_PATH="$corpus_metrics_diff_path" export CODEX_PLUGIN_DOCTOR_ACTION_CONTRACT_PATH="$output_contract_path" export CODEX_PLUGIN_DOCTOR_ACTION_REGISTRY_PATH="$registry_report_path" + export CODEX_PLUGIN_DOCTOR_ACTION_SUBMISSION_JSON_PATH="$submission_json_output" + export CODEX_PLUGIN_DOCTOR_ACTION_SUBMISSION_SUMMARY_PATH="$submission_summary_output" export CODEX_PLUGIN_DOCTOR_ACTION_REVIEW_BUNDLE_PATH="$review_bundle_path" export CODEX_PLUGIN_DOCTOR_ACTION_REVIEW_BUNDLE_VERIFICATION_PATH="$review_bundle_verification_path" node <<'NODE' const fs = require("node:fs"); + const path = require("node:path"); const enabled = (name) => process.env[name] === "true"; + const displayTargetPath = (value) => { + if (/^file:/iu.test(value) || path.isAbsolute(value) || path.win32.isAbsolute(value)) return "[absolute-path-redacted]"; + return value === "" ? "" : path.posix.normalize(value.replace(/\\/gu, "/")); + }; const report = (key, enabledEnv, pathEnv) => ({ enabled: enabled(enabledEnv), path: process.env[pathEnv] || "" @@ -424,7 +472,7 @@ runs: reportDirectory: process.env.CODEX_PLUGIN_DOCTOR_ACTION_REPORT_DIR || "", artifactName: process.env.CODEX_PLUGIN_DOCTOR_ACTION_ARTIFACT_NAME || "", target: { - path: process.env.CODEX_PLUGIN_DOCTOR_ACTION_PATH || "", + path: displayTargetPath(process.env.CODEX_PLUGIN_DOCTOR_ACTION_PATH || ""), installed: enabled("CODEX_PLUGIN_DOCTOR_ACTION_INSTALLED"), runtime: enabled("CODEX_PLUGIN_DOCTOR_ACTION_RUNTIME") }, @@ -437,6 +485,8 @@ runs: corpusMetricsDiff: report("corpusMetricsDiff", "CODEX_PLUGIN_DOCTOR_ACTION_CORPUS_METRICS_DIFF", "CODEX_PLUGIN_DOCTOR_ACTION_CORPUS_METRICS_DIFF_PATH"), contract: report("contract", "CODEX_PLUGIN_DOCTOR_ACTION_CONTRACT", "CODEX_PLUGIN_DOCTOR_ACTION_CONTRACT_PATH"), registryReport: report("registryReport", "CODEX_PLUGIN_DOCTOR_ACTION_REGISTRY", "CODEX_PLUGIN_DOCTOR_ACTION_REGISTRY_PATH"), + submissionJson: report("submissionJson", "CODEX_PLUGIN_DOCTOR_ACTION_SUBMISSION", "CODEX_PLUGIN_DOCTOR_ACTION_SUBMISSION_JSON_PATH"), + submissionSummary: report("submissionSummary", "CODEX_PLUGIN_DOCTOR_ACTION_SUBMISSION", "CODEX_PLUGIN_DOCTOR_ACTION_SUBMISSION_SUMMARY_PATH"), reviewBundle: report("reviewBundle", "CODEX_PLUGIN_DOCTOR_ACTION_REVIEW_BUNDLE", "CODEX_PLUGIN_DOCTOR_ACTION_REVIEW_BUNDLE_PATH"), reviewBundleVerification: report("reviewBundleVerification", "CODEX_PLUGIN_DOCTOR_ACTION_REVIEW_BUNDLE_VERIFY", "CODEX_PLUGIN_DOCTOR_ACTION_REVIEW_BUNDLE_VERIFICATION_PATH") } @@ -450,6 +500,7 @@ runs: NODE printf "%s" "$status" > "$status_file" + printf "%s" "$submission_ran" > "$submission_state_file" { echo "status=$status" @@ -463,6 +514,8 @@ runs: echo "output-contract-path=$output_contract_path" echo "action-manifest-path=$action_manifest_path" echo "registry-report-path=$registry_report_path" + echo "submission-json-path=$submission_json_output" + echo "submission-summary-path=$submission_summary_output" echo "review-bundle-path=$review_bundle_path" echo "review-bundle-verification-path=$review_bundle_verification_path" } >> "$GITHUB_OUTPUT" @@ -475,19 +528,29 @@ runs: report_dir="${{ inputs['output-dir'] }}" summary_path="$report_dir/codex-plugin-doctor-summary.md" + submission_summary_path="$report_dir/codex-plugin-doctor-submission.md" status_file="${RUNNER_TEMP:-.}/codex-plugin-doctor-status" + submission_state_file="${RUNNER_TEMP:-.}/codex-plugin-doctor-submission-ran" status="unknown" + submission_ran=false if [[ -f "$status_file" ]]; then status="$(cat "$status_file")" fi + if [[ -f "$submission_state_file" ]]; then + submission_ran="$(cat "$submission_state_file")" + fi + if [[ -n "${GITHUB_STEP_SUMMARY:-}" && -f "$summary_path" ]]; then cat "$summary_path" >> "$GITHUB_STEP_SUMMARY" - exit 0 fi - if [[ -n "${GITHUB_STEP_SUMMARY:-}" ]]; then + if [[ -n "${GITHUB_STEP_SUMMARY:-}" && "$submission_ran" == "true" && -f "$submission_summary_path" ]]; then + cat "$submission_summary_path" >> "$GITHUB_STEP_SUMMARY" + fi + + if [[ -n "${GITHUB_STEP_SUMMARY:-}" && ! -f "$summary_path" && ( "$submission_ran" != "true" || ! -f "$submission_summary_path" ) ]]; then { echo "## Codex Plugin Doctor" echo "" diff --git a/docs/README.md b/docs/README.md index 4242f07..5c07f8e 100644 --- a/docs/README.md +++ b/docs/README.md @@ -14,6 +14,7 @@ This directory contains public documentation for users, contributors, and securi - [Remote MCP Transport Reliability](architecture/remote-mcp-transport-reliability.md) - [MCP Registry Readiness](architecture/mcp-registry-readiness.md) - [MCP Registry Publication Preflight](architecture/mcp-registry-publication-preflight.md) +- [Public Directory Submission Preflight](architecture/public-directory-submission-preflight.md) - [Real-World Corpus Quality Metrics](architecture/real-world-corpus-quality-metrics.md) - [Corpus Metrics Regression Diff](architecture/corpus-metrics-regression-diff.md) diff --git a/docs/architecture/public-directory-submission-preflight.md b/docs/architecture/public-directory-submission-preflight.md new file mode 100644 index 0000000..648ed47 --- /dev/null +++ b/docs/architecture/public-directory-submission-preflight.md @@ -0,0 +1,256 @@ +# Public Directory Submission Preflight + +## Purpose + +Codex Plugin Doctor will provide an offline preflight for packages intended for the OpenAI public directory. The preflight helps authors find deterministic packaging and listing problems before they enter the submission portal. + +The command does not submit a package and does not claim that OpenAI will accept it. Portal review, developer verification, policy review, OAuth review, and other checks that require external state remain explicit manual work. + +The ruleset is based on the OpenAI plugin packaging, app review, and submission error documentation available on 2026-08-15: + +- +- +- + +## Command Surface + +```bash +codex-plugin-doctor doctor submission +codex-plugin-doctor doctor submission --json +codex-plugin-doctor doctor submission --markdown +codex-plugin-doctor doctor submission --require-ready +``` + +The command is additive. Existing `check`, runtime, audit, registry, and release behavior remains unchanged. + +`doctor submission` is always static and non-executing: + +- no network requests +- no MCP server startup +- no child-process execution +- no portal API access +- no domain verification +- no OAuth credential handling + +The command classifies the package deterministically from its declarations. A package that declares an MCP server or app connection is `mcp-backed`; every other package is `skills-only`. + +The two reported targets are: + +- `skills-only`: the package contains only public-directory-compatible skills and metadata. +- `mcp-backed`: the package declares an MCP server or an app connection. + +## Result Model + +The machine-readable contract is `doctor.submission.json` with schema version `1.0.0`. + +```json +{ + "schemaVersion": "1.0.0", + "rulesetVersion": "openai-directory-2026-08-15", + "targetType": "skills-only", + "status": "pass", + "readiness": "manual_review_required", + "summary": { + "passed": 12, + "warnings": 0, + "blockers": 0, + "manualChecks": 3 + }, + "checks": [], + "findings": [], + "manualChecklist": [] +} +``` + +`status` reports only deterministic automatic checks: + +- `pass`: no automatic blocker was found. +- `fail`: at least one automatic blocker was found. + +`readiness` prevents an offline result from being confused with submission approval: + +- `blocked`: an automatic blocker exists. +- `manual_review_required`: automatic checks passed, but portal or reviewer checks remain. + +The command never emits an automatic `ready` or `accepted` state. + +### Exit Codes + +- `0`: automatic checks passed, including when manual review remains. +- `1`: automatic blockers exist and `--require-ready` was supplied. +- `2`: command usage is invalid. + +Without `--require-ready`, a completed preflight returns `0` even when automatic blockers are reported. This permits advisory adoption. Output still reports `status: "fail"` and `readiness: "blocked"`. + +## Automatic Rules + +New stable finding identifiers use the `plugin.submission.*` namespace. Existing validation rule identifiers do not change. Portal error codes, when known, are stored separately as `portalCode` and never replace the Doctor identifier. + +### Listing And Identity + +The preflight validates: + +- package name and semantic version +- display name at most 30 characters +- short description at most 30 characters +- long description at most 4,000 characters +- developer name at most 80 characters +- supported category value +- no more than 20 capability entries +- each capability at most 120 characters and one line +- no more than 3 unique starter prompts +- each starter prompt at most 128 characters and one line +- no starter prompt containing an `@mention` +- no control or invisible characters in listing text + +For `mcp-backed` targets, the following fields are required: + +- website URL +- support URL +- privacy policy URL +- terms of service URL + +Each URL must: + +- use HTTPS +- contain no embedded credentials +- be at most 1,024 characters + +Unknown listing fields produce a warning when they can be ignored safely. Malformed expected fields produce deterministic findings instead of exceptions. + +### Assets And Component Integrity + +The preflight requires `logo` and `composerIcon` assets. Each asset must: + +- resolve to a regular file contained within the package root after canonical path resolution +- use PNG, JPEG, WebP, or SVG content +- be no larger than 5 MiB +- be square +- have dimensions from 48 through 4,096 pixels +- match its declared file extension +- decode safely within fixed resource limits + +Raster metadata and SVG dimensions are parsed without executing external tools. SVG handling uses safe XML parsing and rejects external entities or remote references needed for validation. + +When `.app.json` is referenced, the preflight validates only the publicly documented package boundary: the declaration points to the root `.app.json`, resolves to a contained regular file, and contains parseable JSON. The public documentation does not define the internal registered-connection mapping schema, so the preflight does not invent or enforce one. A valid local app file is not treated as proof of public-directory eligibility. + +A `skills-only` target that declares screenshot components receives a submission blocker. A package that declares MCP or app components is classified as `mcp-backed` instead. + +### Skill Metadata + +For each skill, `skills/*/agents/openai.yaml` is optional. When present, it is parsed as data with a bounded safe YAML schema. The preflight validates supported fields including: + +- `interface.display_name` +- `interface.short_description` +- `interface.icon_small` +- `interface.icon_large` +- `interface.brand_color` +- `interface.default_prompt` +- `policy.products` +- `policy.allow_implicit_invocation` +- `dependencies.tools` + +The validator also checks: + +- all referenced icon paths remain within the package root +- skill identities are unique +- combined `plugin:skill` identities remain within the documented length limit +- malformed or mixed-shape metadata fails deterministically + +YAML aliases, custom tags, executable types, and unbounded structures are rejected. Metadata content is never executed. + +## Manual Review Checklist + +The report separates portal-only or judgment-based work from automatic checks. Depending on target type, the manual checklist includes: + +- developer or business identity verification +- required attestations +- skill safety review +- MCP demonstration video +- exactly 5 positive and 3 negative MCP tests +- release notes +- production domain verification +- current tool security scan +- tool annotation accuracy and justification +- OAuth reviewer credentials + +Manual items have a state such as `required` or `not_applicable`; they never receive an automatic `passed` state merely because local files exist. + +## Privacy And Evidence + +Findings expose only the minimum evidence needed to locate a problem: + +- field names +- counts and limits +- package-relative paths +- normalized check identifiers + +Reports must not include full prompts, descriptions, credentials, file contents, absolute package roots, or decoded asset data. Text, JSON, Markdown, GitHub Action artifacts, and future report consumers share the same redacted result model. + +## Ruleset Governance + +The initial embedded ruleset is `openai-directory-2026-08-15`. + +Each ruleset records: + +- a stable version identifier +- source URLs +- the date the sources were reviewed +- deterministic automatic constraints +- the manual checklist definitions + +The command does not fetch rule updates at runtime. A ruleset update is a reviewed source change with tests and changelog coverage. This keeps identical package inputs reproducible in local development and CI. + +## Architecture + +The implementation will use four focused core modules: + +- `submission-ruleset.ts`: immutable rules and manual-check definitions +- `submission-preflight.ts`: package classification and result orchestration +- `submission-assets.ts`: bounded asset inspection +- `submission-skill-metadata.ts`: safe `agents/openai.yaml` parsing and validation + +Text, JSON, and Markdown renderers consume the same domain result. Public TypeScript exports and output contracts are additive. + +Small pure-JavaScript dependencies may be introduced only when necessary for safe YAML, raster metadata, or SVG parsing. Native modules, heavy dependency trees, postinstall requirements, and install-time code execution are not acceptable for this feature. + +## GitHub Action + +Submission preflight is opt-in: + +```yaml +- uses: Esquetta/CodexPluginDoctor@v1.59.0 + with: + submission: "true" + require-submission-ready: "true" +``` + +`submission` runs the offline preflight and publishes its reports with the existing Action artifacts. `require-submission-ready` applies the strict automatic-blocker exit gate. Existing Action defaults remain unchanged. + +## Verification Contract + +Implementation is complete only when tests cover: + +- valid and invalid `skills-only` and `mcp-backed` fixtures +- listing limits, duplicate prompts, and Unicode edge cases +- `.app.json` root path, regular-file, containment, and JSON parsing rules +- safe YAML shapes and path traversal in `agents/openai.yaml` +- asset magic bytes, extension mismatch, dimensions, square ratio, and size limit +- URL requirements +- manual checks never becoming automatic passes +- text, JSON, Markdown, exit-code, and output-contract behavior +- absence of network and child-process activity +- package-relative evidence on Windows and POSIX-style inputs +- opt-in GitHub Action behavior with unchanged defaults + +Release verification includes targeted tests, the full test suite, TypeScript build, corpus checks, dependency audit, package-content inspection, source security self-scan, and the existing release check. + +## Out Of Scope + +- portal authentication or submission +- domain verification actions +- OAuth flow testing or credential storage +- MCP execution or live tool calls +- runtime verification of tool annotations +- judging demonstration or test quality +- predicting or claiming OpenAI directory acceptance diff --git a/docs/guides/github-action.md b/docs/guides/github-action.md index cb0cc4e..03355bb 100644 --- a/docs/guides/github-action.md +++ b/docs/guides/github-action.md @@ -27,9 +27,9 @@ The Action transfers these boolean inputs through environment-backed shell varia Use local Registry metadata gating when the repository contains a `server.json` intended for publication: ```yaml -- uses: Esquetta/CodexPluginDoctor@v1.58.0 +- uses: Esquetta/CodexPluginDoctor@v1.59.0 with: - version: "1.58.0" + version: "1.59.0" path: . registry-metadata: ./server.json require-registry-readiness: "true" @@ -37,6 +37,23 @@ Use local Registry metadata gating when the repository contains a `server.json` The Action writes `mcp-registry-readiness.json` and exposes `registry-report-path`. This path is checked locally; the Action does not inspect the live Registry and does not grant network access. `require-registry-readiness` requires `registry-metadata` and blocks warnings as well as failures. +## Public Directory Submission Preflight + +Use the submission preflight only when a workflow needs its separate offline report: + +```yaml +- uses: Esquetta/CodexPluginDoctor@v1.59.0 + with: + version: "1.59.0" + path: . + submission: "true" + require-submission-ready: "true" +``` + +`submission` writes `codex-plugin-doctor-submission.json` and `codex-plugin-doctor-submission.md` under `output-dir`, uploads them with the existing artifact directory, appends the Markdown report after the primary summary, and exposes `submission-json-path` and `submission-summary-path` outputs. It does not require runtime or network access, forwards neither runtime nor network consent, and does not start MCP servers or use portal, domain-verification, or OAuth credentials. + +The report's automatic status is separate from manual review: a passing automatic result is still `manual_review_required`, not portal approval. The Action never submits a package or claims acceptance. `require-submission-ready` makes automatic blockers fail the Action status; it requires `submission: "true"`, otherwise the Action records usage status `2` without running a submission command. + ## Recommended Workflow ```yaml @@ -53,9 +70,9 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v5 - - uses: Esquetta/CodexPluginDoctor@v1.58.0 + - uses: Esquetta/CodexPluginDoctor@v1.59.0 with: - version: "1.58.0" + version: "1.59.0" path: . runtime: "true" policy: codex-publish @@ -82,9 +99,9 @@ Every action run also writes `codex-plugin-doctor-action-manifest.json`. The man Use SARIF when repository security tooling should ingest validation findings. ```yaml -- uses: Esquetta/CodexPluginDoctor@v1.58.0 +- uses: Esquetta/CodexPluginDoctor@v1.59.0 with: - version: "1.58.0" + version: "1.59.0" path: . sarif: "true" ``` @@ -96,9 +113,9 @@ The action writes `codex-plugin-doctor.sarif` into `output-dir`. Uploading it to Use artifact and summary controls when the workflow needs custom retention or wants to disable generated report uploads. ```yaml -- uses: Esquetta/CodexPluginDoctor@v1.58.0 +- uses: Esquetta/CodexPluginDoctor@v1.59.0 with: - version: "1.58.0" + version: "1.59.0" path: . output-dir: doctor-ci-reports artifact-name: codex-plugin-doctor-reports @@ -126,6 +143,8 @@ The action also exposes these workflow outputs for follow-up steps: - `output-contract-path` - `action-manifest-path` - `registry-report-path` +- `submission-json-path` +- `submission-summary-path` - `review-bundle-path` - `review-bundle-verification-path` @@ -134,11 +153,11 @@ The action also exposes these workflow outputs for follow-up steps: Use review bundle artifacts when a pull request or release workflow should preserve signed runtime approval, runtime policy, attestation, and release evidence handoff files. ```yaml -- uses: Esquetta/CodexPluginDoctor@v1.58.0 +- uses: Esquetta/CodexPluginDoctor@v1.59.0 env: CODEX_PLUGIN_DOCTOR_SIGNING_KEY: ${{ secrets.CODEX_PLUGIN_DOCTOR_SIGNING_KEY }} with: - version: "1.58.0" + version: "1.59.0" path: . review-bundle: "true" review-bundle-verify: "true" @@ -169,9 +188,9 @@ The CLI can produce badge output for release notes, README automation, or a stat Use a private corpus metrics manifest to measure reviewed precision, recall, and false-positive share in CI. The action writes only the public-safe metrics report into its artifact directory; snapshots, manifest contents, local paths, and review notes are not copied. ```yaml -- uses: Esquetta/CodexPluginDoctor@v1.58.0 +- uses: Esquetta/CodexPluginDoctor@v1.59.0 with: - version: "1.58.0" + version: "1.59.0" path: . corpus-metrics-manifest: ../private-corpus/metrics.json ``` @@ -179,9 +198,9 @@ Use a private corpus metrics manifest to measure reviewed precision, recall, and This writes `corpus-metrics.json`. To compare the result with a retained report and fail the job on regression: ```yaml -- uses: Esquetta/CodexPluginDoctor@v1.58.0 +- uses: Esquetta/CodexPluginDoctor@v1.59.0 with: - version: "1.58.0" + version: "1.59.0" path: . corpus-metrics-manifest: ../private-corpus/metrics.json corpus-metrics-baseline: .doctor-baselines/corpus-metrics.json @@ -210,9 +229,9 @@ The history file is newline-delimited JSON. Store it as an artifact, cache, or r The composite action can also append history directly: ```yaml -- uses: Esquetta/CodexPluginDoctor@v1.58.0 +- uses: Esquetta/CodexPluginDoctor@v1.59.0 with: - version: "1.58.0" + version: "1.59.0" path: . runtime: "true" history: validation-history.jsonl @@ -232,9 +251,9 @@ Use profiles when a consuming workflow needs a named validation policy instead o The composite action can pass profiles directly: ```yaml -- uses: Esquetta/CodexPluginDoctor@v1.58.0 +- uses: Esquetta/CodexPluginDoctor@v1.59.0 with: - version: "1.58.0" + version: "1.59.0" path: . profile: publish ``` @@ -244,9 +263,9 @@ The composite action can pass profiles directly: Use policy presets when a workflow should apply one of the opinionated release gates without adding a local `.codex-doctor.json`. ```yaml -- uses: Esquetta/CodexPluginDoctor@v1.58.0 +- uses: Esquetta/CodexPluginDoctor@v1.59.0 with: - version: "1.58.0" + version: "1.59.0" path: . policy: codex-publish ``` @@ -258,9 +277,9 @@ Supported policy values are `codex-publish`, `mcp-strict`, and `security`. The C Use installed-cache mode only in environments where Codex plugins are already available on the runner. ```yaml -- uses: Esquetta/CodexPluginDoctor@v1.58.0 +- uses: Esquetta/CodexPluginDoctor@v1.59.0 with: - version: "1.58.0" + version: "1.59.0" installed: "true" filter: github runtime: "false" @@ -271,9 +290,9 @@ Use installed-cache mode only in environments where Codex plugins are already av Pin both the action ref and npm package version for reproducible CI: ```yaml -- uses: Esquetta/CodexPluginDoctor@v1.58.0 +- uses: Esquetta/CodexPluginDoctor@v1.59.0 with: - version: "1.58.0" + version: "1.59.0" ``` Use `version: "latest"` only when the consuming repository intentionally wants automatic CLI upgrades. diff --git a/docs/rules/catalog.md b/docs/rules/catalog.md index 0114d94..eaff223 100644 --- a/docs/rules/catalog.md +++ b/docs/rules/catalog.md @@ -42,6 +42,52 @@ codex-plugin-doctor explain plugin.manifest.missing | `plugin.heuristic.skill_description.too_long` | warn | Skill description is likely too verbose. | | `plugin.skill.asset_reference.missing` | warn | Skill references a missing local support asset such as `scripts/...`, `templates/...`, `assets/...`, or `examples/...`. | +## Public Directory Submission Preflight Rules + +| Rule ID | Severity | Meaning | +| --- | --- | --- | +| `plugin.submission.package.invalid` | fail | Plugin manifest is missing or invalid for submission preflight. | +| `plugin.submission.package.too_large` | fail | Plugin manifest exceeds the submission preflight size limit. | +| `plugin.submission.package.name` | fail | Plugin package name is invalid for the listing. | +| `plugin.submission.package.version` | fail | Plugin version is not valid semantic versioning. | +| `plugin.submission.interface.required` | fail | Listing interface metadata is required. | +| `plugin.submission.interface.display_name` | fail | Listing display name is missing or invalid. | +| `plugin.submission.interface.short_description` | fail | Listing short description is missing or invalid. | +| `plugin.submission.interface.long_description` | fail | Listing long description is missing or invalid. | +| `plugin.submission.interface.developer_name` | fail | Listing developer name is missing or invalid. | +| `plugin.submission.interface.category` | fail | Listing category is missing or unsupported. | +| `plugin.submission.interface.capabilities` | fail | Listing capabilities are not a valid bounded list. | +| `plugin.submission.interface.capability` | fail | A listing capability is invalid. | +| `plugin.submission.interface.default_prompt` | fail | Listing starter prompts are invalid or duplicated. | +| `plugin.submission.interface.url` | fail | A required listing URL is invalid. | +| `plugin.submission.interface.unknown_field` | warn | Listing metadata includes an unsupported field. | +| `plugin.submission.component.app` | fail | App declaration must reference a contained parseable root file. | +| `plugin.submission.component.excluded` | fail | A component is not allowed for this submission target type. | +| `plugin.submission.app.invalid_path` | fail | App declaration resolves outside the package. | +| `plugin.submission.asset.required` | fail | A required branding asset is missing. | +| `plugin.submission.asset.invalid_path` | fail | Branding asset path is invalid. | +| `plugin.submission.asset.missing` | fail | Branding asset cannot be found or read. | +| `plugin.submission.asset.unsupported_format` | fail | Branding asset format or file type is unsupported. | +| `plugin.submission.asset.too_large` | fail | Branding asset exceeds the size limit. | +| `plugin.submission.asset.unsafe_svg` | fail | SVG branding asset is unsafe or invalid. | +| `plugin.submission.asset.decode_failed` | fail | Branding asset cannot be decoded safely. | +| `plugin.submission.asset.extension_mismatch` | fail | Branding asset extension does not match its content. | +| `plugin.submission.asset.dimensions` | fail | Branding asset dimensions are outside the allowed range. | +| `plugin.submission.asset.not_square` | fail | Branding asset must be square. | +| `plugin.submission.skill.required` | fail | Skills-only submission requires a valid skill. | +| `plugin.submission.skill.invalid_manifest` | fail | Skills declaration must use the root skills directory. | +| `plugin.submission.skill.invalid_path` | fail | Skill path is not safely contained in the package. | +| `plugin.submission.skill.invalid_file` | fail | Skill entrypoint is not a valid contained file. | +| `plugin.submission.skill.invalid_yaml` | fail | Skill frontmatter YAML is invalid or unsafe. | +| `plugin.submission.skill.invalid_shape` | fail | Skill frontmatter has an unsupported shape. | +| `plugin.submission.skill.identity` | fail | Skill identity metadata is invalid or duplicated. | +| `plugin.submission.skill.too_many` | fail | Skills directory exceeds the bounded submission preflight entry or skill limit. | +| `plugin.submission.skill.budget_exceeded` | fail | Skill metadata exceeds the aggregate submission preflight size limit. | +| `plugin.submission.skill.agent.invalid_path` | fail | Optional agent metadata path is invalid. | +| `plugin.submission.skill.agent.invalid_file` | fail | Optional agent metadata is not a valid contained file. | +| `plugin.submission.skill.agent.invalid_yaml` | fail | Optional agent metadata YAML is invalid or unsafe. | +| `plugin.submission.skill.agent.invalid_shape` | fail | Optional agent metadata has an unsupported shape. | + ## MCP Rules | Rule ID | Severity | Meaning | diff --git a/package-lock.json b/package-lock.json index 5322d27..d1f4375 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,13 +1,17 @@ { "name": "codex-plugin-doctor", - "version": "1.58.0", + "version": "1.59.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "codex-plugin-doctor", - "version": "1.58.0", + "version": "1.59.0", "license": "MIT", + "dependencies": { + "fast-xml-parser": "^5.11.0", + "yaml": "^2.9.0" + }, "bin": { "codex-plugin-doctor": "dist/cli.js" }, @@ -470,6 +474,18 @@ "dev": true, "license": "MIT" }, + "node_modules/@nodable/entities": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@nodable/entities/-/entities-3.0.0.tgz", + "integrity": "sha512-8L9xFeTYKhm49xfIypoe2W5wV1m/3Z58kT+7kR9A8OyFxcPduI4VmxaUMQyKYrRjUoLLSXv6EKKID5Tvj9cUVw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/nodable" + } + ], + "license": "MIT" + }, "node_modules/@rollup/rollup-android-arm-eabi": { "version": "4.60.2", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.2.tgz", @@ -1009,6 +1025,18 @@ "url": "https://opencollective.com/vitest" } }, + "node_modules/anynum": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/anynum/-/anynum-1.0.1.tgz", + "integrity": "sha512-N6//FLET/tXYNM/F6ABca1oH6fWB+KlTt909Le28WMDBk8oaT4vY17DCrwg2MvmuqUKt3Ni4N5dGJ/EoBgcO6A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT" + }, "node_modules/assertion-error": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", @@ -1153,6 +1181,45 @@ "node": ">=12.0.0" } }, + "node_modules/fast-xml-builder": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.3.1.tgz", + "integrity": "sha512-pIM/1n3ntFXKYrUZwW7QCK0gAW7XY+wzj1YMIV3tLDvPj/V+zTGJK5e3/4WJfwj0qWw2ElNXiTixda/R+3YSug==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "path-expression-matcher": "^1.6.2", + "xml-naming": "^0.3.0" + } + }, + "node_modules/fast-xml-parser": { + "version": "5.11.0", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.11.0.tgz", + "integrity": "sha512-9IGxMqvqLOnqP+Egi1nqDHKv5k8aZ7r9n558enxcucmyVGEBNPAU+MOg/8jPIS7rO7sSq4gFm1/nHtiaubMruw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "@nodable/entities": "^3.0.0", + "fast-xml-builder": "^1.2.0", + "is-unsafe": "^2.0.0", + "path-expression-matcher": "^1.6.2", + "strnum": "^2.4.2", + "xml-naming": "^0.3.0" + }, + "bin": { + "fxparser": "src/cli/cli.js" + } + }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -1186,6 +1253,18 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/is-unsafe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-unsafe/-/is-unsafe-2.0.0.tgz", + "integrity": "sha512-2LdV822R+wmI86unXA93WCFpL6g+av8ynWk0nrHyJqGop5VoocYsSLFgN8jrfalT6iGeLNM4KXuVSsULP53kEA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT" + }, "node_modules/js-tokens": { "version": "9.0.1", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", @@ -1218,9 +1297,9 @@ "license": "MIT" }, "node_modules/nanoid": { - "version": "3.3.16", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", - "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==", + "version": "3.3.18", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", "dev": true, "funding": [ { @@ -1236,6 +1315,21 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/path-expression-matcher": { + "version": "1.6.2", + "resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.6.2.tgz", + "integrity": "sha512-enSlaiat05iasnzmgNxRj8reFdj3puY2QpNgP1aPIaVfT6nn9ICuPoFlKHk8EN22HcwewshO+mN2DGbkCEOtqQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/pathe": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", @@ -1391,6 +1485,21 @@ "url": "https://github.com/sponsors/antfu" } }, + "node_modules/strnum": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.4.2.tgz", + "integrity": "sha512-rDG3Ah4TV0k1hWvLSzkZtMmLN9+eS+h3knq4MP6A42Y3Yh5qGNnOUs1jJkoSr8FG5dsL28c7KgkIBzSEykqtuw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "anynum": "^1.0.1" + } + }, "node_modules/tinybench": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", @@ -1679,6 +1788,36 @@ "engines": { "node": ">=8" } + }, + "node_modules/xml-naming": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/xml-naming/-/xml-naming-0.3.0.tgz", + "integrity": "sha512-ghig2TBE/H11aOVgmahA3MhimvkBr6JIYknH/Dhdk10nXwdbIqBJsbfMxpvFPG8bAw77gN29aQWvKpmVoPlvPQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/yaml": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", + "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==", + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } } } } diff --git a/package.json b/package.json index c2caf1a..256dcf3 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "codex-plugin-doctor", - "version": "1.58.0", + "version": "1.59.0", "description": "CLI-first validator for Codex plugins, skills, and MCP package surfaces with runtime MCP protocol validation.", "type": "module", "main": "./dist/index.js", @@ -60,5 +60,9 @@ "tsx": "^4.20.4", "typescript": "^5.9.2", "vitest": "^3.2.4" + }, + "dependencies": { + "fast-xml-parser": "^5.11.0", + "yaml": "^2.9.0" } } diff --git a/src/core/output-contract.ts b/src/core/output-contract.ts index 316d2fd..6613580 100644 --- a/src/core/output-contract.ts +++ b/src/core/output-contract.ts @@ -246,6 +246,82 @@ const publicSchemaDefinitions: Array<{ } } }, + { + id: "doctor.submission.json", + command: "codex-plugin-doctor doctor submission --json", + required: [ + "schemaVersion", + "rulesetVersion", + "targetType", + "status", + "readiness", + "summary", + "checks", + "findings", + "manualChecklist" + ], + properties: { + rulesetVersion: { const: "openai-directory-2026-08-15" }, + targetType: { type: "string", enum: ["skills-only", "mcp-backed"] }, + status: { type: "string", enum: ["pass", "fail"] }, + readiness: { type: "string", enum: ["blocked", "manual_review_required"] }, + summary: { + type: "object", + required: ["passed", "warnings", "blockers", "manualChecks"], + properties: { + passed: { type: "integer", minimum: 0 }, + warnings: { type: "integer", minimum: 0 }, + blockers: { type: "integer", minimum: 0 }, + manualChecks: { type: "integer", minimum: 0 } + }, + additionalProperties: false + }, + checks: { + type: "array", + items: { + type: "object", + required: ["id", "status", "findingIds"], + properties: { + id: { type: "string", enum: ["listing", "components", "assets", "skills"] }, + status: { type: "string", enum: ["pass", "warn", "fail"] }, + findingIds: { type: "array", items: { type: "string" } } + }, + additionalProperties: false + } + }, + findings: { + type: "array", + items: { + type: "object", + required: ["id", "severity", "message"], + properties: { + id: { type: "string", pattern: "^plugin\\.submission\\." }, + severity: { type: "string", enum: ["warn", "fail"] }, + message: { type: "string" }, + portalCode: { type: "string" }, + evidence: { + type: "object", + additionalProperties: { type: ["string", "number", "boolean", "null"] } + } + }, + additionalProperties: false + } + }, + manualChecklist: { + type: "array", + items: { + type: "object", + required: ["id", "label", "state"], + properties: { + id: { type: "string" }, + label: { type: "string" }, + state: { type: "string", enum: ["required", "not_applicable"] } + }, + additionalProperties: false + } + } + } + }, { id: "doctor.installed.check.json", command: "codex-plugin-doctor check --installed --json", diff --git a/src/core/shell-completion.ts b/src/core/shell-completion.ts index 3de1376..e38cf55 100644 --- a/src/core/shell-completion.ts +++ b/src/core/shell-completion.ts @@ -20,6 +20,10 @@ const topLevelCommands = [ "config" ]; +const doctorCommands = ["submission"]; +const submissionFlags = ["--json", "--markdown", "--output", "--require-ready"]; +const fishSubmissionCondition = "__fish_seen_subcommand_from doctor; and __fish_seen_subcommand_from submission"; + function bashCompletion(): string { return [ "_codex_plugin_doctor() {", @@ -29,16 +33,26 @@ function bashCompletion(): string { " prev=\"${COMP_WORDS[COMP_CWORD-1]}\"", "", ` local commands="${topLevelCommands.join(" ")}"`, + ` local doctor_commands="${doctorCommands.join(" ")}"`, + ` local submission_flags="${submissionFlags.join(" ")}"`, "", " case \"${prev}\" in", " codex-plugin-doctor)", " COMPREPLY=( $(compgen -W \"${commands}\" -- \"${cur}\") )", " return 0", " ;;", + " doctor)", + " COMPREPLY=( $(compgen -W \"${doctor_commands}\" -- \"${cur}\") )", + " return 0", + " ;;", " esac", "", " case \"${cur}\" in", " --*)", + " if [[ ${COMP_WORDS[1]} == \"doctor\" && ${COMP_WORDS[2]} == \"submission\" ]]; then", + " COMPREPLY=( $(compgen -W \"${submission_flags}\" -- \"${cur}\") )", + " return 0", + " fi", " local flags=\"--json --output --runtime --policy --help\"", " COMPREPLY=( $(compgen -W \"${flags}\" -- \"${cur}\") )", " return 0", @@ -64,9 +78,20 @@ function zshCompletion(): string { " typeset -A opt_args", "", ` local commands=(${topLevelCommands.join(" ")})`, + ` local doctor_commands=(${doctorCommands.join(" ")})`, + "", + " if [[ \"$words[2]\" == \"doctor\" && \"$words[3]\" == \"submission\" ]]; then", + " _arguments -C \\", + " '*--json[Output as JSON]' \\", + " '*--markdown[Output as Markdown]' \\", + " '*--output[Write to file]:file:_files' \\", + " '*--require-ready[Fail when automatic checks are blocked]'", + " return", + " fi", "", " _arguments -C \\", " '1:command:(${commands})' \\", + " '2:doctor command:(${doctor_commands})' \\", " '*--json[Output as JSON]' \\", " '*--output[Write to file]:file:_files' \\", " '*--runtime[Enable runtime probes]' \\", @@ -85,6 +110,11 @@ function fishCompletion(): string { "complete -c codex-plugin-doctor -s h -l help -d 'Show help'", "complete -c codex-plugin-doctor -l json -d 'Output as JSON'", "complete -c codex-plugin-doctor -l output -d 'Write to file' -r", + `complete -c codex-plugin-doctor -n "${fishSubmissionCondition}" -l json -d 'Output as JSON'`, + `complete -c codex-plugin-doctor -n "${fishSubmissionCondition}" -l markdown -d 'Output as Markdown'`, + `complete -c codex-plugin-doctor -n "${fishSubmissionCondition}" -l output -d 'Write to file' -r`, + `complete -c codex-plugin-doctor -n "${fishSubmissionCondition}" -l require-ready -d 'Fail when automatic checks are blocked'`, + `complete -c codex-plugin-doctor -n "__fish_seen_subcommand_from doctor; and not __fish_seen_subcommand_from ${doctorCommands.join(" ")}" -a "${doctorCommands.join(" ")}"`, "complete -c codex-plugin-doctor -l runtime -d 'Enable runtime probes'", "complete -c codex-plugin-doctor -l policy -d 'Apply policy' -x -a 'codex-publish mcp-strict security'", "" diff --git a/src/core/submission-assets.ts b/src/core/submission-assets.ts new file mode 100644 index 0000000..2cdef6f --- /dev/null +++ b/src/core/submission-assets.ts @@ -0,0 +1,417 @@ +import { readFile, stat } from "node:fs/promises"; +import path from "node:path"; +import { inflateSync } from "node:zlib"; + +import { XMLParser, XMLValidator } from "fast-xml-parser"; + +import type { DiscoveredPackage } from "../domain/types.js"; +import { resolveSafePackagePath } from "./plugin-components.js"; +import type { SubmissionFinding } from "./submission-preflight.js"; + +const maxAssetBytes = 5 * 1024 * 1024; +const MAX_DECODED_PNG_BYTES = 72 * 1024 * 1024; +const minimumDimension = 48; +const maximumDimension = 4096; +const assetFields = ["logo", "composerIcon"] as const; +const extensions = new Map([[".png", "png"], [".jpg", "jpeg"], [".jpeg", "jpeg"], [".webp", "webp"], [".svg", "svg"]] as const); +const sofMarkers = new Set([0xc0, 0xc1, 0xc2, 0xc3, 0xc5, 0xc6, 0xc7, 0xc9, 0xca, 0xcb, 0xcd, 0xce, 0xcf]); +const numeric = /^[+-]?(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?$/; + +type AssetFormat = "png" | "jpeg" | "webp" | "svg"; +type Dimensions = { width: number; height: number }; +type Evidence = SubmissionFinding["evidence"]; + +export interface SubmissionAssetResult { + findings: SubmissionFinding[]; +} + +function finding( + id: `plugin.submission.asset.${string}`, + message: string, + evidence: Evidence +): SubmissionFinding { + return { id, severity: "fail", message, evidence }; +} + +function assetEvidence(field: string, packagePath?: string, format?: AssetFormat, dimensions?: Dimensions): Evidence { + return { + field, + ...(packagePath === undefined ? {} : { path: packagePath }), + ...(format === undefined ? {} : { format }), + ...(dimensions === undefined ? {} : dimensions) + }; +} + +function crc32(buffer: Uint8Array): number { + let crc = 0xffffffff; + for (const byte of buffer) { + crc ^= byte; + for (let bit = 0; bit < 8; bit += 1) { + crc = (crc >>> 1) ^ (0xedb88320 & -(crc & 1)); + } + } + return (crc ^ 0xffffffff) >>> 0; +} + +function pngScanlines(width: number, height: number, bitDepth: number, channels: number, interlace: number): number[] | null { + if (!Number.isInteger(width) || !Number.isInteger(height) || width < 1 || height < 1 + || width > maximumDimension || height > maximumDimension) { + return null; + } + const bytesPerRow = (pixels: number) => Math.ceil((pixels * bitDepth * channels) / 8); + if (interlace === 0) return Array.from({ length: height }, () => bytesPerRow(width)); + if (interlace !== 1) return null; + const passes = [[0, 0, 8, 8], [4, 0, 8, 8], [0, 4, 4, 8], [2, 0, 4, 4], [0, 2, 2, 4], [1, 0, 2, 2], [0, 1, 1, 2]] as const; + const rows: number[] = []; + for (const [left, top, horizontal, vertical] of passes) { + const passWidth = width <= left ? 0 : Math.ceil((width - left) / horizontal); + const passHeight = height <= top ? 0 : Math.ceil((height - top) / vertical); + for (let row = 0; row < passHeight; row += 1) rows.push(bytesPerRow(passWidth)); + } + return rows; +} + +function validPngEncoding(bitDepth: number, colorType: number, compression: number, filter: number, interlace: number): boolean { + const supportedBitDepths: Record = { + 0: [1, 2, 4, 8, 16], 2: [8, 16], 3: [1, 2, 4, 8], 4: [8, 16], 6: [8, 16] + }; + return compression === 0 && filter === 0 && (interlace === 0 || interlace === 1) + && supportedBitDepths[colorType]?.includes(bitDepth) === true; +} + +function readPng(buffer: Uint8Array): Dimensions | null { + if (buffer.length < 33 || ![137, 80, 78, 71, 13, 10, 26, 10].every((byte, index) => buffer[index] === byte)) { + return null; + } + const view = new DataView(buffer.buffer, buffer.byteOffset, buffer.byteLength); + let dimensions: Dimensions | null = null; + let scanlines: number[] | null = null; + const idatParts: Uint8Array[] = []; + let idatBytes = 0; + let offset = 8; + for (let chunks = 0; chunks < 128 && offset + 12 <= buffer.length; chunks += 1) { + const length = view.getUint32(offset); + const typeOffset = offset + 4; + const dataOffset = offset + 8; + if (length > buffer.length - dataOffset - 4) return null; + const dataEnd = dataOffset + length; + if (crc32(buffer.slice(typeOffset, dataEnd)) !== view.getUint32(dataEnd)) return null; + const type = fourCc(buffer, typeOffset); + if (offset === 8) { + if (type !== "IHDR" || length !== 13) return null; + dimensions = { width: view.getUint32(dataOffset), height: view.getUint32(dataOffset + 4) }; + const bitDepth = buffer[dataOffset + 8]; + const colorType = buffer[dataOffset + 9]; + if (!validPngEncoding(bitDepth, colorType, buffer[dataOffset + 10], buffer[dataOffset + 11], buffer[dataOffset + 12])) return null; + const channels = colorType === 0 || colorType === 3 ? 1 : colorType === 2 ? 3 : colorType === 4 ? 2 : 4; + scanlines = pngScanlines(dimensions.width, dimensions.height, bitDepth, channels, buffer[dataOffset + 12]); + if (scanlines === null) return null; + } else if (type === "IDAT") { + idatParts.push(buffer.slice(dataOffset, dataEnd)); + idatBytes += length; + } else if (type === "IEND") { + if (length !== 0 || dimensions === null || scanlines === null || idatBytes === 0 || dataEnd + 4 !== buffer.length) return null; + const expectedBytes = scanlines.reduce((total, rowBytes) => total + rowBytes + 1, 0); + if (expectedBytes > MAX_DECODED_PNG_BYTES) return null; + const idat = new Uint8Array(idatBytes); + let idatOffset = 0; + for (const part of idatParts) { idat.set(part, idatOffset); idatOffset += part.length; } + try { + const output = inflateSync(idat, { maxOutputLength: expectedBytes }); + if (output.length !== expectedBytes) return null; + let outputOffset = 0; + for (const rowBytes of scanlines) { + if (output[outputOffset] > 4) return null; + outputOffset += rowBytes + 1; + } + return outputOffset === output.length ? dimensions : null; + } catch { + return null; + } + } + offset = dataEnd + 4; + } + return null; +} + +function readJpeg(buffer: Uint8Array): Dimensions | null { + if (buffer.length < 4 || buffer[0] !== 0xff || buffer[1] !== 0xd8) { + return null; + } + const view = new DataView(buffer.buffer, buffer.byteOffset, buffer.byteLength); + let dimensions: Dimensions | null = null; + let scans = 0; + let offset = 2; + for (let steps = 0; steps < 512 && offset < buffer.length; steps += 1) { + if (buffer[offset] !== 0xff) return null; + while (offset < buffer.length && buffer[offset] === 0xff) { + offset += 1; + } + if (offset >= buffer.length) { + return null; + } + const marker = buffer[offset++]; + if (marker === 0xd9) { + return dimensions !== null && scans > 0 && offset === buffer.length ? dimensions : null; + } + if (marker === 0x01 || (marker >= 0xd0 && marker <= 0xd7)) { + continue; + } + if (offset + 2 > buffer.length) { + return null; + } + const length = view.getUint16(offset); + if (length < 2 || offset + length > buffer.length) { + return null; + } + if (sofMarkers.has(marker)) { + const start = offset + 2; + if (start + 5 > offset + length) { + return null; + } + dimensions = { height: view.getUint16(start + 1), width: view.getUint16(start + 3) }; + } + if (marker === 0xda) { + if (dimensions === null) return null; + const componentCount = buffer[offset + 2]; + if (componentCount < 1 || length !== 6 + (componentCount * 2)) return null; + scans += 1; + offset += length; + for (let entropySteps = 0; entropySteps < buffer.length && offset < buffer.length; entropySteps += 1) { + if (buffer[offset] !== 0xff) { + offset += 1; + continue; + } + if (offset + 1 >= buffer.length) return null; + const entropyMarker = buffer[offset + 1]; + if (entropyMarker === 0x00 || (entropyMarker >= 0xd0 && entropyMarker <= 0xd7)) { + offset += 2; + continue; + } + break; + } + continue; + } + offset += length; + } + return null; +} + +function fourCc(buffer: Uint8Array, offset: number): string { + return String.fromCharCode(...buffer.slice(offset, offset + 4)); +} + +function readWebp(buffer: Uint8Array): Dimensions | null { + if (buffer.length < 20 || fourCc(buffer, 0) !== "RIFF" || fourCc(buffer, 8) !== "WEBP") { + return null; + } + const view = new DataView(buffer.buffer, buffer.byteOffset, buffer.byteLength); + const riffSize = view.getUint32(4, true); + if (riffSize !== buffer.length - 8) { + return null; + } + let dimensions: Dimensions | null = null; + let offset = 12; + for (let chunks = 0; chunks < 128 && offset + 8 <= buffer.length; chunks += 1) { + const chunkType = fourCc(buffer, offset); + const chunkLength = view.getUint32(offset + 4, true); + const payload = offset + 8; + const paddedLength = chunkLength + (chunkLength % 2); + if (paddedLength > buffer.length - payload) return null; + let detected: Dimensions | null = null; + if (chunkType === "VP8X") { + if (chunkLength < 10) return null; + detected = { + width: 1 + buffer[payload + 4] + (buffer[payload + 5] << 8) + (buffer[payload + 6] << 16), + height: 1 + buffer[payload + 7] + (buffer[payload + 8] << 8) + (buffer[payload + 9] << 16) + }; + } else if (chunkType === "VP8L") { + if (chunkLength < 5 || buffer[payload] !== 0x2f) return null; + const packed = view.getUint32(payload + 1, true); + detected = { width: (packed & 0x3fff) + 1, height: ((packed >>> 14) & 0x3fff) + 1 }; + } else if (chunkType === "VP8 ") { + if (chunkLength < 10 || buffer[payload + 3] !== 0x9d || buffer[payload + 4] !== 0x01 || buffer[payload + 5] !== 0x2a) return null; + detected = { width: view.getUint16(payload + 6, true) & 0x3fff, height: view.getUint16(payload + 8, true) & 0x3fff }; + } + dimensions ??= detected; + offset = payload + paddedLength; + } + return offset === buffer.length ? dimensions : null; +} + +function rasterAsset(buffer: Uint8Array): { format: Exclude; dimensions: Dimensions } | null { + const candidates: Array<[Exclude, (source: Uint8Array) => Dimensions | null]> = [ + ["png", readPng], ["jpeg", readJpeg], ["webp", readWebp] + ]; + for (const [format, reader] of candidates) { + const dimensions = reader(buffer); + if (dimensions !== null) return { format, dimensions }; + } + return null; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function numberFrom(value: unknown): number | null { + if (typeof value !== "string" || !numeric.test(value)) return null; + const number = Number(value); + return Number.isFinite(number) ? number : null; +} + +function normalizeCssEscapes(source: string): string | null { + if (source.length > maxAssetBytes) return null; + let normalized = ""; + for (let index = 0; index < source.length; index += 1) { + if (source[index] !== "\\") { + normalized += source[index]; + continue; + } + index += 1; + if (index >= source.length) return null; + if (/[0-9A-Fa-f]/u.test(source[index])) { + let digits = ""; + while (digits.length < 6 && index < source.length && /[0-9A-Fa-f]/u.test(source[index])) { + digits += source[index++]; + } + const codePoint = Number.parseInt(digits, 16); + if (codePoint === 0 || codePoint > 0x10ffff || (codePoint >= 0xd800 && codePoint <= 0xdfff)) return null; + normalized += String.fromCodePoint(codePoint); + if (source[index] === "\r" && source[index + 1] === "\n") index += 1; + else if (/[ \t\r\n\f]/u.test(source[index] ?? "")) index += 1; + index -= 1; + continue; + } + normalized += source[index]; + } + return normalized; +} + +function isSafeCss(source: string): boolean { + const css = normalizeCssEscapes(source); + if (css === null || /@\s*import\b/iu.test(css)) return false; + for (const match of css.matchAll(/url\s*\(\s*(?:(["'])(.*?)\1|([^)]*))\s*\)/giu)) { + if (!/^#[^\s]*$/u.test((match[2] ?? match[3] ?? "").trim())) return false; + } + return true; +} + +function inspectSvgValue(value: unknown, context: "css" | "href" | undefined = undefined): boolean { + if (typeof value === "string") { + if (context === "css") return isSafeCss(value); + return context !== "href" || value.trim() === "" || /^#[^\s]*$/u.test(value.trim()); + } + if (Array.isArray(value)) return value.every((item) => inspectSvgValue(item, context)); + if (!isRecord(value)) return true; + return Object.entries(value).every(([key, item]) => { + const childContext = context === "css" || key === "style" || key === "@_style" + ? "css" + : key === "@_href" || key === "@_xlink:href" ? "href" : undefined; + return inspectSvgValue(item, childContext); + }); +} + +function svgDimensions(content: string): Dimensions | null { + if (/ part === null)) return null; + return { width: parts[2]!, height: parts[3]! }; + } + const width = numberFrom(root["@_width"]); + const height = numberFrom(root["@_height"]); + return width === null || height === null ? null : { width, height }; + } catch { + return null; + } +} + +function dimensionFinding(field: string, packagePath: string, format: AssetFormat, dimensions: Dimensions): SubmissionFinding | null { + const evidence = assetEvidence(field, packagePath, format, dimensions); + if (!Number.isInteger(dimensions.width) || !Number.isInteger(dimensions.height) || dimensions.width <= 0 || dimensions.height <= 0) { + return finding("plugin.submission.asset.dimensions", "Asset dimensions must be positive integers.", evidence); + } + if (dimensions.width !== dimensions.height) { + return finding("plugin.submission.asset.not_square", "Asset dimensions must be square.", evidence); + } + if (dimensions.width < minimumDimension || dimensions.width > maximumDimension) { + return finding("plugin.submission.asset.dimensions", "Asset dimensions are outside the allowed range.", { ...evidence, limit: minimumDimension }); + } + return null; +} + +async function validateAsset(rootPath: string, field: typeof assetFields[number], value: unknown): Promise { + if (value === undefined) { + return [finding("plugin.submission.asset.required", "Branding asset is required.", assetEvidence(field))]; + } + if (typeof value !== "string") { + return [finding("plugin.submission.asset.invalid_path", "Branding asset path is invalid.", assetEvidence(field))]; + } + const resolved = await resolveSafePackagePath(rootPath, value); + if (resolved === null) { + return [finding("plugin.submission.asset.invalid_path", "Branding asset path is invalid.", assetEvidence(field))]; + } + const extension = extensions.get(path.extname(resolved.packagePath).toLowerCase() as ".png" | ".jpg" | ".jpeg" | ".webp" | ".svg"); + if (extension === undefined) { + return [finding("plugin.submission.asset.unsupported_format", "Branding asset format is unsupported.", assetEvidence(field, resolved.packagePath))]; + } + let details; + try { + details = await stat(resolved.path); + } catch { + return [finding("plugin.submission.asset.missing", "Branding asset is missing.", assetEvidence(field, resolved.packagePath, extension))]; + } + if (!details.isFile()) { + return [finding("plugin.submission.asset.unsupported_format", "Branding asset must be a regular file.", assetEvidence(field, resolved.packagePath, extension))]; + } + if (details.size > maxAssetBytes) { + return [finding("plugin.submission.asset.too_large", "Branding asset exceeds the size limit.", { ...assetEvidence(field, resolved.packagePath, extension), limit: maxAssetBytes })]; + } + let content: Uint8Array; + try { + content = await readFile(resolved.path); + } catch { + return [finding("plugin.submission.asset.missing", "Branding asset cannot be read.", assetEvidence(field, resolved.packagePath, extension))]; + } + if (extension === "svg") { + let source: string; + try { + source = new TextDecoder("utf-8", { fatal: true }).decode(content); + } catch { + return [finding("plugin.submission.asset.unsafe_svg", "SVG must be valid UTF-8.", assetEvidence(field, resolved.packagePath, extension))]; + } + const dimensions = svgDimensions(source); + if (dimensions === null) { + return [finding("plugin.submission.asset.unsafe_svg", "SVG is unsafe or lacks valid dimensions.", assetEvidence(field, resolved.packagePath, extension))]; + } + const invalidDimensions = dimensionFinding(field, resolved.packagePath, extension, dimensions); + return invalidDimensions === null ? [] : [invalidDimensions]; + } + const decoded = rasterAsset(content); + if (decoded === null) { + return [finding("plugin.submission.asset.decode_failed", "Branding asset could not be decoded.", assetEvidence(field, resolved.packagePath, extension))]; + } + if (decoded.format !== extension) { + return [finding("plugin.submission.asset.extension_mismatch", "Branding asset extension does not match its content.", assetEvidence(field, resolved.packagePath, decoded.format, decoded.dimensions))]; + } + const invalidDimensions = dimensionFinding(field, resolved.packagePath, decoded.format, decoded.dimensions); + return invalidDimensions === null ? [] : [invalidDimensions]; +} + +export async function validateSubmissionAssets(discoveredPackage: DiscoveredPackage): Promise { + const listing = discoveredPackage.manifest.interface; + const interfaceValues = isRecord(listing) ? listing : {}; + const findings: SubmissionFinding[] = []; + for (const field of assetFields) findings.push(...await validateAsset(discoveredPackage.rootPath, field, interfaceValues[field])); + return { findings }; +} diff --git a/src/core/submission-preflight.ts b/src/core/submission-preflight.ts new file mode 100644 index 0000000..b15379e --- /dev/null +++ b/src/core/submission-preflight.ts @@ -0,0 +1,361 @@ +import { lstat, readFile, realpath, stat } from "node:fs/promises"; +import path from "node:path"; + +import type { DiscoveredPackage, PluginManifest } from "../domain/types.js"; +import { validateSubmissionAssets } from "./submission-assets.js"; +import { submissionManualChecks, submissionRuleset } from "./submission-ruleset.js"; +import { validateSubmissionSkillMetadata } from "./submission-skill-metadata.js"; + +export interface SubmissionFinding { + id: `plugin.submission.${string}`; + severity: "warn" | "fail"; + message: string; + portalCode?: string; + evidence?: Record; +} + +export interface SubmissionCheck { + id: "listing" | "components" | "assets" | "skills"; + status: "pass" | "warn" | "fail"; + findingIds: string[]; +} + +export interface SubmissionManualCheck { + id: string; + label: string; + state: "required" | "not_applicable"; +} + +export interface SubmissionPreflightReport { + schemaVersion: "1.0.0"; + rulesetVersion: "openai-directory-2026-08-15"; + targetType: "skills-only" | "mcp-backed"; + status: "pass" | "fail"; + readiness: "blocked" | "manual_review_required"; + summary: { passed: number; warnings: number; blockers: number; manualChecks: number }; + checks: SubmissionCheck[]; + findings: SubmissionFinding[]; + manualChecklist: SubmissionManualCheck[]; +} + +type Evidence = SubmissionFinding["evidence"]; +type TargetType = SubmissionPreflightReport["targetType"]; + +const packageNamePattern = /^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/; +const semverPattern = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/; +const singleLineUnsupported = /[\u0000-\u001F\u007F\u2028\u2029\u200B-\u200F\u202A-\u202E\u2060-\u206F\uFEFF]/u; +const multilineUnsupported = /[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F\u2028\u2029\u200B-\u200F\u202A-\u202E\u2060-\u206F\uFEFF]/u; +const knownInterfaceFields = new Set([ + "displayName", "shortDescription", "longDescription", "developerName", "category", + "capabilities", "websiteURL", "supportURL", "privacyPolicyURL", "termsOfServiceURL", + "brandColor", "brandColorDark", "defaultPrompt", "composerIcon", "logo", "screenshots" +]); +const maxSubmissionManifestBytes = 1024 * 1024; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function finding( + id: `plugin.submission.${string}`, + severity: SubmissionFinding["severity"], + message: string, + evidence?: Evidence +): SubmissionFinding { + return evidence === undefined ? { id, severity, message } : { id, severity, message, evidence }; +} + +function invalidField(field: string, evidence?: Evidence): SubmissionFinding { + return finding(`plugin.submission.interface.${field}`, "fail", "Listing field is invalid.", { + field, + ...evidence + }); +} + +function hasValidText(value: unknown, maximum: number, multiline = false): value is string { + return typeof value === "string" + && value.trim().length > 0 + && value.length <= maximum + && !(multiline ? multilineUnsupported : singleLineUnsupported).test(value); +} + +function validateRequiredText( + source: Record, + property: string, + field: string, + limit: number, + multiline: boolean, + findings: SubmissionFinding[] +): void { + if (!hasValidText(source[property], limit, multiline)) { + findings.push(invalidField(field, { limit })); + } +} + +function validatePackage(manifest: Record, findings: SubmissionFinding[]): void { + const name = manifest.name; + if (typeof name !== "string" || name.length > submissionRuleset.limits.packageName || !packageNamePattern.test(name)) { + findings.push(finding("plugin.submission.package.name", "fail", "Package name is invalid.", { + field: "name", limit: submissionRuleset.limits.packageName + })); + } + + const version = manifest.version; + if (typeof version !== "string" || version.length > submissionRuleset.limits.version || !semverPattern.test(version)) { + findings.push(finding("plugin.submission.package.version", "fail", "Package version is invalid.", { + field: "version", limit: submissionRuleset.limits.version + })); + } +} + +function validateCapabilities(value: unknown, findings: SubmissionFinding[]): void { + if (value === undefined) { + return; + } + if (!Array.isArray(value) || value.length > submissionRuleset.limits.capabilities) { + findings.push(invalidField("capabilities", { limit: submissionRuleset.limits.capabilities })); + } + if (!Array.isArray(value)) { + return; + } + if (value.some((capability) => !hasValidText(capability, submissionRuleset.limits.capability))) { + findings.push(invalidField("capability", { limit: submissionRuleset.limits.capability })); + } +} + +function normalizePrompt(prompt: string): string { + return prompt.normalize("NFKC").trim().replace(/\s+/gu, " "); +} + +function validateDefaultPrompt(value: unknown, findings: SubmissionFinding[]): void { + if (value === undefined) { + return; + } + const prompts = typeof value === "string" ? [value] : value; + if (!Array.isArray(prompts) || prompts.length > submissionRuleset.limits.starterPrompts) { + findings.push(invalidField("default_prompt", { limit: submissionRuleset.limits.starterPrompts })); + return; + } + const normalized = new Set(); + const invalid = prompts.some((prompt) => { + if (!hasValidText(prompt, submissionRuleset.limits.starterPrompt) || prompt.includes("@")) { + return true; + } + const normalizedPrompt = normalizePrompt(prompt); + if (normalized.has(normalizedPrompt)) { + return true; + } + normalized.add(normalizedPrompt); + return false; + }); + if (invalid) { + findings.push(invalidField("default_prompt", { limit: submissionRuleset.limits.starterPrompt })); + } +} + +function isValidHttpsUrl(value: unknown): value is string { + if (typeof value !== "string" || value.length === 0 || value.length > submissionRuleset.limits.url + || value.trim() !== value || singleLineUnsupported.test(value)) { + return false; + } + try { + const parsed = new URL(value); + return parsed.protocol === "https:" && parsed.hostname.length > 0 + && parsed.username.length === 0 && parsed.password.length === 0 && parsed.hash.length === 0; + } catch { + return false; + } +} + +function validateListing(manifest: Record, targetType: TargetType): SubmissionFinding[] { + const findings: SubmissionFinding[] = []; + validatePackage(manifest, findings); + + const listing = manifest.interface; + if (!isRecord(listing)) { + findings.push(finding("plugin.submission.interface.required", "fail", "Listing interface mapping is required.", { field: "interface" })); + return findings; + } + + validateRequiredText(listing, "displayName", "display_name", submissionRuleset.limits.displayName, false, findings); + validateRequiredText(listing, "shortDescription", "short_description", submissionRuleset.limits.shortDescription, false, findings); + validateRequiredText(listing, "longDescription", "long_description", submissionRuleset.limits.longDescription, true, findings); + validateRequiredText(listing, "developerName", "developer_name", submissionRuleset.limits.developerName, false, findings); + validateRequiredText(listing, "category", "category", 80, false, findings); + if (typeof listing.category === "string" && !submissionRuleset.categories.includes(listing.category as never)) { + findings.push(invalidField("category")); + } + validateCapabilities(listing.capabilities, findings); + validateDefaultPrompt(listing.defaultPrompt, findings); + + if (targetType === "mcp-backed") { + for (const property of ["websiteURL", "supportURL", "privacyPolicyURL", "termsOfServiceURL"]) { + if (!isValidHttpsUrl(listing[property])) { + findings.push(finding("plugin.submission.interface.url", "fail", "Required listing URL is invalid.", { + field: property, limit: submissionRuleset.limits.url + })); + } + } + } + + for (const key of Object.keys(listing)) { + if (!knownInterfaceFields.has(key)) { + findings.push(finding("plugin.submission.interface.unknown_field", "warn", "Listing interface contains an unsupported field.", { field: key })); + } + } + return findings; +} + +function isWithin(rootPath: string, candidatePath: string): boolean { + const relative = path.relative(rootPath, candidatePath); + return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative)); +} + +async function validateApp(rootPath: string, declaration: unknown): Promise { + if (declaration === undefined) { + return []; + } + const invalid = () => [finding("plugin.submission.component.app", "fail", "App declaration must reference a contained parseable root file.", { path: ".app.json" })]; + const invalidPath = () => [finding("plugin.submission.app.invalid_path", "fail", "App declaration resolves outside the package.", { path: ".app.json" })]; + if (declaration !== "./.app.json") { + return invalid(); + } + const candidatePath = path.resolve(rootPath, ".app.json"); + try { + const [canonicalRoot, canonicalCandidate, details] = await Promise.all([ + realpath(rootPath), + realpath(candidatePath), + stat(candidatePath) + ]); + if (!isWithin(canonicalRoot, canonicalCandidate)) { + return invalidPath(); + } + if (!details.isFile() || details.size > 5 * 1024 * 1024) { + return invalid(); + } + JSON.parse(await readFile(candidatePath, "utf8")); + return []; + } catch { + return invalid(); + } +} + +function checkStatus(findings: SubmissionFinding[]): SubmissionCheck["status"] { + if (findings.some((item) => item.severity === "fail")) { + return "fail"; + } + return findings.some((item) => item.severity === "warn") ? "warn" : "pass"; +} + +function check(id: SubmissionCheck["id"], findings: SubmissionFinding[]): SubmissionCheck { + return { id, status: checkStatus(findings), findingIds: findings.map((item) => item.id) }; +} + +function checklist(targetType: TargetType): SubmissionManualCheck[] { + return submissionManualChecks.map((item) => ({ + id: item.id, + label: item.label, + state: item.mcpOnly && targetType === "skills-only" ? "not_applicable" : "required" + })); +} + +function invalidPackageReport(): SubmissionPreflightReport { + const findings = [finding("plugin.submission.package.invalid", "fail", "Plugin manifest is missing or invalid.")]; + const checks = [check("listing", findings), check("components", []), check("assets", []), check("skills", [])]; + return { + schemaVersion: "1.0.0", + rulesetVersion: submissionRuleset.version, + targetType: "skills-only", + status: "fail", + readiness: "blocked", + summary: { passed: 3, warnings: 0, blockers: 1, manualChecks: 3 }, + checks, + findings, + manualChecklist: checklist("skills-only") + }; +} + +function oversizedPackageReport(): SubmissionPreflightReport { + const findings = [finding("plugin.submission.package.too_large", "fail", "Plugin manifest exceeds the submission preflight size limit.", { + path: ".codex-plugin/plugin.json", limit: maxSubmissionManifestBytes + })]; + const checks = [check("listing", findings), check("components", []), check("assets", []), check("skills", [])]; + return { + schemaVersion: "1.0.0", + rulesetVersion: submissionRuleset.version, + targetType: "skills-only", + status: "fail", + readiness: "blocked", + summary: { passed: 3, warnings: 0, blockers: 1, manualChecks: 3 }, + checks, + findings, + manualChecklist: checklist("skills-only") + }; +} + +async function discoverSubmissionPackage(targetPath: string): Promise { + const rootPath = path.resolve(targetPath); + const manifestPath = path.join(rootPath, ".codex-plugin", "plugin.json"); + try { + const linkDetails = await lstat(manifestPath); + if (!linkDetails.isFile()) return null; + const details = await stat(manifestPath); + if (details.size > maxSubmissionManifestBytes) return "too_large"; + const content = new TextDecoder("utf-8", { fatal: true }).decode(await readFile(manifestPath)); + return { rootPath, manifestPath, manifest: JSON.parse(content) as PluginManifest }; + } catch { + return null; + } +} + +export async function buildSubmissionPreflight(targetPath: string): Promise { + if (typeof targetPath !== "string") { + return invalidPackageReport(); + } + const discovered = await discoverSubmissionPackage(targetPath); + if (discovered === "too_large") { + return oversizedPackageReport(); + } + if (!discovered || !isRecord(discovered.manifest)) { + return invalidPackageReport(); + } + + const manifest = discovered.manifest; + const targetType: TargetType = manifest.mcpServers !== undefined || manifest.apps !== undefined + ? "mcp-backed" + : "skills-only"; + const listingFindings = validateListing(manifest, targetType); + const componentFindings = await validateApp(discovered.rootPath, manifest.apps); + const assetFindings = (await validateSubmissionAssets(discovered)).findings; + const skillFindings = (await validateSubmissionSkillMetadata(discovered, targetType)).findings; + const interfaceValue = manifest.interface; + if (targetType === "skills-only" && isRecord(interfaceValue) && interfaceValue.screenshots !== undefined) { + componentFindings.push(finding("plugin.submission.component.excluded", "fail", "Screenshots are excluded for skills-only submissions.", { field: "screenshots" })); + } + const findings = [...listingFindings, ...componentFindings, ...assetFindings, ...skillFindings]; + const checks = [ + check("listing", listingFindings), + check("components", componentFindings), + check("assets", assetFindings), + check("skills", skillFindings) + ]; + const blockers = findings.filter((item) => item.severity === "fail").length; + const manualChecklist = checklist(targetType); + + return { + schemaVersion: "1.0.0", + rulesetVersion: submissionRuleset.version, + targetType, + status: blockers > 0 ? "fail" : "pass", + readiness: blockers > 0 ? "blocked" : "manual_review_required", + summary: { + passed: checks.filter((item) => item.status === "pass").length, + warnings: findings.filter((item) => item.severity === "warn").length, + blockers, + manualChecks: manualChecklist.filter((item) => item.state === "required").length + }, + checks, + findings, + manualChecklist + }; +} diff --git a/src/core/submission-ruleset.ts b/src/core/submission-ruleset.ts new file mode 100644 index 0000000..5fbe257 --- /dev/null +++ b/src/core/submission-ruleset.ts @@ -0,0 +1,39 @@ +export const submissionRuleset = Object.freeze({ + version: "openai-directory-2026-08-15", + reviewedAt: "2026-08-15", + sources: Object.freeze([ + "https://developers.openai.com/plugins/build/plugins", + "https://developers.openai.com/plugins/deploy/app-review", + "https://developers.openai.com/plugins/deploy/submission-errors" + ]), + limits: Object.freeze({ + packageName: 64, + version: 64, + displayName: 30, + shortDescription: 30, + longDescription: 4000, + developerName: 80, + capabilities: 20, + capability: 120, + starterPrompts: 3, + starterPrompt: 128, + url: 1024 + }), + categories: Object.freeze([ + "Productivity", "Creativity", "Developer Tools", "Business & Operations", + "Data & Analytics", "Communication", "Education & Research", "Security", + "Finance", "Healthcare", "Travel", "Entertainment", "Other" + ]) +}); + +export const submissionManualChecks = Object.freeze([ + Object.freeze({ id: "developer-business-identity", label: "Developer and business identity", mcpOnly: false }), + Object.freeze({ id: "attestations", label: "Required attestations", mcpOnly: false }), + Object.freeze({ id: "skill-safety-scan", label: "Skill safety scan", mcpOnly: false }), + Object.freeze({ id: "demo-video", label: "Demo video", mcpOnly: true }), + Object.freeze({ id: "tool-tests", label: "Exactly 5 positive and 3 negative tool tests", mcpOnly: true }), + Object.freeze({ id: "release-notes", label: "Release notes", mcpOnly: true }), + Object.freeze({ id: "production-domain-verification", label: "Production domain verification and current tool scan", mcpOnly: true }), + Object.freeze({ id: "tool-annotations", label: "Tool annotations and justifications", mcpOnly: true }), + Object.freeze({ id: "oauth-reviewer-credentials", label: "OAuth reviewer credentials", mcpOnly: true }) +]); diff --git a/src/core/submission-skill-metadata.ts b/src/core/submission-skill-metadata.ts new file mode 100644 index 0000000..55e7ef1 --- /dev/null +++ b/src/core/submission-skill-metadata.ts @@ -0,0 +1,348 @@ +import { lstat, opendir, readFile, realpath, stat } from "node:fs/promises"; +import path from "node:path"; + +import { isAlias, isNode, parseDocument, visit } from "yaml"; + +import type { DiscoveredPackage } from "../domain/types.js"; +import type { SubmissionFinding } from "./submission-preflight.js"; + +type TargetType = "skills-only" | "mcp-backed"; +type Evidence = SubmissionFinding["evidence"]; +type Metadata = Record; + +const maxSkillBytes = 1024 * 1024; +const maxAgentBytes = 256 * 1024; +const maxAggregateMetadataBytes = 16 * 1024 * 1024; +const maxDirectoryEntries = 256; +const maxSkillDirectories = 100; +const unsupportedText = /[\u0000-\u001F\u007F\u2028\u2029\u200B-\u200F\u202A-\u202E\u2060-\u206F\uFEFF]/u; +const interfaceKeys = new Set(["display_name", "short_description", "icon_small", "icon_large", "brand_color", "default_prompt"]); +const policyKeys = new Set(["products", "allow_implicit_invocation"]); +const dependencyKeys = new Set(["tools"]); +const toolDescriptorKeys = new Set(["type", "value", "description", "transport", "url"]); +const agentKeys = new Set(["interface", "policy", "dependencies"]); + +export interface SubmissionSkillMetadataResult { + findings: SubmissionFinding[]; + skillCount: number; +} + +function isRecord(value: unknown): value is Metadata { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function isWithin(rootPath: string, candidatePath: string): boolean { + const relative = path.relative(rootPath, candidatePath); + return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative)); +} + +function packagePath(rootPath: string, targetPath: string): string { + return path.relative(rootPath, targetPath).split(path.sep).join("/"); +} + +function finding( + id: `plugin.submission.skill.${string}`, + message: string, + evidence?: Evidence +): SubmissionFinding { + return evidence === undefined + ? { id, severity: "fail", message } + : { id, severity: "fail", message, evidence }; +} + +function supportedText(value: unknown, limit = Number.MAX_SAFE_INTEGER): value is string { + return typeof value === "string" && value.trim().length > 0 && value.length <= limit && !unsupportedText.test(value); +} + +function parseSafeYaml(source: string): { value: Metadata } | { error: "yaml" | "shape" } { + const document = parseDocument(source, { schema: "core", uniqueKeys: true, strict: true, prettyErrors: false }); + let alias = false; + let nonCoreTag = false; + visit(document, (_key, node) => { + if (isAlias(node)) alias = true; + if (isNode(node) && node.tag !== undefined && !node.tag.startsWith("tag:yaml.org,2002:")) nonCoreTag = true; + }); + if (document.errors.length > 0 || alias || nonCoreTag) return { error: "yaml" }; + try { + const value = document.toJS({ maxAliasCount: 0 }); + return isRecord(value) ? { value } : { error: "shape" }; + } catch { + return { error: "yaml" }; + } +} + +function splitSkillFile(source: string): { frontmatter: string; body: string } | null { + const match = /^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)([\s\S]*)$/u.exec(source); + if (!match || match[2].trim().length === 0) return null; + return { frontmatter: match[1], body: match[2] }; +} + +async function safeDirectory(rootPath: string, skillsPath: string): Promise<{ canonicalRoot: string; canonicalSkills: string } | null> { + try { + const [canonicalRoot, canonicalSkills, details] = await Promise.all([realpath(rootPath), realpath(skillsPath), stat(skillsPath)]); + return details.isDirectory() && isWithin(canonicalRoot, canonicalSkills) ? { canonicalRoot, canonicalSkills } : null; + } catch { + return null; + } +} + +type AggregateRead = { kind: "source"; source: string } | { kind: "invalid" } | { kind: "budget"; nextBytes: number }; + +interface AggregateBudget { + bytes: number; +} + +function budgetFinding(bytes: number): SubmissionFinding { + return finding("plugin.submission.skill.budget_exceeded", "Skill metadata exceeds the aggregate submission preflight size limit.", { + count: bytes, limit: maxAggregateMetadataBytes + }); +} + +async function readSafeUtf8(filePath: string, maximum: number, budget: AggregateBudget): Promise { + try { + const details = await stat(filePath); + if (!details.isFile() || details.size > maximum) return { kind: "invalid" }; + const nextBytes = budget.bytes + details.size; + if (nextBytes > maxAggregateMetadataBytes) return { kind: "budget", nextBytes }; + budget.bytes = nextBytes; + return { kind: "source", source: new TextDecoder("utf-8", { fatal: true }).decode(await readFile(filePath)) }; + } catch { + return { kind: "invalid" }; + } +} + +function rejectUnknownKeys(value: Metadata, allowed: Set): boolean { + return Object.keys(value).some((key) => !allowed.has(key)); +} + +function isToolDescriptor(value: unknown): value is Metadata { + return isRecord(value) && !rejectUnknownKeys(value, toolDescriptorKeys) + && (value.type === "mcp" || value.type === "cli") + && (value.type !== "cli" || (value.transport === undefined && value.url === undefined)) + && supportedText(value.value) + && (value.description === undefined || supportedText(value.description)) + && (value.transport === undefined || supportedText(value.transport)) + && (value.url === undefined || supportedText(value.url)); +} + +async function validateIconPath( + rootPath: string, + canonicalRoot: string, + skillRoot: string, + skillPath: string, + field: "icon_small" | "icon_large", + value: unknown +): Promise { + if (typeof value !== "string" || value.trim() !== value || !supportedText(value) + || path.isAbsolute(value) || /^[A-Za-z]:[\\/]/u.test(value)) { + return finding("plugin.submission.skill.agent.invalid_path", "Optional agent icon path is invalid.", { path: skillPath, field }); + } + const iconPath = path.resolve(skillRoot, value); + if (!isWithin(rootPath, iconPath)) { + return finding("plugin.submission.skill.agent.invalid_path", "Optional agent icon path is invalid.", { path: skillPath, field }); + } + try { + const [canonicalIcon, details] = await Promise.all([realpath(iconPath), stat(iconPath)]); + if (!details.isFile() || !isWithin(canonicalRoot, canonicalIcon)) throw new Error("unsafe icon"); + return null; + } catch { + return finding("plugin.submission.skill.agent.invalid_path", "Optional agent icon path is invalid.", { path: skillPath, field }); + } +} + +async function validateAgentFile( + rootPath: string, + skillRoot: string, + skillPath: string, + budget: AggregateBudget +): Promise { + const agentPath = path.join(skillRoot, "agents", "openai.yaml"); + let agentDetails; + try { + agentDetails = await lstat(agentPath); + } catch { + return []; + } + if (agentDetails.isSymbolicLink()) { + return [finding("plugin.submission.skill.agent.invalid_path", "Optional agent metadata must not be a symbolic link.", { path: skillPath })]; + } + if (!agentDetails.isFile()) { + return [finding("plugin.submission.skill.agent.invalid_file", "Optional agent metadata must be a regular file.", { path: packagePath(rootPath, agentPath) })]; + } + + let canonicalRoot: string; + let canonicalSkill: string; + let canonicalAgent: string; + try { + [canonicalRoot, canonicalSkill, canonicalAgent] = await Promise.all([realpath(rootPath), realpath(skillRoot), realpath(agentPath)]); + } catch { + return [finding("plugin.submission.skill.agent.invalid_file", "Optional agent metadata must be a readable regular file.", { path: packagePath(rootPath, agentPath) })]; + } + if (!isWithin(canonicalRoot, canonicalAgent) || !isWithin(canonicalSkill, canonicalAgent)) { + return [finding("plugin.submission.skill.agent.invalid_path", "Optional agent metadata resolves outside its skill.", { path: skillPath })]; + } + + const source = await readSafeUtf8(agentPath, maxAgentBytes, budget); + if (source.kind === "budget") { + return [budgetFinding(source.nextBytes)]; + } + if (source.kind === "invalid") { + return [finding("plugin.submission.skill.agent.invalid_file", "Optional agent metadata must be a bounded UTF-8 regular file.", { path: packagePath(rootPath, agentPath), limit: maxAgentBytes })]; + } + const parsed = parseSafeYaml(source.source); + if ("error" in parsed) { + return [finding( + parsed.error === "yaml" ? "plugin.submission.skill.agent.invalid_yaml" : "plugin.submission.skill.agent.invalid_shape", + "Optional agent metadata must be a safe YAML mapping.", + { path: packagePath(rootPath, agentPath) } + )]; + } + + const metadata = parsed.value; + if (rejectUnknownKeys(metadata, agentKeys) || !isRecord(metadata.interface) || rejectUnknownKeys(metadata.interface, interfaceKeys) + || !supportedText(metadata.interface.display_name) || !supportedText(metadata.interface.short_description)) { + return [finding("plugin.submission.skill.agent.invalid_shape", "Optional agent metadata has an unsupported shape.", { path: packagePath(rootPath, agentPath) })]; + } + if (metadata.interface.brand_color !== undefined && (typeof metadata.interface.brand_color !== "string" || !/^#[0-9A-Fa-f]{6}$/u.test(metadata.interface.brand_color))) { + return [finding("plugin.submission.skill.agent.invalid_shape", "Optional agent metadata has an invalid brand color.", { path: packagePath(rootPath, agentPath), field: "brand_color" })]; + } + if (metadata.interface.default_prompt !== undefined && !supportedText(metadata.interface.default_prompt)) { + return [finding("plugin.submission.skill.agent.invalid_shape", "Optional agent metadata has an invalid default prompt.", { path: packagePath(rootPath, agentPath), field: "default_prompt" })]; + } + if (metadata.policy !== undefined && (!isRecord(metadata.policy) || rejectUnknownKeys(metadata.policy, policyKeys) + || (metadata.policy.products !== undefined && (!Array.isArray(metadata.policy.products) || metadata.policy.products.length === 0 + || new Set(metadata.policy.products).size !== metadata.policy.products.length + || metadata.policy.products.some((product) => product !== "CHAT" && product !== "CODEX"))) + || (metadata.policy.allow_implicit_invocation !== undefined && typeof metadata.policy.allow_implicit_invocation !== "boolean"))) { + return [finding("plugin.submission.skill.agent.invalid_shape", "Optional agent policy has an unsupported shape.", { path: packagePath(rootPath, agentPath), field: "policy" })]; + } + if (metadata.dependencies !== undefined && (!isRecord(metadata.dependencies) || rejectUnknownKeys(metadata.dependencies, dependencyKeys) + || !Array.isArray(metadata.dependencies.tools) || metadata.dependencies.tools.length === 0 + || metadata.dependencies.tools.some((tool) => !isToolDescriptor(tool)))) { + return [finding("plugin.submission.skill.agent.invalid_shape", "Optional agent dependencies have an unsupported shape.", { path: packagePath(rootPath, agentPath), field: "dependencies" })]; + } + + for (const field of ["icon_small", "icon_large"] as const) { + const value = metadata.interface[field]; + if (value === undefined) continue; + const iconFinding = await validateIconPath(rootPath, canonicalRoot, skillRoot, skillPath, field, value); + if (iconFinding) return [iconFinding]; + } + return []; +} + +export async function validateSubmissionSkillMetadata( + discoveredPackage: DiscoveredPackage, + targetType: TargetType +): Promise { + const { manifest, rootPath } = discoveredPackage; + if (manifest.skills === undefined) { + return targetType === "skills-only" + ? { findings: [finding("plugin.submission.skill.required", "Skills-only submissions require a valid skill.")], skillCount: 0 } + : { findings: [], skillCount: 0 }; + } + if (manifest.skills !== "./skills" && manifest.skills !== "./skills/") { + return { findings: [finding("plugin.submission.skill.invalid_manifest", "Skills must be declared as the root ./skills directory.", { field: "skills" })], skillCount: 0 }; + } + + const skillsPath = path.resolve(rootPath, manifest.skills); + const safe = await safeDirectory(rootPath, skillsPath); + if (!safe) { + return { findings: [finding("plugin.submission.skill.invalid_path", "Skills directory must be canonically contained in the package.", { path: "skills" })], skillCount: 0 }; + } + + const findings: SubmissionFinding[] = []; + const identities = new Set(); + let skillCount = 0; + const budget: AggregateBudget = { bytes: 0 }; + let entries; + try { + entries = await opendir(skillsPath); + } catch { + return { findings: [finding("plugin.submission.skill.invalid_path", "Skills directory cannot be inspected safely.", { path: "skills" })], skillCount: 0 }; + } + let entryCount = 0; + let skillDirectoryCount = 0; + for await (const entry of entries) { + entryCount += 1; + if (entryCount > maxDirectoryEntries) { + findings.push(finding("plugin.submission.skill.too_many", "Skills directory exceeds the submission preflight entry limit.", { count: entryCount, limit: maxDirectoryEntries })); + break; + } + if (entry.name.startsWith(".") || !entry.isDirectory()) continue; + skillDirectoryCount += 1; + if (skillDirectoryCount > maxSkillDirectories) { + findings.push(finding("plugin.submission.skill.too_many", "Skills directory exceeds the submission preflight skill limit.", { count: skillDirectoryCount, limit: maxSkillDirectories })); + break; + } + const skillRoot = path.join(skillsPath, entry.name); + const skillFile = path.join(skillRoot, "SKILL.md"); + const relativeSkillPath = packagePath(rootPath, skillFile); + let canonicalSkill: string; + try { + canonicalSkill = await realpath(skillRoot); + if (!isWithin(safe.canonicalSkills, canonicalSkill)) throw new Error("unsafe skill root"); + } catch { + findings.push(finding("plugin.submission.skill.invalid_path", "Skill directory resolves outside the declared skills directory.", { path: packagePath(rootPath, skillRoot) })); + continue; + } + try { + const details = await lstat(skillFile); + if (details.isSymbolicLink()) { + findings.push(finding("plugin.submission.skill.invalid_path", "Skill entrypoint must not be a symbolic link.", { path: relativeSkillPath })); + continue; + } + if (!details.isFile() || !isWithin(canonicalSkill, await realpath(skillFile))) { + findings.push(finding("plugin.submission.skill.invalid_file", "Skill entrypoint must be a contained regular file.", { path: relativeSkillPath })); + continue; + } + } catch { + findings.push(finding("plugin.submission.skill.invalid_file", "Skill entrypoint must be a contained regular file.", { path: relativeSkillPath })); + continue; + } + const source = await readSafeUtf8(skillFile, maxSkillBytes, budget); + if (source.kind === "budget") { + findings.push(budgetFinding(source.nextBytes)); + break; + } + if (source.kind === "invalid") { + findings.push(finding("plugin.submission.skill.invalid_file", "Skill entrypoint must be a bounded UTF-8 regular file.", { path: relativeSkillPath, limit: maxSkillBytes })); + continue; + } + const split = splitSkillFile(source.source); + if (!split) { + findings.push(finding("plugin.submission.skill.invalid_file", "Skill entrypoint requires delimited frontmatter and a nonempty body.", { path: relativeSkillPath })); + continue; + } + const parsed = parseSafeYaml(split.frontmatter); + if ("error" in parsed) { + findings.push(finding( + parsed.error === "yaml" ? "plugin.submission.skill.invalid_yaml" : "plugin.submission.skill.invalid_shape", + "Skill frontmatter must be a safe YAML mapping.", + { path: relativeSkillPath } + )); + continue; + } + const name = parsed.value.name; + const description = parsed.value.description; + const normalizedName = typeof name === "string" ? name.normalize("NFKC").trim() : ""; + const pluginName = typeof manifest.name === "string" ? manifest.name.normalize("NFKC").trim() : ""; + if (!supportedText(name) || !supportedText(description, 1024) || normalizedName.length === 0 + || `${pluginName}:${normalizedName}`.length > 64 || identities.has(normalizedName)) { + findings.push(finding("plugin.submission.skill.identity", "Skill identity metadata is invalid or duplicated.", { path: relativeSkillPath, limit: 64 })); + continue; + } + identities.add(normalizedName); + skillCount += 1; + const agentFindings = await validateAgentFile(rootPath, skillRoot, packagePath(rootPath, skillRoot), budget); + findings.push(...agentFindings); + if (agentFindings.some((item) => item.id === "plugin.submission.skill.budget_exceeded")) break; + } + if (targetType === "skills-only" && skillCount === 0 && !findings.some((item) => item.id === "plugin.submission.skill.required")) { + findings.push(finding("plugin.submission.skill.required", "Skills-only submissions require at least one valid skill.", { count: 0 })); + } + if (targetType === "mcp-backed" && skillCount === 0 && findings.length === 0) { + findings.push(finding("plugin.submission.skill.invalid_file", "Declared skills must include at least one valid immediate skill entrypoint.", { path: "skills" })); + } + return { findings, skillCount }; +} diff --git a/src/index.ts b/src/index.ts index 834d524..74a406d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -145,6 +145,31 @@ export { type OutputContractRule, type OutputContractSchema } from "./core/output-contract.js"; +export { + submissionRuleset, + submissionManualChecks +} from "./core/submission-ruleset.js"; +export { + buildSubmissionPreflight, + type SubmissionCheck, + type SubmissionFinding, + type SubmissionManualCheck, + type SubmissionPreflightReport +} from "./core/submission-preflight.js"; +export { + validateSubmissionAssets, + type SubmissionAssetResult +} from "./core/submission-assets.js"; +export { + validateSubmissionSkillMetadata, + type SubmissionSkillMetadataResult +} from "./core/submission-skill-metadata.js"; +export { + renderSubmissionPreflightJson, + renderSubmissionPreflightMarkdown, + renderSubmissionPreflightText, + submissionPreflightExitCode +} from "./reporting/render-submission-report.js"; export { buildDoctorValidationCorpusReport, renderDoctorValidationCorpusJson, diff --git a/src/reporting/render-submission-report.ts b/src/reporting/render-submission-report.ts new file mode 100644 index 0000000..35ff7bc --- /dev/null +++ b/src/reporting/render-submission-report.ts @@ -0,0 +1,94 @@ +import type { + SubmissionCheck, + SubmissionFinding, + SubmissionPreflightReport +} from "../core/submission-preflight.js"; + +function upper(value: string): string { + return value.replace(/[-_]/gu, " ").toUpperCase(); +} + +function findingsByCheck(report: SubmissionPreflightReport): Array<{ + check: SubmissionCheck; + findings: SubmissionFinding[]; +}> { + const findings = new Map( + report.findings.map((finding) => [finding.id, finding]) + ); + + return report.checks.map((check) => ({ + check, + findings: check.findingIds.flatMap((id) => { + const finding = findings.get(id); + return finding ? [finding] : []; + }) + })); +} + +function escapeMarkdown(value: string): string { + return value.replace(/[\\`*_{}\[\]()<>#+\-.!|]/gu, "\\$&"); +} + +function textSummary(report: SubmissionPreflightReport): string[] { + return [ + `Ruleset: ${report.rulesetVersion}`, + `Target: ${report.targetType}`, + `Automatic status: ${upper(report.status)}`, + `Readiness: ${upper(report.readiness)}`, + `Summary: ${report.summary.passed} passed, ${report.summary.warnings} warnings, ${report.summary.blockers} blockers, ${report.summary.manualChecks} manual checks`, + "Manual review is required; automatic checks do not complete directory review." + ]; +} + +export function renderSubmissionPreflightJson(report: SubmissionPreflightReport): string { + return `${JSON.stringify(report, null, 2)}\n`; +} + +export function renderSubmissionPreflightText(report: SubmissionPreflightReport): string { + const lines = ["Submission preflight", "====================", ...textSummary(report), "", "Findings by check"]; + + for (const { check, findings } of findingsByCheck(report)) { + lines.push(`${check.id} (${upper(check.status)})`); + lines.push(...(findings.length === 0 + ? [" None"] + : findings.map((finding) => ` ${upper(finding.severity)} ${finding.id}: ${finding.message}`))); + } + + lines.push("", "Manual checklist"); + lines.push(...report.manualChecklist.map((item) => ` ${upper(item.state)} ${item.id}: ${item.label}`)); + return `${lines.join("\n")}\n`; +} + +export function renderSubmissionPreflightMarkdown(report: SubmissionPreflightReport): string { + const lines = [ + "# Submission preflight", + "", + `- Ruleset: ${escapeMarkdown(report.rulesetVersion)}`, + `- Target: ${escapeMarkdown(report.targetType)}`, + `- Automatic status: ${escapeMarkdown(upper(report.status))}`, + `- Readiness: ${escapeMarkdown(upper(report.readiness))}`, + `- Summary: ${report.summary.passed} passed, ${report.summary.warnings} warnings, ${report.summary.blockers} blockers, ${report.summary.manualChecks} manual checks`, + "", + "Manual review is required; automatic checks do not complete directory review.", + "", + "## Findings by check" + ]; + + for (const { check, findings } of findingsByCheck(report)) { + lines.push("", `### ${escapeMarkdown(check.id)} (${escapeMarkdown(upper(check.status))})`); + lines.push(...(findings.length === 0 + ? ["- None"] + : findings.map((finding) => `- **${escapeMarkdown(upper(finding.severity))}** ${escapeMarkdown(finding.id)}: ${escapeMarkdown(finding.message)}`))); + } + + lines.push("", "## Manual checklist"); + lines.push(...report.manualChecklist.map((item) => `- ${escapeMarkdown(upper(item.state))}: ${escapeMarkdown(item.id)} — ${escapeMarkdown(item.label)}`)); + return `${lines.join("\n")}\n`; +} + +export function submissionPreflightExitCode( + report: SubmissionPreflightReport, + requireReady: boolean +): 0 | 1 { + return requireReady && report.status === "fail" ? 1 : 0; +} diff --git a/src/run-cli.ts b/src/run-cli.ts index ab73ccf..42f97da 100644 --- a/src/run-cli.ts +++ b/src/run-cli.ts @@ -83,6 +83,7 @@ import { renderDoctorOutputContract, renderDoctorOutputContractJson } from "./core/output-contract.js"; +import { buildSubmissionPreflight } from "./core/submission-preflight.js"; import { buildDoctorValidationCorpusReport, renderDoctorValidationCorpusJson, @@ -230,6 +231,12 @@ import { import { renderRuleExplanation } from "./reporting/render-rule-explanation.js"; import { renderSarifReport } from "./reporting/render-sarif-report.js"; import { renderTextReport } from "./reporting/render-text-report.js"; +import { + renderSubmissionPreflightJson, + renderSubmissionPreflightMarkdown, + renderSubmissionPreflightText, + submissionPreflightExitCode +} from "./reporting/render-submission-report.js"; import { applyPolicyToDepAudit, applyPolicyToDoctorConfig, @@ -310,6 +317,14 @@ const defaultIo: CliIo = { } }; +function writeExactStdout(io: CliIo, message: string): void { + if (io === defaultIo) { + process.stdout.write(message); + return; + } + io.writeStdout(message); +} + class CliUsageError extends Error {} function parseRuntimeSandbox( @@ -434,6 +449,9 @@ function printUsage(io: CliIo): void { io.writeStderr( "Remote MCP runtime flags (check, mcp, release check, and doctor release-evidence): --runtime --allow-network [--allow-local-network] [--allow-session-lifecycle] [--require-remote-reliability]\n --allow-session-lifecycle requires --runtime --allow-network and can terminate a remote session.\n --require-remote-reliability requires --runtime --allow-network, blocks non-pass reliability, and does not grant network consent." ); + io.writeStderr( + " codex-plugin-doctor doctor submission [--json|--markdown] [--output ] [--require-ready]" + ); } const suppressUsageText = [ @@ -1587,6 +1605,65 @@ function parseSelectedFixActionIndexes( : null; } +function parseSubmissionCommandArgs(args: string[]): { + targetPath: string; + jsonOutput: boolean; + markdownOutput: boolean; + outputPath: string | null; + requireReady: boolean; +} | CliUsageError { + let targetPath: string | null = null; + let jsonOutput = false; + let markdownOutput = false; + let outputPath: string | null = null; + let requireReady = false; + let optionsEnded = false; + + for (let index = 0; index < args.length; index += 1) { + const argument = args[index]; + + if (argument === "--" && !optionsEnded) { + optionsEnded = true; + } else if (optionsEnded) { + if (targetPath === null) { + targetPath = argument; + } else { + return new CliUsageError(`Unexpected submission argument: ${argument}.`); + } + } else if (argument === "--json") { + if (jsonOutput) return new CliUsageError("Duplicate submission flag: --json."); + jsonOutput = true; + } else if (argument === "--markdown") { + if (markdownOutput) return new CliUsageError("Duplicate submission flag: --markdown."); + markdownOutput = true; + } else if (argument === "--require-ready") { + if (requireReady) return new CliUsageError("Duplicate submission flag: --require-ready."); + requireReady = true; + } else if (argument === "--output") { + if (outputPath !== null) return new CliUsageError("Duplicate submission flag: --output."); + const value = args[index + 1]; + if (!value || value.startsWith("--")) return new CliUsageError("Missing path after --output."); + outputPath = value; + index += 1; + } else if (argument.startsWith("--output=")) { + if (outputPath !== null) return new CliUsageError("Duplicate submission flag: --output."); + const value = argument.slice("--output=".length); + if (!value) return new CliUsageError("Missing path after --output."); + outputPath = value; + } else if (argument.startsWith("--")) { + return new CliUsageError(`Unknown submission flag: ${argument}.`); + } else if (targetPath === null) { + targetPath = argument; + } else { + return new CliUsageError(`Unexpected submission argument: ${argument}.`); + } + } + + if (targetPath === null) return new CliUsageError("Missing target path for submission."); + if (jsonOutput && markdownOutput) return new CliUsageError("Use either --json or --markdown, not both."); + return { targetPath, jsonOutput, markdownOutput, outputPath, requireReady }; +} + export async function runCli( args: string[], io: CliIo = defaultIo, @@ -1729,6 +1806,29 @@ export async function runCli( return 0; } + if (maybePath === "submission") { + const parsedSubmissionArgs = parseSubmissionCommandArgs(remainingArgs); + + if (parsedSubmissionArgs instanceof CliUsageError) { + io.writeStderr(parsedSubmissionArgs.message); + return 2; + } + + const report = await buildSubmissionPreflight(parsedSubmissionArgs.targetPath); + const renderedReport = parsedSubmissionArgs.jsonOutput + ? renderSubmissionPreflightJson(report) + : parsedSubmissionArgs.markdownOutput + ? renderSubmissionPreflightMarkdown(report) + : renderSubmissionPreflightText(report); + + if (parsedSubmissionArgs.outputPath) { + await writeFile(parsedSubmissionArgs.outputPath, renderedReport, "utf8"); + } + + writeExactStdout(io, renderedReport); + return submissionPreflightExitCode(report, parsedSubmissionArgs.requireReady); + } + if (maybePath === "mcp") { const targetPath = remainingArgs[0] && !remainingArgs[0].startsWith("--") ? remainingArgs[0] diff --git a/tests/action-metadata.test.ts b/tests/action-metadata.test.ts index dd5eebc..b16d98c 100644 --- a/tests/action-metadata.test.ts +++ b/tests/action-metadata.test.ts @@ -1,8 +1,65 @@ -import { readFile } from "node:fs/promises"; +import { execFile } from "node:child_process"; +import { mkdtemp, readFile, rm } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { promisify } from "node:util"; import { describe, expect, it } from "vitest"; import packageJson from "../package.json" with { type: "json" }; +const execFileAsync = promisify(execFile); + +async function renderActionManifest(actionMetadata: string, targetPath: string): Promise> { + const start = actionMetadata.indexOf(' const fs = require("node:fs");'); + const end = actionMetadata.indexOf("\n NODE", start); + const manifestDirectory = await mkdtemp(path.join(os.tmpdir(), "codex-plugin-doctor-action-manifest-")); + const manifestPath = path.join(manifestDirectory, "manifest.json"); + const script = actionMetadata.slice(start, end).replace(/^ /gmu, ""); + + try { + await execFileAsync(process.execPath, ["-e", script], { + env: { + ...process.env, + CODEX_PLUGIN_DOCTOR_ACTION_MANIFEST_PATH: manifestPath, + CODEX_PLUGIN_DOCTOR_ACTION_PATH: targetPath + } + }); + return JSON.parse(await readFile(manifestPath, "utf8")) as Record; + } finally { + await rm(manifestDirectory, { recursive: true, force: true }); + } +} + describe("GitHub Action metadata", () => { + it("redacts absolute POSIX, drive, and UNC target paths from the Action manifest", async () => { + const actionMetadata = await readFile("action.yml", "utf8"); + + for (const targetPath of ["/absolute-posix-sentinel", "C:\\\\absolute-drive-sentinel", "\\\\server-sentinel\\share"]) { + const manifest = await renderActionManifest(actionMetadata, targetPath); + const target = manifest.target as { path: string }; + + expect(JSON.stringify(manifest)).not.toContain(targetPath); + expect(target.path).toBe("[absolute-path-redacted]"); + } + + const relative = await renderActionManifest(actionMetadata, "plugins\\\\relative-target"); + expect((relative.target as { path: string }).path).toBe("plugins/relative-target"); + }); + + it("redacts file URI target paths from the Action manifest", async () => { + const actionMetadata = await readFile("action.yml", "utf8"); + + for (const [targetPath, sentinel] of [ + ["file:///uri-sentinel", "uri-sentinel"], + ["file://server-sentinel/share", "server-sentinel"], + ["FiLe:///case-sentinel", "case-sentinel"] + ]) { + const manifest = await renderActionManifest(actionMetadata, targetPath); + + expect(JSON.stringify(manifest)).not.toContain(sentinel); + expect((manifest.target as { path: string }).path).toBe("[absolute-path-redacted]"); + } + }); + it("exposes a composite action that installs and runs codex-plugin-doctor", async () => { const actionMetadata = await readFile("action.yml", "utf8"); @@ -126,6 +183,62 @@ describe("GitHub Action metadata", () => { expect(actionMetadata).toContain("registryReport: report("); }); + it("supports opt-in offline submission preflight reports with strict readiness gating", async () => { + const actionMetadata = await readFile("action.yml", "utf8"); + + expect(actionMetadata).toMatch(/submission:[\s\S]*?default: "false"/); + expect(actionMetadata).toMatch(/require-submission-ready:[\s\S]*?default: "false"/); + expect(actionMetadata).toContain("submission-json-path:"); + expect(actionMetadata).toContain("submission-summary-path:"); + expect(actionMetadata).toContain('SUBMISSION_INPUT: ${{ inputs.submission }}'); + expect(actionMetadata).toContain('REQUIRE_SUBMISSION_READY_INPUT: ${{ inputs[\'require-submission-ready\'] }}'); + expect(actionMetadata).toContain('submission_json_path="$report_dir/codex-plugin-doctor-submission.json"'); + expect(actionMetadata).toContain('submission_summary_path="$report_dir/codex-plugin-doctor-submission.md"'); + expect(actionMetadata).toContain('if [[ "$REQUIRE_SUBMISSION_READY_INPUT" == "true" && "$SUBMISSION_INPUT" != "true" ]]; then'); + expect(actionMetadata).toContain('echo "require-submission-ready requires submission." >&2'); + expect(actionMetadata).toContain("record_status 2"); + expect(actionMetadata).toContain('submission_args=(doctor submission "${{ inputs.path }}" --json --output "$submission_json_path")'); + expect(actionMetadata).toContain("submission_args+=(--require-ready)"); + expect(actionMetadata).toContain('run_doctor "submission preflight" "${submission_args[@]}"'); + expect(actionMetadata).toContain('run_doctor "submission summary" doctor submission "${{ inputs.path }}" --markdown --output "$submission_summary_path"'); + expect(actionMetadata).toContain('submissionJson: report("submissionJson", "CODEX_PLUGIN_DOCTOR_ACTION_SUBMISSION", "CODEX_PLUGIN_DOCTOR_ACTION_SUBMISSION_JSON_PATH")'); + expect(actionMetadata).toContain('submissionSummary: report("submissionSummary", "CODEX_PLUGIN_DOCTOR_ACTION_SUBMISSION", "CODEX_PLUGIN_DOCTOR_ACTION_SUBMISSION_SUMMARY_PATH")'); + expect(actionMetadata).toContain('echo "submission-json-path=$submission_json_output"'); + expect(actionMetadata).toContain('echo "submission-summary-path=$submission_summary_output"'); + expect(actionMetadata).toContain('cat "$submission_summary_path" >> "$GITHUB_STEP_SUMMARY"'); + expect(actionMetadata).toContain('run_doctor "check" "${args[@]}" "${history_args[@]}" --no-animations'); + expect(actionMetadata).not.toContain("SUBMISSION_RUNTIME_INPUT"); + expect(actionMetadata).not.toContain("SUBMISSION_ALLOW_NETWORK_INPUT"); + expect(actionMetadata).not.toContain("submission_args+=(--runtime"); + expect(actionMetadata).not.toContain("submission_args+=(--allow-network"); + }); + + it("rejects installed-cache submission preflight requests without producing submission reports", async () => { + const actionMetadata = await readFile("action.yml", "utf8"); + + expect(actionMetadata).toContain('elif [[ "$SUBMISSION_INPUT" == "true" && "${{ inputs.installed }}" == "true" ]]; then'); + expect(actionMetadata).toContain('echo "Submission preflight requires a single package path, not installed-cache mode." >&2'); + expect(actionMetadata).toContain('record_status 2'); + expect(actionMetadata).toContain('elif [[ "$SUBMISSION_INPUT" == "true" ]]; then\n submission_ran=true\n submission_args=(doctor submission "${{ inputs.path }}" --json --output "$submission_json_path")'); + expect(actionMetadata).toContain('submission_json_output=""'); + expect(actionMetadata).toContain('submission_summary_output=""'); + expect(actionMetadata).toContain('submission_json_output="$submission_json_path"'); + expect(actionMetadata).toContain('submission_summary_output="$submission_summary_path"'); + }); + + it("leaves optional submission paths empty unless submission reports actually ran", async () => { + const actionMetadata = await readFile("action.yml", "utf8"); + + expect(actionMetadata).toContain('submission_ran=false'); + expect(actionMetadata).toContain('export CODEX_PLUGIN_DOCTOR_ACTION_SUBMISSION="$submission_ran"'); + expect(actionMetadata).toContain('export CODEX_PLUGIN_DOCTOR_ACTION_SUBMISSION_JSON_PATH="$submission_json_output"'); + expect(actionMetadata).toContain('export CODEX_PLUGIN_DOCTOR_ACTION_SUBMISSION_SUMMARY_PATH="$submission_summary_output"'); + expect(actionMetadata).toContain('echo "submission-json-path=$submission_json_output"'); + expect(actionMetadata).toContain('echo "submission-summary-path=$submission_summary_output"'); + expect(actionMetadata).toContain('submission_ran="$(cat "$submission_state_file")"'); + expect(actionMetadata).toContain('if [[ -n "${GITHUB_STEP_SUMMARY:-}" && "$submission_ran" == "true" && -f "$submission_summary_path" ]]; then'); + }); + it("documents loopback-only consent without permitting private or reserved ranges", async () => { const actionMetadata = await readFile("action.yml", "utf8"); const actionUsage = await readFile("docs/guides/github-action.md", "utf8"); diff --git a/tests/completion.test.ts b/tests/completion.test.ts index abd8815..bafeaa0 100644 --- a/tests/completion.test.ts +++ b/tests/completion.test.ts @@ -18,7 +18,7 @@ function createIo() { describe("shell completion", () => { describe("generateCompletion", () => { - it("generates bash completion script", () => { + it("scopes bash submission flags to doctor submission", () => { const output = generateCompletion("bash"); expect(output).toContain("_codex_plugin_doctor"); @@ -26,22 +26,34 @@ describe("shell completion", () => { expect(output).toContain("check"); expect(output).toContain("audit"); expect(output).toContain("init-git-hooks"); + expect(output).toContain("submission"); + expect(output).toContain('local submission_flags="--json --markdown --output --require-ready"'); + expect(output).toContain('local flags="--json --output --runtime --policy --help"'); + expect(output).toContain('${COMP_WORDS[1]} == "doctor"'); }); - it("generates zsh completion script", () => { + it("scopes zsh submission flags to doctor submission", () => { const output = generateCompletion("zsh"); expect(output).toContain("#compdef codex-plugin-doctor"); expect(output).toContain("_arguments"); expect(output).toContain("check"); + expect(output).toContain("submission"); + expect(output).toContain('[[ "$words[2]" == "doctor" && "$words[3]" == "submission" ]]'); + expect(output).toContain("'*--require-ready[Fail when automatic checks are blocked]'"); + expect(output).toContain("'*--runtime[Enable runtime probes]'"); }); - it("generates fish completion script", () => { + it("scopes fish submission flags to doctor submission", () => { const output = generateCompletion("fish"); expect(output).toContain("complete -c codex-plugin-doctor"); expect(output).toContain("__fish_seen_subcommand_from"); expect(output).toContain("codex-publish"); + expect(output).toContain("submission"); + expect(output).toContain('__fish_seen_subcommand_from doctor; and __fish_seen_subcommand_from submission'); + expect(output).toContain('-l require-ready'); + expect(output).not.toContain('complete -c codex-plugin-doctor -l require-ready'); }); }); diff --git a/tests/contract-command.test.ts b/tests/contract-command.test.ts index 0d3fcac..72ed675 100644 --- a/tests/contract-command.test.ts +++ b/tests/contract-command.test.ts @@ -74,6 +74,11 @@ describe("doctor contract command", () => { id: "doctor.check.json", command: "codex-plugin-doctor check --json" }), + expect.objectContaining({ + id: "doctor.submission.json", + command: "codex-plugin-doctor doctor submission --json", + outputKind: null + }), expect.objectContaining({ id: "doctor.installed.check.json", command: "codex-plugin-doctor check --installed --json" @@ -226,6 +231,9 @@ describe("doctor contract command", () => { const checkSchema = output.schemas.find( (surface: { id: string }) => surface.id === "doctor.check.json" ); + const submissionSchema = output.schemas.find( + (surface: { id: string }) => surface.id === "doctor.submission.json" + ); const mcpSchema = output.schemas.find( (surface: { id: string }) => surface.id === "doctor.mcp.json" ); @@ -248,6 +256,26 @@ describe("doctor contract command", () => { "summary", "findings" ]); + expect(submissionSchema.schema.required).toEqual([ + "schemaVersion", + "rulesetVersion", + "targetType", + "status", + "readiness", + "summary", + "checks", + "findings", + "manualChecklist" + ]); + expect(submissionSchema.schema.properties).toMatchObject({ + targetType: { enum: ["skills-only", "mcp-backed"] }, + status: { enum: ["pass", "fail"] }, + readiness: { enum: ["blocked", "manual_review_required"] }, + summary: { type: "object" }, + checks: { type: "array" }, + findings: { type: "array" }, + manualChecklist: { type: "array" } + }); expect(mcpSchema.schema.required).toEqual([ "schemaVersion", "kind", diff --git a/tests/public-readiness.test.ts b/tests/public-readiness.test.ts index 6c8b13d..d851027 100644 --- a/tests/public-readiness.test.ts +++ b/tests/public-readiness.test.ts @@ -203,4 +203,44 @@ describe("public repository readiness", () => { expect(releaseGating).toContain("--require-registry-readiness"); expect(security).toContain("Registry publication proves namespace control"); }); + + it("documents offline public directory submission preflight without implying portal approval", async () => { + const readme = await readText("README.md"); + const docsReadme = await readText("docs/README.md"); + const architecture = await readText("docs/architecture/public-directory-submission-preflight.md"); + const actionGuide = await readText("docs/guides/github-action.md"); + const catalog = await readText("docs/rules/catalog.md"); + + expect(readme).toContain("codex-plugin-doctor doctor submission "); + expect(readme).toContain("--json"); + expect(readme).toContain("--markdown"); + expect(readme).toContain("--require-ready"); + expect(readme).toContain("manual_review_required"); + expect(docsReadme).toContain("Public Directory Submission Preflight"); + expect(architecture).toContain("no network requests"); + expect(architecture).toContain("does not submit a package"); + expect(architecture).toContain("manual_review_required"); + expect(actionGuide).toContain("Esquetta/CodexPluginDoctor@v1.59.0"); + expect(actionGuide).toContain('submission: "true"'); + expect(actionGuide).toContain('require-submission-ready: "true"'); + expect(actionGuide).toContain("submission-json-path"); + expect(actionGuide).toContain("submission-summary-path"); + expect(actionGuide).toContain("does not require runtime or network access"); + expect(actionGuide).toContain("requires `submission: \"true\"`"); + expect(actionGuide).not.toMatch(/internal (implementation )?plan/i); + expect(actionGuide).not.toMatch(/portal acceptance|automatic manual pass/i); + + for (const [id, severity] of [ + ["plugin.submission.package.invalid", "fail"], + ["plugin.submission.interface.unknown_field", "warn"], + ["plugin.submission.asset.extension_mismatch", "fail"], + ["plugin.submission.package.too_large", "fail"], + ["plugin.submission.skill.agent.invalid_yaml", "fail"], + ["plugin.submission.skill.required", "fail"], + ["plugin.submission.skill.too_many", "fail"], + ["plugin.submission.skill.budget_exceeded", "fail"] + ]) { + expect(catalog).toContain(`| \`${id}\` | ${severity} |`); + } + }); }); diff --git a/tests/release-check.test.ts b/tests/release-check.test.ts index 5a37bcf..d22e5b8 100644 --- a/tests/release-check.test.ts +++ b/tests/release-check.test.ts @@ -12,7 +12,7 @@ import { } from "../scripts/release-check.mjs"; describe("release check registry version gate", () => { - it("keeps package and lockfile roots on the 1.58.0 release version", async () => { + it("keeps package and lockfile roots on the 1.59.0 release version", async () => { const packageJson = JSON.parse(await readFile("package.json", "utf8")) as { version: string; }; @@ -21,7 +21,7 @@ describe("release check registry version gate", () => { packages: { "": { version: string } }; }; - expect(packageJson.version).toBe("1.58.0"); + expect(packageJson.version).toBe("1.59.0"); expect(packageLock.version).toBe(packageJson.version); expect(packageLock.packages[""].version).toBe(packageJson.version); }); diff --git a/tests/release-notes.test.ts b/tests/release-notes.test.ts index 27fd0ec..790e044 100644 --- a/tests/release-notes.test.ts +++ b/tests/release-notes.test.ts @@ -10,16 +10,32 @@ import { describe("extractReleaseSection", () => { it("records the latest release and restores the two shipped release sections", async () => { const changelog = await readFile("CHANGELOG.md", "utf8"); - const latestRelease = changelog.indexOf("## [1.58.0] - 2026-08-11"); - const previousRelease = changelog.indexOf("## [1.57.0] - 2026-08-08"); - const olderRelease = changelog.indexOf("## [1.56.0] - 2026-08-02"); + const latestRelease = changelog.indexOf("## [1.59.0] - 2026-08-17"); + const previousRelease = changelog.indexOf("## [1.58.0] - 2026-08-11"); + const olderRelease = changelog.indexOf("## [1.57.0] - 2026-08-08"); expect(latestRelease).toBeGreaterThanOrEqual(0); expect(previousRelease).toBeGreaterThan(latestRelease); expect(olderRelease).toBeGreaterThan(previousRelease); + expect(extractReleaseSection(changelog, "1.59.0")).toContain( + "offline `doctor submission `" + ); expect(extractReleaseSection(changelog, "1.58.0")).toContain("current official MCP layouts"); expect(extractReleaseSection(changelog, "1.57.0")).toContain("npm pack dry-run"); - expect(extractReleaseSection(changelog, "1.56.0")).toContain("doctor size "); + }); + + it("keeps current README and Action examples pinned to the latest release", async () => { + const [readme, actionGuide] = await Promise.all([ + readFile("README.md", "utf8"), + readFile("docs/guides/github-action.md", "utf8") + ]); + + expect(readme).toContain("Esquetta/CodexPluginDoctor@v1.59.0"); + expect(readme).toContain('version: "1.59.0"'); + expect(actionGuide).toContain("Esquetta/CodexPluginDoctor@v1.59.0"); + expect(actionGuide).toContain('version: "1.59.0"'); + expect(actionGuide).not.toContain("Esquetta/CodexPluginDoctor@v1.58.0"); + expect(actionGuide).not.toContain('version: "1.58.0"'); }); it("extracts the matching version section from the changelog", () => { diff --git a/tests/release-sync.test.ts b/tests/release-sync.test.ts index 809bd8b..c4bd658 100644 --- a/tests/release-sync.test.ts +++ b/tests/release-sync.test.ts @@ -5,12 +5,12 @@ import { describe, expect, it } from "vitest"; import { evaluateReleaseSync } from "../src/release/release-sync.js"; describe("evaluateReleaseSync", () => { - it("uses the 1.58.0 stable release target", async () => { + it("uses the 1.59.0 stable release target", async () => { const packageJson = JSON.parse(await readFile("package.json", "utf8")) as { version: string; }; - expect(packageJson.version).toBe("1.58.0"); + expect(packageJson.version).toBe("1.59.0"); }); it("passes when npm, remote tag, GitHub release, and latest release match", () => { diff --git a/tests/submission-assets.test.ts b/tests/submission-assets.test.ts new file mode 100644 index 0000000..11ca2b8 --- /dev/null +++ b/tests/submission-assets.test.ts @@ -0,0 +1,357 @@ +import { mkdir, mkdtemp, readFile, symlink, writeFile } from "node:fs/promises"; +import { deflateSync } from "node:zlib"; +import os from "node:os"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; + +import type { DiscoveredPackage } from "../src/domain/types.js"; +import { type SubmissionAssetResult, validateSubmissionAssets } from "../src/core/submission-assets.js"; + +type AssetFiles = Record; + +const crc32 = (data: Uint8Array) => { + let crc = 0xffffffff; + for (const byte of data) { + crc ^= byte; + for (let bit = 0; bit < 8; bit += 1) crc = (crc >>> 1) ^ (0xedb88320 & -(crc & 1)); + } + return (crc ^ 0xffffffff) >>> 0; +}; + +const pngChunk = (type: string, content: Uint8Array) => { + const chunk = new Uint8Array(content.length + 12); + const view = new DataView(chunk.buffer); + view.setUint32(0, content.length); + chunk.set(type.split("").map((character) => character.charCodeAt(0)), 4); + chunk.set(content, 8); + view.setUint32(content.length + 8, crc32(chunk.slice(4, content.length + 8))); + return chunk; +}; + +const png = (width: number, height: number) => { + const header = new Uint8Array(13); + const view = new DataView(header.buffer); + view.setUint32(0, width); view.setUint32(4, height); + header.set([1, 0, 0, 0, 0], 8); + const scanlines = new Uint8Array(width > 4096 || height > 4096 ? 1 : (1 + Math.ceil(width / 8)) * height); + const idat = new Uint8Array(deflateSync(scanlines)); + const parts = [new Uint8Array([137, 80, 78, 71, 13, 10, 26, 10]), pngChunk("IHDR", header), pngChunk("IDAT", idat), pngChunk("IEND", new Uint8Array())]; + const image = new Uint8Array(parts.reduce((size, part) => size + part.length, 0)); + let offset = 0; + for (const part of parts) { image.set(part, offset); offset += part.length; } + return image; +}; + +const pngWithIdat = (idat: Uint8Array) => { + const header = new Uint8Array(13); + const view = new DataView(header.buffer); + view.setUint32(0, 48); view.setUint32(4, 48); + header.set([1, 0, 0, 0, 0], 8); + const parts = [new Uint8Array([137, 80, 78, 71, 13, 10, 26, 10]), pngChunk("IHDR", header), pngChunk("IDAT", idat), pngChunk("IEND", new Uint8Array())]; + const image = new Uint8Array(parts.reduce((size, part) => size + part.length, 0)); + let offset = 0; + for (const part of parts) { image.set(part, offset); offset += part.length; } + return image; +}; + +const pngWithHeader = (width: number, height: number, bitDepth: number, colorType: number, idat: Uint8Array) => { + const header = new Uint8Array(13); + const view = new DataView(header.buffer); + view.setUint32(0, width); view.setUint32(4, height); + header.set([bitDepth, colorType, 0, 0, 0], 8); + const parts = [new Uint8Array([137, 80, 78, 71, 13, 10, 26, 10]), pngChunk("IHDR", header), pngChunk("IDAT", idat), pngChunk("IEND", new Uint8Array())]; + const image = new Uint8Array(parts.reduce((size, part) => size + part.length, 0)); + let offset = 0; + for (const part of parts) { image.set(part, offset); offset += part.length; } + return image; +}; + +const jpegSof = (width: number, height: number, marker = 0xc0) => { + const data = new Uint8Array(21); + data.set([0xff, 0xd8, 0xff, marker, 0, 17, 8]); + const view = new DataView(data.buffer); + view.setUint16(7, height); + view.setUint16(9, width); + data.set([3, 1, 17, 0, 2, 17, 0, 3, 17, 0], 11); + return data; +}; + +const jpegWithSos = (width: number, height: number, includeEoi: boolean) => { + const sof = jpegSof(width, height); + const data = new Uint8Array(sof.length + 15 + (includeEoi ? 2 : 0)); + data.set(sof); + data.set([0xff, 0xda, 0, 12, 3, 1, 0, 2, 0, 3, 0, 0, 63, 0, 0], sof.length); + if (includeEoi) data.set([0xff, 0xd9], data.length - 2); + return data; +}; + +const jpeg = (width: number, height: number) => jpegWithSos(width, height, true); + +const jpegSos = (component: number, spectralStart = 0, spectralEnd = 63) => new Uint8Array([0xff, 0xda, 0, 8, 1, component, 0, spectralStart, spectralEnd, 0]); + +const jpegDht = () => new Uint8Array([0xff, 0xc4, 0, 20, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]); + +const concat = (...parts: Uint8Array[]) => { + const result = new Uint8Array(parts.reduce((length, part) => length + part.length, 0)); + let offset = 0; + for (const part of parts) { result.set(part, offset); offset += part.length; } + return result; +}; + +const progressiveMultiScanJpeg = (width: number, height: number) => concat( + jpegSof(width, height, 0xc2), + jpegSos(1, 0, 0), new Uint8Array([0x11, 0xff, 0x00, 0xff, 0xd0, 0x22]), + jpegDht(), + jpegSos(1, 1, 63), new Uint8Array([0x33, 0xff, 0xd9]) +); + +const webp = (variant: "VP8X" | "VP8L" | "VP8", width: number, height: number) => { + const payload = variant === "VP8X" ? new Uint8Array(10) : variant === "VP8L" ? new Uint8Array(5) : new Uint8Array(10); + const view = new DataView(payload.buffer); + if (variant === "VP8X") { + payload.set([0, 0, 0, 0]); + payload[4] = (width - 1) & 0xff; payload[5] = ((width - 1) >>> 8) & 0xff; payload[6] = ((width - 1) >>> 16) & 0xff; + payload[7] = (height - 1) & 0xff; payload[8] = ((height - 1) >>> 8) & 0xff; payload[9] = ((height - 1) >>> 16) & 0xff; + } else if (variant === "VP8L") { + payload[0] = 0x2f; + const packed = (width - 1) | ((height - 1) << 14); + view.setUint32(1, packed, true); + } else { + payload.set([0, 0, 0, 0x9d, 0x01, 0x2a]); + view.setUint16(6, width, true); view.setUint16(8, height, true); + } + const data = new Uint8Array(20 + payload.length + (payload.length % 2)); + data.set([82, 73, 70, 70]); + new DataView(data.buffer).setUint32(4, data.length - 8, true); + data.set([87, 69, 66, 80, ...variant.padEnd(4, " ").split("").map((character) => character.charCodeAt(0))], 8); + new DataView(data.buffer).setUint32(16, payload.length, true); + data.set(payload, 20); + return data; +}; + +const webpChunk = (type: string, payload: Uint8Array) => { + const chunk = new Uint8Array(8 + payload.length + (payload.length % 2)); + chunk.set(type.split("").map((character) => character.charCodeAt(0)), 0); + new DataView(chunk.buffer).setUint32(4, payload.length, true); + chunk.set(payload, 8); + return chunk; +}; + +const webpWithTrailingExif = () => { + const base = webp("VP8L", 48, 48); + const chunks = concat(base.slice(12), webpChunk("EXIF", new Uint8Array([1]))); + const data = new Uint8Array(12 + chunks.length); + data.set([82, 73, 70, 70]); + new DataView(data.buffer).setUint32(4, data.length - 8, true); + data.set([87, 69, 66, 80], 8); + data.set(chunks, 12); + return data; +}; + +const svg = (attributes: string) => ``; + +async function packageWithAssets(interfaceValues: Record, files: AssetFiles = {}): Promise { + const rootPath = await mkdtemp(path.join(os.tmpdir(), "codex-plugin-doctor-submission-assets-")); + await mkdir(path.join(rootPath, ".codex-plugin")); + for (const [relativePath, content] of Object.entries(files)) { + const filePath = path.join(rootPath, relativePath); + await mkdir(path.dirname(filePath), { recursive: true }); + await writeFile(filePath, content); + } + return { rootPath, manifestPath: path.join(rootPath, ".codex-plugin", "plugin.json"), manifest: { interface: interfaceValues } } as DiscoveredPackage; +} + +async function findingIds(interfaceValues: Record, files: AssetFiles = {}): Promise { + const result: SubmissionAssetResult = await validateSubmissionAssets(await packageWithAssets(interfaceValues, files)); + return result.findings.map((finding) => finding.id); +} + +describe("submission assets", () => { + it.each([ + ["PNG", "./logo.png", png(48, 48)], + ["JPEG", "./logo.jpg", jpeg(48, 48)], + ["WebP VP8X", "./logo.webp", webp("VP8X", 48, 48)], + ["WebP VP8L", "./logo.webp", webp("VP8L", 48, 48)], + ["WebP VP8", "./logo.webp", webp("VP8", 48, 48)], + ["largest PNG", "./logo.png", png(4096, 4096)], + ["SVG viewBox", "./logo.svg", svg('viewBox="0 0 48 48"')], + ["SVG comma viewBox", "./logo.svg", svg('viewBox="0,0,48,48"')], + ["SVG dimensions", "./logo.svg", svg('width="48" height="48"')] + ])("accepts a valid %s asset", async (_name, assetPath, content) => { + expect(await findingIds({ logo: assetPath, composerIcon: assetPath }, { [assetPath.slice(2)]: content })).toEqual([]); + }); + + it("requires both branding assets", async () => { + expect(await findingIds({}, {})).toEqual(["plugin.submission.asset.required", "plugin.submission.asset.required"]); + }); + + it("accepts a structurally complete progressive multi-scan JPEG", async () => { + expect(await findingIds({ logo: "./logo.jpg", composerIcon: "./logo.jpg" }, { "logo.jpg": progressiveMultiScanJpeg(48, 48) })) + .toEqual([]); + }); + + it("accepts a WebP image chunk followed by padded EXIF metadata", async () => { + expect(await findingIds({ logo: "./logo.webp", composerIcon: "./logo.webp" }, { "logo.webp": webpWithTrailingExif() })) + .toEqual([]); + }); + + it("rejects a PNG whose declared IHDR is truncated", async () => { + const truncated = new Uint8Array(24); + truncated.set([137, 80, 78, 71, 13, 10, 26, 10, 0, 0, 0, 13, 73, 72, 68, 82]); + new DataView(truncated.buffer).setUint32(16, 48); + new DataView(truncated.buffer).setUint32(20, 48); + expect(await findingIds({ logo: "./logo.png", composerIcon: "./logo.png" }, { "logo.png": truncated })) + .toEqual(["plugin.submission.asset.decode_failed", "plugin.submission.asset.decode_failed"]); + }); + + it("rejects a CRC-valid PNG with invalid IDAT zlib data", async () => { + expect(await findingIds({ logo: "./logo.png", composerIcon: "./logo.png" }, { "logo.png": pngWithIdat(new Uint8Array([1, 2, 3])) })) + .toEqual(["plugin.submission.asset.decode_failed", "plugin.submission.asset.decode_failed"]); + }); + + it("rejects a PNG whose declared decoded raster exceeds 72 MiB before inflation", async () => { + const compressedByte = new Uint8Array(deflateSync(new Uint8Array([0]))); + expect(await findingIds({ logo: "./logo.png", composerIcon: "./logo.png" }, { "logo.png": pngWithHeader(4096, 4096, 16, 6, compressedByte) })) + .toEqual(["plugin.submission.asset.decode_failed", "plugin.submission.asset.decode_failed"]); + }); + + it("rejects an unpadded odd-length VP8L payload", async () => { + expect(await findingIds({ logo: "./logo.webp", composerIcon: "./logo.webp" }, { "logo.webp": webp("VP8L", 48, 48).slice(0, -1) })) + .toEqual(["plugin.submission.asset.decode_failed", "plugin.submission.asset.decode_failed"]); + }); + + it("rejects a JPEG scan without terminal EOI", async () => { + expect(await findingIds({ logo: "./logo.jpg", composerIcon: "./logo.jpg" }, { "logo.jpg": jpegWithSos(48, 48, false) })) + .toEqual(["plugin.submission.asset.decode_failed", "plugin.submission.asset.decode_failed"]); + }); + + it("rejects a JPEG with an invalid SOS header", async () => { + const malformed = jpegWithSos(48, 48, true); + malformed[25] = 0; + expect(await findingIds({ logo: "./logo.jpg", composerIcon: "./logo.jpg" }, { "logo.jpg": malformed })) + .toEqual(["plugin.submission.asset.decode_failed", "plugin.submission.asset.decode_failed"]); + }); + + it.each([undefined, "logo.png", "../logo.png", "./../logo.png", "/logo.png", 42])("rejects invalid asset paths", async (value) => { + const ids = await findingIds({ logo: value, composerIcon: "./icon.png" }, { "icon.png": png(48, 48) }); + expect(ids).toContain(value === undefined ? "plugin.submission.asset.required" : "plugin.submission.asset.invalid_path"); + }); + + it.each([ + ["missing", "./missing.png", {}], + ["directory", "./assets", { assets: "" }], + ["empty", "./logo.png", { "logo.png": new Uint8Array() }], + ["unsupported extension", "./logo.gif", { "logo.gif": png(48, 48) }], + ["unsupported content", "./logo.png", { "logo.png": new Uint8Array([1, 2, 3]) }], + ["extension mismatch", "./logo.jpg", { "logo.jpg": png(48, 48) }], + ["malformed PNG IHDR", "./logo.png", { "logo.png": (() => { const image = png(48, 48); image[11] = 12; return image; })() }], + ["truncated png", "./logo.png", { "logo.png": png(48, 48).slice(0, 20) }], + ["overflow dimensions", "./logo.png", { "logo.png": png(0xffffffff, 48) }], + ["rectangle", "./logo.png", { "logo.png": png(48, 49) }], + ["one pixel under", "./logo.png", { "logo.png": png(47, 47) }], + ["one pixel over", "./logo.png", { "logo.png": png(4097, 4097) }] + ])("rejects %s", async (_name, assetPath, files) => { + const ids = await findingIds({ logo: assetPath, composerIcon: assetPath }, files as AssetFiles); + expect(ids.some((id) => id.startsWith("plugin.submission.asset."))).toBe(true); + }); + + it("rejects a directory, truncated JPEG and malformed WebP", async () => { + const directoryPackage = await packageWithAssets({ logo: "./assets", composerIcon: "./icon.png" }, { "icon.png": png(48, 48) }); + await mkdir(path.join(directoryPackage.rootPath, "assets")); + expect((await validateSubmissionAssets(directoryPackage)).findings.map((finding) => finding.id)) + .toContain("plugin.submission.asset.unsupported_format"); + expect(await findingIds({ logo: "./logo.jpg", composerIcon: "./logo.jpg" }, { "logo.jpg": new Uint8Array([0xff, 0xd8, 0xff, 0xc0, 0, 17]) })) + .toContain("plugin.submission.asset.decode_failed"); + const malformed = webp("VP8X", 48, 48); + new DataView(malformed.buffer).setUint32(4, 0, true); + expect(await findingIds({ logo: "./logo.webp", composerIcon: "./logo.webp" }, { "logo.webp": malformed })) + .toContain("plugin.submission.asset.decode_failed"); + }); + + it("rejects an oversized asset before decoding it", async () => { + const ids = await findingIds({ logo: "./logo.png", composerIcon: "./logo.png" }, { "logo.png": new Uint8Array(5 * 1024 * 1024 + 1) }); + expect(ids).toEqual(["plugin.submission.asset.too_large", "plugin.submission.asset.too_large"]); + }); + + it.each([ + ["invalid XML", ""], + ["missing dimensions", ""], + ["units", svg('width="48px" height="48px"')], + ["percent", svg('width="100%" height="100%"')], + ["nonpositive", svg('width="0" height="48"'), "plugin.submission.asset.dimensions"], + ["rectangle", svg('width="48" height="49"'), "plugin.submission.asset.not_square"], + ["doctype", ''], + ["entity", ''], + ["external href", ''], + ["relative external href", ''], + ["external xlink", '', "plugin.submission.asset.unsafe_svg"] + ].map(([name, content, expected = "plugin.submission.asset.unsafe_svg"]) => [name, content, expected] as const))("rejects unsafe SVG %s", async (_name, content, expected) => { + const ids = await findingIds({ logo: "./logo.svg", composerIcon: "./logo.svg" }, { "logo.svg": content }); + expect(ids).toContain(expected); + }); + + it.each([ + ["CSS import", ''], + ["CSS url", ''], + ["inline CSS url", ''], + ["non-fragment CSS url", ''], + ["case and whitespace import", ''] + ])("rejects SVG %s remote CSS", async (_name, content) => { + expect(await findingIds({ logo: "./logo.svg", composerIcon: "./logo.svg" }, { "logo.svg": content })) + .toEqual(["plugin.submission.asset.unsafe_svg", "plugin.submission.asset.unsafe_svg"]); + }); + + it("rejects XML-character-reference and CSS-escaped remote imports", async () => { + const content = ''; + expect(await findingIds({ logo: "./logo.svg", composerIcon: "./logo.svg" }, { "logo.svg": content })) + .toEqual(["plugin.submission.asset.unsafe_svg", "plugin.submission.asset.unsafe_svg"]); + }); + + it("consumes CRLF after CSS hexadecimal escapes", async () => { + const content = ''; + expect(await findingIds({ logo: "./logo.svg", composerIcon: "./logo.svg" }, { "logo.svg": content })) + .toEqual(["plugin.submission.asset.unsafe_svg", "plugin.submission.asset.unsafe_svg"]); + }); + + it("rejects XML-character-reference href values after parsing", async () => { + const content = ''; + expect(await findingIds({ logo: "./logo.svg", composerIcon: "./logo.svg" }, { "logo.svg": content })) + .toEqual(["plugin.submission.asset.unsafe_svg", "plugin.submission.asset.unsafe_svg"]); + }); + + it("allows fragment-only SVG CSS URLs", async () => { + expect(await findingIds({ logo: "./logo.svg", composerIcon: "./logo.svg" }, { "logo.svg": '' })) + .toEqual([]); + }); + + it("allows inline SVG CSS without a remote reference", async () => { + expect(await findingIds({ logo: "./logo.svg", composerIcon: "./logo.svg" }, { "logo.svg": '' })) + .toEqual([]); + }); + + it("rejects invalid UTF-8 SVG", async () => { + const ids = await findingIds({ logo: "./logo.svg", composerIcon: "./logo.svg" }, { "logo.svg": new Uint8Array([0xc3, 0x28]) }); + expect(ids).toContain("plugin.submission.asset.unsafe_svg"); + }); + + it("rejects a junction that canonically escapes the package", async () => { + const assetPackage = await packageWithAssets({ logo: "./outside/logo.png", composerIcon: "./icon.png" }, { "icon.png": png(48, 48) }); + const external = await mkdtemp(path.join(os.tmpdir(), "codex-plugin-doctor-submission-assets-escape-")); + await writeFile(path.join(external, "logo.png"), png(48, 48)); + await symlink(external, path.join(assetPackage.rootPath, "outside"), "junction"); + const findings = await validateSubmissionAssets(assetPackage); + expect(findings.findings.map((finding) => finding.id)).toContain("plugin.submission.asset.invalid_path"); + expect(JSON.stringify(findings)).not.toContain(external); + }); + + it("keeps findings package-relative and its validator offline", async () => { + const assetPackage = await packageWithAssets({ logo: "./secret.png", composerIcon: "./secret.png" }, { "secret.png": new Uint8Array([1]) }); + const result = await validateSubmissionAssets(assetPackage); + expect(JSON.stringify(result)).not.toContain(assetPackage.rootPath); + expect(JSON.stringify(result)).not.toContain("secret.png\u0000"); + expect(JSON.stringify(result)).toContain('"path":"secret.png"'); + const source = await readFile(new URL("../src/core/submission-assets.ts", import.meta.url), "utf8"); + expect(source).not.toMatch(/\b(fetch|exec|spawn|http|https)\b/u); + }); +}); diff --git a/tests/submission-command.test.ts b/tests/submission-command.test.ts new file mode 100644 index 0000000..9cea31a --- /dev/null +++ b/tests/submission-command.test.ts @@ -0,0 +1,187 @@ +import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; + +import * as doctor from "../src/index.js"; +import { runCli } from "../src/run-cli.js"; + +function createIo() { + const stdout: string[] = []; + const stderr: string[] = []; + + return { + stdout, + stderr, + io: { + writeStdout(message: string) { stdout.push(message); }, + writeStderr(message: string) { stderr.push(message); } + } + }; +} + +const validManifest = { + name: "submission-command", + version: "1.0.0", + skills: "./skills", + interface: { + displayName: "Submission check", + shortDescription: "Check directory data", + longDescription: "submission-description-sentinel", + developerName: "Example Developer", + category: "Developer Tools", + logo: "./assets/logo.svg", + composerIcon: "./assets/composer-icon.svg", + defaultPrompt: "submission-prompt-sentinel" + } +}; + +const validSvg = ''; + +async function writeSubmissionPackage(blocked = false): Promise { + const target = await mkdtemp(path.join(os.tmpdir(), "codex-plugin-doctor-submission-command-")); + const manifest = blocked + ? { + ...validManifest, + interface: { + ...validManifest.interface, + websiteURL: "https://user:url-credential-sentinel@example.com" + }, + mcpServers: "./.mcp.json", + apps: "./.app.json" + } + : validManifest; + + await mkdir(path.join(target, ".codex-plugin"), { recursive: true }); + await mkdir(path.join(target, "assets"), { recursive: true }); + await mkdir(path.join(target, "skills", "check"), { recursive: true }); + await writeFile(path.join(target, ".codex-plugin", "plugin.json"), JSON.stringify(manifest), "utf8"); + await writeFile(path.join(target, "assets", "logo.svg"), validSvg, "utf8"); + await writeFile(path.join(target, "assets", "composer-icon.svg"), validSvg, "utf8"); + if (blocked) { + await writeFile(path.join(target, ".app.json"), '{"name":"app-json-sentinel"}', "utf8"); + } + await writeFile( + path.join(target, "skills", "check", "SKILL.md"), + "---\nname: check\ndescription: skill-description-sentinel\n---\n\nSkill body tool-value-sentinel\n", + "utf8" + ); + + return target; +} + +function expectRedacted(output: string, target: string): void { + expect(output).not.toContain(target); + expect(output).not.toContain("submission-description-sentinel"); + expect(output).not.toContain("submission-prompt-sentinel"); + expect(output).not.toContain("url-credential-sentinel"); + expect(output).not.toContain("app-json-sentinel"); + expect(output).not.toContain("skill-description-sentinel"); + expect(output).not.toContain("tool-value-sentinel"); +} + +describe("doctor submission command", () => { + it("renders a valid package as text without crossing the manual review boundary", async () => { + const target = await writeSubmissionPackage(); + const { io, stdout, stderr } = createIo(); + + const exitCode = await runCli(["doctor", "submission", target], io); + const output = stdout.join(""); + + expect(exitCode).toBe(0); + expect(stderr).toEqual([]); + expect(output).toContain("Ruleset: openai-directory-2026-08-15"); + expect(output).toContain("Target: skills-only"); + expect(output).toContain("Automatic status: PASS"); + expect(output).toContain("Readiness: MANUAL REVIEW REQUIRED"); + expect(output).toContain("Manual checklist"); + expect(output.toLowerCase()).not.toMatch(/accepted|approved|ready for directory/); + expectRedacted(output, target); + }); + + it.each([ + ["--json", "\"schemaVersion\": \"1.0.0\""], + ["--markdown", "# Submission preflight"] + ])("renders %s output and writes the exact same bytes", async (flag, expected) => { + const target = await writeSubmissionPackage(true); + const outputPath = path.join(target, `submission${flag === "--json" ? ".json" : ".md"}`); + const { io, stdout, stderr } = createIo(); + + const exitCode = await runCli(["doctor", "submission", target, flag, "--output", outputPath], io); + const output = stdout.join(""); + + expect(exitCode).toBe(0); + expect(stderr).toEqual([]); + expect(output).toContain(expected); + expect(await readFile(outputPath, "utf8")).toBe(output); + expectRedacted(output, target); + }); + + it("returns a blocking exit only when --require-ready is selected", async () => { + const target = await writeSubmissionPackage(true); + const advisory = createIo(); + const required = createIo(); + + expect(await runCli(["doctor", "submission", target], advisory.io)).toBe(0); + expect(await runCli(["doctor", "submission", target, "--require-ready"], required.io)).toBe(1); + expect(advisory.stderr).toEqual([]); + expect(required.stderr).toEqual([]); + }); + + it("accepts dash-prefixed paths after -- and treats later flags as positionals", async () => { + const accepted = createIo(); + const extra = createIo(); + + expect(await runCli(["doctor", "submission", "--", "--literal-target"], accepted.io)).toBe(0); + expect(accepted.stderr).toEqual([]); + expect(await runCli(["doctor", "submission", "--", "--literal-target", "--require-ready"], extra.io)).toBe(2); + expect(extra.stderr.join("")).toContain("Unexpected submission argument: --require-ready."); + }); + + it("accepts --output= when the output filename begins with dashes", async () => { + const target = await writeSubmissionPackage(); + const outputPath = `--submission-output-${Date.now()}.json`; + const { io, stdout, stderr } = createIo(); + + try { + const exitCode = await runCli( + ["doctor", "submission", target, "--json", `--output=${outputPath}`], + io + ); + + expect(exitCode).toBe(0); + expect(stderr).toEqual([]); + expect(await readFile(outputPath, "utf8")).toBe(stdout.join("")); + } finally { + await rm(outputPath, { force: true }); + } + }); + + it.each([ + [[], "Missing target path"], + [["--json", "--markdown"], "Use either --json or --markdown"], + [["--json", "--json"], "Duplicate submission flag"], + [["--output"], "Missing path after --output"], + [["--wat"], "Unknown submission flag"], + [["--wat", "--", "--literal-target"], "Unknown submission flag"], + [["one", "two"], "Unexpected submission argument"] + ])("rejects invalid arguments %#", async (suffix, message) => { + const target = suffix.length === 0 ? [] : [await writeSubmissionPackage(), ...suffix]; + const { io, stdout, stderr } = createIo(); + + const exitCode = await runCli(["doctor", "submission", ...target], io); + + expect(exitCode).toBe(2); + expect(stdout).toEqual([]); + expect(stderr.join("")).toContain(message); + }); + + it("exports the submission report API", () => { + expect(doctor.submissionRuleset.version).toBe("openai-directory-2026-08-15"); + expect(doctor.buildSubmissionPreflight).toBeTypeOf("function"); + expect(doctor.renderSubmissionPreflightJson).toBeTypeOf("function"); + expect(doctor.renderSubmissionPreflightText).toBeTypeOf("function"); + expect(doctor.renderSubmissionPreflightMarkdown).toBeTypeOf("function"); + expect(doctor.submissionPreflightExitCode).toBeTypeOf("function"); + }); +}); diff --git a/tests/submission-preflight.test.ts b/tests/submission-preflight.test.ts new file mode 100644 index 0000000..62db558 --- /dev/null +++ b/tests/submission-preflight.test.ts @@ -0,0 +1,422 @@ +import { mkdir, mkdtemp, symlink, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { deflateSync } from "node:zlib"; +import { describe, expect, it, vi } from "vitest"; + +import { buildSubmissionPreflight } from "../src/core/submission-preflight.js"; +import { submissionRuleset } from "../src/core/submission-ruleset.js"; + +const validManifest = { + name: "submission-preflight", + version: "1.2.3", + skills: "./skills", + interface: { + displayName: "Submission helper", + shortDescription: "Check a submission", + longDescription: "A local submission checker.\nIt remains offline.", + developerName: "Example Developer", + category: "Developer Tools", + logo: "./assets/logo.png", + composerIcon: "./assets/composer-icon.png", + capabilities: ["Checks public listing metadata"], + defaultPrompt: "Check my plugin submission" + } +}; + +const crc32 = (data: Uint8Array) => { + let crc = 0xffffffff; + for (const byte of data) { + crc ^= byte; + for (let bit = 0; bit < 8; bit += 1) crc = (crc >>> 1) ^ (0xedb88320 & -(crc & 1)); + } + return (crc ^ 0xffffffff) >>> 0; +}; + +const pngChunk = (type: string, content: Uint8Array) => { + const chunk = new Uint8Array(content.length + 12); + const view = new DataView(chunk.buffer); + view.setUint32(0, content.length); + chunk.set(type.split("").map((character) => character.charCodeAt(0)), 4); + chunk.set(content, 8); + view.setUint32(content.length + 8, crc32(chunk.slice(4, content.length + 8))); + return chunk; +}; + +const validPng = (() => { + const header = new Uint8Array(13); + const view = new DataView(header.buffer); + view.setUint32(0, 48); view.setUint32(4, 48); + header.set([1, 0, 0, 0, 0], 8); + const idat = new Uint8Array(deflateSync(new Uint8Array((1 + Math.ceil(48 / 8)) * 48))); + const parts = [new Uint8Array([137, 80, 78, 71, 13, 10, 26, 10]), pngChunk("IHDR", header), pngChunk("IDAT", idat), pngChunk("IEND", new Uint8Array())]; + const image = new Uint8Array(parts.reduce((size, part) => size + part.length, 0)); + let offset = 0; + for (const part of parts) { image.set(part, offset); offset += part.length; } + return image; +})(); + +const validAssetFiles: Record = { + "assets/logo.png": validPng, + "assets/composer-icon.png": validPng +}; +const validSkillFiles: Record = { + "skills/check/SKILL.md": "---\nname: check\ndescription: Check a plugin submission\n---\n\nCheck the plugin submission.\n" +}; + +async function writePackage( + manifest: unknown, + files: Record = {}, + includeSkill = true +): Promise { + const directory = await mkdtemp(path.join(os.tmpdir(), "codex-plugin-doctor-submission-")); + const manifestDirectory = path.join(directory, ".codex-plugin"); + + await mkdir(manifestDirectory, { recursive: true }); + await writeFile(path.join(manifestDirectory, "plugin.json"), JSON.stringify(manifest), "utf8"); + + for (const [relativePath, content] of Object.entries({ ...validAssetFiles, ...(includeSkill ? validSkillFiles : {}), ...files })) { + const filePath = path.join(directory, relativePath); + + await mkdir(path.dirname(filePath), { recursive: true }); + await writeFile(filePath, content); + } + + return directory; +} + +function findingIds(report: Awaited>): string[] { + return report.findings.map((finding) => finding.id); +} + +function validMcpManifest(overrides: Record = {}) { + return { + ...validManifest, + mcpServers: "./.mcp.json", + interface: { + ...validManifest.interface, + websiteURL: "https://example.com", + supportURL: "https://example.com/support", + privacyPolicyURL: "https://example.com/privacy", + termsOfServiceURL: "https://example.com/terms" + }, + ...overrides + }; +} + +describe("submission preflight", () => { + it("publishes the immutable directory ruleset", () => { + expect(submissionRuleset).toEqual({ + version: "openai-directory-2026-08-15", + reviewedAt: "2026-08-15", + sources: [ + "https://developers.openai.com/plugins/build/plugins", + "https://developers.openai.com/plugins/deploy/app-review", + "https://developers.openai.com/plugins/deploy/submission-errors" + ], + limits: { + packageName: 64, + version: 64, + displayName: 30, + shortDescription: 30, + longDescription: 4000, + developerName: 80, + capabilities: 20, + capability: 120, + starterPrompts: 3, + starterPrompt: 128, + url: 1024 + }, + categories: [ + "Productivity", "Creativity", "Developer Tools", "Business & Operations", + "Data & Analytics", "Communication", "Education & Research", "Security", + "Finance", "Healthcare", "Travel", "Entertainment", "Other" + ] + }); + expect(Object.isFrozen(submissionRuleset)).toBe(true); + }); + + it.each([ + ["skills-only", validManifest], + ["mcp-backed", validMcpManifest()], + ["mcp-backed", { ...validManifest, apps: "./.app.json" }], + ["mcp-backed", { ...validManifest, mcpServers: null }], + ["mcp-backed", { ...validManifest, apps: null }] + ] as const)("classifies declarations by presence as %s", async (targetType, manifest) => { + const report = await buildSubmissionPreflight(await writePackage(manifest)); + + expect(report.targetType).toBe(targetType); + }); + + it("returns a manual-review-ready skills-only report without side effects", async () => { + const fetchSpy = vi.spyOn(globalThis, "fetch"); + const report = await buildSubmissionPreflight(await writePackage(validManifest)); + + expect(report).toMatchObject({ + schemaVersion: "1.0.0", + rulesetVersion: "openai-directory-2026-08-15", + targetType: "skills-only", + status: "pass", + readiness: "manual_review_required", + summary: { passed: 4, warnings: 0, blockers: 0, manualChecks: 3 } + }); + expect(report.checks).toEqual([ + { id: "listing", status: "pass", findingIds: [] }, + { id: "components", status: "pass", findingIds: [] }, + { id: "assets", status: "pass", findingIds: [] }, + { id: "skills", status: "pass", findingIds: [] } + ]); + expect(report.manualChecklist).toEqual([ + { id: "developer-business-identity", label: "Developer and business identity", state: "required" }, + { id: "attestations", label: "Required attestations", state: "required" }, + { id: "skill-safety-scan", label: "Skill safety scan", state: "required" }, + { id: "demo-video", label: "Demo video", state: "not_applicable" }, + { id: "tool-tests", label: "Exactly 5 positive and 3 negative tool tests", state: "not_applicable" }, + { id: "release-notes", label: "Release notes", state: "not_applicable" }, + { id: "production-domain-verification", label: "Production domain verification and current tool scan", state: "not_applicable" }, + { id: "tool-annotations", label: "Tool annotations and justifications", state: "not_applicable" }, + { id: "oauth-reviewer-credentials", label: "OAuth reviewer credentials", state: "not_applicable" } + ]); + expect(report.manualChecklist.every((item) => item.state !== "passed")).toBe(true); + expect(fetchSpy).not.toHaveBeenCalled(); + }); + + it("integrates bounded skill metadata into the skills check without requiring skills for MCP-only packages", async () => { + const missingSkill = await buildSubmissionPreflight(await writePackage(validManifest, {}, false)); + const mcpOnly = await buildSubmissionPreflight(await writePackage({ + ...validMcpManifest(), + skills: undefined + }, {}, false)); + + expect(missingSkill.checks.find((item) => item.id === "skills")).toMatchObject({ + status: "fail", + findingIds: expect.arrayContaining(["plugin.submission.skill.invalid_path"]) + }); + expect(missingSkill).toMatchObject({ status: "fail", readiness: "blocked" }); + expect(mcpOnly.checks.find((item) => item.id === "skills")).toEqual({ id: "skills", status: "pass", findingIds: [] }); + expect(mcpOnly).toMatchObject({ status: "pass", readiness: "manual_review_required" }); + }); + + it("blocks missing and malformed manifests without exposing the package path", async () => { + const missing = await mkdtemp(path.join(os.tmpdir(), "codex-plugin-doctor-submission-missing-")); + const malformed = await mkdtemp(path.join(os.tmpdir(), "codex-plugin-doctor-submission-malformed-")); + + await mkdir(path.join(malformed, ".codex-plugin")); + await writeFile(path.join(malformed, ".codex-plugin", "plugin.json"), "{", "utf8"); + + for (const target of [missing, malformed]) { + const report = await buildSubmissionPreflight(target); + const invalid = report.findings.find((finding) => finding.id === "plugin.submission.package.invalid"); + + expect(report).toMatchObject({ status: "fail", readiness: "blocked", targetType: "skills-only" }); + expect(invalid).toMatchObject({ severity: "fail" }); + expect(JSON.stringify(report)).not.toContain(target); + } + }); + + it("bounds the submission manifest before decoding or parsing it", async () => { + const target = await mkdtemp(path.join(os.tmpdir(), "codex-plugin-doctor-submission-large-manifest-")); + const sentinel = "submission-manifest-secret"; + await mkdir(path.join(target, ".codex-plugin")); + await writeFile(path.join(target, ".codex-plugin", "plugin.json"), sentinel.padEnd(1024 * 1024 + 1, "x"), "utf8"); + + const report = await buildSubmissionPreflight(target); + + expect(report).toMatchObject({ status: "fail", readiness: "blocked" }); + expect(findingIds(report)).toEqual(["plugin.submission.package.too_large"]); + expect(JSON.stringify(report)).not.toContain(sentinel); + expect(JSON.stringify(report)).not.toContain(target); + }); + + it("fails closed for a non-string public target input", async () => { + const report = await buildSubmissionPreflight(null as unknown as string); + + expect(report).toMatchObject({ + targetType: "skills-only", + status: "fail", + readiness: "blocked" + }); + expect(findingIds(report)).toEqual(["plugin.submission.package.invalid"]); + expect(JSON.stringify(report)).not.toContain("null"); + }); + + it.each([ + ["package name", { ...validMcpManifest({ skills: undefined }), name: "a".repeat(64) }, false], + ["package name one over", { ...validManifest, name: "a".repeat(65) }, true], + ["version", { ...validManifest, version: "1.2.3+" + "a".repeat(58) }, false], + ["version one over", { ...validManifest, version: "1.2.3+" + "a".repeat(59) }, true], + ["invalid package name", { ...validManifest, name: "bad name" }, true], + ["invalid strict semver", { ...validManifest, version: "01.2.3" }, true], + ["malformed package fields", { ...validManifest, name: [], version: {} }, true] + ])("enforces %s boundaries", async (_caseName, manifest, invalid) => { + const report = await buildSubmissionPreflight(await writePackage(manifest)); + + expect(report.status === "fail").toBe(invalid); + }); + + it.each([ + ["displayName", "a".repeat(30), false], + ["displayName", "a".repeat(31), true], + ["shortDescription", "a".repeat(30), false], + ["shortDescription", "a".repeat(31), true], + ["longDescription", "a".repeat(4000), false], + ["longDescription", "a".repeat(4001), true], + ["developerName", "a".repeat(80), false], + ["developerName", "a".repeat(81), true] + ])("enforces %s length boundary", async (field, value, invalid) => { + const report = await buildSubmissionPreflight(await writePackage({ + ...validManifest, + interface: { ...validManifest.interface, [field]: value } + })); + + expect(report.status === "fail").toBe(invalid); + }); + + it("rejects malformed listing mappings, blank fields, forbidden newlines and invisible controls", async () => { + const cases: Array<{ interface: unknown; expected: string }> = [ + { interface: null, expected: "plugin.submission.interface.required" }, + { interface: { ...validManifest.interface, displayName: " \t " }, expected: "plugin.submission.interface.display_name" }, + { interface: { ...validManifest.interface, shortDescription: "line\nbreak" }, expected: "plugin.submission.interface.short_description" }, + { interface: { ...validManifest.interface, developerName: "a\r\nb" }, expected: "plugin.submission.interface.developer_name" }, + { interface: { ...validManifest.interface, category: "Developer\u2028Tools" }, expected: "plugin.submission.interface.category" }, + { interface: { ...validManifest.interface, displayName: "zero\u200Bwidth" }, expected: "plugin.submission.interface.display_name" }, + { interface: { ...validManifest.interface, longDescription: "bidi\u202Etext" }, expected: "plugin.submission.interface.long_description" }, + { interface: { ...validManifest.interface, longDescription: "acceptable\r\nmultiline" }, expected: "" } + ]; + + for (const testCase of cases) { + const report = await buildSubmissionPreflight(await writePackage({ ...validManifest, interface: testCase.interface })); + + if (testCase.expected) { + expect(findingIds(report)).toContain(testCase.expected); + } else { + expect(report.status).toBe("pass"); + } + } + }); + + it("validates categories, capabilities, prompts and redacts listing content", async () => { + const sentinel = "listing-content-sentinel"; + const report = await buildSubmissionPreflight(await writePackage({ + ...validManifest, + interface: { + ...validManifest.interface, + category: "Not a category", + capabilities: ["valid", " ", "line\nbreak", "a".repeat(121), ...Array.from({ length: 17 }, (_, index) => `c${index}`)], + defaultPrompt: [" Prompt", "Prompt ", `@app ${sentinel}`, "one too many"] + } + })); + + expect(findingIds(report)).toEqual(expect.arrayContaining([ + "plugin.submission.interface.category", + "plugin.submission.interface.capabilities", + "plugin.submission.interface.capability", + "plugin.submission.interface.default_prompt" + ])); + expect(JSON.stringify(report)).not.toContain(sentinel); + }); + + it.each([ + ["20 capabilities", Array.from({ length: 20 }, (_, index) => index === 0 ? "a".repeat(120) : `capability ${index}`), ["one", "two", "a".repeat(128)], true], + ["21 capabilities", Array.from({ length: 21 }, (_, index) => `capability ${index}`), ["one"], false], + ["prompt one over", ["capability"], ["a".repeat(129)], false], + ["four prompts", ["capability"], ["one", "two", "three", "four"], false] + ])("accepts only bounded capability and prompt entries for %s", async (_caseName, capabilities, defaultPrompt, valid) => { + const report = await buildSubmissionPreflight(await writePackage({ + ...validManifest, + interface: { ...validManifest.interface, capabilities, defaultPrompt } + })); + + expect(report.status === "pass").toBe(valid); + }); + + it("requires valid MCP listing URLs and never returns their contents", async () => { + const sentinel = "url-secret-sentinel"; + const report = await buildSubmissionPreflight(await writePackage(validMcpManifest({ + interface: { + ...validMcpManifest().interface, + websiteURL: `https://user:${sentinel}@example.com/path#fragment`, + supportURL: "http://example.com", + privacyPolicyURL: " ", + termsOfServiceURL: "https://example.com/\u200B" + } + }))); + + expect(findingIds(report)).toContain("plugin.submission.interface.url"); + expect(JSON.stringify(report)).not.toContain(sentinel); + expect(report.manualChecklist.filter((item) => item.state === "required")).toHaveLength(9); + }); + + it("warns for unknown interface fields without retaining values and blocks skills-only screenshots", async () => { + const sentinel = "unknown-value-sentinel"; + const report = await buildSubmissionPreflight(await writePackage({ + ...validManifest, + interface: { ...validManifest.interface, unknownSubmissionField: sentinel, screenshots: ["./shot.png"] } + })); + + expect(findingIds(report)).toEqual(expect.arrayContaining([ + "plugin.submission.interface.unknown_field", + "plugin.submission.component.excluded" + ])); + expect(report.status).toBe("fail"); + expect(report.readiness).toBe("blocked"); + expect(report.summary).toMatchObject({ warnings: 1, blockers: 1, passed: 2 }); + expect(report.findings.find((finding) => finding.id === "plugin.submission.component.excluded")) + .toMatchObject({ severity: "fail" }); + expect(JSON.stringify(report)).not.toContain(sentinel); + }); + + it.each([ + ["missing app", "./.app.json", {}, "plugin.submission.component.app"], + ["wrong path", "./apps/app.json", { "apps/app.json": "{}" }, "plugin.submission.component.app"], + ["wrong type", [], {}, "plugin.submission.component.app"], + ["traversal", "../.app.json", { "../.app.json": "{}" }, "plugin.submission.component.app"], + ["invalid JSON", "./.app.json", { ".app.json": "{" }, "plugin.submission.component.app"] + ])("blocks %s at the app boundary", async (_caseName, apps, files, expected) => { + const report = await buildSubmissionPreflight(await writePackage({ ...validMcpManifest(), apps }, files)); + + expect(findingIds(report)).toContain(expected); + }); + + it.each(["null", "[]", "42", "true", "\"a primitive\"", "{}"]) ("accepts parseable %s app JSON without inferring its schema", async (contents) => { + const report = await buildSubmissionPreflight(await writePackage({ ...validMcpManifest(), apps: "./.app.json" }, { ".app.json": contents })); + + expect(report.status).toBe("pass"); + }); + + if (process.platform !== "win32") { + it("rejects a file symlink whose canonical app path escapes the package", async () => { + const target = await writePackage({ ...validMcpManifest(), apps: "./.app.json" }); + const external = path.join(os.tmpdir(), `submission-app-escape-${Date.now()}.json`); + + await writeFile(external, "{}", "utf8"); + await symlink(external, path.join(target, ".app.json"), "file"); + + const report = await buildSubmissionPreflight(target); + + expect(findingIds(report)).toContain("plugin.submission.app.invalid_path"); + expect(JSON.stringify(report)).not.toContain(external); + }); + } + + it("rejects a junction whose canonical app path escapes the package before file classification", async () => { + const target = await writePackage({ ...validMcpManifest(), apps: "./.app.json" }); + const external = await mkdtemp(path.join(os.tmpdir(), "codex-plugin-doctor-submission-app-escape-")); + + await symlink(external, path.join(target, ".app.json"), "junction"); + + const report = await buildSubmissionPreflight(target); + + expect(findingIds(report)).toContain("plugin.submission.app.invalid_path"); + expect(JSON.stringify(report)).not.toContain(external); + }); + + it("rejects a root app declaration that points at a directory", async () => { + const target = await writePackage({ ...validMcpManifest(), apps: "./.app.json" }); + await mkdir(path.join(target, ".app.json")); + + const report = await buildSubmissionPreflight(target); + + expect(findingIds(report)).toContain("plugin.submission.component.app"); + }); +}); diff --git a/tests/submission-skill-metadata.test.ts b/tests/submission-skill-metadata.test.ts new file mode 100644 index 0000000..15089bf --- /dev/null +++ b/tests/submission-skill-metadata.test.ts @@ -0,0 +1,383 @@ +import { mkdir, mkdtemp, readFile, symlink, unlink, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { describe, expect, it, vi } from "vitest"; + +import { validateSubmissionSkillMetadata } from "../src/core/submission-skill-metadata.js"; + +const skill = (name = "check", description = "Checks plugin metadata") => `---\nname: ${name}\ndescription: ${description}\n---\n\nUse the checker.\n`; +const agent = `interface:\n display_name: Check\n short_description: Check plugin metadata\n`; +const toolDescriptors = `dependencies:\n tools:\n - type: mcp\n value: figma\n description: Figma design tools\n transport: streamable_http\n url: https://example.com/mcp\n - type: cli\n value: adb\n description: Android device bridge\n`; + +async function packageWith( + skills: unknown, + files: Record = {}, + name = "submission-plugin" +) { + const rootPath = await mkdtemp(path.join(os.tmpdir(), "codex-plugin-doctor-submission-skills-")); + const manifestPath = path.join(rootPath, ".codex-plugin", "plugin.json"); + await mkdir(path.dirname(manifestPath), { recursive: true }); + await writeFile(manifestPath, JSON.stringify({ name, skills }), "utf8"); + for (const [relativePath, contents] of Object.entries(files)) { + const target = path.join(rootPath, relativePath); + await mkdir(path.dirname(target), { recursive: true }); + await writeFile(target, contents); + } + return { rootPath, manifestPath, manifest: { name, skills } }; +} + +function ids(result: Awaited>) { + return result.findings.map((finding) => finding.id); +} + +describe("submission skill metadata", () => { + it("requires a declared valid skill for skills-only targets", async () => { + const result = await validateSubmissionSkillMetadata(await packageWith(undefined), "skills-only"); + + expect(ids(result)).toEqual(["plugin.submission.skill.required"]); + expect(result.skillCount).toBe(0); + }); + + it("allows an MCP-backed target with no skills declaration", async () => { + const result = await validateSubmissionSkillMetadata(await packageWith(undefined), "mcp-backed"); + + expect(result).toEqual({ findings: [], skillCount: 0 }); + }); + + it.each(["skills", "./skills/child", "../skills", [], null])("rejects invalid skills declaration %j", async (skills) => { + const result = await validateSubmissionSkillMetadata(await packageWith(skills), "mcp-backed"); + + expect(ids(result)).toContain("plugin.submission.skill.invalid_manifest"); + }); + + it("accepts an immediate skill with no optional agent metadata", async () => { + const result = await validateSubmissionSkillMetadata(await packageWith("./skills", { + "skills/check/SKILL.md": skill() + }), "skills-only"); + + expect(result).toEqual({ findings: [], skillCount: 1 }); + }); + + it("inspects only non-hidden immediate skill directories", async () => { + const result = await validateSubmissionSkillMetadata(await packageWith("./skills", { + "skills/check/SKILL.md": skill(), + "skills/.ignored/SKILL.md": "not valid frontmatter" + }), "skills-only"); + + expect(result).toEqual({ findings: [], skillCount: 1 }); + }); + + it("limits immediate non-hidden skill directories without exposing their paths", async () => { + const sentinel = "too-many-skill-secret"; + const files = Object.fromEntries(Array.from({ length: 101 }, (_, index) => [ + `skills/check-${index}/SKILL.md`, + skill(`check-${index}`, `Checks ${sentinel}`) + ])); + const result = await validateSubmissionSkillMetadata(await packageWith("./skills", files), "skills-only"); + + expect(ids(result).filter((id) => id === "plugin.submission.skill.too_many")).toHaveLength(1); + expect(JSON.stringify(result)).not.toContain(sentinel); + }); + + it("limits aggregate skill metadata bytes before reading more content", async () => { + const sentinel = "aggregate-skill-secret"; + const body = sentinel.padEnd(1024 * 1024 - 80, "x"); + const files = Object.fromEntries(Array.from({ length: 17 }, (_, index) => [ + `skills/check-${index}/SKILL.md`, + `---\nname: check-${index}\ndescription: Check aggregate metadata\n---\n\n${body}\n` + ])); + const result = await validateSubmissionSkillMetadata(await packageWith("./skills", files), "skills-only"); + + expect(ids(result).filter((id) => id === "plugin.submission.skill.budget_exceeded")).toHaveLength(1); + expect(JSON.stringify(result)).not.toContain(sentinel); + }); + + it("rejects a visible immediate skill directory without a regular entrypoint", async () => { + const result = await validateSubmissionSkillMetadata(await packageWith("./skills", { + "skills/check/.keep": "" + }), "skills-only"); + + expect(ids(result)).toEqual(expect.arrayContaining([ + "plugin.submission.skill.invalid_file", + "plugin.submission.skill.required" + ])); + }); + + it("rejects agent fields at the top level", async () => { + const result = await validateSubmissionSkillMetadata(await packageWith("./skills/", { + "skills/check/SKILL.md": skill(), + "skills/check/agents/openai.yaml": `${agent}icon_small: ./icon.png\nicon_large: ./icon.png\nbrand_color: \"#123ABC\"\ndefault_prompt: Check this plugin\npolicy:\n products: [CHAT, CODEX]\n allow_implicit_invocation: true\ndependencies:\n tools: [read_file]\n`, + "skills/check/icon.png": "not-inspected-as-image" + }), "skills-only"); + + expect(ids(result)).toContain("plugin.submission.skill.agent.invalid_shape"); + }); + + it("accepts complete safe agent metadata inside interface", async () => { + const result = await validateSubmissionSkillMetadata(await packageWith("./skills/", { + "skills/check/SKILL.md": skill(), + "skills/check/agents/openai.yaml": `interface:\n display_name: Check\n short_description: Check plugin metadata\n icon_small: ./icon.png\n icon_large: ./icon.png\n brand_color: \"#123ABC\"\n default_prompt: Check this plugin\npolicy:\n products: [CHAT, CODEX]\n allow_implicit_invocation: true\n${toolDescriptors}`, + "skills/check/icon.png": "not-inspected-as-image" + }), "skills-only"); + + expect(result).toEqual({ findings: [], skillCount: 1 }); + }); + + it.each([ + ["missing frontmatter", "name: check\ndescription: text\n", "plugin.submission.skill.invalid_file"], + ["frontmatter root array", "---\n- name: check\n---\nbody\n", "plugin.submission.skill.invalid_shape"], + ["alias", "---\nname: &name check\ndescription: *name\n---\nbody\n", "plugin.submission.skill.invalid_yaml"], + ["custom tag", "---\nname: !custom check\ndescription: text\n---\nbody\n", "plugin.submission.skill.invalid_yaml"], + ["duplicate key", "---\nname: check\nname: again\ndescription: text\n---\nbody\n", "plugin.submission.skill.invalid_yaml"], + ["blank metadata", "---\nname: \" \"\ndescription: \" \"\n---\nbody\n", "plugin.submission.skill.identity"], + ["missing body", "---\nname: check\ndescription: text\n---\n", "plugin.submission.skill.invalid_file"] + ] as const)("rejects %s", async (_caseName, contents, expected) => { + const result = await validateSubmissionSkillMetadata(await packageWith("./skills", { + "skills/check/SKILL.md": contents + }), "skills-only"); + + expect(ids(result)).toContain(expected); + }); + + it("rejects invalid UTF-8 and oversized skill files", async () => { + const invalidUtf8 = await validateSubmissionSkillMetadata(await packageWith("./skills", { + "skills/check/SKILL.md": new Uint8Array([0xff, 0xfe]) + }), "skills-only"); + const oversized = await validateSubmissionSkillMetadata(await packageWith("./skills", { + "skills/check/SKILL.md": `${skill()}${"x".repeat(1024 * 1024)}` + }), "skills-only"); + + expect(ids(invalidUtf8)).toContain("plugin.submission.skill.invalid_file"); + expect(ids(oversized)).toContain("plugin.submission.skill.invalid_file"); + }); + + it("uses normalized unique plugin and skill identities at the 64 character boundary", async () => { + const packageName = "p".repeat(58); + const boundary = await validateSubmissionSkillMetadata(await packageWith("./skills", { + "skills/check/SKILL.md": skill("c".repeat(5)) + }, packageName), "skills-only"); + const duplicate = await validateSubmissionSkillMetadata(await packageWith("./skills", { + "skills/one/SKILL.md": skill("café"), + "skills/two/SKILL.md": skill("cafe\u0301") + }), "skills-only"); + const oneOver = await validateSubmissionSkillMetadata(await packageWith("./skills", { + "skills/check/SKILL.md": skill("c".repeat(6)) + }, packageName), "skills-only"); + + expect(boundary.findings).toEqual([]); + expect(ids(duplicate)).toContain("plugin.submission.skill.identity"); + expect(ids(oneOver)).toContain("plugin.submission.skill.identity"); + }); + + it.each([ + ["missing interface", "policy: { products: [CHAT], allow_implicit_invocation: true }\ndependencies: { tools: [read_file] }\n", "plugin.submission.skill.agent.invalid_shape"], + ["wrong interface", "interface: []\n", "plugin.submission.skill.agent.invalid_shape"], + ["malformed yaml", "interface: [\n", "plugin.submission.skill.agent.invalid_yaml"], + ["alias", "interface: &meta { display_name: Check, short_description: Check }\npolicy: *meta\n", "plugin.submission.skill.agent.invalid_yaml"], + ["custom tag", "interface: !custom { display_name: Check, short_description: Check }\n", "plugin.submission.skill.agent.invalid_yaml"], + ["unsupported key", `${agent}unexpected: value\n`, "plugin.submission.skill.agent.invalid_shape"], + ["policy shape", `${agent}policy: { products: [CHAT, CHAT], allow_implicit_invocation: yes }\n`, "plugin.submission.skill.agent.invalid_shape"], + ["string dependency", `${agent}dependencies: { tools: [read_file] }\n`, "plugin.submission.skill.agent.invalid_shape"], + ["unsupported descriptor", `${agent}dependencies: { tools: [{ type: mcp, value: figma, unsupported: no }] }\n`, "plugin.submission.skill.agent.invalid_shape"], + ["blank descriptor", `${agent}dependencies: { tools: [{ type: cli, value: \" \" }] }\n`, "plugin.submission.skill.agent.invalid_shape"], + ["CLI transport", `${agent}dependencies: { tools: [{ type: cli, value: adb, transport: streamable_http }] }\n`, "plugin.submission.skill.agent.invalid_shape"], + ["CLI URL", `${agent}dependencies: { tools: [{ type: cli, value: adb, url: https://example.com }] }\n`, "plugin.submission.skill.agent.invalid_shape"] + ] as const)("rejects agent metadata with %s", async (_caseName, contents, expected) => { + const result = await validateSubmissionSkillMetadata(await packageWith("./skills", { + "skills/check/SKILL.md": skill(), + "skills/check/agents/openai.yaml": contents + }), "skills-only"); + + expect(ids(result)).toContain(expected); + }); + + it.each([ + "policy: { products: [CHAT] }\n", + "policy: { allow_implicit_invocation: false }\n", + "policy: {}\n" + ])("accepts partial policy metadata %j", async (policy) => { + const result = await validateSubmissionSkillMetadata(await packageWith("./skills", { + "skills/check/SKILL.md": skill(), + "skills/check/agents/openai.yaml": `${agent}${policy}` + }), "skills-only"); + + expect(result).toEqual({ findings: [], skillCount: 1 }); + }); + + it("accepts MCP and CLI tool descriptors without retaining their values", async () => { + const result = await validateSubmissionSkillMetadata(await packageWith("./skills", { + "skills/check/SKILL.md": skill(), + "skills/check/agents/openai.yaml": `${agent}${toolDescriptors}` + }), "skills-only"); + + expect(result).toEqual({ findings: [], skillCount: 1 }); + }); + + it("accepts package-root and relative icon assets", async () => { + const result = await validateSubmissionSkillMetadata(await packageWith("./skills", { + "skills/check/SKILL.md": skill(), + "skills/check/agents/openai.yaml": `interface:\n display_name: Check\n short_description: Check plugin metadata\n icon_small: assets/local.svg\n icon_large: ../../assets/logo.svg\n`, + "skills/check/assets/local.svg": "local", + "assets/logo.svg": "root" + }), "skills-only"); + + expect(result).toEqual({ findings: [], skillCount: 1 }); + }); + + it("accepts dot-prefixed and parent-relative icon assets inside the package", async () => { + const result = await validateSubmissionSkillMetadata(await packageWith("./skills", { + "skills/check/SKILL.md": skill(), + "skills/check/agents/openai.yaml": `interface:\n display_name: Check\n short_description: Check plugin metadata\n icon_small: ./assets/local.svg\n icon_large: ../.shared/icon.svg\n`, + "skills/check/assets/local.svg": "local", + "skills/.shared/icon.svg": "shared" + }), "skills-only"); + + expect(result).toEqual({ findings: [], skillCount: 1 }); + }); + + it("rejects icon paths outside the package without retaining the path", async () => { + const sentinel = "icon-path-secret"; + const result = await validateSubmissionSkillMetadata(await packageWith("./skills", { + "skills/check/SKILL.md": skill(), + "skills/check/agents/openai.yaml": `interface:\n display_name: Check\n short_description: Check plugin metadata\n icon_small: ../../../${sentinel}.svg\n` + }), "skills-only"); + + expect(ids(result)).toContain("plugin.submission.skill.agent.invalid_path"); + expect(JSON.stringify(result)).not.toContain(sentinel); + }); + + it("redacts invalid tool descriptor values, URLs, and descriptions", async () => { + const sentinel = "tool-descriptor-secret"; + const result = await validateSubmissionSkillMetadata(await packageWith("./skills", { + "skills/check/SKILL.md": skill(), + "skills/check/agents/openai.yaml": `${agent}dependencies:\n tools:\n - type: invalid\n value: ${sentinel}\n description: ${sentinel}\n url: https://${sentinel}.example\n` + }), "skills-only"); + + expect(ids(result)).toContain("plugin.submission.skill.agent.invalid_shape"); + expect(JSON.stringify(result)).not.toContain(sentinel); + }); + + it("rejects oversized and invalid UTF-8 agent metadata", async () => { + const invalidUtf8 = await validateSubmissionSkillMetadata(await packageWith("./skills", { + "skills/check/SKILL.md": skill(), + "skills/check/agents/openai.yaml": new Uint8Array([0xff, 0xfe]) + }), "skills-only"); + const oversized = await validateSubmissionSkillMetadata(await packageWith("./skills", { + "skills/check/SKILL.md": skill(), + "skills/check/agents/openai.yaml": `${agent}${"x".repeat(256 * 1024)}` + }), "skills-only"); + + expect(ids(invalidUtf8)).toContain("plugin.submission.skill.agent.invalid_file"); + expect(ids(oversized)).toContain("plugin.submission.skill.agent.invalid_file"); + }); + + it("rejects agent file paths that escape a skill and does not expose their contents", async () => { + const sentinel = "agent-secret-sentinel"; + const result = await validateSubmissionSkillMetadata(await packageWith("./skills", { + "skills/check/SKILL.md": skill(), + "skills/check/agents/openai.yaml": `interface:\n display_name: Check\n short_description: Check plugin metadata\n icon_small: ../${sentinel}.png\n` + }), "skills-only"); + + expect(ids(result)).toContain("plugin.submission.skill.agent.invalid_path"); + expect(JSON.stringify(result)).not.toContain(sentinel); + }); + + it("rejects a junction that makes the skills directory escape the package", async () => { + const discovered = await packageWith("./skills"); + const external = await mkdtemp(path.join(os.tmpdir(), "codex-plugin-doctor-submission-skills-escape-")); + await mkdir(path.join(external, "check")); + await writeFile(path.join(external, "check", "SKILL.md"), skill(), "utf8"); + await symlink(external, path.join(discovered.rootPath, "skills"), "junction"); + + const result = await validateSubmissionSkillMetadata(discovered, "skills-only"); + + expect(ids(result)).toContain("plugin.submission.skill.invalid_path"); + expect(JSON.stringify(result)).not.toContain(external); + }); + + if (process.platform !== "win32") { + it("rejects a SKILL.md symlink that escapes its skill without reading external content", async () => { + const sentinel = "external-skill-secret"; + const discovered = await packageWith("./skills", { "skills/check/SKILL.md": skill() }); + const external = path.join(os.tmpdir(), `codex-plugin-doctor-external-${Date.now()}.md`); + const skillFile = path.join(discovered.rootPath, "skills", "check", "SKILL.md"); + await writeFile(external, `---\nname: check\ndescription: ${sentinel}\n---\nbody\n`, "utf8"); + await unlink(skillFile); + await symlink(external, skillFile, "file"); + + const result = await validateSubmissionSkillMetadata(discovered, "skills-only"); + + expect(ids(result)).toEqual(expect.arrayContaining([ + "plugin.submission.skill.invalid_path", + "plugin.submission.skill.required" + ])); + expect(JSON.stringify(result)).not.toContain(sentinel); + expect(JSON.stringify(result)).not.toContain(external); + }); + } + + if (process.platform === "win32") { + it("rejects a SKILL.md junction without following its reparse target", async () => { + const sentinel = "external-junction-secret"; + const discovered = await packageWith("./skills", { "skills/check/SKILL.md": skill() }); + const external = await mkdtemp(path.join(os.tmpdir(), "codex-plugin-doctor-external-skill-junction-")); + const skillFile = path.join(discovered.rootPath, "skills", "check", "SKILL.md"); + await writeFile(path.join(external, "secret.md"), sentinel, "utf8"); + await unlink(skillFile); + await symlink(external, skillFile, "junction"); + + const result = await validateSubmissionSkillMetadata(discovered, "skills-only"); + + expect(ids(result)).toEqual(expect.arrayContaining([ + "plugin.submission.skill.required" + ])); + expect(ids(result)).toEqual(expect.arrayContaining([ + expect.stringMatching(/^plugin\.submission\.skill\.invalid_(path|file)$/u) + ])); + expect(JSON.stringify(result)).not.toContain(sentinel); + expect(JSON.stringify(result)).not.toContain(external); + }); + } + + if (process.platform !== "win32") { + it("rejects an in-skill agent-file symlink before parsing its content", async () => { + const discovered = await packageWith("./skills", { + "skills/check/SKILL.md": skill(), + "skills/check/agents/metadata.yaml": agent + }); + const agentPath = path.join(discovered.rootPath, "skills", "check", "agents", "openai.yaml"); + await symlink(path.join(discovered.rootPath, "skills", "check", "agents", "metadata.yaml"), agentPath, "file"); + + const result = await validateSubmissionSkillMetadata(discovered, "skills-only"); + + expect(ids(result)).toContain("plugin.submission.skill.agent.invalid_path"); + }); + } + + if (process.platform === "win32") { + it("rejects an agents junction that escapes the skill", async () => { + const discovered = await packageWith("./skills", { "skills/check/SKILL.md": skill() }); + const external = await mkdtemp(path.join(os.tmpdir(), "codex-plugin-doctor-external-agents-junction-")); + await writeFile(path.join(external, "openai.yaml"), agent, "utf8"); + await symlink(external, path.join(discovered.rootPath, "skills", "check", "agents"), "junction"); + + const result = await validateSubmissionSkillMetadata(discovered, "skills-only"); + + expect(ids(result)).toContain("plugin.submission.skill.agent.invalid_path"); + expect(JSON.stringify(result)).not.toContain(external); + }); + } + + it("does not fetch, spawn processes, or expose untrusted skill content", async () => { + const sentinel = "skill-secret-sentinel"; + const fetchSpy = vi.spyOn(globalThis, "fetch"); + const result = await validateSubmissionSkillMetadata(await packageWith("./skills", { + "skills/check/SKILL.md": `---\nname: check\ndescription: ${sentinel}\n---\nbody\n` + }), "skills-only"); + + expect(fetchSpy).not.toHaveBeenCalled(); + expect(JSON.stringify(result)).not.toContain(sentinel); + expect(await readFile(new URL("../src/core/submission-skill-metadata.ts", import.meta.url), "utf8")) + .not.toMatch(/child_process|node:child_process/u); + }); +});