Add OpenAI Codex plugin and marketplace linting - #471
Conversation
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>
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>
# Conflicts: # .skillsaw-card.svg
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>
Every finding the Codex, CodeRabbit and panel reviewers left open on this PR, verified against the code first and each guarded by a test that fails without the fix. Discovery - .codex-plugin/ is the plugin evidence, not the manifest inside it, so a directory whose plugin.json was deleted keeps its CODEX_PLUGIN type and codex-plugin-json-valid reports the missing entrypoint. - A sibling catalog named *marketplace.json is taken on existence, so broken JSON in api_marketplace.json is reported rather than hidden. Unrelated JSON in .agents/plugins/ still has to duck-type. - Skills are followed to the directories the manifest's `skills` field names, not just literal skills/ — for a plugin under .codex/plugins/ that is the only route into the tree. - CODEX_PLUGIN and CODEX_MARKETPLACE joined _TYPE_PRIORITY, below the Claude types, so a Codex-only repo stops reporting `unknown`. Lint tree - Inline `hooks` objects become a CodexInlineHooksBlock and reach hooks-dangerous, hooks-prohibited and hooks-json-valid. A `curl | sh` SessionStart hook written inline was invisible to all three. - A Codex-only plugin's .mcp.json is attached, so the MCP rules see it when no PluginNode owns the directory. - The agentskill-* rules share one SKILL_REPO_TYPES set, now including CODEX_PLUGIN, so Codex-hosted skills are actually checked. Rules - Manifest and marketplace paths are rejected when they resolve outside their root through a symlink, not only on `..` and absolute strings. - `name: ""` is a required-field error in both manifests instead of falling through to the kebab-case warning a default fail-on:error lets pass. - An npm registry must name a host: "https:registry.example.com" parsed clean before. - A present `category` must be a non-empty string. - Local marketplace entries register by path only. Crediting name and path independently let one crossed entry cover two plugins. - plugin-json-required keeps firing on a directory with an explicit .claude-plugin/ marker — a dual-ecosystem plugin whose Claude manifest is gone is exactly what that rule reports. Validation: 3103 tests pass; openai/plugins is byte-identical at 6755 findings and zero from codex-* rules; openshift-eng/ai-helpers exits 0. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Second round of Codex reviewer findings.
- `mcpServers` is followed in both non-conventional forms — a path to a
file other than `.mcp.json`, and the inline server map real plugins
ship. Those servers name commands the host spawns, so they now reach
mcp-valid-json and mcp-prohibited the way `.mcp.json` already did.
A `CodexInlineMcpBlock` carries the by-value form, sharing the
`_InlineJsonPayload` behaviour with the inline hooks block.
- A malformed inline hooks event is carried into the tree instead of
filtered out. `codex-plugin-json-valid` deliberately skips hook objects,
so `{"SessionStart": {...}}` with a non-list value was reported by
nothing at all; hooks-json-valid now rejects it. Merging copies the list
rather than aliasing it, since the manifest dict comes from the shared
read cache.
- `skillsaw docs` documents Codex-only plugins. `extract_docs()` walked
`PluginNode`s alone, so a repository it had just classified `codex-plugin`
produced no plugin metadata, hooks or MCP documentation. Dual-ecosystem
plugins are still documented once, through their Claude manifest.
The three near-identical manifest-path readers (`hooks`, `skills`,
`mcpServers`) now share `_codex_declared_paths`, and the inline object
readers share `_codex_inline_objects`.
Validation: 3114 tests pass (11 new; 7 fail without these fixes, the rest
are the deliberate still-works guards). openai/plugins lint output stays
byte-identical at 6755 findings; `skillsaw docs` on it exits 0 and now
emits plugin metadata. openshift-eng/ai-helpers exits 0.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Panel BLOCKING plus nine Codex connector findings. Installed plugins (panel BLOCKING) - codex-plugin-json-valid and codex-plugin-structure now stand down on `.codex/plugins/*`. `is_codex_installed_plugin` had one call site, so a developer who installed a third-party plugin failed a default `fail-on: error` run on someone else's manifest — skillsaw does not walk `.claude/plugins/*` at all, which settles the intended posture. The hooks, MCP and agentskill rules keep running there: those commands execute in this checkout, and that coverage is the point. A defective vendor plugin is now in `codex/broken`, with a companion test asserting hooks-dangerous still fires on it so no future fix can over-correct into a blanket exclude. docs/repo-types.md states the full contract per rule. Discovery - `Path.resolve()` is wrapped everywhere it touches a manifest string. An embedded NUL raises ValueError, and discovery runs during `RepositoryContext.__init__`, so it aborted the whole lint instead of producing a violation. - `plugins/*` and `.codex/plugins/*` are checked for containment. Those probes use `is_dir()`, which follows a symlink out of the repository and pulled an external manifest, hooks, MCP servers and skills into the tree. - Inline `hooks` and `mcpServers` produce one block per declared object rather than a merge. Merging had to discard an occurrence when an array repeated an event or a server name, and either loss hides a defect. Activation - Codex plugin rules and the agentskill-* rules now fire for CODEX_MARKETPLACE too, the same way PLUGIN_REPO_TYPES carries MARKETPLACE. `--type codex-marketplace` discovered every local plugin and then checked none of them. Rules and docs - File-valued manifest fields reject a directory (and `skills` a file). `exists()` passed, then the tree dropped the declaration silently. - `skillsaw docs` renders a Codex catalog as a marketplace. `marketplace_data` only loads the Claude manifest, so a Codex catalog fell to the single-page renderer, which shows plugins[0] and drops the rest — on openai/plugins that was 1 rendered plugin out of 180. - MCP servers report their real source file instead of a hard-coded `.mcp.json`. Also moved the pure Codex helpers to `formats/codex.py` alongside `formats/promptfoo.py`, and documented why `_InlineJsonPayload.inline_data` must be redeclared in each subclass — both panel follow-ups. Validation: 3132 tests pass (18 new; 12 fail without these fixes). openai/plugins lint output byte-identical at 6755 findings; `skillsaw docs --format markdown` on it goes from 1 file to 181. openshift-eng/ai-helpers exits 0. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
LintTarget compares by (type, resolved path), which assumes the path identifies the config. It does not for by-value config: a manifest may declare an array of inline hooks or mcpServers objects, so several blocks legitimately share one manifest path while carrying different payloads. Under the inherited equality they compared equal, so any set() would have silently dropped all but one — losing hooks the security rules exist to see. Nothing in the tree does that today (the lint_tree `seen` sets hold paths, and these blocks bypass _add_block), so this is latent rather than live, but the collision is a trap for the next reader. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
# Conflicts: # .skillsaw-card.svg
Round four: one CodeRabbit finding and six from the Codex connector. - `safe_resolve` and `escapes_root` also catch `RuntimeError`. A symlink loop raises `RuntimeError` before Python 3.13 and `OSError` from 3.13 on; skillsaw supports 3.9-3.14, so catching only the latter left the lint aborting on the older half of the range. Caught by CodeRabbit. - `.agents/plugins/marketplace.json` is taken on existence rather than `is_file()`. A directory in place of the reserved entrypoint declassified the repository instead of being reported. - `_discover_skills_in_dir` takes an optional containment root, passed for Codex plugins. `skills/external` as a symlink out of the repository was still followed — the direct probes were fixed last round, the recursive child scan was not. The pre-Codex call sites pass nothing, so their behaviour is unchanged. - `"skills": "./"` names the plugin root, which is a legal place to keep a skill. File-valued fields still reject it. - Duplicate entry names are tracked across sibling catalogs. Codex aggregates them into one namespace and `skillsaw docs` writes one page per name, so two catalogs claiming a name silently lost a page. The single-catalog message is unchanged; the other file is named only when it differs. - `skillsaw docs` no longer publishes `.codex/plugins/*` installs as members of the repository's catalog — the same authorship line the registration and manifest-quality rules draw. - `_plugin_filename` sanitises every platform separator, the drive-letter colon and `..`. A kebab-case violation is only a warning, so `docs` cannot assume `lint` rejected a hostile name first; on Windows `\` is a real separator and the joined path could leave the output directory. The resolve-failure tests inject the exception rather than building real symlink loops — no single interpreter in the supported range takes both branches, so a real loop would only ever exercise one of them. Validation: 3153 tests pass (20 new; 10 fail without these fixes). Merged main (#458) and regenerated. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Caught by running the previous commit against openai/plugins: the cross-catalog duplicate check put 29 spurious ERRORs on the official reference catalog. It lists 29 of its 180 plugins in both marketplace.json and api_marketplace.json with byte-identical sources — a curated second listing, not an ambiguity. Codex resolves both to the same plugin and `docs` writes the same page either way. The defect the reviewer described is narrower: two catalogs claiming one name for *different* sources, where aggregation has to pick one and a docs page is genuinely lost. Only that is reported now. Within a single file any repeat stays a defect, exactly as before. openai/plugins is byte-identical to the pre-branch baseline again. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The panel returned APPROVE with no blocking findings. These are its optional follow-ups, minus the two it explicitly scoped elsewhere. - **Symlink escape (Security + QA, converged).** The guard checked the plugin directory but not the reserved subdirectory, so `plugins/victim/.codex-plugin -> /outside` passed: `victim` is genuinely in-repo, and `is_dir()` follows the link. skillsaw read an out-of-tree manifest, and codex-plugin-structure enumerated that external directory's filenames into lint output under an in-repo-looking path. Both halves are containment-checked now, with the companion test QA specified. The identical hole on the Claude side is left alone — the panel asked for that as a separate PR, and it is not this PR's to widen. - **extract_docs was O(plugins x skills).** Every plugin re-resolved every SkillNode in the tree — 99.2% of runtime on a 180-plugin catalog. Both sides are resolved once now: openai/plugins goes 2.67s -> 1.42s. - **Removed the only rule-package-to-rule-package import in the tree.** `is_absolute_path` / `has_parent_traversal` move to a new `skillsaw.paths`; `marketplace/json_valid.py` re-exports them so any external importer keeps resolving. - Minor: `safe_resolve` in `is_codex_installed_plugin`; `_extract_hooks` / `_extract_mcp_servers` typed `LintTarget`, which is what they always accepted; the duplicate `exists()` stat hoisted; docs/extractor imports `safe_resolve` from `formats.codex` rather than the context re-export. Not taken, both by the panel's own reasoning: the Claude-side symlink hole (separate PR), and the `registration.py:165` apostrophe truncation, which is a pre-existing Claude bug unrelated to Codex. Validation: 3155 tests pass. openai/plugins byte-identical to the pre-branch baseline; `docs --format markdown` still 181 files; openshift-eng/ai-helpers exit 0 with an unchanged 480 findings. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Review feedback on the PR's own growth: comments and docstrings had begun
describing the review history rather than the shipped behaviour.
- Test sections were grouped as "Review follow-ups, round three/four" —
organised by when they were written rather than what they cover. Now
grouped by subject: repository classification and manifest shapes,
authored vs. installed content, hostile and malformed inputs.
- Comments no longer describe behaviour that never shipped ("without this
a Codex catalog fell through to the single-page renderer") or cite
figures from a review round ("99.2% of extract_docs runtime", "29 of its
180 plugins"). The reasoning stays where it explains the code; the
archaeology goes.
- `docs/repo-types.md` said the same thing three times for `hooks`,
`skills` and `mcpServers`. One sentence states the shared rule and a
three-row table gives the specifics.
No behaviour change: 3155 tests pass, openai/plugins byte-identical,
ai-helpers exit 0.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
# Conflicts: # .skillsaw-card.svg
Fifteen findings from the Codex connector on e954170. All reproduce; each fix carries a test verified to fail without it. Containment — three paths reached outside the checkout - The catalog entrypoint is resolved and rejected when it leaves the repository. This one had teeth: codex-marketplace-registration writes the catalog back when it registers a plugin, so `fix --suggest` through a symlinked marketplace.json would have overwritten an external file. - An existing `.codex-plugin/plugin.json` must itself resolve inside the plugin. A *missing* manifest still counts as a plugin, so the earlier "discovery keys off the directory" behaviour is unchanged. - `hooks/hooks.json` and `.mcp.json` are found by convention rather than declared, so nothing had checked them; declared paths already were. - The direct-skill branch checks containment. Only the recursive branch did, so a symlinked `skills/` was appended without a check. Crashes - A directory symlink cycle inside a plugin (`skills/a/loop -> ../..`) stays contained, passes every guard, and recursed until Python raised RecursionError during context construction. Visited directories are now tracked separately from discovered skills. - A non-string entry in `recommended-fields` raised TypeError from `field not in data`, which the linter turns into a rule crash — no manifest got validated at all. - `parsed.port` raises on `:not-a-port`; the port is only decoded on access, so nothing looked at it. Now validated with the rest of the URL. Correctness - `--type single-plugin` no longer discovers Codex content. Discovery ran before the override was installed, so the tree attached manifests, hooks and skills the override existed to exclude. - Duplicate-name detection compares normalised source identities. Raw JSON made `"./plugins/foo"` and `{"source":"local","path":"plugins/foo"}` look like different sources and reported a duplicate that was not one. - An inline `mcpServers` map is only unwrapped when the wrapper key is the sole key — a bare map may hold a server named `mcpServers`, and unwrapping on presence discarded every sibling. - Agent Skill autofix stands down on `.codex/plugins/*`. The checks still run, but `skillsaw fix` was free to rewrite a third-party SKILL.md and record the rename in this repository. Generated docs - Rendering is gated on a populated marketplace model rather than the primary repo type. A Codex catalog in a repo that is also APM or dot-claude lost the slot and fell to the single-page renderer. - Page filenames are collision-safe. Sanitising is lossy — `a/b`, `a:b` and `a-b` all reduce to `a-b.md` — and pages are keyed by filename, so one plugin silently replaced another while both index rows linked to the survivor. - `interface.category` is read, matching `interface.displayName`. - Remote catalog entries get a metadata-only doc, so the index stops reporting fewer plugins than the catalog lists. Validation: 3175 tests pass (20 new; 14 fail without these fixes). openai/plugins byte-identical at 6755 findings, docs still 181 files; openshift-eng/ai-helpers exit 0. Merged main. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Eight findings from the Codex connector on 94262e3. Several are edge cases the previous round's fixes left open. Containment — two read paths bypassed discovery's boundary - `codex-marketplace-registration._check_entries()` reads the manifest itself, so discovery's containment does not cover it. A symlinked manifest put an external file's name into a violation message. - A skill directory can be contained while its `SKILL.md` is a symlink out. The directory was checked; the entrypoint was not. Allocation and normalisation — both mine from last round - Page filenames are allocated by probing a reserved set. Suffixing on a counter meant a generated `a-b-2.md` could collide with a plugin genuinely named `a-b-2`, and the later page overwrote the earlier. - Source normalisation strips exactly one leading `./`. `lstrip("./")` removes an arbitrary run of dots and slashes, so `./.plugins/foo` and `./plugins/foo` compared equal and a real conflict was suppressed. - Remote entries are collected from every catalog rather than the first. A repository that splits its listing across siblings lost the second file's remote-only entries entirely. Manifest arrays - `hooks`, `skills` and `mcpServers` permit one array level. The validator flattened recursively while discovery reads one level, so `[["./custom-hooks.json"]]` validated clean and never became a HooksBlock — executable commands passing every check and reaching no hook rule. Nested arrays are now reported as invalid values. Docs and autofix - A `PluginNode` does not imply a Claude plugin: legacy discovery creates one for any directory with `commands/` or `skills/`. A Codex-only plugin that also ships commands was misread as dual-ecosystem, so its docs fell back to the directory name and lost every manifest field. The two extractors now partition on a real Claude identity. - The registration autofix refuses a non-kebab manifest name. Writing one into the catalog traded a registration error for a naming error and published the identifier hosts use as the component namespace. The apostrophe-name test is split in two: one asserts such a name is now refused, the other keeps the original guard that fix() pairs violations by re-rendering check()'s message rather than parsing the name back out. Validation: 3184 tests pass (9 new; 7 fail without these fixes). openai/plugins byte-identical at 6755 findings, docs still 181 files; openshift-eng/ai-helpers exit 0. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Seven findings from the Codex connector on 291b609. Four are consequences of last round's fixes. Containment - A manifest must resolve inside *its own* plugin, not merely inside the repository. `plugins/a/.codex-plugin -> plugins/b/.codex-plugin` stays in the checkout, so the repo-wide check accepted it and plugin A was discovered and documented using B's manifest. Generated docs - The HTML renderer gates on the populated model like the Markdown one. Only Markdown was changed last round, so a Codex catalog in an APM or dot-claude repo still lost the grid, navigation, category filter and search. - Filenames are reserved case-folded. `Foo.md` and `foo.md` are one file on default macOS and Windows installs, so the exact-case set handed out both and the second page overwrote the first. - Codex remote entries are merged even when a Claude marketplace supplies the catalog identity. The `elif` meant a repository with both formats dropped every remote-only Codex entry. - A malformed local source is no longer published as a remote one. `{"source": "local"}` with no path yields `None` from `codex_local_source_path`, which the remote branch read as "not local" and advertised a page for a plugin Codex skips. - Hooks and MCP docs are read from the legacy `PluginNode` as well as the Codex node. For a plugin shipping `commands/`, legacy discovery claims `hooks/hooks.json` and `.mcp.json` first and the tree's `seen` set keeps them off the Codex node — so moving metadata to the Codex extractor last round left those two behind. Autofix - A name the catalog already spells is not advertised as fixable. `fix()` refuses to append a duplicate name, so the offer was a no-op that left the violation standing after the user applied it. This needed its own name set: `_registered()` deliberately ignores local entries' names. Validation: 3191 tests pass (7 new; 6 fail without these fixes). openai/plugins byte-identical at 6755 findings, docs still 181 files; openshift-eng/ai-helpers exit 0. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…erage Eleven findings from the Codex connector on ad0107b. Security - Skill `references/` files are containment-checked against the owning Codex plugin. Only the skill directory and SKILL.md were, so a plugin could symlink a reference out of the checkout — and the SAFE content fixes rewrite these files in place, so `skillsaw fix` would have written through it. - The generated HTML escaped values into inline `onclick` handlers with an entity-encoded quote. `innerHTML` decodes entities *before* the handler compiles, so the quote came back live and a crafted category such as `x');alert(document.domain);//` executed on click. Values are now escaped for the JS context first, then for HTML. Pre-existing — Claude categories flow through the same path — but Codex categories and the wider marketplace rendering made it reachable from a new input. Discovery - A document declared as both `hooks` and `mcpServers` is attached twice. `seen` is keyed by path, so the hooks attachment claimed it and the servers reached neither MCP rule. Fixed narrowly rather than by rekeying the shared set: `(path, type)` looks more correct but weakens the plugin-contributor duplicate guard and promptfoo fragment sharing, both of which depend on path identity. - A dangling in-repository catalog symlink stays discovered, so the rule reports it instead of the repository silently losing its type. Rules and config - `OpenclawMetadataRule` carries its own host-type list, so Codex-hosted skills were skipped by it even after the shared set was widened. - A non-iterable `installation-values` / `authentication-values` raised TypeError from the membership test, crashing the rule mid-catalog. - `.pre-commit-hooks.yaml` triggers on the Codex discovery inputs (`.codex-plugin/`, `.codex/plugins/`, `.agents/plugins/*.json`). With `pass_filenames: false` a commit touching only those skipped skillsaw entirely, contrary to what the hook documents. Generated docs - Codex-only classification keys off what discovery accepted rather than a raw `is_file()`, which followed a rejected symlinked manifest and dropped the plugin from the docs altogether. - `_BlockSources` deduplicates by node identity: a legacy PluginNode has the Codex node as a descendant, so inline hooks and servers were listed twice. - Legacy nodes are indexed once instead of rescanned per Codex plugin, which was O(codex x legacy) filesystem resolutions. - The `skills` field takes directory paths, not inline config — the docs promised an inline form that discovery never reads. Validation: 3208 tests pass (17 new; 12 fail without these fixes). openai/plugins byte-identical at 6755 findings, docs still 181 files; openshift-eng/ai-helpers exit 0. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Six findings from the Codex connector on 427ce62. Three follow from last round's fixes. Security - `homepage` and `repository` are dropped unless they use a safe scheme. HTML-escaping an href stops attribute breakout but does nothing about the scheme, so `javascript:` in a manifest a marketplace merely accepted would run script in the docs origin when a reader clicks through. Second instance of this shape after the inline-handler escaping, and the same root cause: escaping for the wrong context. Consequences of the previous round - The conventional `.mcp.json` is seeded into the Codex MCP set. The second-attachment fix read every claimed path as needing another MCP interpretation, so a manifest declaring `"mcpServers": "./.mcp.json"` got two blocks and doubled every violation and doc entry. - `recommended-fields` is normalised when the *setting itself* is not a list. Last round guarded each entry, but a scalar fails at the loop before that guard runs, and config loading does not enforce `config_schema` types — so the rule crashed for every manifest. - `plugin-json-required` decides Codex identity from contained discovery rather than a raw `is_file()`. With a manifest symlinked out of the plugin, discovery rejects it, so no Codex rule covers the directory — and the Claude rule was standing down too, leaving it unreported by anything. Generated docs - A nested plugin's skills go to the nearest containing plugin root. When the repository root is itself a plugin, everything under `plugins/` is relative to both roots, so the root's page duplicated and misattributed every nested plugin's skills. - Windows reserved device basenames are suffixed. `con`, `nul`, `com1` and friends stay reserved with an extension, so `con.md` cannot be created and generation failed for the whole catalog on Windows. Validation: 3208 tests pass (23 new; 17 fail without these fixes). openai/plugins byte-identical at 6755 findings, docs still 181 files; openshift-eng/ai-helpers exit 0. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
- Escape attribute values with an attribute-context escaper: esc() serialises a text node and leaves the double quote intact, which closes an attribute and lets a second handler follow it. - Keep evals/evals.json inside its owning Codex plugin. The rules read and rewrite it, so a symlink out of the plugin is a read and a write outside the checkout. - Attribute nested content to the nearest owning plugin, not the first matching root. - Stand rename-refs autofix down on skills installed under .codex/plugins/, matching the rest of the authored-vs-installed line. - Register a catalog entry by name only when its source is genuinely remote. A malformed entry names nothing installable, and crediting it silenced the unregistered report for a real directory. - Advertise agentskill-name fixability honestly on installed skills, where fix() already stood down. - Resolve the Codex roots once per docs extraction instead of once per legacy plugin node. - Keep a plugin whose .codex-plugin is a regular file or a dangling symlink: the reserved name is occupied either way, and discarding it un-typed the repository so nothing reported the broken entrypoint. - Reserve README.md so a plugin named "readme" cannot be overwritten by the index page. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Blocking: - Guard the stat() calls that follow safe_resolve. Path.resolve() does not stat, so it returns a path that the next is_dir() raises on, and pathlib swallows ENAMETOOLONG only from Python 3.13 on. On 3.9-3.12 a manifest declaring a 4000-character path aborted the whole lint from inside RepositoryContext.__init__, where the rule-execution-error guard cannot reach it: traceback, exit 1, nothing reported. Correctness: - Build the HTML page from the catalog's membership, as the Markdown renderer does. A remote-only catalog rendered an empty grid. - Derive catalog membership from the catalog's plugins array rather than from everything discovery found; a dot-claude repo published .claude itself as an entry. Overlay each listing's category onto the matched plugin, which is where the field usually lives. - Filter discovered catalogs through the exclusion predicate. The lint tree filtered them; skillsaw docs read the list directly. - Keep an installed plugin's skills out of the standalone-skills pass. Skipping the plugin left its skills matched by no PluginDoc, so they were published as this repository's own content. - Read the Claude rules' Codex stand-down from the filesystem, not from repo_types or from discovery. Both are switched off by an explicit --type, so --type marketplace resurrected on a Codex repo the exact false positives the stand-down exists to remove. - Reject a non-string hook type before the set membership test. A list-valued type raised TypeError and took hook validation for every remaining block with it. - Match sibling catalogs on a name boundary; a bare endswith claimed notamarketplace.json. Compare the primary by resolved path so a case-insensitive filesystem does not list it twice. - KEBAB_CASE anchors with \Z. "$" also matches before a trailing newline, so "my-plugin\n" passed and the autofix published it. - Coerce sort keys with name_str; a list-valued name raised TypeError. - Report a non-object interface, which every check below silently skipped. Structure: - Move the eight stateless codex_* methods off RepositoryContext into formats/codex.py as free functions over a plugin_dir, and import them there rather than through context's re-export. - Type the new helpers; drop a noqa on an import used seven lines down. Tests and docs: - CLI-level autofix coverage through Linter.fix_and_apply, including BOM and CRLF preservation and the whole-file reserialisation, which is pinned rather than endorsed. - commands/ and a README in the clean fixture, so plugin-json-required iterates something and its stand-down is actually guarded. - Malformed catalog and manifest shapes; the visible plugins/* symlink case, with the pre-existing agentskills-walk gap marked xfail. - Document the Codex stand-down on both affected rule pages; correct the sibling-discovery condition and the claim that policy.authentication is undocumented; add Codex to the maintenance skill's description. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
# Conflicts: # .skillsaw-card.svg
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Review Panel VerdictDisposition: REQUEST_CHANGES Specialist FindingsArchitecture ReviewerVerified against the code, not the description. Full suite run: 3482 passed ( 1. BLOCKING — silent lint/fix scope expansion for existing Claude repos —
Action: restrict the widening to the genuinely-new surfaces ( 2. SUGGESTION —
Action: either delete the 3. SUGGESTION — a rule answers a Claude-ownership question with a fresh filesystem probe —
Action: 4. SUGGESTION — the lint tree reaches into a private context method — *sorted(p for p in context._codex_claim_set() if not context.is_path_excluded(p)),The single-pass plugin loop depends on a private. Action: expose the union as a public method (e.g. 5. SUGGESTION —
Action: initialize 6. SUGGESTION —
Action: pick one. Either make 7. SUGGESTION — the new
Action: add 8. SUGGESTION —
Action: add an explicit 9. NOTE — new bare 10. NOTE — URI-scheme handling inconsistent between two link scanners — 11. NOTE — provenance is not quite 12. NOTE — one of the three claimed behavior changes isn't in this diff. Checked and clean: import cycles/module boundaries ( Python ExpertFull test suite passes (3482), 1. SUGGESTION — skill discovery is ~2.5x slower on repos with no Codex content —
Measured on
The only thing Action: if child_bound is None and (
safe_exists(item / CODEX_PLUGIN_MANIFEST[0]) or resolved in self._codex_claim_set()
) and self.provenance(item).codex_only:
child_bound = resolvedand move 2. SUGGESTION —
Measured: gating on Action: 3. SUGGESTION —
Profiled at 120 plugins: Action: build the ownership index once — 4. SUGGESTION — a file created by this PR is on the bare- The ratchet's docstring says "existing debt is grandfathered per issue #463, but any new file reaching for bare Action: use 5. NOTE — 6. NOTE — tree builder reaches into 7. NOTE — Checked and clean: Python 3.9 compatibility (no Security & Supply Chain Reviewer1. BLOCKING — The PR adds
Demonstrated against the code as checked out. A plugin shipping And a manifest with The parentheses are escaped ( Action: add 2. SUGGESTION — the generated page filename is interpolated into the index link destination with no escaping — lines.append(f"| [{label}]({fname}) | {desc} | {ver} |")
Plugin name Action: use the CommonMark pointy-bracket form 3. SUGGESTION —
Extrapolating, a 1.6 MB command body is ~45 minutes for one plugin. The regex is unchanged from base, but this PR newly routes Codex plugin Action: cap the input to 4. NOTE — 5. NOTE — the 6. NOTE — Checked and clean: QA EngineerTest suite: PASSES.
Verified good: all five new rules fire on 1. SUGGESTION — ten uncovered violation branches in The lowest-covered new rule (93%) has ten violation branches with zero test assertions anywhere. Each confirmed live and reachable by driving it from
These are the plainest schema branches — exactly what a refactor of Action: add a parametrized test to 2. SUGGESTION — the positive duck-typing branch of sibling-catalog discovery is never exercised —
Action: add the positive twin — write a 3. SUGGESTION — the autofix's catalog target selection is unpinned —
Action: add 4. SUGGESTION — the non-string- The guard is unconditional — it protects every repo type, including plain Claude plugins, from a Action: mirror the test in 5. SUGGESTION — the Asserted only as Action: add an integration test with 6. SUGGESTION — no permission-error test anywhere in the new Codex suite — The Action: add one root-guarded 7. SUGGESTION — two documented guards in Lines 194/511/514-515 ( Action: for 332, use the existing 8. NOTE — 9. NOTE — encoding failure conflated with syntax failure — 10. NOTE — coverage residue. Working tree left clean; all probe tests written to Technical WriterDocs coverage for this PR is unusually thorough — five new rule docs, a new rule category page, a new 1. BLOCKING — rule doc misstates which manifest fields get the path-kind check — The doc states:
The implementation drives this off Verified at runtime (temp fixture, restored): a manifest with Rationale for BLOCKING: a reader following this doc will believe skillsaw guards their Action: rewrite the paragraph to match 2. SUGGESTION — the The published hook's Action: add a 3. SUGGESTION — doc implies an " Action: drop it, or reword to make clear it is upstream guidance skillsaw does not enforce. 4. SUGGESTION — The table says Action: teach the generator to emit 5. SUGGESTION — uneven depth across the five new Codex rule docs
Action: add the missing 6. NOTE — Verified clean: Generated-file drift: none — ran Ecosystem ReviewerVerdict: BELONGS IN CORE. No scope blocker.I went in prepared to recommend redirect-to-plugin given the ~6.6k Adoption clears the bar comfortably, by a wide margin over several formats already in core:
Standardization favors core, decisively — the point that moved me most. Codex's authoritative manifest lookup is not a private path: // codex-rs/exec-server-protocol/src/protocol.rs:46
pub const DISCOVERABLE_PLUGIN_MANIFEST_PATHS: &[&str] = &[
".codex-plugin/plugin.json",
".claude-plugin/plugin.json",
".cursor-plugin/plugin.json",
];plus The plugin API genuinely cannot deliver this. Verified against the base commit. Spec conformance verified against the implementation, not the PR description — checked against 1. SUGGESTION — the
// codex-rs/core-plugins/src/manifest.rs:60
struct RawPluginCommandManifest {
#[serde(default)]
commands: Option<RawPluginManifestPaths>,
}
// :177 pub(crate) fn load_plugin_command_paths(...)
// :187 resolve_manifest_paths(&plugin_root, "commands", Some(&commands))The PR covers Action: add 2. SUGGESTION — The module comment explains why Action: extend the module comment to name all three 3. NOTE — cite the generated schema for 4. NOTE — Checked and clean: manifest and marketplace paths both correct, with the Slopinator ReviewerReviewed Overall: unusually clean. Keyword sweeps for the classic residue vocabulary ( Four residue findings survived, all clustered in the new test package. 1. BLOCKING — review-history narration in a shipped docstring —
The last clause narrates an event inside this PR's review. A stranger opening this file in a year cannot verify it and gains nothing; the first half already justifies the ratio form completely. Action: cut the trailing clause. 2. BLOCKING — round-scoped measurement plus a phantom bug — # Truncate-first makes this near-constant (ratio ~1); the old
# quadratic scan lands near 16. 8 leaves headroom for noise."the old quadratic scan" describes an implementation that does not exist on Action: # The display cap is applied before the scan, so a 4x larger input
# must not cost 4x the work. 8 leaves headroom for measurement noise.3. BLOCKING — fix-sequence narration and a change announcement — # Formerly a strict xfail: the generic walk had no containment
# boundary. _discover_skills_in_dir now derives one from
# provenance whenever it starts in or descends into a Codex-only
# directory, closing this route too."Formerly a strict xfail" is keyed to the state of this branch mid-review, not to anything that ships. "now derives … closing this route too" is a change announcement — git records that. What the test actually asserts never gets stated. Action: # _discover_skills_in_dir derives a containment boundary from
# provenance whenever it starts in or descends into a Codex-only
# directory, so the generic walk cannot leave the checkout either.4. BLOCKING — before/after narration of an optimization that never shipped separately — # One resolve per lookup, for the queried path itself. Before the
# memo it was one per root per lookup as well, which two rules pay
# once per skill."Before the memo it was…" describes a pre-memoization state that exists only in this branch's history. Action: # One resolve per lookup, for the queried path itself: the root set
# is memoized, and two rules pay this lookup once per skill.5. SUGGESTION — the "Adding an ecosystem" recipe is one 15-line sentence — Seven imperative clauses chained across semicolons and three em dashes. Every other entry in this file is a bounded bullet, and a recipe is the one thing that most wants to be an ordered list — this is the shape an agent will follow step by step. Action: break it into a numbered list, one step per current semicolon clause, keeping the wording; leave "teach 6. SUGGESTION — narration of the PR's own authoring —
Narrates something that happened during this PR's authoring; a future maintainer has no diff and cannot check it, and the passive hides who produced it. The actionable half is the instruction to read the Action: 7. NOTE — deictic pointing at a review conversation — 8. NOTE — past indicative for a conditional invariant — 9. NOTE — branch commit subjects. ~20 of the branch's commits are keyed to review iterations rather than behavior — 10. NOTE — PR body is a version-scoped document and exempt from diff-anchoring rules. One nit: "zero Panel SynthesisThis is a large, unusually well-executed change. The Ecosystem Reviewer went in expecting to recommend redirect-to-plugin and came back with the opposite conclusion, on strong evidence: Codex's manifest lookup is a published constant that reads Claude's and Cursor's manifests, the schema is headed for a vendor-neutral domain ( Four blocking items stand between this and merge. Two are substantive; two are cheap but required. The one that matters most is not about Codex at all. The second is a mitigation claim that does not hold. The Security Reviewer demonstrated that The remaining two blockers are inexpensive. The Corroboration across specialists strengthened four non-blocking items. Architecture and Python independently flagged the tree builder reaching into The highest-value optional item is performance. Python measured context construction on a Codex-free repo going from 94ms to 238ms, with On disagreements: there were none of substance. No specialist contradicted another; where two looked at the same code they reached the same conclusion. The Ecosystem Reviewer's Two housekeeping notes for the author: the PR description lists the Required Actions Before Merge
Optional Follow-upsOrdered by value. None block merge.
Generated by skillsaw-review-panel |
1. Widening in _resolve_lint_paths is Codex-only again: a .claude-plugin manifest path keeps its established scope, so neither lint nor fix reaches files the caller never named. The old widening-behavior test is replaced by one pinning the fix blast radius. 2. Close the Markdown-renderer injection gap: _table_cell entity-encodes angle brackets, and the previously raw heading/meta sinks (skill and agent names, command headings, license, compatibility, allowed tools) fold through it. THREAT_MODEL T15 drops to partially_mitigated, naming description prose as accepted risk. Hostile-metadata test added for the Markdown path. 3. The codex-plugin-json-valid doc now states the kinds the rule actually checks (hooks, path-valued mcpServers, skills) and says apps/interface assets are containment-checked only. 4. De-narrate four review-history comments in tests/codex/. Also from the Codex round: catalog-claimed directories join the Codex ownership roots (Codex-exclusive ones only, so docs still refuse to publish a dual-identity claim Codex cannot install). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Response to the panel roundAll four required actions are addressed in
Also folded in from the Codex round: catalog-claimed directories now join the Codex ownership roots, so Declined, with reasons (replied inline): the PEP 604 / Python 3.9 report (the module has Validation after the fixes: 3,484 tests green, The optional follow-ups are recorded rather than folded in — the perf recovery, the |
|
@coderabbitai review --dir src/skillsaw |
|
✅ Action performedReview finished.
|
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 2f173ea62f
ℹ️ 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".
Review Panel VerdictDisposition: REQUEST_CHANGES Specialist FindingsArchitecture ReviewerReviewed the source surface ( The layering is genuinely good: 1. BLOCKING —
Measured on a synthetic 200-plugin / 4000-command marketplace: Four passes is exactly the real load: Recommended action: memoize the scope filter. Either (a) cache 2. BLOCKING — Synthetic repo (120 plugins × 25 skills = 3000 skills, pure Claude, no cProfile of Three hot spots, all unconditional work for a Codex-only concern:
Recommended action: guard the 3. SUGGESTION — A private call across a module boundary is the edge that silently breaks when the caching strategy inside 4. SUGGESTION —
5. SUGGESTION —
6. SUGGESTION — Works by ancestor test, but the contract is now "path or directory, whichever" and nothing pins it. Also 7. SUGGESTION — Four mutable dedupe sets whose interaction rules are subtle and load-bearing, expressible only as closure state — nothing can unit-test them in isolation. Extract a 8. NOTE — dead import: 9. NOTE — 10. NOTE — Checked, no issue found: no import cycles ( Python ExpertNo BLOCKING findings. Full suite passes (3484 passed), 1. SUGGESTION — a brand-new file was added to the bare- The ratchet docstring states the contract: "existing debt is grandfathered per issue #463, but any new file reaching for bare Recommended action: use 2. SUGGESTION —
3. SUGGESTION — linear scan of
4. SUGGESTION —
5. NOTE — 6. NOTE — 7. NOTE — 8. NOTE — 9. NOTE — 10. NOTE — 11. NOTE — Clean: packaging change is version-only (0.17.0 → 0.18.0), matched in Security & Supply Chain ReviewerVerified the PR's own security claims by exercising them, not by reading the prose. One blocking hole; the rest of the claimed hardening holds up. 1. BLOCKING — Catalog-claimed directory with a symlinked
if codex_manifest_is_contained(plugin_dir) or (
resolved is not None and resolved in self._codex_claim_set() # <-- no marker containment
):
ecosystems.add("codex")
This is exactly the hole the registration rule already patches locally for itself ( Reproduced (git-committable, relative symlink, fresh repo on this branch):
Recommended action: close it at the single ownership point per the repo's own "Ecosystem provenance" rule. Either (a) in 2. SUGGESTION — Three Markdown-renderer sinks bypass Every other metadata scalar folds through lines.append(f"### {hook.event_type}") # arbitrary JSON key
lines.append(f"**Matcher:** `{entry.matcher}`") # arbitrary string
lines.append(f"**Paths:** {', '.join(f'`{g}`' for g in rule.globs)}")
T15's mitigation text says " Recommended action: wrap all three in 3. SUGGESTION — Redaction happens at message construction, not at any sink. The new Codex-gated MCP check echoes an untrusted manifest key unredacted: Recommended action: route 4. NOTE — U+2028/U+2029 survive 5. NOTE — 6. NOTE — the generic repo walk still follows symlinks out of the checkout ( Checked and clean: QA EngineerSuite verification (run, not trusted): Verified — the three behavior changes: Verified — new-rule fixtures & autofix invariants: 1. SUGGESTION — nine unexecuted diagnostics in a new 506-line ERROR-severity rule — All are reachable — I built a catalog firing every one, producing correct but unasserted messages ( 2. SUGGESTION — new debt added under the "grandfathered" banner in the same commit that introduced the ratchet —
3. SUGGESTION — untested load-bearing Genuinely reachable: def test_a_regular_file_at_the_marker_name_does_not_crash(self, tmp_path):
repo = tmp_path / "r"; (repo / "plugins" / "foo").mkdir(parents=True)
(repo / "plugins" / "foo" / ".codex-plugin").write_text("not a dir")
ctx = RepositoryContext(repo)
assert CodexPluginStructureRule({}).check(ctx) == []
assert any("Missing .codex-plugin/plugin.json" in m
for m in messages(CodexPluginJsonValidRule({}).check(ctx)))4. SUGGESTION — two new symlink-loop guards pass vacuously on the 3.13/3.14 CI legs — Both are covered only by tests using a real self-symlink. @pytest.mark.parametrize("exc", [RuntimeError, OSError, ValueError])
def test_path_matches_patterns_never_raises(monkeypatch, tmp_path, exc):
monkeypatch.setattr(Path, "resolve", lambda self, *a, **k: (_ for _ in ()).throw(exc("nope")))
assert path_matches_patterns(tmp_path / "x", tmp_path, ["**/x"]) is False5. SUGGESTION — the duck-typing catalog-acceptance branch is never executed —
6. SUGGESTION — Registering one plugin rewrites every line of a catalog authored at any other indentation, changing line counts. 7. NOTE — the The only delta versus base is 8. NOTE — 9. NOTE — 10. NOTE — No BLOCKING findings. Everything probed behaves correctly at runtime; every item is a missing assertion, dead code, or a convention slip. Technical WriterVerification performed (clean). Re-ran Doc accuracy vs code — verified against source. All five new rules: Behavior changes — traced end-to-end. Completeness. 1. SUGGESTION — the pre-commit hook trigger widened with no change signal —
2. SUGGESTION — external rule authors have no signal that
3. SUGGESTION — "(no autofix)" misdescribes rules that never autofix — The 4. SUGGESTION — the non-Codex half of the
5. NOTE — 6. NOTE — 7. NOTE — No BLOCKING documentation issues. Ecosystem ReviewerVerdict: ACCEPT INTO CORE — no redirect to plugin. Adoption evidence (all checked live):
Core already ships rule packages for OpenClaw, promptfoo, APM, and CodeRabbit — all substantially more niche than a first-party OpenAI format backed by a 100k-star CLI. This is single-vendor rather than a cross-tool standard, which normally favors plugins, but the vendor is OpenAI and the trajectory is clearly ascending. Redirecting this to a plugin would be inconsistent with what core already covers. Conformance verification. Ran the author's own stated regression check: cloned Spec accuracy — verified against raw upstream, not summarizers. Fetched 1. SUGGESTION — The project's pre-push checklist item 5 requires
Reproducing ai-helpers' exact command ( The change is an improvement, not a defect. The ecosystem-relevant point: ai-helpers contains zero Codex content (0 Recommended action: confirm the docs-output change is an accepted breaking change and call it out in the 0.18.0 release notes. If the 2. SUGGESTION — the
3. SUGGESTION — The rule declares On size and the Slopinator ReviewerScope checked: every added/changed code comment and docstring under Method: verified the issue-number convention is pre-existing ( 1. BLOCKING — phantom-bug docstring narrating a regression that never shipped — The base ref's docstring reads "A trailing Recommended action: replace lines 49–52 with a statement of the invariant, no history: 2. SUGGESTION — the PR body's lead claim contradicts its own change section — PR #471 body, opening paragraph The lead sentence claims "zero behavior change for Claude-only repositories," and the body then carries a section headed "Behavior changes affecting existing (non-Codex) repositories" listing three. A Claude-only repository is non-Codex, and all three reach it. The supporting evidence ("
3. NOTE — 4. NOTE — 5. NOTE — commit-message chain: roughly half the 40 commits are titled by review round rather than by change ( 6. NOTE — nine near-identical Clean: no round-numbered test organization anywhere in Panel SynthesisThe feature is right for core and the engineering is above the bar for this repo. The Ecosystem Reviewer independently verified adoption (openai/codex at 101k stars; openai/plugins an active first-party catalog of 180 plugins; ~2,100 GitHub code hits for The panel also independently confirmed the PR's own claims rather than accepting them: QA ran the suite (3484 passed, 94% coverage — the stated numbers are accurate), the Technical Writer regenerated the entire docs tree from a pristine archive and found zero drift, and Security exercised the XSS, credential-redaction, containment, and autofix-stand-down hardening against hostile fixtures and found most of it genuinely holds. Six blocking items, all in scope and all cheap relative to the size of the change. They cluster into three themes: Security — one real containment escape, one reproduced docs injection. Security found that Performance — a measured regression on repositories with no Codex content. Architecture benchmarked both halves. Accuracy — two claims the panel could not reproduce. Ecosystem bisected the pre-push checklist's own gate: Notable near-misses that stayed below blocking. Python and QA both landed on Filtered as noise or non-actionable: the catalog-reserialization indentation concern (consistent with the pre-existing Claude rule, explicitly pinned as known behavior); the generic symlink walk (confirmed identical on the merge base); the Nothing here challenges the design. Every blocker is a leak at a seam the architecture already defines correctly — a second path around a single ownership point, three sinks that skipped a helper the other twenty use, a memoization boundary crossed, and two claims that outran their evidence. Land the fixes and this is a strong addition. Required Actions Before Merge
Strongly recommended alongside the above (two specialists each, one-line fixes):
Optional Follow-upsEncapsulation and layering — promote Performance (non-blocking) — build the owner index once in Test coverage — parametrized tests for the nine unexecuted Documentation — a Naming — consider renaming Refactoring and polish — extract a Generated by skillsaw-review-panel |
A Codex catalog claim declares a directory; it never licenses reading through it. Discovery containment-checks the `.codex-plugin` marker, but `provenance()` had a second route to `codex=True` — membership in the claim set — that skipped the check. A catalog-claimed directory whose marker symlinked out of the checkout was therefore adopted whole: the lint tree built a Codex manifest node over the external path, codex-plugin-structure iterated it, and the report became a directory listing of files the repository does not contain, published wherever skillsaw output goes. The check belongs at the ownership layer, so `codex_marker_escapes()` joins `codex_manifest_is_contained()` in `formats/codex.py` and `provenance()` consults it before crediting a claim. A directory with no marker at all does not escape, so a markerless claim still stands and codex-plugin-json-valid still reports the manifest the catalog promised. codex-marketplace-registration's local containment re-derivation goes with it: the entry check now asks the same shared reader, so "usable manifest" means one thing across discovery, provenance, and the rule. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The file was added to `GRANDFATHERED_BARE_RESOLVE` in the same commit that introduced the ratchet, while already importing `safe_resolve` four lines away. Its one bare call sat in the discovered-plugin scan, where a symlink loop or unreadable parent raises and costs the rule every finding it had for the entire catalog — the whole failure mode the ratchet exists to stop. The `is_file()` beside it becomes `safe_is_file` for the same reason, and the allowlist entry goes, so the ratchet only tightens. The raising branch differs by interpreter version, so the regression test injects the exception rather than building a real symlink loop, which would exercise only whichever branch the runtime takes. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Hook event names, hook matchers, and rule globs reached the Markdown renderer raw while every other metadata scalar folded through `_table_cell()`. An event name is an arbitrary JSON object key: a hostile `hooks.json` whose key carried newlines emitted a second heading, a live `javascript:` link, and a raw `<img onerror>` into the generated page, and a backtick in a matcher or glob closed the code span around it. The HTML renderer escaped all three correctly — this was Markdown only. Rule names fold too: they come from a filename stem, and a filesystem accepts every Markdown control character in a name. THREAT_MODEL T15 claimed this mitigation already and scoped its accepted risk to `description` prose, so the entry is corrected to describe what ships rather than what was intended. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The server name is an `mcpServers` object key, reachable from an inline `mcpServers` map in a Codex `plugin.json` — inside the entry point where T14 claims manifest-derived values have their URL userinfo stripped before reaching a violation message. CI uploads these reports as JSON and SARIF artifacts, so the new check echoed a credential-shaped key straight into one. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three places consulted Codex ownership unconditionally, and the cost fell hardest on repositories with no Codex content, which get nothing back for it. Measured on a synthetic 200-plugin marketplace and a pure-Claude 120-plugin / 3000-skill repository, against merge-base 43654b2: scoped_find(CommandBlock) x4 base 0.000s before 0.174s after 0.000s RepositoryContext.__init__ base 0.152s before 0.224s after 0.163s lint tree build base 0.660s before 0.877s after 0.716s Path.resolve calls base 25568 before 43594 after 26542 `find()` is memoized because the tree is static while rules run, and `scoped_find()` wrapped that memoized call in a per-node, per-rule provenance consult — handing the cost straight back. `find_filtered()` memoizes a filtered view in the same cache as `find()`, so every path that drops the find memo drops these with it, and rules sharing a scope share one list. `provenance()` gains an unresolved-path cache key so a repeat consult costs a dict lookup rather than a realpath, and `in_format_scope` memoizes its decision per (directory, ecosystem). The discovery walk stops resolving `SKILL.md` for directories that do not hold one and stops asking provenance per directory when the checkout has no Codex evidence at all — conservatively, since a `--type` override switches discovery off but never the author's declaration, so a contained manifest still declares Codex and still bounds the walk. `codex_plugin_owning` tests its cached root set before resolving, and `_add_openai_metadata` tests for the file before resolving three paths to describe it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The `_pattern_variants` docstring described a regression that no released skillsaw ever had: the widening it warns about was introduced inside this branch and reverted four commits later. A reader of main has no diff to check it against and would go looking for a version where the default excludes swallowed skill-structure errors. The invariant is what matters, and `docs/configuration.md` already states the shipped semantic. Verified identical to the merge base across 8 patterns x 10 relative paths: the only delta in this branch is `list` -> `sorted(set)`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`Path.resolve(strict=False)` stopped raising on symlink loops in Python 3.13, so the real-symlink tests covering the guards in `path_matches_patterns` and the file-read cache pass vacuously on the 3.13 and 3.14 CI legs — deleting either `except` clause would not fail them. skillsaw supports 3.9-3.14, so no single interpreter can reproduce every raising branch; the branch already established injection as the answer for `safe_resolve` and `escapes_root`, and these two now match it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`skillsaw docs` output changed for every repository, Claude-only ones included: the HTML now escapes JavaScript-string contexts with a dedicated escaper and attribute contexts with the attribute escaper, and plugin pages carry the manifest's author. Verified against openshift-eng/ai-helpers, which commits its generated docs and gates CI on them: the merge base regenerates byte-identically, this branch produces an 84-line diff and fails the repository's own plugins-doc-up-to-date rule until it regenerates. The change is worth keeping — it closes an XSS boundary — but the upgrade cost belongs where a CI author will find it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 053f1bb1ca
ℹ️ 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".
| manifest = plugin_dir.joinpath(*CODEX_PLUGIN_MANIFEST) | ||
| if contained_resolve(manifest, root) is None: | ||
| return False | ||
| return safe_is_file(manifest) |
There was a problem hiding this comment.
Preserve Codex ownership for marker-only plugins
When a plugin has a contained .codex-plugin/ directory but no plugin.json and the user runs with an explicit non-Codex type such as --type marketplace, Codex discovery is disabled so _codex_claim_set() is empty, and this safe_is_file() requirement makes provenance classify the directory as unclaimed/Claude. The lint tree can then run Claude scoped rules on a broken Codex plugin instead of standing them down; treat the contained marker as Codex evidence and only reject an existing manifest that resolves outside the plugin.
AGENTS.md reference: AGENTS.md:L161-L164
Useful? React with 👍 / 👎.
| manifest = plugin_dir.joinpath(*CODEX_PLUGIN_MANIFEST) | ||
| if contained_resolve(manifest, root) is None: | ||
| return False | ||
| return safe_is_file(manifest) |
There was a problem hiding this comment.
Preserve Codex ownership for marker-only plugins
When a plugin has a contained .codex-plugin/ directory but no plugin.json and the user runs with an explicit non-Codex type such as --type marketplace, Codex discovery is disabled so _codex_claim_set() is empty, and this safe_is_file() requirement makes provenance classify the directory as unclaimed/Claude. The lint tree can then run Claude scoped rules on a broken Codex plugin instead of standing them down; treat the contained marker as Codex evidence and only reject an existing manifest that resolves outside the plugin.
Useful? React with 👍 / 👎.
| if ":" in text[start:]: | ||
| text = text[:start] + "[redacted]" |
There was a problem hiding this comment.
Redact truncated passwordless userinfo
For a long passwordless-userinfo URL such as https://<long-token>@host where the @host falls beyond _MAX_DISPLAY, this truncates before _redact_userinfo() can see the host and only redacts the severed tail when it contains :. That means even callers already using safe_display() can still emit the first 500 characters of token-style credentials into text/JSON/SARIF diagnostics; treat a truncated URL/userinfo-looking tail as sensitive or inspect bounded lookahead before returning the prefix.
Useful? React with 👍 / 👎.
| candidate = contained_resolve(plugin_dir / item, root) | ||
| if candidate is None: | ||
| continue | ||
| # ``"skills": "./"`` points at the plugin root, which is a legal | ||
| # place to keep a skill. A file-valued field naming the root is | ||
| # meaningless, so only directory-valued fields accept it. | ||
| if candidate == root and not want_dir: | ||
| continue | ||
| if safe_is_dir(candidate) if want_dir else safe_is_file(candidate): | ||
| found.append(candidate) | ||
| return found |
There was a problem hiding this comment.
Preserve lexical paths for installed declared skills
When an installed plugin is reached through a symlink under .codex/plugins/ and its manifest declares a non-default skills path, returning the resolved candidate stores skill nodes under the symlink target instead of the .codex/plugins/... path. Agent Skill fixers and the linter’s vendor-managed check then no longer recognize those SKILL.md files as installed content, so skillsaw fix can rewrite third-party plugin files despite the diagnostic-only invariant; preserve the lexical path after containment validation or decide fixability from the owning plugin root.
Useful? React with 👍 / 👎.
| _add_codex_block(node, plugin_path / ".mcp.json", McpBlock, owner=resolved_plugin) | ||
| for declared_mcp in codex_declared_mcp_files(plugin_path): | ||
| _add_parser_block(node, declared_mcp, McpBlock, owner=resolved_plugin) |
There was a problem hiding this comment.
Accept Codex mcp_servers wrappers
When a Codex manifest points mcpServers at a .mcp.json that uses the documented wrapped mcp_servers form, this attaches it to McpBlock, whose parser only unwraps mcpServers or treats the whole object as the direct server map. The actual server entries are then missed by mcp-valid-json, mcp-prohibited, and docs generation while the wrapper key is treated as one malformed server; normalize mcp_servers before attaching or teach McpBlock to accept both wrappers.
Useful? React with 👍 / 👎.
Review Panel VerdictDisposition: REQUEST_CHANGES Specialist FindingsArchitecture ReviewerVerified: 1. SUGGESTION — skillsaw simultaneously demands a Claude manifest and exempts the repo from the marketplace that would publish it. Action: use 2. SUGGESTION —
3. SUGGESTION — 4. SUGGESTION — 5. SUGGESTION — 6. SUGGESTION — 7. NOTE — 8. NOTE — 9. NOTE — 10. NOTE — 11. NOTE — Python ExpertVerification: clean venv, No BLOCKING findings. Quality is high: no mutable default args, no bare 1. SUGGESTION — Both the raw C0 escape and the embedded 2. SUGGESTION — 3. SUGGESTION — 4. SUGGESTION — 5. NOTE — 6. NOTE — 7. NOTE — 8. NOTE — Security & Supply Chain Reviewer1. SUGGESTION — Raw 2. SUGGESTION — claude_dirs = {pn.path.resolve() for pn in context.lint_tree.find(PluginNode) if not _is_codex_only(...)}
3. NOTE — 4. NOTE — 5. NOTE — 6. NOTE — 7. NOTE — Verified clean — the PR's security claims hold up under attack:
QA EngineerTest suite: PASS — Coverage on new code: Verified genuinely covered (all six areas the panel asked about):
No BLOCKING issues. All findings are coverage gaps in defensive branches. 1. SUGGESTION — def test_fix_targets_the_primary_catalog(self, tmp_path):
repo = copy_fixture("codex/clean", tmp_path)
sibling = repo / ".agents" / "plugins" / "api_marketplace.json"
sibling.write_text(json.dumps({"name": "example-api", "plugins": []}), encoding="utf-8")
_write_plugin(repo / "plugins" / "newcomer",
{"name": "newcomer", "version": "1.0.0", "description": "x"})
before_sibling = sibling.read_bytes()
results = self._fix(repo)
assert [r.file_path.name for r in results] == ["marketplace.json"]
assert sibling.read_bytes() == before_sibling2. SUGGESTION — 3. SUGGESTION — 4. SUGGESTION — def test_provenance_dir_overrides_are_pinned(self):
from skillsaw import lint_target as lt
from skillsaw.blocks import frontmatter as fm
overriding = {cls.__name__ for mod in (lt, fm) for cls in vars(mod).values()
if isinstance(cls, type) and "provenance_dir" in cls.__dict__}
assert overriding == {"LintTarget", "PluginNode", "CommandBlock", "AgentBlock"}, (
"scope filtering fails open for node types without the override")5. NOTE — 6. NOTE — 7. NOTE — 8. NOTE — 9. NOTE — Technical Writer1. BLOCKING — A user following this doc gets 2. SUGGESTION — 3. SUGGESTION — 4. NOTE — 5. NOTE — Checked and correct:
Ecosystem ReviewerBottom line: BELONGS IN CORE. No blocking findings. Adoption clears the bar by a wide margin, the implementation's field expectations match the real spec with unusual fidelity, and a redirect-to-plugin is architecturally infeasible for the majority of this change's measured value. 1. Adoption — verified, well above the bar
I cloned Precedent: core already ships rules for 2. Spec fidelity — all five rules checked against the live spec and the 180-plugin corpus. Ran the branch's linter against the real corpus: 973 total violations, zero
3. Core-vs-plugin — redirect is infeasible. A narrow Codex rule plugin is technically buildable ( I measured (a) empirically by running the base commit
Net −671 false positives — the PR's "~700 fewer" claim is verified, not asserted. Base was reporting SUGGESTION — plugin-root {"hooks":{"PostToolUse":[{"matcher":"Write|Edit","hooks":[{"type":"command","command":"./scripts/post_write_figma_parity_check.sh"}]}]}}
SUGGESTION — Codex-schema catalogs at NOTE — NOTE — spec churn is real but the rules are built for it. The format moved during the review window: Sources: openai/plugins · openai/codex · Build plugins (OpenAI) · Build skills · Plugins overview · openai/codex#16430 Slopinator ReviewerBaseline note: this branch already contains dedicated cleanup commits ( 1. BLOCKING — # An openai/plugins-shaped catalog: every plugin Codex-claimed with a
# local source, so repository detection consults the claim set once per
# child — the shape whose discovery cost was once quadratic."the shape whose discovery cost was once quadratic" describes a state that never existed in any released skillsaw. 2. SUGGESTION — 3. NOTE — three ragged mid-paragraph line wraps left by in-place edits — text spliced into existing paragraphs without re-flowing, leaving a short line mid-sentence in files otherwise wrapped at ~65–70 columns: Checked and clean:
Panel SynthesisAll seven specialists completed. This is a large change (206 files, ~16k insertions) and it holds up unusually well under scrutiny — the panel found no correctness regressions, no security vulnerabilities, and no architectural faults that compound. Two BLOCKING findings survived arbitration; both are small, in-scope edits. Ratified: this belongs in skillsaw core. The Ecosystem Reviewer's adoption case is not merely argued, it is measured. The security claims survived adversarial testing. The Security Reviewer did not take the T15 threat-model work on faith: Corroboration across specialists. Two independent findings landed on the same seams and are weighted accordingly. Architecture and Python both flagged the private reach-through at A theme worth naming: partially-adopted new helpers. This PR introduces three safety helpers and adopts each incompletely. Test quality is high and honestly reported. 3,505 tests pass, 94% coverage, new modules at 93–100%. QA verified the claims rather than restating them: One finding deserves the author's judgment rather than the panel's. The Ecosystem Reviewer found that plugin-root Disagreement resolution. No specialist contradicted another. Two findings were filtered as out of scope: the Technical Writer's On the Slopinator blocker. The Backward compatibility. The four disclosed behavior changes to neutral machinery are each regression-pinned, and the Technical Writer confirmed the Disposition is REQUEST_CHANGES on the strength of the two BLOCKING items. Both are edits to a comment and a generated doc string; neither touches shipped logic. Everything else is optional. Required Actions Before Merge
Optional Follow-upsHighest value first:
Documentation:
Code quality / performance:
Separate issues (pre-existing, not this PR):
Generated by skillsaw-review-panel |
The CLI path section listed .claude-plugin/plugin.json among the manifest paths that widen to the owning root, but _resolve_lint_paths deliberately widens Codex surfaces only — widening a Claude manifest path would expand what an existing invocation reads, and what fix writes, beyond the path the caller named. A reader following the old text would lint the bare .claude-plugin/ directory, get "Repo type: unknown" with zero manifest rules run, and read the silence as a pass. State the actual behavior: only Codex manifest paths widen, a Claude manifest path lints nothing manifest-shaped, and the plugin root is the path to name. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The codex-marketplace scale's comment said the shape's discovery cost "was once quadratic", but that state only ever existed inside this branch (introduced by 28172ee, fixed by 852a8b5) — no released skillsaw had it, so a reader on main has no diff to check the claim against. Say what the scale is for instead: stressing claim-set memoization. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 74176cf341
ℹ️ 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".
| # codex_catalog_exists() checks existence, not validity: a broken | ||
| # catalog is still the author saying this is a Codex marketplace, | ||
| # and codex-marketplace-json-valid reports what is wrong with it. | ||
| if context.codex_catalog_exists() and not self._has_claude_plugin(context): |
There was a problem hiding this comment.
Report the missing Claude marketplace for unclaimed plugins
When a repo has a Codex catalog plus an unclaimed legacy plugin such as plugins/legacy/commands/foo.md, _plugins_dir_suggests_claude_marketplace() still infers MARKETPLACE, but this exemption returns clean solely because no .claude-plugin marker exists. That leaves the unclaimed PluginNode in Claude scope and plugin-json-required can still fail, while the companion .claude-plugin/marketplace.json error is incorrectly suppressed; base the exemption on provenance/scoped plugin ownership rather than the marker probe.
AGENTS.md reference: AGENTS.md:L148-L153
Useful? React with 👍 / 👎.
| owner = self.codex_plugin_owning(path) | ||
| return owner is not None and self.provenance(owner).codex_only |
There was a problem hiding this comment.
Keep Codex ownership checks independent of discovery
With an explicit non-Codex override such as --type marketplace, a plugins/foo/.codex-plugin/plugin.json directory that is discovered via the legacy plugins scan can still be classified as Codex-only by provenance() and have its hooks/MCP blocks attached, but codex_plugin_owning() builds roots from the disabled Codex discovery path and returns None. In that context hooks-json-valid and mcp-valid-json skip the Codex-only matcher/non-empty-command checks for those blocks, so manifest evidence should also feed this ownership predicate or it needs a provenance ancestor fallback.
AGENTS.md reference: AGENTS.md:L161-L164
Useful? React with 👍 / 👎.
Review Panel VerdictDisposition: REQUEST_CHANGES Specialist FindingsArchitecture ReviewerFull suite run on the branch: 3505 passed. No import cycles among the new modules; 1. BLOCKING —
Reproduced: repo with
This is a Claude-only repository with no Codex content anywhere, so it sits outside the "dual-manifest repos keep exact 0.17 results" guarantee, and it violates Recommended action. Keep the containment (the security rationale is sound — autofix must not write outside the checkout), but do not lose the content silently. Either (a) emit the same 2. SUGGESTION —
3. SUGGESTION —
4. SUGGESTION —
5. SUGGESTION — 6. SUGGESTION — NOTES. Verified clean. Provenance is a genuine single source of truth everywhere except Python ExpertAll 3505 tests pass; 1. SUGGESTION — Measured on a synthetic 1800-directory repo: cProfile on the Only one of the seven is unavoidable. Fix: cache the 2. SUGGESTION —
3. SUGGESTION — NOTES. Security & Supply Chain Reviewer1. BLOCKING —
Reproduced — temp workspace with A file two directories from the named path was rewritten. The same code block's own comment names exactly this hazard as the reason Calibration note: this is user-initiated, not an attacker-controlled primitive. Rated blocking because it is an unbounded, silent write-scope expansion that the repo's own stated invariant forbids. Recommended action. Don't widen at all in the 2. SUGGESTION — Reproduced. A 3. SUGGESTION —
4. SUGGESTION — AST-counted at 5. SUGGESTION — NOTE. Verified clean — what I actually exercised. Claim-escape containment holds across four variants beyond the shipped fixture (relative marker symlink, absolute marker symlink, real QA Engineer
1. SUGGESTION — Line 153 is uncovered in the full run. Only the negatives are tested (invalid JSON, 2. SUGGESTION — 3. SUGGESTION —
4. SUGGESTION — 5. SUGGESTION — 6. SUGGESTION — NOTES. PR description claims vs. the tree. "3,505 tests" ✅ exact. "94% coverage" — measured 93%. "docs byte-stability … test-pinned" — not accurate: there is no golden/snapshot test of docs output anywhere in Prior follow-ups, current state. Plugin-root Technical WriterPrior BLOCKING finding — confirmed fixed. 1. SUGGESTION — The first sentence is right; everything it enumerates is HTML-only ( Three distinct Markdown changes: every metadata scalar now folds through 2. SUGGESTION —
3. SUGGESTION — 4. SUGGESTION — 5. SUGGESTION — NOTES. Verified clean. All five Ecosystem ReviewerVerdict: IN SCOPE FOR CORE — no blocking objection. I looked hard for a redirect-to-plugin case and the evidence runs decisively the other way. Two independent lines each suffice on their own. Adoption evidence (verified independently; as of 2026-07-28). Cloned Comparative bar. Claude Code's official built-in marketplace held ~101 plugins as of March 2026; The structural argument is decisive on its own. The plugin surface is exactly three hooks ( 1. SUGGESTION — I fetched 2. SUGGESTION — Slopinator ReviewerPrior finding verified fixed. Checked: every added 1. SUGGESTION —
T15 itself is added by this same PR one line above ( 2. NOTE — squash-merge commit message (not a tracked file) — the branch's subjects are heavily round-keyed ( No findings on — the code comments are the strongest part of this branch; the three prior cleanup passes appear thorough and I could not find a fourth pass's worth of residue. Deliberately not flagged: Panel SynthesisAll seven specialists completed. This is the fourth panel on this branch, and the two BLOCKING findings from the third ( The change continues to hold up unusually well for its size. The Ecosystem Reviewer's scope verdict is unambiguous and well-evidenced: Codex belongs in core, and the structural argument settles it independently of adoption numbers — ~2,100 lines here modify existing core rules so they stand down on Codex content, and no plugin hook can do that. The security surface was probed hard and empirically: claim-escape containment held across four symlink variants beyond the shipped fixture, docs filename traversal survived 11 hostile plugin names, the URL allow-list neutralized every scheme-obfuscation attempt, Two BLOCKING findings survived arbitration. I reproduced both myself rather than relying on the specialists' write-ups. On the
An entire plugin — its manifest, its commands, its naming and frontmatter — reaches no rule at all, and the grade improves, which is the worst possible signal. Symlinked plugin directories are a normal monorepo layout, and this repo has no Codex content anywhere, so it falls outside the "dual-manifest repos keep exact 0.17 results" guarantee. On the Neither blocker is a design flaw; both are narrow and in scope. Together they're a coherent theme worth naming: the containment work correctly identified that path handling needed bounds, and got the Codex side's bounds slightly wrong in both directions — too tight in discovery (silently dropping content), too loose in the CLI (silently widening writes). Filtered out. Several specialists re-raised open follow-ups from prior panels ( Corroboration. The On the PR description. QA verified the test count exactly (3505) but measured 93% coverage, not 94%, and found two claims overstated: "docs byte-stability … test-pinned" is not true (there is no golden/snapshot test of docs output anywhere in One item I'd weight above the other optional work: the Disposition is REQUEST_CHANGES on the strength of the two reproduced BLOCKING items. Both are contained fixes — a log line plus a violation, and a narrowed widening rule. Everything else is optional. Required Actions Before Merge
Optional Follow-upsHighest value first:
Documentation:
Code quality:
Separate issues (not this PR):
Generated by skillsaw-review-panel |
…ectory _resolve_lint_paths widened .codex-plugin/plugin.json and files under .agents/plugins/ outward to an owning plugin or repository root. That expanded what lint reads — and what fix writes — beyond the path the caller named: lint <ws>/.agents/plugins/marketplace.json reached private-project/skills/helper/SKILL.md two directories away, and fix rewrote it. The $HOME/anchor bounds only capped where the climb stopped, not how much scope it added (~/work/.agents/plugins/... still widened to all of ~/work). The feature never shipped: main resolves files to their parent, the pre-commit hook passes no filenames (pass_filenames: false), and no documented workflow names a manifest file. Remove it rather than narrow it — a file now resolves to the directory that owns it, for every ecosystem, matching the released Claude behavior. Callers who want manifest rules name the plugin's root directory; docs/cli.md (via CLI_PATH_SECTION) says so. The new tests pin the invariant: naming a manifest lints nothing outside its owning directory, and fix cannot write outside it (fixture codex/manifest-path-scope carries a live SUGGEST autofix outside the named path to prove the guard is not vacuous). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…silently
_discover_from_plugins_dir drops any plugins/* child whose resolved
location falls outside the repository root (contained_resolve returns
None). The containment is deliberate — autofix must never write
outside the checkout — but the drop was invisible: a Claude repo with
plugins/foo symlinked to a sibling checkout went from Grade C+ / 8
violations on 0.17 to Grade B / 1, an entire plugin losing all rule
coverage while the grade improved.
Make the drop visible on both channels:
- Log the same warning _resolve_plugin_source emits for escaping
marketplace sources ("Plugin 'x' source './plugins/x' escapes
repository root. Skipping.").
- Record the dropped directory on context.escaped_plugin_dirs and
have marketplace-json-valid file a violation for each entry, so the
drop appears in --format json and SARIF output. WARNING, not the
rule's ERROR default: the plugin's content is skipped, not known to
be defective, so a previously-clean repo keeps exit 0.
The fixture ships the escaping symlink as a real symlink (mode
120000), and copy_fixture in tests/test_integration.py now copies
with symlinks=True so copytree cannot rebuild it as a plain directory
and void the test.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: df339706f9
ℹ️ 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".
| scheme, sep, _ = candidate.partition(":") | ||
| if not sep: | ||
| return candidate # relative or bare host — no scheme to abuse | ||
| return candidate if scheme.lower() in _SAFE_URL_SCHEMES else "" |
There was a problem hiding this comment.
Reject credentialed generated-docs URLs
When a Codex plugin manifest accidentally sets homepage or repository to an HTTP(S) URL with userinfo, such as https://user:token@example.com/repo, _safe_url() returns it unchanged because the scheme is allowed. The Markdown and HTML renderers then embed that exact value in generated docs, so publishing skillsaw docs output leaks the credential in links and page source; reject or strip username/password before returning accepted URLs.
Useful? React with 👍 / 👎.
| if (evals_dir / "evals.json").exists(): | ||
| # Present, but resolving outside the owning Codex plugin. | ||
| continue |
There was a problem hiding this comment.
Report escaping evals files as missing
For a Codex-owned skill where evals/evals.json is a symlink that resolves outside the owning plugin, contained_eval_file() correctly refuses to read it, but this branch then treats the lexical file's existence as a reason to emit no violation at all. That leaves an evals/ directory with no bundled usable evals.json passing the default agentskill-evals check; report this as missing or as a containment violation instead of continuing silently.
Useful? React with 👍 / 👎.
Adds first-class OpenAI Codex plugin and marketplace linting: five new rules, Codex-aware discovery, and docs generation for Codex catalogs — no new Codex rules fire on Claude-only repositories (three deliberate fixes to neutral machinery, plus a change to generated-docs output, are listed below).
What this adds
rules/builtin/codex/:codex-plugin-json-valid,codex-plugin-structure,codex-marketplace-json-valid,codex-marketplace-registration(with a SUGGEST-confidence autofix that appends missing catalog entries), andcodex-openai-metadata(validatesagents/openai.yaml, the first structured-YAML lint target). All default toenabled: auto, gated on marker-detected repo types — Claude-only repos run none of them..codex-plugin/plugin.jsonmanifests,.agents/plugins/marketplace.jsoncatalogs (and sibling catalogs), catalog-claimed manifest-less directories, and vendor installs under.codex/plugins/(linted, but diagnostic-only: autofix stands down for content the repo doesn't author).skillsaw docsrenders Codex plugins and marketplaces (Markdown + HTML), with the docs boundary threat-modeled (T15) and hardened: URL scheme allow-listing with entity decoding, attribute/JS-string escaping, filename collision handling, credential redaction on every diagnostic sink.Architecture
PluginProvenance— ecosystem ownership is decided once, inRepositoryContext.provenance(), filesystem-first and--type-invariant. Every consumer reads the record; no rule or tree code probes the filesystem for ownership.provenance_scope = "claude"; filtering happens in one place. The ecosystem-tightened hooks/MCP checks apply only to Codex-exclusive directories, so dual-manifest repos keep their exact 0.17 results (mutation-pinned byTestDualManifestBackwardCompat).discovery/codex.py— state-free discovery module; the context stays the stateful orchestrator holding caches and rendering provenance verdicts. Adding the next ecosystem = an evidence probe, a discovery leg, and a rules package (recipe in the development rules).paths.pyadditions (safe_*,contained_resolve) anddiagnostics.py(safe_display), with a ratchet test banning new bare.resolve()calls (Retire the symlink-crash bug class: sweep bare Path.resolve() behind safe_resolve() and enforce with a banned-API test #463).Behavior changes affecting existing (non-Codex) repositories
Deliberate changes to neutral machinery — flagged here because they can change results on upgrade:
content-broken-internal-reference: URI-scheme targets (e.g.app://connector URIs) are treated as external, not broken repo paths.hooks-json-valid: a non-string hooktypeis now reported instead of aborting the entire rule with aTypeError(base behavior silently skipped all hook validation for the repo)._pattern_variantsproduces the same expansion as base for every pattern.skillsaw docsoutput changed for every repository, Claude-only ones included: the generated HTML escapes JavaScript-string contexts with a dedicatedescJsAttrand attribute contexts withescAttr, and plugin pages carry the manifest'sauthorfield. A repository that commits generated docs and gates CI on them must regenerate on upgrade — disclosed indocs/ci.mdunder "Committed generated docs" for the 0.18.0 notes.Validation
make lint/make updateclean; self-lint A+.codex-*findings across 180 plugins / 582 skills, and a net false-positive reduction vs. base (~700 fewer, mostlyagentskill-unreferenced-filesand provenance-gated Claude-format FPs), with zero new violations from any pre-existing rule.openshift-eng/ai-helpers(Claude-only): zerocodex-*findings and no new lint violations. Its committeddocs/index.htmldoes change (84-line diff — theescJsAttr/escAttrhardening plus the newauthorfield), so itsplugins-doc-up-to-datecustom rule fails until the repo regenerates. Verified by bisect: base regenerates byte-identically, this branch does not.--typeinvariance, and dual-manifest compat are all test-pinned; a serial deep review of the final branch returned zero blockers.Provenance
Supersedes #451 (closed; its review history stays there). Developed on the
codex-plugin-rulesfeature branch through reviewed refactor PRs #464–#469; furthercontext.pydecomposition tracked in #470.🤖 Generated with Claude Code