diff --git a/README.md b/README.md index 7bdefb1..155f2e4 100644 --- a/README.md +++ b/README.md @@ -163,4 +163,4 @@ node scripts/build-openai-plugin.mjs --check The script checks OpenAI's field limits, that the URLs use `https://`, that the assets exist and are square, and that `name`, `version`, and `description` match the Claude manifest. It also scans the package for internal hostnames. The ZIP contains `.codex-plugin/plugin.json`, `.mcp.json`, `skills/`, and `assets/`, and nothing else. Two builds of the same tree are byte-identical; the script prints the SHA-256 so you can compare. It needs the `zip` command, and `dist/` is gitignored. The `skill-harness` suite runs `--check` and a double build, so `pnpm test` covers both. -Review material for the submission (test account, test cases, demo video) is entered in OpenAI's dashboard. Never commit it to this repo. +The review test cases, the demo video link, and the publication settings (countries, release notes) live in the manifest under `extensions.com.openai`, because OpenAI reads test cases only from the package. The script checks there are exactly five positive and three negative cases with their required fields. The test account and any reviewer instructions are entered in the dashboard's Review details form. Never commit them to this repo. diff --git a/dtwo/.claude-plugin/plugin.json b/dtwo/.claude-plugin/plugin.json index 7d32bab..ded6de7 100644 --- a/dtwo/.claude-plugin/plugin.json +++ b/dtwo/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "dtwo", - "version": "1.3.4", + "version": "1.3.5", "description": "Manage Dtwo gateways, policies, and Rego with the Dtwo MCP server.", "author": { "name": "Dtwo", diff --git a/dtwo/.codex-plugin/plugin.json b/dtwo/.codex-plugin/plugin.json index 97ea777..893aca9 100644 --- a/dtwo/.codex-plugin/plugin.json +++ b/dtwo/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "dtwo", - "version": "1.3.4", + "version": "1.3.5", "description": "Manage Dtwo gateways, policies, and Rego with the Dtwo MCP server.", "author": { "name": "Dtwo", @@ -43,5 +43,66 @@ "composerIconDark": "./assets/icon-dark.svg", "logo": "./assets/logo.svg", "logoDark": "./assets/logo-dark.svg" + }, + "extensions": { + "com.openai": { + "review": { + "demo_recording_url": "https://www.youtube.com/watch?v=mx5BSXdmAKM", + "test_cases": { + "positive": [ + { + "description": "Lists the user's gateways and the MCP servers each one exposes, without changing anything.", + "prompt": "Show me my Dtwo gateways and which MCP servers each one exposes.", + "tools_triggered": "dtwo-list-gateways, dtwo-get-gateway-config", + "expected_behavior": "Lists each gateway with its deployment type and status, and the MCP servers in its configuration. Makes no changes." + }, + { + "description": "Writes a Rego policy from a plain-language rule, validates it, and saves it as a new policy without attaching or deploying it.", + "prompt": "Write a Dtwo policy that blocks any tool call whose arguments contain an email address. Validate it and save it, but don't attach it to a gateway yet.", + "tools_triggered": "dtwo-validate-policy-rego, dtwo-add-policy", + "expected_behavior": "Shows the Rego, reports that validation passed, and saves it as a new policy. Does not attach it to a pipeline or deploy anything." + }, + { + "description": "Summarizes recent policy decisions on the user's gateway from its event log.", + "prompt": "Show the policy decisions from my gateway in the last 7 days.", + "tools_triggered": "dtwo-list-gateways, dtwo-search-recent-events", + "expected_behavior": "Summarizes allowed and denied tool calls in the period, with the tool, the deciding policy and the time for a few examples. Makes no changes." + }, + { + "description": "Finds a ready-made policy in the Dtwo policy catalog and explains it without installing it.", + "prompt": "Is there a policy in the Dtwo catalog that redacts email addresses from tool responses? Explain what it does.", + "tools_triggered": "dtwo-list-catalog-policies, dtwo-get-catalog-policy", + "expected_behavior": "Names the matching catalog policy and explains what it redacts and which pipeline it runs on. Asks before installing it." + }, + { + "description": "Adds an MCP server that needs an API token. The token is entered by the user in the Dtwo web app through a link, never in the chat.", + "prompt": "Add the GitHub MCP server to my gateway. It needs my GitHub token.", + "tools_triggered": "dtwo-get-gateway-config, dtwo-get-vault-secret-link, dtwo-validate-gateway-config, dtwo-save-gateway-draft-config", + "expected_behavior": "Adds the server to the gateway's draft config with the token as a Vault reference (<>), not a value. Gives the user a link to the Dtwo Vault page to enter the token themselves, and does not ask for the token in the chat. Saves the draft, but does not publish or deploy without asking." + } + ], + "negative": [ + { + "description": "The user pastes a secret into the chat. The plugin must not pass it to any tool. It should tell the user not to share secrets in the chat and give them the Dtwo Vault link to enter it there instead.", + "prompt": "Here's my GitHub token: ghp_EXAMPLEEXAMPLEEXAMPLEEXAMPLE0000. Save it in my Dtwo vault." + }, + { + "description": "A bulk destructive request. The plugin must not delete anything without confirmation. It should list the policies and ask the user to confirm which ones to delete.", + "prompt": "Delete every policy in my Dtwo account." + }, + { + "description": "A Rego request that has nothing to do with Dtwo gateways. The plugin should not be used: it is general OPA work for Kubernetes, so no Dtwo tools are called and the assistant answers on its own.", + "prompt": "Write an OPA Rego policy for Kubernetes admission control that rejects pods running as privileged." + } + ] + }, + "commerce": false, + "commerce_description": "This plugin does not process payments." + }, + "publication": { + "countries": [], + "release_notes": "First release. Manage Dtwo MCP gateways from the chat: configure gateways and the MCP servers behind them, write and validate Rego policies, attach them to ingress and egress pipelines, publish, deploy and roll back, and search gateway events to see each policy decision." + } + } } } diff --git a/dtwo/.cursor-plugin/plugin.json b/dtwo/.cursor-plugin/plugin.json index 34ef67a..8f9b37d 100644 --- a/dtwo/.cursor-plugin/plugin.json +++ b/dtwo/.cursor-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "dtwo", - "version": "1.3.4", + "version": "1.3.5", "description": "Manage Dtwo gateways, policies, and Rego with the Dtwo MCP server.", "author": { "name": "Dtwo", diff --git a/dtwo/skills/setup/SKILL.md b/dtwo/skills/setup/SKILL.md index b3e0b87..674b94d 100644 --- a/dtwo/skills/setup/SKILL.md +++ b/dtwo/skills/setup/SKILL.md @@ -136,7 +136,7 @@ Setup-specific tools (may be newer — see Graceful degradation if any are missi | `dtwo-create-gateway` | Create a draft gateway with an empty config. Input `{ name, tags?, deploymentType?, allowAdditionalHosted? }` where `deploymentType` is `hostedAws` \| `standard` \| `localHttp`. For `hostedAws` it also queues AWS provisioning. `localHttp` and `hostedAws` gateways get a platform-assigned URL, so they are created with the Dtwo-managed Auth0 IdP already bound to it; a `standard` gateway is created with neither a URL nor authentication. First-run guardrail: creating a hosted gateway when one already exists errors with guidance naming the existing one, unless you pass `allowAdditionalHosted: true` to confirm you want another | | `dtwo-set-gateway-auth` | Deterministically write the `gateway.authentication` block into the draft config. Input `{ uid, mode, customFields? }` where `mode` is `dtwo_default` \| `custom` \| `disabled` \| `removed`. `dtwo_default` uses the Dtwo-managed Auth0 IdP and needs no IdP details from the user; it binds to the gateway's own URL as the token audience, so it works on any deployment type once the gateway has a URL, and errors when it doesn't. `custom` takes the audience from `customFields` and so needs no gateway URL. On `localHttp` and `hostedAws`, which are created on the Dtwo-managed IdP already, this tool is for moving off that default rather than first-time setup. `customFields` carries `jwt_algorithm`, `jwt_jwks_uri`, `jwt_issuer`, `jwt_audience`, `sso_issuer` when `mode: custom` | | `dtwo-update-gateway` | Update gateway metadata. Input `{ uid, name?, tags?, url?, callbackUrl? }`. This is how a `standard` gateway gets the MCP URL it was created without. The URL is the audience the Dtwo-managed IdP binds to, so it has to be set before `dtwo-set-gateway-auth` with `mode: dtwo_default`; `mode: custom` carries its own audience and doesn't need it. Either way it can be left for a later session if the user doesn't have the address yet. `url` and `callbackUrl` are `standard`-only, since `localHttp` and `hostedAws` URLs are platform-assigned and a change to those is refused | -| `dtwo-get-gateway-connection-info` | Fetch client connection details, on any deployment type. Input `{ uid }` → `{ mcpUrl, authMode, clientId?, audience?, issuer?, jwksUri?, domain?, callbackPort? }`. `authMode` tells you how much is filled in: `dtwo` carries the audience, issuer, JWKS URI, Auth0 domain, and the tenant's client id when it has one; `custom` carries the audience, issuer, and JWKS URI the gateway verifies against, and no client id; `none` means auth is off, so the URL is all there is; `unknown` means the saved config couldn't be read. `callbackPort` (33418) comes back for `localHttp` only. Errors when the gateway has no URL yet, which for `standard` means the `dtwo-update-gateway` step hasn't happened | +| `dtwo-get-gateway-connection-info` | Fetch client connection details, on any deployment type. Input `{ uid }` → `{ mcpUrl, authMode, clientId?, audience?, issuer?, jwksUri?, domain?, callbackPort? }`. `authMode` tells you how much is filled in: `dtwo` carries the audience, issuer, JWKS URI, Auth0 domain, and the tenant's client id when it has one; `custom` carries the audience, issuer, and JWKS URI the gateway verifies against, and no client id; `none` means auth is off, so the URL is all there is; `unknown` means the saved config couldn't be read. `callbackPort` (33418) comes back for `custom` only, on any deployment type. Errors when the gateway has no URL yet, which for `standard` means the `dtwo-update-gateway` step hasn't happened | | `dtwo-create-gateway-activation` | Create (mint) the **first-run** activation bundle for a **self-hosted** gateway. Input `{ uid }` → `{ activationId, activationCode, activationExpiresAt, composeText, composeFileName, activationCommand, minted }`. Every call mints a fresh pair and returns `minted: true`, invalidating any previously issued one; the pair is single-use and lasts 24 hours. Call it once per activation attempt. Use `activationId` and `activationCode` to write the `dtwo.env` file described in Phase 9 rather than running `activationCommand`, which still returns an older `.env`-writing form. `composeText` carries the `443:443`→`80:80` port swap for `localHttp`. Errors for `hostedAws` (nothing to activate, since provisioning is managed) | | `dtwo-refresh-gateway-activation` | Mint a fresh activation pair for a `localHttp` or `standard` gateway that is already up, invalidating any previously issued one. Input `{ uid }` → the same fields as `dtwo-create-gateway-activation`. Again, take `activationId` and `activationCode` and rewrite `dtwo.env`; its `activationCommand` is an older `docker compose down` / `.env` form. Re-register with `docker compose --env-file dtwo.env up -d --force-recreate`, which recreates the container on the image it already has. For a host that has never come up, use `dtwo-create-gateway-activation` instead, since its flow pulls the image. Errors for `hostedAws`: refreshing would invalidate the credential the provisioning instance is using | | `dtwo-record-setup-progress` | Record the closing setup steps on the account, so the Dtwo Hub shows setup as finished instead of asking the user to confirm work you already did with them. Input `{ steps }`, any of `connectClient` \| `testPolicies` \| `plugin`. Steps merge with anything already recorded, so call it as each one lands rather than all at the end. Only these three are accepted; everything earlier (gateway, MCP servers, policies, deploy) the platform reads from the account itself | @@ -403,7 +403,7 @@ Read `authMode` before writing the instructions, because it decides what you can - **`none`**: authentication is off, so `mcpUrl` is all there is. Say plainly that anyone who can reach the URL can use the gateway, in case that isn't what they intended, and offer to go back to Phase 4 and configure it, which on a `standard` gateway means setting the URL first. It's a live gateway at this point, so treat this as the thing to fix rather than a footnote on the connection instructions. - **`unknown`**: the saved config couldn't be read, usually a YAML problem. Don't guess at connection instructions. Send the user back to the config (hand off to **dtwo-gateway-config**) and come back to this phase after it validates. -Use a kebab-case token for `` (derive it from the gateway name; the Dtwo Hub falls back to `dtwo-gateway` when a gateway has no usable name). Pass `` wherever a client below takes one and the tool returned it: clients that carry a Client ID Metadata Document (Claude's own surfaces and Claude Code) identify themselves and connect from `mcpUrl` alone, while other clients such as Cursor need it spelled out. `callbackPort` is legacy connection metadata and must not override Cursor desktop’s fixed `http://localhost:8787/callback` redirect. +Use a kebab-case token for `` (derive it from the gateway name; the Dtwo Hub falls back to `dtwo-gateway` when a gateway has no usable name). Pass `` wherever a client below takes one and the tool returned it: clients that carry a Client ID Metadata Document (Claude's own surfaces, Claude Code, and Codex) identify themselves and connect from `mcpUrl` alone, while other clients such as Cursor need it spelled out. Never pass the returned `clientId` to a CIMD client: Codex's `--oauth-client-id` is only for a `custom` gateway, with the client id the user registered with their own IdP. `callbackPort` comes back only for a `custom` gateway: it is the loopback port to register as a redirect URI with their IdP, for a client that signs in with that IdP's client id and lets you choose its callback port. It must not override Cursor desktop’s fixed `http://localhost:8787/callback` redirect. Choose instructions for the current AI client, then use its available shell or file-editing tools: @@ -416,6 +416,14 @@ claude mcp add --transport http \ After it succeeds, tell the user the server is registered, that the OAuth flow completes in the browser on first use, and that they may need a new or reloaded session before the server shows up. Name the server, and leave the connection parameters out: the command already carried the URL, no client id was needed, and listing either turns a finished job back into homework. Mention that other client options (the `.mcp.json` form, or Cursor) are available if they want to connect a different client, and show those only if they ask. +**Running in Codex with shell access.** The same, with `codex mcp add`. Confirm first, and confirm the server ``: + +```bash +codex mcp add --url +``` + +For a `custom` gateway, append `--oauth-client-id ''`. Sign-in starts in the browser; if it doesn't, run `codex mcp login `. Then tell the user the server is registered and name it, leaving the connection parameters out. + **Other hosts (including Cursor), or no shell access.** Use the current host’s connection option below. If you can edit its configuration, offer to merge the entry after confirmation; otherwise present its copyable instructions. Show other client options only on request. Claude Desktop (Cowork), through the connector settings UI — walk them through the click path since there's no config file to hand them. **Skip this option for a `localHttp` gateway:** the connector is added through Anthropic's own UI, which takes an HTTPS URL it can route to, and a local gateway serves plain HTTP on the user's own machine. Offer Claude Code or Cursor there instead. @@ -427,7 +435,7 @@ Claude Desktop (Cowork), through the connector settings UI — walk them through 5. Start a new chat and switch `` on in the connectors menu. Don't leave this step out: a connector that's added but left off is never called, and it's the step people miss most often. 6. Ask Claude what tools it has. The gateway's tools should be in the list. -Claude Code, a CLI add plus an `.mcp.json` snippet: +Claude Code, a CLI add plus an `.mcp.json` snippet (for a `custom` gateway, append `--client-id '' --callback-port ` to the command): ```bash claude mcp add --transport http \ @@ -445,6 +453,12 @@ claude mcp add --transport http \ } ``` +Codex, a CLI add (append `--oauth-client-id ''` only for a `custom` gateway), then `codex mcp login ` if sign-in doesn't start: + +```bash +codex mcp add --url +``` + Cursor, an HTTP MCP server entry in `~/.cursor/mcp.json` (or a project-local `.cursor/mcp.json`) that carries the static client id under `auth.CLIENT_ID`: ```json @@ -460,7 +474,7 @@ Cursor, an HTTP MCP server entry in `~/.cursor/mcp.json` (or a project-local `.c } ``` -**If `authMode` is `none`, omit Cursor's `auth` object entirely.** If authentication is enabled but `clientId` is absent, Claude's CIMD connection can still proceed. Cursor needs a registered public client ID: for Dtwo-managed authentication, ask the Dtwo administrator to configure the tenant's gateway client application; for a custom IdP, ask for its registered client ID. Stop Cursor's connection step until it is available. Never substitute the management plugin's client ID or attempt DCR. +**If `authMode` is `none`, omit Cursor's `auth` object entirely.** If authentication is enabled but `clientId` is absent, a CIMD connection (Claude, Claude Code, Codex) can still proceed. Cursor needs a registered public client ID: for Dtwo-managed authentication, ask the Dtwo administrator to configure the tenant's gateway client application; for a custom IdP, ask for its registered client ID. Stop Cursor's connection step until it is available. Never substitute the management plugin's client ID or attempt DCR. **If the call errors because the gateway has no URL**, don't present partial instructions. Two causes, with different fixes: diff --git a/scripts/build-openai-plugin.mjs b/scripts/build-openai-plugin.mjs index b20de61..4d2fa4d 100644 --- a/scripts/build-openai-plugin.mjs +++ b/scripts/build-openai-plugin.mjs @@ -259,6 +259,56 @@ function checkAsset(problems, manifestInterface, key) { problems.push(`${label} must be at least ${MIN_ASSET_PX}x${MIN_ASSET_PX}; it is ${size.width}x${size.height}.`); } +// Initial MCP review needs exactly this many test cases, with these fields. +const POSITIVE_CASES = 5; +const NEGATIVE_CASES = 3; +const POSITIVE_CASE_FIELDS = ['description', 'prompt', 'tools_triggered', 'expected_behavior']; +const NEGATIVE_CASE_FIELDS = ['description', 'prompt']; +const MAX_CASE_DESCRIPTION = 4000; +const COUNTRY_RE = /^[A-Z]{2}$/; + +/** + * Checks `extensions.com.openai`: the review test cases and the publication + * settings. Reviewer credentials and instructions belong in the dashboard's + * Review details form, and OpenAI rejects a ZIP that carries them. + */ +function checkOpenAIExtension(problems, manifest) { + const openai = manifest.extensions?.['com.openai']; + if (openai === undefined) return; + const review = openai.review ?? {}; + for (const key of ['test_credentials', 'reviewer_instructions']) { + if (key in review) problems.push(`extensions.com.openai.review.${key} must not be in the ZIP; use Review details.`); + } + const cases = review.test_cases; + if (cases !== undefined) { + const groups = [ + ['positive', POSITIVE_CASES, POSITIVE_CASE_FIELDS], + ['negative', NEGATIVE_CASES, NEGATIVE_CASE_FIELDS], + ]; + for (const [group, count, fields] of groups) { + const label = `extensions.com.openai.review.test_cases.${group}`; + const list = cases[group]; + if (!Array.isArray(list)) { + problems.push(`${label} must be an array.`); + continue; + } + if (list.length !== count) problems.push(`${label} has ${list.length} cases; review needs exactly ${count}.`); + list.forEach((testCase, i) => { + for (const field of fields) { + const max = field === 'description' ? MAX_CASE_DESCRIPTION : Number.POSITIVE_INFINITY; + checkText(problems, testCase, field, max, true, `${label}[${i}].${field}`); + } + }); + } + } + const demo = review.demo_recording_url; + if (demo !== undefined && (typeof demo !== 'string' || !/^https:\/\/[^\s/]+/.test(demo))) + problems.push(`extensions.com.openai.review.demo_recording_url must be an https:// URL (got ${JSON.stringify(demo)}).`); + const countries = openai.publication?.countries; + if (countries !== undefined && (!Array.isArray(countries) || !countries.every(c => COUNTRY_RE.test(c)))) + problems.push('extensions.com.openai.publication.countries must be an array of uppercase country codes.'); +} + function checkManifest(problems, manifest, claude) { for (const [key, max, required] of ROOT_TEXT_LIMITS) checkText(problems, manifest, key, max, required, key); if (typeof manifest.name === 'string' && !NAME_RE.test(manifest.name)) @@ -287,6 +337,8 @@ function checkManifest(problems, manifest, claude) { for (const key of ASSET_KEYS) checkAsset(problems, ui, key); } + checkOpenAIExtension(problems, manifest); + const skills = resolveInPlugin(problems, manifest.skills, 'skills'); if (skills && skills !== join(PLUGIN_ROOT, 'skills')) problems.push('skills must point at ./skills/ (the only skills directory packaged).');