← Files DerivARCHIVED FILE

.github/workflows/verify-surface.mjs

28.5 KB · Nov 6, 2026 · 07:10 UTC

↓ Download file

#!/usr/bin/env node
// verify-surface.mjs — PUBLIC-repo surface guard, run by verify-surface.yml.
//
// Self-contained on purpose: the private build script is never promoted, so this
// inlines the same allowlist (the runtime paths) and the content-gate
// patterns. Keep it in step with the source repository's promotion tooling — with ONE
// intentional exception: internal hostname and repository-name gating is enforced
// ONLY on the private side (the source repository's promotion build), which runs before anything
// leaves the source repo. This public checker carries NO internal-hostnames gate at
// all, so it discloses none of those internal names.
//
// Fail-closed checks over the public tree:
//   1. Surface: every path is an allowlisted runtime path OR under this repo's
//      own `.github/`. Anything else fails and is named.
//   2. Presence: SECURITY.md, PRIVACY.md, and the MCP declaration pair
//      (.mcp.json and mcp.json) must exist (independent of ALLOW).
//   3. MCP URL pin: mcpServers.deriv must sit at https://mcp-api-v2.deriv.com/mcp
//      in both files — type "http" in the vendor-dialect .mcp.json, and
//      type "streamable-http" under the Agent Plugins v1 $schema in mcp.json.
//      The two transport spellings are pinned separately, never compared. No
//      hostname-pattern gate.
//   4. Content gates: secrets/key material, tooling/planning artefacts, and
//      local-runtime remnants — any hit fails and is named. (Internal hostname /
//      repo-name gating is private-side only, per the note above, so this public
//      checker discloses none of those names.) The `.github/` CI machinery is
//      exempt from the content grep (it carries the gate patterns themselves and
//      automation tokens); it is covered by the surface check instead.
//   5. Internal-reference scan over this repo's own `.github/` machinery.
//   6. Codex listing contract.
//
// File source (two modes):
//   - default: the committed tree via `git ls-files` (public-repo CI).
//   - `--dir <path>`: a FILESYSTEM WALK of <path> (used by publish.yml to check a
//     freshly-unpacked, not-yet-tracked staging tree — `git ls-files` there would
//     return empty and pass vacuously, a silent failure).
//
// Contract: exit 0 when all checks pass; exit non-zero and print `FAIL:` lines
// otherwise.

import { existsSync, readFileSync, readdirSync } from 'node:fs';
import { join, relative, sep } from 'node:path';
import { execFileSync } from 'node:child_process';

const ALLOW = new Set([
  'plugin.json',
  '.mcp.json',
  'mcp.json',
  '.cursor-plugin/plugin.json',
  '.cursor-plugin/marketplace.json',
  '.claude-plugin/plugin.json',
  '.claude-plugin/marketplace.json',
  '.codex-plugin/plugin.json',
  '.agents/plugins/marketplace.json',
  'rules/deriv-api-conventions.mdc',
  'assets/logo.png',
  'assets/logo-dark.png',
  'LICENSE',
  'README.md',
  'CONTRIBUTING.md',
  'SECURITY.md',
  'PRIVACY.md',
  'skills/deriv-auth/SKILL.md',
  'skills/deriv-auth/agents/openai.yaml',
  'skills/deriv-auth/references/implementation-checklist.md',
  'skills/deriv-llms/SKILL.md',
  'skills/deriv-llms/references/account-nickname.md',
  'skills/deriv-llms/references/active-symbols.md',
  'skills/deriv-llms/references/api-overview.md',
  'skills/deriv-llms/references/authentication.md',
  'skills/deriv-llms/references/auto-get.md',
  'skills/deriv-llms/references/auto-list-strategies.md',
  'skills/deriv-llms/references/auto-list.md',
  'skills/deriv-llms/references/auto-pause.md',
  'skills/deriv-llms/references/auto-resume.md',
  'skills/deriv-llms/references/auto-start.md',
  'skills/deriv-llms/references/auto-stop.md',
  'skills/deriv-llms/references/balance.md',
  'skills/deriv-llms/references/bulk-purchase.md',
  'skills/deriv-llms/references/buy.md',
  'skills/deriv-llms/references/cancel.md',
  'skills/deriv-llms/references/contract-types.md',
  'skills/deriv-llms/references/contract-update-history.md',
  'skills/deriv-llms/references/contract-update.md',
  'skills/deriv-llms/references/contracts-for.md',
  'skills/deriv-llms/references/contracts-list.md',
  'skills/deriv-llms/references/create-account.md',
  'skills/deriv-llms/references/errors.md',
  'skills/deriv-llms/references/examples.md',
  'skills/deriv-llms/references/forget-all.md',
  'skills/deriv-llms/references/forget.md',
  'skills/deriv-llms/references/get-accounts.md',
  'skills/deriv-llms/references/getting-started.md',
  'skills/deriv-llms/references/health.md',
  'skills/deriv-llms/references/markup-statistics.md',
  'skills/deriv-llms/references/oauth.md',
  'skills/deriv-llms/references/payment-agent-client-settings-update.md',
  'skills/deriv-llms/references/payment-agent-client-settings.md',
  'skills/deriv-llms/references/payment-agent-get.md',
  'skills/deriv-llms/references/payment-agent-list.md',
  'skills/deriv-llms/references/payment-agent-statistics.md',
  'skills/deriv-llms/references/payment-agent-transfer-status.md',
  'skills/deriv-llms/references/payment-agent-transfer.md',
  'skills/deriv-llms/references/payment-agent-withdraw-status.md',
  'skills/deriv-llms/references/payment-agent-withdraw-verification.md',
  'skills/deriv-llms/references/payment-agent-withdraw.md',
  'skills/deriv-llms/references/ping.md',
  'skills/deriv-llms/references/portfolio.md',
  'skills/deriv-llms/references/profit-table.md',
  'skills/deriv-llms/references/proposal-open-contract.md',
  'skills/deriv-llms/references/proposal.md',
  'skills/deriv-llms/references/reset-demo-balance.md',
  'skills/deriv-llms/references/sell.md',
  'skills/deriv-llms/references/statement.md',
  'skills/deriv-llms/references/ticks-history.md',
  'skills/deriv-llms/references/ticks.md',
  'skills/deriv-llms/references/time.md',
  'skills/deriv-llms/references/trading-times.md',
  'skills/deriv-llms/references/transaction.md',
  'skills/deriv-llms/references/wallet-list.md',
  'skills/deriv-llms/references/wallet-transactions.md',
  'skills/deriv-llms/references/websocket.md',
  'skills/deriv-llms/references/workflows.md',
  'skills/deriv-llms/references/ws-demo.md',
  'skills/deriv-llms/references/ws-public.md',
  'skills/deriv-llms/references/ws-real.md',
  'skills/deriv-market-data/SKILL.md',
  'skills/deriv-market-data/agents/openai.yaml',
  'skills/deriv-market-data/references/implementation-checklist.md',
  'skills/deriv-trade-lifecycle/SKILL.md',
  'skills/deriv-trade-lifecycle/agents/openai.yaml',
  'skills/deriv-trade-lifecycle/references/implementation-checklist.md',
  'skills/deriv-trade-types/SKILL.md',
  'skills/deriv-trade-types/agents/openai.yaml',
  'skills/deriv-trade-types/references/accumulators.md',
  'skills/deriv-trade-types/references/advanced.md',
  'skills/deriv-trade-types/references/barrier-options.md',
  'skills/deriv-trade-types/references/catalog.md',
  'skills/deriv-trade-types/references/digits.md',
  'skills/deriv-trade-types/references/multipliers.md',
  'skills/deriv-trade-types/references/up-down.md',
  'skills/deriv-trade-types/references/vanilla-turbos.md',
  'skills/deriv-trading-app/SKILL.md',
  'skills/deriv-trading-app/agents/openai.yaml',
  'skills/deriv-trading-app/references/new-api-only.md',
  'skills/deriv-llms/references/partners-analytics-overview.md',
  'skills/deriv-llms/references/partners-client-tags-check.md',
  'skills/deriv-llms/references/wallet-exchange-rate.md',
  'skills/deriv-llms/references/wallet-transfer-exchange.md',
  'skills/deriv-llms/references/wallet-transfer-platforms.md',
  'skills/deriv-llms/references/wallet-transfer-validate.md',
  'skills/deriv-llms/references/wallet-transfer.md',
  'skills/deriv-trading-app/references/chart-selection.md',
  'skills/deriv-lightweight-charts/SKILL.md',
  'skills/deriv-lightweight-charts/agents/openai.yaml',
  'skills/deriv-lightweight-charts/references/setup-and-feed.md',
  'skills/deriv-lightweight-charts/references/trade-overlays.md',
  'skills/deriv-smartcharts/SKILL.md',
  'skills/deriv-smartcharts/agents/openai.yaml',
  'skills/deriv-smartcharts/references/chart-data-and-assets.md',
  'skills/deriv-smartcharts/references/feed-adapter.md',
  'skills/deriv-smartcharts/references/trade-overlays.md',
]);

// NOTE: there is deliberately NO internal-hostnames gate here. Internal hostname
// and repository-name gating (the private repo name, the corporate domain, and the
// other internal identifiers) is enforced only on the private side, in
// the source repository's promotion build, which runs before anything leaves it.
// Naming those patterns in this public checker would make the checker itself
// disclose the very names it would otherwise guard, so the whole gate is omitted.
const CONTENT_GATES = [
  {
    id: 'secrets',
    patterns: [
      /phc_[A-Za-z0-9]{20,}/,
      /ghp_[A-Za-z0-9]{20,}/,
      /github_pat_[A-Za-z0-9_]{20,}/,
      /-----BEGIN (?:RSA |EC |OPENSSH |DSA |PGP )?PRIVATE KEY-----/,
    ],
    filenamePatterns: [/^\.env(\..+)?$/],
  },
  {
    id: 'tooling-artefacts',
    // The tool-name patterns are assembled from fragments so this public file
    // does not itself spell out the source repository's internal tooling.
    patterns: [
      new RegExp(['open', 'spec'].join('')),
      new RegExp(['spec', 'to', 'pr'].join('-')),
      new RegExp('\\b' + ['ds', 'tp'].join('') + '\\b'),
      /PLAN\.md/,
      /(^|[\s"'(/])specs\//m,
      /\.claude\/(?!skills\/)|(?<!~\/)\.claude\/skills\//,
      new RegExp('\\.' + ['build', 'wright'].join('')),
      new RegExp('\\.' + ['spec', 'to', 'pr'].join('-')),
      /AGENTS\.md/,
      /CLAUDE\.md/,
    ],
  },
  {
    id: 'local-runtime',
    patterns: [
      /(^|[\s"'(/])mcp\//m,
      /node_modules/,
      /(^|[\s"'(/])dist\//m,
    ],
  },
];

const failures = [];
const fail = (m) => failures.push(m);
const PIN_SOURCE_KEYS = new Set(['source', 'url', 'ref']);
const pinSourceExtraKey = (source) => {
  if (!source || typeof source !== 'object' || Array.isArray(source)) return null;
  return Object.keys(source).find((key) => !PIN_SOURCE_KEYS.has(key)) ?? null;
};

// --dir <path> → filesystem-walk that path; otherwise git ls-files the cwd.
const dirIdx = process.argv.indexOf('--dir');
const walkDir = dirIdx !== -1 ? process.argv[dirIdx + 1] : null;

function* walk(dir, base = dir) {
  for (const entry of readdirSync(dir, { withFileTypes: true })) {
    if (entry.name === '.git') continue;
    const abs = join(dir, entry.name);
    if (entry.isDirectory()) yield* walk(abs, base);
    else if (entry.isFile()) yield relative(base, abs).split(sep).join('/');
  }
}

const root = walkDir || '.';
const paths = walkDir
  ? [...walk(walkDir)]
  : execFileSync('git', ['ls-files'], { encoding: 'utf8' })
      .split('\n')
      .map((l) => l.trim())
      .filter(Boolean);

// 1. Surface check: allowlisted runtime path or under .github/.
for (const path of paths) {
  if (ALLOW.has(path)) continue;
  if (path === '.github' || path.startsWith('.github/')) continue;
  fail(`unexpected path outside the allowlist (+ .github/): "${path}"`);
}

// 2. Presence gate.
for (const file of ['SECURITY.md', 'PRIVACY.md', '.mcp.json', 'mcp.json']) {
  if (!existsSync(join(root, file))) fail(`required file missing: ${file}`);
}

const PRODUCTION_MCP_URL = 'https://mcp-api-v2.deriv.com/mcp';
const STANDARD_MCP_SCHEMA_URL = 'https://agent-plugins.org/schemas/1.0.0/mcp.schema.json';
// 3. MCP URL pin, over the declaration pair. The two files name the same server
//    at the same url and differ only in schema and transport spelling: the
//    vendor dialect Claude Code and Cursor read, and the standard one Codex and
//    any Agent Plugins v1 host reads. The Codex compatibility manifest's
//    mcpServers field names ./.mcp.json, but this package is portable: under
//    OpenAI's package rules (https://developers.openai.com/plugins/deploy/submission)
//    a portable package's root mcp.json is what OpenAI and Codex discover, and
//    that field cannot replace it. That matches the measurement on 2026-10-01
//    with Codex CLI 0.159.2. Both files name the same server at the same url.
//    The spellings are pinned separately, never compared.
if (existsSync(join(root, '.mcp.json'))) {
  try {
    const mcp = JSON.parse(readFileSync(join(root, '.mcp.json'), 'utf8'));
    const deriv = mcp && mcp.mcpServers && mcp.mcpServers.deriv;
    if (!deriv || deriv.type !== 'http' || deriv.url !== PRODUCTION_MCP_URL) {
      fail(
        `MCP URL pin: mcpServers.deriv must have type "http" and url ${PRODUCTION_MCP_URL}`,
      );
    }
  } catch (err) {
    fail(`MCP URL pin: .mcp.json is not valid JSON (${err.message})`);
  }
}

if (existsSync(join(root, 'mcp.json'))) {
  try {
    const standard = JSON.parse(readFileSync(join(root, 'mcp.json'), 'utf8'));
    const deriv = standard && standard.mcpServers && standard.mcpServers.deriv;
    if (!standard || standard.$schema !== STANDARD_MCP_SCHEMA_URL) {
      fail(`MCP standard pin: mcp.json $schema must be ${STANDARD_MCP_SCHEMA_URL}`);
    }
    if (!deriv || deriv.url !== PRODUCTION_MCP_URL) {
      fail(`MCP standard pin: mcp.json mcpServers.deriv url must be ${PRODUCTION_MCP_URL}`);
    }
    if (!deriv || deriv.type !== 'streamable-http') {
      fail('MCP standard pin: mcp.json mcpServers.deriv type must be "streamable-http"');
    }
  } catch (err) {
    fail(`MCP standard pin: mcp.json is not valid JSON (${err.message})`);
  }
}

// 4. Content gates over the runtime surface (skip the .github/ machinery).
for (const path of paths) {
  if (path === '.github' || path.startsWith('.github/')) continue;
  const base = path.split('/').pop();
  let content = '';
  try {
    content = readFileSync(join(root, path), 'utf8');
  } catch {
    content = '';
  }
  for (const gate of CONTENT_GATES) {
    for (const pattern of gate.patterns || []) {
      const m = content.match(pattern);
      if (m) fail(`content gate "${gate.id}": ${path} matched ${pattern} ("${m[0]}")`);
    }
    for (const pattern of gate.filenamePatterns || []) {
      if (pattern.test(base)) fail(`content gate "${gate.id}": ${path} matched filename ${pattern}`);
    }
  }
}

// 5. Internal-reference scan over this repo's own .github/ machinery. The content
//    gates above skip .github/ (it carries the gate patterns themselves), so this
//    narrower scan covers the one thing that must never appear there: references
//    to the source repository's private planning documents, issue numbers, or
//    tooling file names. The two tooling-name patterns are assembled from fragments
//    so this file does not itself contain the names it rejects.
const INTERNAL_REF_PATTERNS = [
  /PLAN\.md/,
  /issue\s+#\d+/i,
  /\(#\d+\)/,
  new RegExp(['build', 'promotion', 'bundle'].join('-')),
  new RegExp(['promotion', 'allowlist'].join('-')),
];
for (const path of paths) {
  if (!(path === '.github' || path.startsWith('.github/'))) continue;
  let content = '';
  try {
    content = readFileSync(join(root, path), 'utf8');
  } catch {
    content = '';
  }
  for (const pattern of INTERNAL_REF_PATTERNS) {
    const m = content.match(pattern);
    if (m) fail(`internal reference in CI machinery: ${path} matched ${pattern} ("${m[0]}")`);
  }
}

// 6. Codex listing contract. Official package rules require a relative
//    branding path (not an HTTPS URL) and directory-submission length limits on
//    the title and subtitle. The Codex compatibility manifest declares MCP as
//    ./.mcp.json, which has no effect in a portable package (see step 3).
const CODEX_CATEGORIES = new Set([
  'Productivity',
  'Creativity',
  'Developer Tools',
  'Business & Operations',
  'Data & Analytics',
  'Communication',
  'Education & Research',
  'Security',
  'Finance',
  'Healthcare',
  'Travel',
  'Entertainment',
  'Other',
]);
const HEX = /^#[0-9A-Fa-f]{6}$/;
const SEMVER = /^\d+\.\d+\.\d+$/;
const loadJson = (path) => {
  try {
    return JSON.parse(readFileSync(join(root, path), 'utf8'));
  } catch (err) {
    fail(`codex listing: ${path} is not valid JSON (${err.message})`);
    return null;
  }
};
for (const path of ['plugin.json', '.cursor-plugin/plugin.json', '.claude-plugin/plugin.json']) {
  const manifest = loadJson(path);
  if (manifest?.extensions && typeof manifest.extensions === 'object' &&
      !Array.isArray(manifest.extensions) && 'com.openai' in manifest.extensions) {
    fail(`${path}: "extensions.com.openai" must not appear`);
  }
}
const requireHttps = (label, value) => {
  if (!isHttpsUrl(value)) {
    fail(`codex listing: ${label} must be an https URL`);
  }
};
const requireAsset = (label, value) => {
  if (typeof value !== 'string' || !value.startsWith('./') || value.includes('..')) {
    fail(`codex listing: ${label} must be a relative path starting with ./`);
    return;
  }
  if (/^https?:/i.test(value)) {
    fail(`codex listing: ${label} must not be a URL`);
    return;
  }
  const rel = value.slice(2);
  if (!existsSync(join(root, rel))) fail(`codex listing: ${label} file missing (${rel})`);
};

const OPENAI_REVIEW_TOOL_NAMES = new Set([
  'search_docs',
  'search_endpoints',
  'get_schema',
  'get_field',
  'validate_payload',
  'get_example',
  'get_skill',
  'guide_api_conventions',
  'guide_authenticate',
  'guide_handle_errors',
  'guide_oauth_app_setup',
  'guide_place_contract',
  'guide_rest_vs_websocket',
  'guide_subscribe_ticks',
]);
const OPENAI_POSITIVE_CASE_COUNT = 5;
const OPENAI_NEGATIVE_CASE_COUNT = 3;
const OPENAI_SCOPED_BANNED_KEYS = ['apps', 'hooks', 'onboardingSkill'];
const OPENAI_RECURSIVE_BANNED_KEYS = ['test_credentials', 'reviewer_instructions'];
const OPENAI_POSITIVE_CASE_TOOL_ORDER = [
  'search_docs',
  'search_endpoints',
  'get_schema',
  'get_field',
  'validate_payload',
];

function jsonHasAppReference(node) {
  if (typeof node === 'string') return node.includes('.app.json');
  if (Array.isArray(node)) return node.some(jsonHasAppReference);
  if (node && typeof node === 'object') {
    return Object.entries(node).some(([key, value]) => key.includes('.app.json') || jsonHasAppReference(value));
  }
  return false;
}

function isHttpsUrl(value) {
  if (typeof value !== 'string' || value !== value.trim() || !/^https:\/\/[^/\s]+/.test(value)) return false;
  try {
    return new URL(value).protocol === 'https:';
  } catch {
    return false;
  }
}

const codex = loadJson('.codex-plugin/plugin.json');
if (codex) {
  const iface = codex.interface && typeof codex.interface === 'object' ? codex.interface : null;
  if (!iface) fail('codex listing: interface object is required');
  if (codex.name !== 'deriv') fail('codex listing: name must be deriv');
  if (typeof codex.version !== 'string' || !SEMVER.test(codex.version)) {
    fail('codex listing: version must be semver');
  }
  if (typeof codex.description !== 'string' || !codex.description || codex.description.length > 1024) {
    fail('codex listing: description is required and must be 1024 characters or fewer');
  }
  if (!codex.author || codex.author.name !== 'deriv') fail('codex listing: author.name must be deriv');
  // Which file Codex reads instead is explained at step 3.
  if (codex.mcpServers !== './.mcp.json') {
    fail('codex listing: mcpServers must be ./.mcp.json (Codex reads the root mcp.json)');
  }
  if (codex.skills !== './skills/') fail('codex listing: skills must be ./skills/');
  for (const field of OPENAI_SCOPED_BANNED_KEYS) {
    if (field in codex) {
      fail(`codex listing: ${field} must be omitted${field === 'apps' ? '; there is no .app.json' : ''}`);
    }
  }
  const jsonHasKey = (node, key) => {
    if (Array.isArray(node)) return node.some((item) => jsonHasKey(item, key));
    if (node && typeof node === 'object') {
      if (Object.prototype.hasOwnProperty.call(node, key)) return true;
      return Object.values(node).some((value) => jsonHasKey(value, key));
    }
    return false;
  };
  for (const field of OPENAI_RECURSIVE_BANNED_KEYS) {
    if (jsonHasKey(codex, field)) {
      fail(`codex listing: ${field} must not appear`);
    }
  }
  if (jsonHasAppReference(codex)) fail('codex listing: .app.json references must not appear');

  const openai = codex.extensions?.['com.openai'];
  if (!openai || typeof openai !== 'object' || Array.isArray(openai)) {
    fail('codex listing: extensions.com.openai must be a non-array object');
  } else {
    for (const field of OPENAI_SCOPED_BANNED_KEYS) {
      if (field in openai) fail(`codex listing: extensions.com.openai.${field} must be omitted`);
    }
    const review = openai.review;
    if (!review || typeof review !== 'object' || Array.isArray(review)) {
      fail('codex listing: extensions.com.openai.review must be an object');
    } else {
      if (review.commerce !== false) fail('codex listing: review.commerce must be false');
      if (typeof review.commerce_description !== 'string' || !review.commerce_description.trim()) {
        fail('codex listing: review.commerce_description must be a non-empty string');
      }
      if ('demo_recording_url' in review) requireHttps('review.demo_recording_url', review.demo_recording_url);
      const positive = review.test_cases?.positive;
      const negative = review.test_cases?.negative;
      if (!Array.isArray(positive) || positive.length !== OPENAI_POSITIVE_CASE_COUNT) {
        fail(`codex listing: review.test_cases.positive must be an array of length ${OPENAI_POSITIVE_CASE_COUNT}`);
      } else {
        positive.forEach((c, i) => {
          for (const field of ['description', 'prompt', 'tools_triggered', 'expected_behavior']) {
            if (typeof c?.[field] !== 'string' || !c[field].trim()) {
              fail(`codex listing: positive[${i}].${field} must be a non-empty string`);
            }
          }
          if (typeof c?.tools_triggered === 'string') {
            const tokens = c.tools_triggered.split(',').map((t) => t.trim());
            for (const token of tokens) {
              if (token.includes('__') || token.startsWith('plugin-')) {
                fail('codex listing: tools_triggered must use a bare tool name');
              } else if (!OPENAI_REVIEW_TOOL_NAMES.has(token)) {
                fail('codex listing: tools_triggered token is not a permitted bare name');
              }
            }
            if (tokens.length !== 1 || tokens[0] !== OPENAI_POSITIVE_CASE_TOOL_ORDER[i]) {
              fail(`codex listing: positive[${i}].tools_triggered must be exactly ${OPENAI_POSITIVE_CASE_TOOL_ORDER[i]}`);
            }
          }
        });
      }
      if (!Array.isArray(negative) || negative.length !== OPENAI_NEGATIVE_CASE_COUNT) {
        fail(`codex listing: review.test_cases.negative must be an array of length ${OPENAI_NEGATIVE_CASE_COUNT}`);
      } else {
        negative.forEach((c, i) => {
          for (const field of ['description', 'prompt']) {
            if (typeof c?.[field] !== 'string' || !c[field].trim()) {
              fail(`codex listing: negative[${i}].${field} must be a non-empty string`);
            }
          }
        });
      }
    }
    if (typeof openai.publication?.release_notes !== 'string' || !openai.publication.release_notes.trim()) {
      fail('codex listing: publication.release_notes must be a non-empty string');
    }
  }
  if (iface) {
    if (iface.developerName !== codex.author.name) {
      fail('codex listing: interface.developerName must match author.name');
    }
    if (typeof iface.displayName !== 'string' || !iface.displayName || iface.displayName.length > 30) {
      fail('codex listing: displayName must be 30 characters or fewer');
    }
    if (typeof iface.shortDescription !== 'string' || !iface.shortDescription || iface.shortDescription.includes('\n') || iface.shortDescription.length > 30) {
      fail('codex listing: shortDescription must be one line of 30 characters or fewer');
    }
    if (typeof iface.longDescription !== 'string' || !iface.longDescription || iface.longDescription.length > 4000) {
      fail('codex listing: longDescription is required');
    }
    if (!CODEX_CATEGORIES.has(iface.category)) fail('codex listing: category is not a supported Codex category');
    if (!Array.isArray(iface.capabilities) || iface.capabilities.length === 0 || iface.capabilities.length > 20) {
      fail('codex listing: capabilities must be a non-empty list of at most 20 entries');
    }
    if (typeof iface.brandColor !== 'string' || !HEX.test(iface.brandColor)) {
      fail('codex listing: brandColor must be a six-digit hex color');
    }
    requireHttps('homepage', codex.homepage);
    requireHttps('author.url', codex.author.url);
    requireHttps('interface.websiteURL', iface.websiteURL);
    requireHttps('interface.privacyPolicyURL', iface.privacyPolicyURL);
    requireHttps('interface.termsOfServiceURL', iface.termsOfServiceURL);
    requireHttps('interface.supportURL', iface.supportURL);
    requireAsset('interface.logo', iface.logo);
    requireAsset('interface.logoDark', iface.logoDark);
    requireAsset('interface.composerIcon', iface.composerIcon);
    const prompts = Array.isArray(iface.defaultPrompt) ? iface.defaultPrompt : [];
    if (prompts.length === 0 || prompts.length > 3) fail('codex listing: defaultPrompt must contain 1–3 strings');
    const seen = new Set();
    for (const prompt of prompts) {
      if (typeof prompt !== 'string' || !prompt || prompt.includes('\n') || prompt.length > 128) {
        fail('codex listing: each defaultPrompt must be one line of 128 characters or fewer');
      }
      if (prompt.includes('@')) fail('codex listing: defaultPrompt must not contain @mentions');
      const key = prompt.normalize('NFC').trim();
      if (seen.has(key)) fail('codex listing: defaultPrompt entries must be unique');
      seen.add(key);
    }
    if ('screenshots' in iface) fail('codex listing: interface.screenshots must be absent');
  }
  const cursor = loadJson('.cursor-plugin/plugin.json');
  if (cursor && cursor.version !== codex.version) {
    fail('codex listing: version must stay in lockstep with .cursor-plugin/plugin.json');
  }
  const rootPlugin = loadJson('plugin.json');
  if (rootPlugin && rootPlugin.version !== codex.version) {
    fail('codex listing: version must stay in lockstep with plugin.json');
  }
}

const marketplace = loadJson('.agents/plugins/marketplace.json');
if (marketplace) {
  const entry = Array.isArray(marketplace.plugins) ? marketplace.plugins[0] : null;
  if (!entry) fail('codex listing: marketplace must declare one plugin entry');
  else {
    if (entry.name !== 'deriv') fail('codex listing: marketplace plugin name must be deriv');
    const expectedRef = typeof codex?.version === 'string' ? `v${codex.version}` : null;
    const extra = pinSourceExtraKey(entry.source);
    const isUrlPin =
      entry.source &&
      entry.source.source === 'url' &&
      entry.source.url === 'https://github.com/deriv-com/deriv-api-plugin.git' &&
      entry.source.ref === expectedRef;
    if (extra && isUrlPin) fail(`.agents/plugins/marketplace.json: extra key "${extra}"`);
    else if (!isUrlPin) {
      fail('codex listing: marketplace source must be the url pin at the lockstep version tag');
    }
    if (!entry.policy || entry.policy.installation !== 'AVAILABLE' || entry.policy.authentication !== 'ON_INSTALL') {
      fail('codex listing: marketplace policy must be AVAILABLE / ON_INSTALL');
    }
    if (entry.category !== 'Developer Tools') fail('codex listing: marketplace category must be Developer Tools');
  }
}

const claudeMarketplace = loadJson('.claude-plugin/marketplace.json');
if (claudeMarketplace) {
  const entry = Array.isArray(claudeMarketplace.plugins) ? claudeMarketplace.plugins[0] : null;
  if (!entry) fail('claude listing: marketplace must declare one plugin entry');
  else {
    const claudePlugin = loadJson('.claude-plugin/plugin.json');
    if (claudePlugin) {
      const cursor = loadJson('.cursor-plugin/plugin.json');
      if (cursor && cursor.version !== claudePlugin.version) {
        fail('claude listing: version must stay in lockstep with .cursor-plugin/plugin.json');
      }
      const rootPlugin = loadJson('plugin.json');
      if (rootPlugin && rootPlugin.version !== claudePlugin.version) {
        fail('claude listing: version must stay in lockstep with plugin.json');
      }
    }
    const expectedRef = typeof claudePlugin?.version === 'string' ? `v${claudePlugin.version}` : null;
    const extra = pinSourceExtraKey(entry.source);
    const isUrlPin =
      entry.source &&
      entry.source.source === 'url' &&
      entry.source.url === 'https://github.com/deriv-com/deriv-api-plugin.git' &&
      entry.source.ref === expectedRef;
    if (extra && isUrlPin) fail(`.claude-plugin/marketplace.json: extra key "${extra}"`);
    else if (!isUrlPin) {
      fail('claude listing: marketplace source must be the url pin at the lockstep version tag');
    }
  }
}

if (failures.length > 0) {
  for (const line of failures) console.error(`FAIL: ${line}`);
  console.error(`\nverify-surface: ${failures.length} problem(s) found`);
  process.exit(1);
}
console.log(`verify-surface: OK — ${paths.length} path(s), surface + presence + MCP URL pin + content + internal-reference + Codex gates clean`);

SHA-256: 1a505d6c4da61a05002e675d1563362ebe53a5289a7763f717bdb0dd9ea07bc3