Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .claude-plugin/marketplace.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
"name": "nutrient",
"source": "./plugins/nutrient",
"description": "Parse, extract from and classify documents with Nutrient DWS: OCR for scans, Markdown with layout, structured fields with citations, and labels, as stored runs in your Nutrient account.",
"version": "1.0.0",
"version": "1.0.1",
"author": { "name": "Nutrient", "url": "https://www.nutrient.io" },
"homepage": "https://www.nutrient.io/api/",
"license": "MIT"
Expand Down
2 changes: 2 additions & 0 deletions .github/workflows/check.yml
Original file line number Diff line number Diff line change
Expand Up @@ -17,3 +17,5 @@ jobs:
run: python3 ci/check.py
- name: Build the OpenAI package
run: python3 ci/package_openai.py
- name: Check packaging boundaries and reproducibility
run: python3 -m unittest discover -s ci -p 'test_*.py'
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
dist/
__pycache__/
4 changes: 4 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,10 @@ OpenAI's directory takes a ZIP of `plugins/nutrient`, uploaded for each release.

Never change `name` in `plugin.json`: it is the plugin's permanent identity.

For packaging changes, also run `python3 -m unittest discover -s ci -p 'test_*.py'`. The ZIP uses an explicit file list
in `ci/package_openai.py`; add new referenced resources there deliberately. Never add reviewer access or local app mappings.
See [the submission handoff](docs/openai-submission.md) for checks that still require the owner and a live ChatGPT run.

## Refreshing the tool snapshot

`ci/tools.json` and `ci/tools-chatgpt.json` are the catalogs Hosted MCP (`hosted-mcp/` in the private Nutrient monorepo)
Expand Down
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,12 @@ On Team and Enterprise plans, an Owner can roll the plugin out to the whole orga
- [`ci/check.py`](ci/check.py): checks the plugin against [`ci/tools.json`](ci/tools.json), a snapshot of the service's
tools; see [AGENTS.md](AGENTS.md) for changing the skill and refreshing the snapshot

## OpenAI submission

The existing submission package is built with `python3 ci/package_openai.py`. See the
[submission handoff](docs/openai-submission.md) for validation, review cases and remaining owner steps. A passing local
build is not a completed directory review.

## License

[MIT](LICENSE)
34 changes: 30 additions & 4 deletions ci/check.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,19 @@ def collect(node, names):
collect(item, names)


def credential_fields(node):
"""Reject credential containers even when nested in an otherwise supported extension."""
forbidden = {"clientsecret", "accesstoken", "refreshtoken", "apikey", "password", "testcredentials", "reviewerinstructions"}
if isinstance(node, dict):
for key, value in node.items():
check(re.sub(r"[_-]", "", key).lower() not in forbidden,
f"credential field {key} stays outside the public package")
credential_fields(value)
elif isinstance(node, list):
for value in node:
credential_fields(value)


marketplace = load(ROOT / ".claude-plugin" / "marketplace.json")
manifest = load(PLUGIN / ".claude-plugin" / "plugin.json")
entries = [entry for entry in marketplace.get("plugins", []) if entry.get("name") == manifest.get("name")]
Expand All @@ -80,6 +93,7 @@ def collect(node, names):

# OpenAI (ChatGPT and Codex): .codex-plugin/plugin.json shares .mcp.json and skills/ with Claude's manifest.
codex = load(PLUGIN / ".codex-plugin" / "plugin.json")
credential_fields(codex)
check(codex.get("name") == manifest.get("name") and codex.get("version") == manifest.get("version"),
".codex-plugin/plugin.json has the same name and version as .claude-plugin/plugin.json")
check(codex.get("mcpServers") == "./.mcp.json" and codex.get("skills") == "./skills/",
Expand All @@ -104,10 +118,12 @@ def collect(node, names):
data = image.read_bytes() if image.is_relative_to(PLUGIN) and image.is_file() else b""
# A complete PNG: signature, then IHDR's width and height, ending with the IEND chunk.
size = struct.unpack(">II", data[16:24]) if data[:8] == b"\x89PNG\r\n\x1a\n" and data.endswith(b"IEND\xaeB`\x82") else None
check(size is not None and size[0] == size[1] and size[0] >= 48,
f".codex-plugin/plugin.json {field} is a complete square PNG of at least 48 pixels inside the plugin")
check(size is not None and size[0] == size[1] and 48 <= size[0] <= 4096 and len(data) <= 5 * 1024 * 1024,
f".codex-plugin/plugin.json {field} is a complete square PNG, 48–4096 pixels and at most 5 MiB, inside the plugin")
# OpenAI's review metadata: exactly five positive and three negative cases for the initial MCP review, no credentials.
openai = codex.get("extensions", {}).get("com.openai", {})
check(set(openai) <= {"review", "publication"},
".codex-plugin/plugin.json OpenAI extension has only reviewed fields (no local apps or hooks)")
review = openai.get("review", {})
cases = review.get("test_cases", {})
positive, negative = cases.get("positive", []), cases.get("negative", [])
Expand All @@ -130,9 +146,19 @@ def collect(node, names):
check(len(openai_entries) == 1 and openai_entries[0].get("policy", {}).get("installation") in ("AVAILABLE", "INSTALLED_BY_DEFAULT", "NOT_AVAILABLE"),
".agents/plugins/marketplace.json gives the plugin a valid installation policy")

servers = load(PLUGIN / ".mcp.json").get("mcpServers", {})
check(list(servers) == ["nutrient"] and servers["nutrient"].get("type") == "http" and servers["nutrient"].get("url") == SERVICE,
mcp = load(PLUGIN / ".mcp.json")
check(mcp == {"mcpServers": {"nutrient": {"type": "http", "url": SERVICE}}},
f".mcp.json declares one server, nutrient, at {SERVICE} over HTTP (OpenAI connects one server per plugin)")
# The production connection uses OAuth discovery. No fixed headers, embedded credentials, local app mappings,
# or registration overrides belong in this package. Changes to authentication need an explicit review here.
check(set(codex) <= {"name", "version", "description", "author", "homepage", "repository", "license", "keywords",
"skills", "mcpServers", "interface", "extensions"},
".codex-plugin/plugin.json has only the reviewed package fields (no local apps or hooks)")
for catalog in ("tools.json", "tools-chatgpt.json"):
for tool in load(ROOT / "ci" / catalog):
annotations = tool.get("annotations", {})
check(all(type(annotations.get(hint)) is bool for hint in ("readOnlyHint", "destructiveHint", "openWorldHint")),
f"{catalog} {tool['name']} has explicit boolean tool annotations")

skills = sorted((PLUGIN / "skills").glob("*/SKILL.md"))
check(len(skills) > 0, "the plugin has at least one skill")
Expand Down
40 changes: 28 additions & 12 deletions ci/package_openai.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,22 +15,34 @@

ROOT = Path(__file__).resolve().parent.parent
PLUGIN = ROOT / "plugins" / "nutrient"
# Claude's manifest, and files an editor or OS leaves behind.
EXCLUDED_DIRECTORIES = {".claude-plugin", "__pycache__"}
EXCLUDED_FILES = {".DS_Store", "Thumbs.db"}
# Keep review notes, local app mappings, credentials and unexpected files out of the public ZIP.
# Add new resources here deliberately when the plugin starts referencing them.
PACKAGE_FILES = (
".codex-plugin/plugin.json",
".mcp.json",
"LICENSE",
"README.md",
"assets/logo.png",
"skills/nutrient-documents/SKILL.md",
)

for path in (PLUGIN, *PLUGIN.rglob("*")):
if path.is_symlink():
sys.exit(f"FAIL symbolic links are not allowed in the plugin: {path.relative_to(ROOT)}")

subprocess.run([sys.executable, str(ROOT / "ci" / "check.py")], check=True)

manifest = json.loads((PLUGIN / ".codex-plugin" / "plugin.json").read_text())
files = sorted(
path for path in PLUGIN.rglob("*")
if path.is_file() and path.name not in EXCLUDED_FILES
and not EXCLUDED_DIRECTORIES & set(path.relative_to(PLUGIN).parts[:-1])
)
names = [path.relative_to(PLUGIN).as_posix() for path in files]
for required in (".codex-plugin/plugin.json", ".mcp.json"):
if required not in names:
sys.exit(f"FAIL the package has no {required}")
names = sorted(PACKAGE_FILES)
files = [PLUGIN / name for name in names]
for path in files:
if not path.is_file():
sys.exit(f"FAIL the package has no {path.relative_to(PLUGIN)}")
resources = [manifest["interface"][field] for field in ("logo", "composerIcon")]
resources.extend(path.relative_to(PLUGIN).as_posix() for path in (PLUGIN / "skills").rglob("*") if path.is_file())
for resource in resources:
if resource.removeprefix("./") not in names:
sys.exit(f"FAIL add referenced resource {resource} to PACKAGE_FILES")

output = ROOT / "dist" / f"nutrient-openai-{manifest['version']}.zip"
output.parent.mkdir(exist_ok=True)
Expand All @@ -41,6 +53,10 @@
entry.compress_type = zipfile.ZIP_DEFLATED
archive.writestr(entry, path.read_bytes())

if output.stat().st_size > 100 * 1024 * 1024:
output.unlink()
sys.exit("FAIL the OpenAI package exceeds 100 MiB")

digest = hashlib.sha256(output.read_bytes()).hexdigest()
print("\n".join(" " + name for name in names))
print(f"PASS {output.relative_to(ROOT)} ({output.stat().st_size} bytes, sha256 {digest})")
97 changes: 97 additions & 0 deletions ci/test_package_openai.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
"""Exercise the public ZIP boundary in disposable copies, without network access."""

import json
import shutil
import subprocess
import sys
import tempfile
import unittest
import zipfile
from pathlib import Path

ROOT = Path(__file__).resolve().parent.parent
EXPECTED = {
".codex-plugin/plugin.json", ".mcp.json", "LICENSE", "README.md",
"assets/logo.png", "skills/nutrient-documents/SKILL.md",
}


class PackageTests(unittest.TestCase):
def setUp(self):
self.temp = tempfile.TemporaryDirectory()
self.addCleanup(self.temp.cleanup)
self.root = Path(self.temp.name)
for name in ("ci", "plugins", ".claude-plugin", ".agents"):
shutil.copytree(ROOT / name, self.root / name, ignore=shutil.ignore_patterns("__pycache__"))
for name in ("README.md", "LICENSE"):
shutil.copy2(ROOT / name, self.root / name)
self.plugin = self.root / "plugins" / "nutrient"

def build(self, success=True):
result = subprocess.run([sys.executable, str(self.root / "ci/package_openai.py")],
capture_output=True, text=True)
self.assertEqual(result.returncode == 0, success, result.stdout + result.stderr)
if not success:
self.assertFalse(list((self.root / "dist").glob("*.zip")))
return result.stdout + result.stderr

def edit_json(self, relative, edit):
path = self.plugin / relative
value = json.loads(path.read_text())
edit(value)
path.write_text(json.dumps(value))

def test_reproducible_package_and_private_files_excluded(self):
for name in (".env", ".app.json", "reviewer-credentials.txt"):
(self.plugin / name).write_text("PACKAGING-CANARY-PRIVATE")
self.build()
archive_path, = (self.root / "dist").glob("*.zip")
first = archive_path.read_bytes()
self.build()
self.assertEqual(first, archive_path.read_bytes())
with zipfile.ZipFile(archive_path) as archive:
self.assertEqual(set(archive.namelist()), EXPECTED)
self.assertIsNone(archive.testzip())
for name in archive.namelist():
self.assertNotIn(b"PACKAGING-CANARY-PRIVATE", archive.read(name))
manifest = json.loads(archive.read(".codex-plugin/plugin.json"))
self.assertEqual(manifest["name"], "nutrient")
self.assertEqual(manifest["mcpServers"], "./.mcp.json")
self.assertLess(len(first), 100 * 1024 * 1024)

def test_fixed_authentication_header_rejected(self):
self.edit_json(".mcp.json", lambda m: m["mcpServers"]["nutrient"].update(
headers={"Authorization": "Bearer PACKAGING-CANARY-PRIVATE"}))
self.assertIn("FAIL .mcp.json", self.build(success=False))

def test_nested_client_secret_rejected(self):
self.edit_json(".codex-plugin/plugin.json", lambda m: m["extensions"]["com.openai"].update(
auth={"clientSecret": "PACKAGING-CANARY-PRIVATE"}))
self.assertIn("credential field clientSecret", self.build(success=False))

def test_local_app_mapping_rejected(self):
self.edit_json(".codex-plugin/plugin.json", lambda m: m.update(apps="./.app.json"))
self.assertIn("no local apps or hooks", self.build(success=False))

def test_nested_local_app_mapping_rejected(self):
self.edit_json(".codex-plugin/plugin.json", lambda m: m["extensions"]["com.openai"].update(apps="./.app.json"))
self.assertIn("no local apps or hooks", self.build(success=False))

def test_symlink_to_external_file_rejected(self):
outside = self.root / "private.txt"
outside.write_text("PACKAGING-CANARY-PRIVATE")
logo = self.plugin / "assets/logo.png"
logo.unlink()
logo.symlink_to(outside)
self.assertIn("symbolic links", self.build(success=False))

def test_non_boolean_annotation_rejected(self):
path = self.root / "ci/tools-chatgpt.json"
tools = json.loads(path.read_text())
tools[0]["annotations"]["readOnlyHint"] = "false"
path.write_text(json.dumps(tools))
self.assertIn("explicit boolean tool annotations", self.build(success=False))


if __name__ == "__main__":
unittest.main()
Loading
Loading