From 2ddc2d42e5656fb3762e4acb67261fcb123984d8 Mon Sep 17 00:00:00 2001 From: Jothi Mani Rajendran <250249270+jothimani-rajendran@users.noreply.github.com> Date: Mon, 21 Sep 2026 23:48:01 +0000 Subject: [PATCH 1/2] Add devin plugin format and marketplace tree Teaches chock's emitter `--format devin` (a native `.devin-plugin/` package: `plugin.json`, `skills//SKILL.md`, and a root-level `hooks.json`, mirroring codex.py/cursor.py function for function) and `chock marketplace build --tree devin`, pinned to agentseam 0.3.2, which records Devin's native plugin layout. Devin's manifest documents no plugin-root token for hook commands, only that they receive a `$DEVIN_PLUGIN_ROOT` environment variable, so the hook command is chock's own shell expansion of that variable rather than a vendor-recorded token. The package claims no enforcement tier: `posture.enforced_devin()` states the vendor's own fail-open, best- effort caveat instead. Devin has no marketplace index file, so `--tree devin` writes a root `.devin-plugin/plugin.json` meta-plugin whose `optionalPlugins` point `git-subdir` entries at each built plugin, gated on a new `--url` option. Split `marketplace.py` into `marketplace_core.py` (tree-generic index/ lock/catalog logic) and `marketplace_devin.py` (the root-manifest path) to keep every file under the repo's 300-line review budget. Co-Authored-By: Claude Sonnet 5 Signed-off-by: Jothi Mani Rajendran <250249270+jothimani-rajendran@users.noreply.github.com> --- .chock/bin/claude_code.py | 4 +- .chock/bin/codex_cli.py | 4 +- .chock/bin/cursor.py | 4 +- .chock/bin/devin.py | 4 +- .chock/bin/gemini_cli.py | 4 +- .chock/bin/grok.py | 4 +- .chock/bin/tabnine.py | 4 +- .chock/bin/vscode_copilot.py | 4 +- .chock/bin/windsurf.py | 4 +- CHANGELOG.md | 22 ++ docs/cli-reference.md | 71 +++--- pyproject.toml | 2 +- requirements/brand-assets.in | 2 +- requirements/brand-assets.txt | 6 +- src/chock/plugin/cli.py | 8 +- src/chock/plugin/data/stores/devin.json | 3 + src/chock/plugin/devin.py | 131 ++++++++++ src/chock/plugin/marketplace.py | 235 ++++-------------- src/chock/plugin/marketplace_core.py | 224 +++++++++++++++++ src/chock/plugin/marketplace_devin.py | 40 +++ src/chock/plugin/posture.py | 15 ++ src/chock/plugin/store.py | 18 +- tests/fixtures/runtime_goldens/antigravity.py | 4 +- tests/fixtures/runtime_goldens/claude_code.py | 4 +- tests/fixtures/runtime_goldens/codex_cli.py | 4 +- tests/fixtures/runtime_goldens/cursor.py | 4 +- tests/fixtures/runtime_goldens/devin.py | 4 +- tests/fixtures/runtime_goldens/gemini_cli.py | 4 +- tests/fixtures/runtime_goldens/grok.py | 4 +- tests/fixtures/runtime_goldens/tabnine.py | 4 +- .../runtime_goldens/vscode_copilot.py | 4 +- tests/fixtures/runtime_goldens/windsurf.py | 4 +- tests/test_devin_plugin.py | 160 ++++++++++++ tests/test_marketplace.py | 41 +++ 34 files changed, 789 insertions(+), 265 deletions(-) create mode 100644 src/chock/plugin/data/stores/devin.json create mode 100644 src/chock/plugin/devin.py create mode 100644 src/chock/plugin/marketplace_core.py create mode 100644 src/chock/plugin/marketplace_devin.py create mode 100644 tests/test_devin_plugin.py diff --git a/.chock/bin/claude_code.py b/.chock/bin/claude_code.py index e4c6207..d8f56f9 100755 --- a/.chock/bin/claude_code.py +++ b/.chock/bin/claude_code.py @@ -1,4 +1,4 @@ -# Generated by agentseam 0.3.1 -- bundle("claude_code"). Do not hand-edit, except the +# Generated by agentseam 0.3.2 -- bundle("claude_code"). Do not hand-edit, except the # HANDLER block below (between "agentseam handler >>>" and "<<< agentseam handler"), # which is exactly what this file leaves for you to fill in. # @@ -23,7 +23,7 @@ import warnings as _warnings # ------------------------------------------------------------------------------ -# contract (agentseam 0.3.1) +# contract (agentseam 0.3.2) """Canonical event vocabulary, normalized envelope, and decision type.""" diff --git a/.chock/bin/codex_cli.py b/.chock/bin/codex_cli.py index ebad293..bdea4c5 100755 --- a/.chock/bin/codex_cli.py +++ b/.chock/bin/codex_cli.py @@ -1,4 +1,4 @@ -# Generated by agentseam 0.3.1 -- bundle("codex_cli"). Do not hand-edit, except the +# Generated by agentseam 0.3.2 -- bundle("codex_cli"). Do not hand-edit, except the # HANDLER block below (between "agentseam handler >>>" and "<<< agentseam handler"), # which is exactly what this file leaves for you to fill in. # @@ -22,7 +22,7 @@ import warnings as _warnings # ------------------------------------------------------------------------------ -# contract (agentseam 0.3.1) +# contract (agentseam 0.3.2) """Canonical event vocabulary, normalized envelope, and decision type.""" diff --git a/.chock/bin/cursor.py b/.chock/bin/cursor.py index 498bab4..a4716f3 100755 --- a/.chock/bin/cursor.py +++ b/.chock/bin/cursor.py @@ -1,4 +1,4 @@ -# Generated by agentseam 0.3.1 -- bundle("cursor"). Do not hand-edit, except the +# Generated by agentseam 0.3.2 -- bundle("cursor"). Do not hand-edit, except the # HANDLER block below (between "agentseam handler >>>" and "<<< agentseam handler"), # which is exactly what this file leaves for you to fill in. # @@ -22,7 +22,7 @@ import warnings as _warnings # ------------------------------------------------------------------------------ -# contract (agentseam 0.3.1) +# contract (agentseam 0.3.2) """Canonical event vocabulary, normalized envelope, and decision type.""" diff --git a/.chock/bin/devin.py b/.chock/bin/devin.py index 4c46897..cb1e666 100755 --- a/.chock/bin/devin.py +++ b/.chock/bin/devin.py @@ -1,4 +1,4 @@ -# Generated by agentseam 0.3.1 -- bundle("devin"). Do not hand-edit, except the +# Generated by agentseam 0.3.2 -- bundle("devin"). Do not hand-edit, except the # HANDLER block below (between "agentseam handler >>>" and "<<< agentseam handler"), # which is exactly what this file leaves for you to fill in. # @@ -22,7 +22,7 @@ import warnings as _warnings # ------------------------------------------------------------------------------ -# contract (agentseam 0.3.1) +# contract (agentseam 0.3.2) """Canonical event vocabulary, normalized envelope, and decision type.""" diff --git a/.chock/bin/gemini_cli.py b/.chock/bin/gemini_cli.py index db789c2..9e671e0 100755 --- a/.chock/bin/gemini_cli.py +++ b/.chock/bin/gemini_cli.py @@ -1,4 +1,4 @@ -# Generated by agentseam 0.3.1 -- bundle("gemini_cli"). Do not hand-edit, except the +# Generated by agentseam 0.3.2 -- bundle("gemini_cli"). Do not hand-edit, except the # HANDLER block below (between "agentseam handler >>>" and "<<< agentseam handler"), # which is exactly what this file leaves for you to fill in. # @@ -22,7 +22,7 @@ import warnings as _warnings # ------------------------------------------------------------------------------ -# contract (agentseam 0.3.1) +# contract (agentseam 0.3.2) """Canonical event vocabulary, normalized envelope, and decision type.""" diff --git a/.chock/bin/grok.py b/.chock/bin/grok.py index 8f09b67..d61208d 100755 --- a/.chock/bin/grok.py +++ b/.chock/bin/grok.py @@ -1,4 +1,4 @@ -# Generated by agentseam 0.3.1 -- bundle("grok"). Do not hand-edit, except the +# Generated by agentseam 0.3.2 -- bundle("grok"). Do not hand-edit, except the # HANDLER block below (between "agentseam handler >>>" and "<<< agentseam handler"), # which is exactly what this file leaves for you to fill in. # @@ -22,7 +22,7 @@ import warnings as _warnings # ------------------------------------------------------------------------------ -# contract (agentseam 0.3.1) +# contract (agentseam 0.3.2) """Canonical event vocabulary, normalized envelope, and decision type.""" diff --git a/.chock/bin/tabnine.py b/.chock/bin/tabnine.py index bcb4148..b29b527 100755 --- a/.chock/bin/tabnine.py +++ b/.chock/bin/tabnine.py @@ -1,4 +1,4 @@ -# Generated by agentseam 0.3.1 -- bundle("tabnine"). Do not hand-edit, except the +# Generated by agentseam 0.3.2 -- bundle("tabnine"). Do not hand-edit, except the # HANDLER block below (between "agentseam handler >>>" and "<<< agentseam handler"), # which is exactly what this file leaves for you to fill in. # @@ -22,7 +22,7 @@ import warnings as _warnings # ------------------------------------------------------------------------------ -# contract (agentseam 0.3.1) +# contract (agentseam 0.3.2) """Canonical event vocabulary, normalized envelope, and decision type.""" diff --git a/.chock/bin/vscode_copilot.py b/.chock/bin/vscode_copilot.py index 6218e70..d0d8d0b 100755 --- a/.chock/bin/vscode_copilot.py +++ b/.chock/bin/vscode_copilot.py @@ -1,4 +1,4 @@ -# Generated by agentseam 0.3.1 -- bundle("vscode_copilot"). Do not hand-edit, except the +# Generated by agentseam 0.3.2 -- bundle("vscode_copilot"). Do not hand-edit, except the # HANDLER block below (between "agentseam handler >>>" and "<<< agentseam handler"), # which is exactly what this file leaves for you to fill in. # @@ -22,7 +22,7 @@ import warnings as _warnings # ------------------------------------------------------------------------------ -# contract (agentseam 0.3.1) +# contract (agentseam 0.3.2) """Canonical event vocabulary, normalized envelope, and decision type.""" diff --git a/.chock/bin/windsurf.py b/.chock/bin/windsurf.py index 90ae182..cd17905 100755 --- a/.chock/bin/windsurf.py +++ b/.chock/bin/windsurf.py @@ -1,4 +1,4 @@ -# Generated by agentseam 0.3.1 -- bundle("windsurf"). Do not hand-edit, except the +# Generated by agentseam 0.3.2 -- bundle("windsurf"). Do not hand-edit, except the # HANDLER block below (between "agentseam handler >>>" and "<<< agentseam handler"), # which is exactly what this file leaves for you to fill in. # @@ -22,7 +22,7 @@ import warnings as _warnings # ------------------------------------------------------------------------------ -# contract (agentseam 0.3.1) +# contract (agentseam 0.3.2) """Canonical event vocabulary, normalized envelope, and decision type.""" diff --git a/CHANGELOG.md b/CHANGELOG.md index fb23bb1..74ca2bf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,27 @@ # Chock changelog +## Unreleased + +- **`devin` plugin format**: `chock plugin build --format devin` packages a policy as a native + Devin plugin (`.devin-plugin/plugin.json` + `skills//SKILL.md` + a root-level `hooks.json`, + not the nested `hooks/hooks.json` every other format uses). Same guard, same adapter, + byte-identical to every other format -- only the envelope differs, and the hook command reaches + its own bundled copies via a shell expansion of `$DEVIN_PLUGIN_ROOT`, the environment variable + the vendor documents hook commands receive (agentseam records no `${...}` plugin-root token for + Devin, unlike Codex or Cursor -- that expansion is chock's own inference, not a vendor-recorded + token). Unlike every other hook format, the package claims no enforcement tier: the vendor's own + docs call plugin hooks "currently best effort and fail open ... so don't rely on them for + crucial guardrails yet," for local Devin sessions (the CLI and Devin Desktop) only, and the + posture text says so instead of claiming a block. +- **`chock marketplace build --tree devin`**: Devin has no marketplace index file -- `devin + plugins install` instead reads a repo's root `.devin-plugin/plugin.json` as a meta-plugin whose + `optionalPlugins` point `git-subdir` entries at each built plugin, so `--tree devin` writes that + root manifest in place of an index (a new `--url` is required; chock never reads `git remote` + for it). `chock-market.lock` and `PLUGINS.md` cover the devin tree the same way they cover every + other tree. +- **Pinned `agentseam==0.3.2`**, which records Devin's native plugin layout; the vendored runtime + goldens moved with it (version stamp only, no handler change). + ## 0.9.2 — `sync` no longer leaves a vendor's hook config pointing at a runtime it just deleted - **Fixed: narrowing `supported_agents` on 0.9.1 left dangling hook entries behind (#151).** diff --git a/docs/cli-reference.md b/docs/cli-reference.md index d007a54..399d524 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -85,15 +85,13 @@ The one "make it so" verb (`uv sync` semantics): recompiles every enabled policy `.chock/compiled/`, reinstalls the git-hook dispatchers, policy wrappers and the in-agent hooks of the agents `supported_agents` names, uninstalls chock's entries from any vendor it no longer names (deleting a config file that held only chock's) before pruning that vendor's runtime, regenerates `INDEX.md` and the `AGENTS.md` pointer, refreshes -the registry, and rewrites `chock.lock`. Run it after editing a policy, toggling config by hand, or bumping the engine -version. A failed recompile never removes the existing compiled tree — the build is staged and swapped in only on -success — and a lockfile-write failure fails the command. An adopter-edited dispatcher is backed up to -`.chock-backup` before being regenerated; custom steps belong in `.d/`. - -- `--check` — write nothing; exit non-zero listing every compiled artifact that no longer - matches its manifest. This is the CI drift gate. -- `--ci` — additionally write the GitHub Actions workflow that runs every compiled `ci-gate` on pull requests. Idempotent; - refuses to overwrite a workflow it did not write. Until this runs, `ci-gate` output is compiled but not enforced. +the registry, and rewrites `chock.lock`. Run after editing a policy, toggling config, or bumping the engine version. A +failed recompile never removes the existing compiled tree — staged and swapped in only on success — and a +lockfile-write failure fails the command. An adopter-edited dispatcher is backed up to `.chock-backup` before +being regenerated; custom steps belong in `.d/`. + +- `--check` — write nothing; exit non-zero listing every compiled artifact that no longer matches its manifest. The CI drift gate. +- `--ci` — additionally write the GitHub Actions workflow running every compiled `ci-gate` on pull requests. Idempotent; refuses to overwrite a workflow it did not write. Until this runs, `ci-gate` output is compiled but not enforced. - `--skills` — additionally refresh the bundled authoring skills in `.agents/skills/`. - `--skip-hooks` — compile and refresh bookkeeping without touching `.git/hooks`. @@ -103,8 +101,7 @@ success — and a lockfile-write failure fails the command. An adopter-edited di chock check [--repo .] [--only validate,verify,evals,matrix,mechanisms,index,conflicts,baseline] [--mode MODE] [--event EVENT] [--base REF] ``` -Runs every truth check, read-only — `check` never regenerates what it measures (that is -`sync`'s job): +Runs every truth check, read-only — `check` never regenerates what it measures (that is `sync`'s job): | Target | What it proves | | :--- | :--- | @@ -197,7 +194,7 @@ diffs the result to catch a stale registry. See [Registry & Lockfile](registry-a ### `plugin build` — package policies as installable plugins ```bash -chock plugin build [--repo .] [--policies-dir base] [--format agent-plugins|claude|copilot|cursor|codex|all] [--out-dir DIST] [--policy ID ...] [--out PATH] [--check] +chock plugin build [--repo .] [--policies-dir base] [--format agent-plugins|claude|copilot|cursor|codex|devin|all] [--out-dir DIST] [--policy ID ...] [--out PATH] [--check] ``` Renders each policy as a plugin. The default `agent-plugins` format writes an @@ -205,28 +202,33 @@ Renders each policy as a plugin. The default `agent-plugins` format writes an `manifest.yaml` stays the source of truth, and a packaged policy is `advisory` wherever it is read: v1 defines no enforcement semantics, so packaging changes no value in `coverage.json`. -The four hook formats ship a byte-identical guard and adapter; only the envelope differs. +The five hook formats ship a byte-identical guard and adapter; only the envelope differs. `claude` (`.claude-plugin/`) is read natively by Claude Code, Copilot CLI, VS Code and Grok Build; `copilot` is the Agent Plugins 1.0 layout under `com.github.copilot/hooks/`; `cursor` (`.cursor-plugin/`, `beforeShellExecution`) and `codex` (`.codex-plugin/`, `PreToolUse`) each -reach a hook engine no other package can, failing **open** when `python3` is absent. They -require `--out-dir` (or `--out`); in-place output is refused so a policy folder is never -mistaken for a published plugin. `--policies-dir` packages a published directory; `--check` -judges without writing. `--policy ID` (repeatable; manifest `id` or directory name, else a -named error) narrows the build and skips `--out-dir` stale-package cleanup. `--out PATH` -(exactly one `--policy`) writes straight to `PATH` instead of `///`. +reach a hook engine no other package can, failing **open** when `python3` is absent. `devin` +(`.devin-plugin/plugin.json` + `hooks.json`, `PreToolUse`) is best-effort by the vendor's own +design, fail-open, not enforced. They require `--out-dir` (or `--out`); in-place output is +refused so a policy folder is never mistaken for a published plugin. `--policies-dir` packages +a published directory; `--check` judges without writing. `--policy ID` (repeatable; manifest +`id` or directory name, else a named error) narrows the build and skips `--out-dir` +stale-package cleanup. `--out PATH` (exactly one `--policy`) writes straight to `PATH` instead +of `///`. ### `marketplace build` — index a built plugin tree ```bash -chock marketplace build [--dist .] [--name chock] [--tree claude|cursor|codex] [--check] +chock marketplace build [--dist .] [--name chock] [--tree claude|cursor|codex|devin] [--url URL] [--check] ``` Scans `//*/` and writes that vendor's index: claude → `.claude-plugin/marketplace.json` + `.github/plugin/marketplace.json` (Copilot CLI's path), cursor → `.cursor-plugin/marketplace.json`, codex → the legacy `.claude-plugin/` -shape Codex reads from git marketplaces. Entries derive from built manifests, never -hand-listed; an empty tree exits 2; `--check` reports drift without writing. +shape Codex reads from git marketplaces. `devin` has no index format: it writes a root +`.devin-plugin/plugin.json` meta-plugin whose `optionalPlugins` point `git-subdir` entries at +each built plugin, and needs `--url` (the marketplace repo's own git URL — never guessed from +`git remote`). Entries derive from built manifests, never hand-listed; an empty tree exits 2; +`--check` reports drift without writing. ### `gateway run` -- the MCP gateway proxy @@ -251,20 +253,17 @@ claims print under **NOT verified**. `require` is the CI-side gate: present, val chock compliance report [--repo .] [--framework owasp_asi] [--json] ``` -Lists the framework controls and which installed policies claim to cover them. Each -control's state is `covered`, `partial`, or `uncovered` (per-claim `coverage` on a policy -is `partial` or `full`). The command fails closed with exit 2 on a missing `--repo`, an -unknown framework nothing claims, or an unknown subcommand. In a repo that has been -synced, a claim also requires the policy's compiled output to exist — a declared control -whose compiled mechanism was deleted is not counted. - -Builtin frameworks (one per data file in `src/chock/authoring/data/`, each enumerated -from its publisher's primary source): `owasp_asi` (ASI01–10), `mitre_atlas` (170 -techniques, from the official machine-readable dataset), `nist_ai_rmf` (the 72 AI RMF 1.0 -subcategories), `eu_ai_act` (a curated set of technical-obligation articles). A policy -claims controls in its manifest's `compliance:` block, keyed by framework name — unknown -framework names still validate, so private frameworks work with `--json` and your own -control list. +Lists the framework controls and which installed policies claim to cover them. Each control's state is `covered`, +`partial`, or `uncovered` (per-claim `coverage` on a policy is `partial` or `full`). The command fails closed with +exit 2 on a missing `--repo`, an unknown framework nothing claims, or an unknown subcommand. In a repo that has been +synced, a claim also requires the policy's compiled output to exist — a declared control whose compiled mechanism +was deleted is not counted. + +Builtin frameworks (one per data file in `src/chock/authoring/data/`, each enumerated from its publisher's primary +source): `owasp_asi` (ASI01–10), `mitre_atlas` (170 techniques, from the official machine-readable dataset), +`nist_ai_rmf` (the 72 AI RMF 1.0 subcategories), `eu_ai_act` (a curated set of technical-obligation articles). A +policy claims controls in its manifest's `compliance:` block, keyed by framework name — unknown framework names +still validate, so private frameworks work with `--json` and your own control list. ## Pre-launch aliases diff --git a/pyproject.toml b/pyproject.toml index ea72523..08e815e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -60,7 +60,7 @@ dependencies = [ # It never ships to an adopter repo -- the vendored PreToolUse/SessionStart runners # stay self-contained stdlib-only files, unaffected by this dependency. Pinned exact # per plan/spine-a/contract.md: the wave boundary is the published PyPI artifact. - "agentseam==0.3.1", + "agentseam==0.3.2", ] [project.optional-dependencies] diff --git a/requirements/brand-assets.in b/requirements/brand-assets.in index 488253e..bd48b58 100644 --- a/requirements/brand-assets.in +++ b/requirements/brand-assets.in @@ -1,5 +1,5 @@ pyyaml>=6.0 jsonschema>=4.18,<5 referencing>=0.35,<0.38 -agentseam==0.3.1 +agentseam==0.3.2 cairosvg==2.9.0 diff --git a/requirements/brand-assets.txt b/requirements/brand-assets.txt index 6c258c6..1360237 100644 --- a/requirements/brand-assets.txt +++ b/requirements/brand-assets.txt @@ -4,9 +4,9 @@ # # pip-compile --generate-hashes --output-file=requirements/brand-assets.txt --strip-extras requirements/brand-assets.in # -agentseam==0.3.1 \ - --hash=sha256:4e5b249162204f0bd90c60e2c67a8417c999881a786d4d18a4e85dca570c6564 \ - --hash=sha256:db7099395724122989ef8fbf1115d09da2cb0194fff9ce2a3ebe979793fc5271 +agentseam==0.3.2 \ + --hash=sha256:63616579111f24db9045113a3ef123f6cbb399333c2affb65f8bbb6956d2fb10 \ + --hash=sha256:869448a47184dc523491932557f4d87243a6fd7e05d5c51913804013395e643c # via -r requirements/brand-assets.in attrs==26.1.0 \ --hash=sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309 \ diff --git a/src/chock/plugin/cli.py b/src/chock/plugin/cli.py index 3ddcb52..ae063c3 100644 --- a/src/chock/plugin/cli.py +++ b/src/chock/plugin/cli.py @@ -21,17 +21,19 @@ from chock.plugin.codex import build_codex_plugin, codex_plugin_differences from chock.plugin.copilot import build_copilot_plugin, copilot_plugin_differences from chock.plugin.cursor import build_cursor_plugin, cursor_plugin_differences +from chock.plugin.devin import build_devin_plugin, devin_plugin_differences from chock.scaffold.recompile import discover_policy_dirs -FORMATS = ("agent-plugins", "claude", "copilot", "cursor", "codex") +FORMATS = ("agent-plugins", "claude", "copilot", "cursor", "codex", "devin") -HOOK_FORMATS = frozenset({"claude", "copilot", "cursor", "codex"}) +HOOK_FORMATS = frozenset({"claude", "copilot", "cursor", "codex", "devin"}) HOOK_EMITTERS = { "claude": (claude_plugin_differences, build_claude_plugin), "copilot": (copilot_plugin_differences, build_copilot_plugin), "cursor": (cursor_plugin_differences, build_cursor_plugin), "codex": (codex_plugin_differences, build_codex_plugin), + "devin": (devin_plugin_differences, build_devin_plugin), } @@ -216,5 +218,7 @@ def main(argv: list[str] | None = None) -> int: print(" .cursor-plugin/plugin.json + hooks/ (beforeShellExecution) per guard policy") if "codex" in formats: print(" .codex-plugin/plugin.json + hooks/ (PreToolUse) per guard policy") + if "devin" in formats: + print(" .devin-plugin/plugin.json + hooks.json (PreToolUse, best-effort) per guard policy") print(" Skills are advisory in any client. Repo-level enforcement still needs `chock sync`.") return 0 diff --git a/src/chock/plugin/data/stores/devin.json b/src/chock/plugin/data/stores/devin.json new file mode 100644 index 0000000..80914e2 --- /dev/null +++ b/src/chock/plugin/data/stores/devin.json @@ -0,0 +1,3 @@ +{ + "owned_subtrees": ["scripts", "hooks.json"] +} diff --git a/src/chock/plugin/devin.py b/src/chock/plugin/devin.py new file mode 100644 index 0000000..7ea571e --- /dev/null +++ b/src/chock/plugin/devin.py @@ -0,0 +1,131 @@ +"""Emit a Devin plugin (`.devin-plugin/`) from a policy directory.""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any + +from agentseam import packaging + +from chock.compile.emitters.in_agent import _guard_script, hooks_map_file +from chock.plugin import posture, store +from chock.plugin.build import ( + _ADVISORY_NOTE_HOOK, + _ADVISORY_NOTE_RULE, + LICENSE_REL, + _one_line, + build_skill, + license_text, + plugin_name, +) +from chock.plugin.claude import POSTURE_ADVISORY, _adapter_source +from chock.plugin.store import SCRIPTS_TEMPLATE as _SCRIPTS_TEMPLATE + +_LAYOUT = packaging.layout("devin") +HOOKS_REL = packaging.supports("devin", packaging.HOOKS) + +#: agentseam records no `declares` convention for Devin (unlike codex_cli/cursor's +#: "./skills"): the vendor's own manifest doc says only "plugin-root-relative, no ..", so +#: this is the plainest value that satisfies that, not a borrowed one. +SKILLS_DECLARED_REL = "skills" + +#: Devin's manifest fields, per the vendor's plugin reference: name, version, description, +#: skills. No author/license/repository/keywords/interface/hooks -- none of those are +#: documented for this manifest, and hooks.json is auto-discovered at the plugin root +#: rather than declared here (unlike codex_cli/cursor, whose manifests must point at +#: `./hooks/hooks.json`). +MANIFEST_KEYS = ( + "name", + "version", + "description", + "skills", +) + +POSTURE_BESTEFFORT_DEVIN = posture.enforced_devin() + +_BESTEFFORT_NOTE_DEVIN = ( + "This policy ships a PreToolUse hook in this plugin's hooks.json, best-effort in Devin: " + "fail-open by the vendor's own design, subject to the fail conditions stated in the " + "plugin description. Repo-wide git-hook and CI coverage still needs `chock sync`. " + "See https://github.com/open-coder-ai/chock" +) + +#: Devin documents that hook commands receive this environment variable, not a `${...}` token +#: expandable inside a hooks.json string -- agentseam's `plugin_root("devin")` is None, so +#: `packaging.executable_ref` returns None for this vendor, unlike codex_cli/cursor's +#: `${PLUGIN_ROOT}`/`${CURSOR_PLUGIN_ROOT}`. Devin runs `type: command` hook entries "the same +#: format as Claude Code hooks", which executes them through a shell, so a shell expansion of +#: the documented variable is chock's own construction here, not a vendor-recorded token. +DEVIN_PLUGIN_ROOT_VAR = "DEVIN_PLUGIN_ROOT" + + +def _hook_command(script: str) -> str: + """One interpreter invocation, via a shell expansion of `$DEVIN_PLUGIN_ROOT`.""" + adapter = f"${DEVIN_PLUGIN_ROOT_VAR}/{_SCRIPTS_TEMPLATE.format(name='devin.py')}" + guard = f"${DEVIN_PLUGIN_ROOT_VAR}/{_SCRIPTS_TEMPLATE.format(name=script)}" + return f'python3 "{adapter}" --guard "{guard}"' + + +def build_devin_manifest(manifest: dict[str, Any], policy_dir: Path, *, enforced: bool) -> dict[str, Any]: + """Derive `.devin-plugin/plugin.json` from a policy manifest.""" + policy_id = str(manifest.get("id") or Path(policy_dir).name) + posture_text = POSTURE_BESTEFFORT_DEVIN if enforced else POSTURE_ADVISORY + + description = _one_line(manifest.get("description")) + + data: dict[str, Any] = { + "name": plugin_name(policy_id), + "description": f"{description} [{posture_text}]".strip(), + "skills": SKILLS_DECLARED_REL, + } + if manifest.get("version"): + data["version"] = str(manifest["version"]) + return {key: data[key] for key in MANIFEST_KEYS if key in data} + + +def devin_plugin_files(policy_dir: Path, manifest: dict[str, Any], repo_root: Path) -> dict[Path, str]: + """The Devin plugin's files as {relative path: content}, writing nothing.""" + policy_dir = Path(policy_dir) + policy_id = str(manifest.get("id") or policy_dir.name) + name = plugin_name(policy_id) + script = _guard_script(policy_dir, policy_id) + + skill = build_skill(policy_dir, manifest, Path(repo_root), hooks=HOOKS_REL if script else None) + if script: + skill = skill.replace(_ADVISORY_NOTE_RULE, _BESTEFFORT_NOTE_DEVIN).replace( + _ADVISORY_NOTE_HOOK, _BESTEFFORT_NOTE_DEVIN + ) + + files: dict[Path, str] = { + Path(_LAYOUT["manifest"]): json.dumps( + build_devin_manifest(manifest, policy_dir, enforced=script is not None), indent=2 + ) + + "\n", + Path(packaging.supports("devin", packaging.SKILL).format(name=name)): skill, + } + licence = license_text(manifest) + if licence: + files[LICENSE_REL] = licence + if script: + files[Path(HOOKS_REL)] = json.dumps(hooks_map_file("devin", _hook_command(script)), indent=2) + "\n" + files[Path(_SCRIPTS_TEMPLATE.format(name="devin.py"))] = _adapter_source("devin") + files[Path(_SCRIPTS_TEMPLATE.format(name=script))] = (policy_dir / "implementations" / script).read_text( + encoding="utf-8" + ) + return files + + +def stale_devin_files(policy_dir: Path, manifest: dict[str, Any], repo_root: Path, out_dir: Path) -> list[Path]: + """Files under this package that the current manifest would no longer produce.""" + return store.stale_store_files("devin", devin_plugin_files, policy_dir, manifest, repo_root, out_dir) + + +def build_devin_plugin(policy_dir: Path, manifest: dict[str, Any], repo_root: Path, out_dir: Path) -> list[Path]: + """Write the Devin package for one policy into a distribution directory.""" + return store.build_store_plugin("devin", devin_plugin_files, policy_dir, manifest, repo_root, out_dir) + + +def devin_plugin_differences(policy_dir: Path, manifest: dict[str, Any], repo_root: Path, out_dir: Path) -> list[str]: + """Report where the on-disk Devin plugin disagrees with what the manifest would produce.""" + return store.store_plugin_differences("devin", devin_plugin_files, policy_dir, manifest, repo_root, out_dir) diff --git a/src/chock/plugin/marketplace.py b/src/chock/plugin/marketplace.py index 6e88a86..4d4755c 100644 --- a/src/chock/plugin/marketplace.py +++ b/src/chock/plugin/marketplace.py @@ -6,187 +6,31 @@ import json import sys from pathlib import Path -from typing import Any - -from agentseam import packaging from chock.emit import write_generated -from chock.lock import compute_pack_hash -from chock.vendors import CHOCK_AGENT - -OWNER = {"name": "open-coder-ai", "url": "https://github.com/open-coder-ai"} - -TREES: dict[str, dict[str, Any]] = { - "claude": { - "index_paths": (Path(".claude-plugin/marketplace.json"), Path(".github/plugin/marketplace.json")), - "style": "claude", - }, - "codex": { - "index_paths": (Path(".claude-plugin/marketplace.json"),), - "style": "claude", - }, - "cursor": { - "index_paths": (Path(".cursor-plugin/marketplace.json"),), - "style": "cursor", - }, -} - -CLAUDE_TREE = "claude" -INDEX_PATHS = TREES["claude"]["index_paths"] - - -def _manifest_rel(tree: str) -> str: - """This tree's plugin manifest path, from agentseam's packaging layout.""" - return packaging.layout(CHOCK_AGENT[tree])["manifest"] - - -def collect_entries(dist_root: Path, tree: str = CLAUDE_TREE) -> list[dict[str, Any]]: - """Index entries from the built plugin manifests, sorted by directory name.""" - manifest_rel = _manifest_rel(tree) - entries: list[dict[str, Any]] = [] - for manifest_path in sorted(Path(dist_root).glob(f"{tree}/*/{manifest_rel}")): - data = json.loads(manifest_path.read_text(encoding="utf-8")) - package_dir = manifest_path.parent.parent if manifest_rel.count("/") else manifest_path.parent - entry: dict[str, Any] = { - "name": data["name"], - "source": f"./{tree}/{package_dir.name}", - "description": data.get("description", ""), - } - if data.get("version"): - entry["version"] = data["version"] - entries.append(entry) - return entries - - -DESCRIPTION = ( - "Chock policies packaged as installable plugins. Generated from the chock-catalog; " - "each plugin states whether it enforces in this client or is advisory." +from chock.plugin.marketplace_core import ( + CATALOG_PAGE, + CLAUDE_TREE, + DESCRIPTION, + INDEX_PATHS, + LOCKFILE_NAME, + NEWLINE, + TREES, + build_index, + build_lock, + catalog_page_differences, + collect_entries, + index_differences, + lock_differences, + render_catalog_page, +) +from chock.plugin.marketplace_devin import ( + DEVIN_ROOT_MANIFEST_REL, + build_devin_root_manifest, + devin_root_manifest_differences, ) - -def build_index(dist_root: Path, name: str, tree: str = CLAUDE_TREE) -> dict[str, Any]: - entries = collect_entries(dist_root, tree) - if TREES[tree]["style"] == "cursor": - return { - "name": name, - "owner": {"name": OWNER["name"]}, - "metadata": {"description": DESCRIPTION}, - "plugins": [{k: e[k] for k in ("name", "source", "description")} for e in entries], - } - return { - "name": name, - "owner": OWNER, - "description": DESCRIPTION, - "plugins": entries, - } - - -LOCKFILE_NAME = "chock-market.lock" - -NEWLINE = chr(10) - - -def build_lock(dist_root: Path) -> dict[str, Any]: - """Hash every plugin directory in every format tree, sorted for a stable diff.""" - dist_root = Path(dist_root) - plugins: dict[str, str] = {} - for manifest in sorted(dist_root.glob("*/*/")): - if not manifest.is_dir() or manifest.parts[-2].startswith("."): - continue - rel = manifest.relative_to(dist_root).as_posix() - plugins[rel] = compute_pack_hash(manifest) - return {"lockfile_version": 1, "plugins": plugins} - - -def lock_differences(dist_root: Path) -> list[str]: - """Report where the on-disk lockfile disagrees with the tree it describes.""" - content = json.dumps(build_lock(dist_root), indent=2, sort_keys=True) + NEWLINE - dest = Path(dist_root) / LOCKFILE_NAME - if not dest.exists(): - return [f"missing: {LOCKFILE_NAME}"] - return [] if dest.read_text(encoding="utf-8") == content else [f"differs: {LOCKFILE_NAME}"] - - -CATALOG_PAGE = "PLUGINS.md" - -CATALOG_DOCS = "https://github.com/open-coder-ai/chock-catalog/blob/main/docs" - - -#: Summaries longer than this are truncated (at _SUMMARY_TRUNCATE_AT) with an ellipsis. -_SUMMARY_MAX_LEN = 99 -_SUMMARY_TRUNCATE_AT = 96 - - -def _summary(description: str) -> str: - """First sentence of the description, with the bracketed posture note stripped.""" - text = description.split("[", maxsplit=1)[0].strip() - first = text.split(". ")[0].strip().rstrip(".") - return (first[:_SUMMARY_TRUNCATE_AT].rstrip() + "...") if len(first) > _SUMMARY_MAX_LEN else first - - -def render_catalog_page(dist_root: Path, tree: str = CLAUDE_TREE) -> str: - """The generated catalog: how many packages enforce, how many advise, and which.""" - dist_root = Path(dist_root) - rows = [] - enforcing = 0 - manifest_rel = _manifest_rel(tree) - for manifest_path in sorted(dist_root.glob(f"{tree}/*/{manifest_rel}")): - pkg = manifest_path.parent.parent - data = json.loads(manifest_path.read_text(encoding="utf-8")) - has_hook = (pkg / "hooks" / "hooks.json").exists() - enforcing += 1 if has_hook else 0 - posture = "enforces" if has_hook else "advisory" - name = data["name"] - rows.append( - f"| [`{name}`]({CATALOG_DOCS}/{name}/README.md) " - f"| {data.get('version', '-')} | {posture} | {_summary(data.get('description', ''))} |" - ) - - total = len(rows) - lines = [ - "# Published plugins", - "", - "", - "", - f"**{total} policies are published here: {enforcing} enforce in this client, " - f"{total - enforcing} are advisory.**", - "", - "An enforcing package ships a `PreToolUse` hook, a guard script and a stdlib-only", - "adapter, and can deny a shell command before the client runs it. It fails open when", - "`python3` or a usable `bash` is unavailable, and asks -- on Codex CLI, denies -- when", - "the guard crashes. An advisory package ships skill text; nothing stops a violation.", - "", - "| plugin | version | in this client | what it does |", - "| :--- | :--- | :--- | :--- |", - *rows, - "", - f"Each name links to its full policy page in the [catalog]({CATALOG_DOCS}): what it", - "solves, how it works, and its honest reach.", - "", - ] - return NEWLINE.join(lines) - - -def catalog_page_differences(dist_root: Path, tree: str = CLAUDE_TREE) -> list[str]: - """Report a catalog page that disagrees with the tree it describes.""" - dest = Path(dist_root) / CATALOG_PAGE - content = render_catalog_page(dist_root, tree) - if not dest.exists(): - return [f"missing: {CATALOG_PAGE}"] - return [] if dest.read_text(encoding="utf-8") == content else [f"differs: {CATALOG_PAGE}"] - - -def index_differences(dist_root: Path, name: str, tree: str = CLAUDE_TREE) -> list[str]: - """Report where the on-disk index files disagree with the plugin tree.""" - content = json.dumps(build_index(dist_root, name, tree), indent=2) + "\n" - differences: list[str] = [] - for rel in TREES[tree]["index_paths"]: - dest = Path(dist_root) / rel - if not dest.exists(): - differences.append(f"missing: {rel.as_posix()}") - elif dest.read_text(encoding="utf-8") != content: - differences.append(f"differs: {rel.as_posix()}") - return differences +__all__ = ["CATALOG_PAGE", "DESCRIPTION", "INDEX_PATHS", "LOCKFILE_NAME", "main"] def main(argv: list[str] | None = None) -> int: @@ -208,8 +52,22 @@ def main(argv: list[str] | None = None) -> int: action="store_true", help="Report index files that are missing or stale, and exit non-zero. Writes nothing.", ) + parser.add_argument( + "--url", + default=None, + help="Git URL of this marketplace's own repo. Required for --tree devin's root meta-plugin; " + "chock does not read `git remote` for this.", + ) args = parser.parse_args(argv) + if args.tree == "devin" and not args.url: + print( + "--tree devin requires --url: its root meta-plugin's git-subdir entries need this " + "marketplace's own repo URL, and chock will not guess it from `git remote`.", + file=sys.stderr, + ) + return 2 + dist_root = Path(args.dist).resolve() entries = collect_entries(dist_root, args.tree) if not entries: @@ -218,7 +76,11 @@ def main(argv: list[str] | None = None) -> int: if args.check: differences = ( - index_differences(dist_root, args.name, args.tree) + ( + devin_root_manifest_differences(dist_root, args.name, args.url) + if args.tree == "devin" + else index_differences(dist_root, args.name, args.tree) + ) + lock_differences(dist_root) + catalog_page_differences(dist_root, args.tree) ) @@ -231,19 +93,26 @@ def main(argv: list[str] | None = None) -> int: print(f"Marketplace index matches the plugin tree ({len(entries)} plugins).") return 0 - index_paths = TREES[args.tree]["index_paths"] - content = json.dumps(build_index(dist_root, args.name, args.tree), indent=2) + "\n" - for rel in index_paths: - dest = dist_root / rel + if args.tree == "devin": + content = json.dumps(build_devin_root_manifest(dist_root, args.name, args.url), indent=2) + "\n" + dest = dist_root / DEVIN_ROOT_MANIFEST_REL dest.parent.mkdir(parents=True, exist_ok=True) write_generated(dest, content) + print(f"Wrote {DEVIN_ROOT_MANIFEST_REL.as_posix()}: a meta-plugin referencing {len(entries)} plugins") + else: + index_paths = TREES[args.tree]["index_paths"] + content = json.dumps(build_index(dist_root, args.name, args.tree), indent=2) + "\n" + for rel in index_paths: + dest = dist_root / rel + dest.parent.mkdir(parents=True, exist_ok=True) + write_generated(dest, content) + print(f"Indexed {len(entries)} plugins into {' and '.join(p.as_posix() for p in index_paths)}") lock = json.dumps(build_lock(dist_root), indent=2, sort_keys=True) + NEWLINE write_generated(dist_root / LOCKFILE_NAME, lock) write_generated(dist_root / CATALOG_PAGE, render_catalog_page(dist_root, args.tree)) - print(f"Indexed {len(entries)} plugins into {' and '.join(p.as_posix() for p in index_paths)}") print(f"Wrote {LOCKFILE_NAME}: sha256 per published plugin directory") print(f"Wrote {CATALOG_PAGE}: {len(entries)} plugins with their posture") return 0 diff --git a/src/chock/plugin/marketplace_core.py b/src/chock/plugin/marketplace_core.py new file mode 100644 index 0000000..366bec1 --- /dev/null +++ b/src/chock/plugin/marketplace_core.py @@ -0,0 +1,224 @@ +"""Tree-generic marketplace logic: index files, the lockfile, and the catalog page. + +Split out of marketplace.py to keep both files under the 300-line review budget. Never +imports marketplace_devin, so devin's own import of this module stays acyclic. +""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any + +from agentseam import packaging + +from chock.lock import compute_pack_hash +from chock.vendors import CHOCK_AGENT + +OWNER = {"name": "open-coder-ai", "url": "https://github.com/open-coder-ai"} + +TREES: dict[str, dict[str, Any]] = { + "claude": { + "index_paths": (Path(".claude-plugin/marketplace.json"), Path(".github/plugin/marketplace.json")), + "style": "claude", + }, + "codex": { + "index_paths": (Path(".claude-plugin/marketplace.json"),), + "style": "claude", + }, + "cursor": { + "index_paths": (Path(".cursor-plugin/marketplace.json"),), + "style": "cursor", + }, + # Devin has no index-file format: the vendor's own template (CognitionAI/team-marketplace- + # template) is a repo whose root .devin-plugin/plugin.json IS the marketplace, a meta-plugin + # whose optionalPlugins point git-subdir entries at each plugin's own folder. index_paths + # stays empty; marketplace.main() routes "devin" to marketplace_devin instead. + "devin": {"index_paths": (), "style": "devin"}, +} + +CLAUDE_TREE = "claude" +INDEX_PATHS = TREES["claude"]["index_paths"] + + +def _manifest_rel(tree: str) -> str: + """This tree's plugin manifest path, from agentseam's packaging layout.""" + return packaging.layout(CHOCK_AGENT[tree])["manifest"] + + +def collect_entries(dist_root: Path, tree: str = CLAUDE_TREE) -> list[dict[str, Any]]: + """Index entries from the built plugin manifests, sorted by directory name.""" + manifest_rel = _manifest_rel(tree) + entries: list[dict[str, Any]] = [] + for manifest_path in sorted(Path(dist_root).glob(f"{tree}/*/{manifest_rel}")): + data = json.loads(manifest_path.read_text(encoding="utf-8")) + package_dir = manifest_path.parent.parent if manifest_rel.count("/") else manifest_path.parent + entry: dict[str, Any] = { + "name": data["name"], + "source": f"./{tree}/{package_dir.name}", + "description": data.get("description", ""), + } + if data.get("version"): + entry["version"] = data["version"] + entries.append(entry) + return entries + + +DESCRIPTION = ( + "Chock policies packaged as installable plugins. Generated from the chock-catalog; " + "each plugin states whether it enforces in this client or is advisory." +) + + +def build_index(dist_root: Path, name: str, tree: str = CLAUDE_TREE) -> dict[str, Any]: + entries = collect_entries(dist_root, tree) + if TREES[tree]["style"] == "cursor": + return { + "name": name, + "owner": {"name": OWNER["name"]}, + "metadata": {"description": DESCRIPTION}, + "plugins": [{k: e[k] for k in ("name", "source", "description")} for e in entries], + } + return { + "name": name, + "owner": OWNER, + "description": DESCRIPTION, + "plugins": entries, + } + + +LOCKFILE_NAME = "chock-market.lock" + +NEWLINE = chr(10) + + +def build_lock(dist_root: Path) -> dict[str, Any]: + """Hash every plugin directory in every format tree, sorted for a stable diff.""" + dist_root = Path(dist_root) + plugins: dict[str, str] = {} + for manifest in sorted(dist_root.glob("*/*/")): + if not manifest.is_dir() or manifest.parts[-2].startswith("."): + continue + rel = manifest.relative_to(dist_root).as_posix() + plugins[rel] = compute_pack_hash(manifest) + return {"lockfile_version": 1, "plugins": plugins} + + +def lock_differences(dist_root: Path) -> list[str]: + """Report where the on-disk lockfile disagrees with the tree it describes.""" + content = json.dumps(build_lock(dist_root), indent=2, sort_keys=True) + NEWLINE + dest = Path(dist_root) / LOCKFILE_NAME + if not dest.exists(): + return [f"missing: {LOCKFILE_NAME}"] + return [] if dest.read_text(encoding="utf-8") == content else [f"differs: {LOCKFILE_NAME}"] + + +CATALOG_PAGE = "PLUGINS.md" + +CATALOG_DOCS = "https://github.com/open-coder-ai/chock-catalog/blob/main/docs" + +#: Summaries longer than this are truncated (at _SUMMARY_TRUNCATE_AT) with an ellipsis. +_SUMMARY_MAX_LEN = 99 +_SUMMARY_TRUNCATE_AT = 96 + + +def _summary(description: str) -> str: + """First sentence of the description, with the bracketed posture note stripped.""" + text = description.split("[", maxsplit=1)[0].strip() + first = text.split(". ")[0].strip().rstrip(".") + return (first[:_SUMMARY_TRUNCATE_AT].rstrip() + "...") if len(first) > _SUMMARY_MAX_LEN else first + + +def _hooks_rel(tree: str) -> str: + """This tree's hooks-file path, relative to a package directory (root or nested).""" + return packaging.supports(CHOCK_AGENT[tree], packaging.HOOKS) + + +#: Per-tree catalog-page vocabulary. Devin gets its own row/summary/explain text so the page +#: never claims "enforce"/"block" for a fail-open, best-effort client; others keep the original. +_CATALOG_WORDS: dict[str, dict[str, str]] = { + "devin": { + "row": "best-effort", + "summary": "{enforcing} are best-effort in this client, {advisory} are advisory", + "explain": ( + "A best-effort package ships a `PreToolUse` hook, a guard script and a stdlib-only " + "adapter. Devin's own docs call plugin hooks fail-open by design -- a hook that " + "fails to load or run lets the session continue without it -- so this is not a " + "guarantee. An advisory package ships skill text; nothing stops a violation." + ), + }, +} +_DEFAULT_CATALOG_WORDS = { + "row": "enforces", + "summary": "{enforcing} enforce in this client, {advisory} are advisory", + "explain": ( + "An enforcing package ships a `PreToolUse` hook, a guard script and a stdlib-only " + "adapter, and can deny a shell command before the client runs it. It fails open when " + "`python3` or a usable `bash` is unavailable, and asks -- on Codex CLI, denies -- when " + "the guard crashes. An advisory package ships skill text; nothing stops a violation." + ), +} + + +def render_catalog_page(dist_root: Path, tree: str = CLAUDE_TREE) -> str: + """The generated catalog: how many packages enforce, how many advise, and which.""" + dist_root = Path(dist_root) + words = _CATALOG_WORDS.get(tree, _DEFAULT_CATALOG_WORDS) + rows = [] + enforcing = 0 + manifest_rel = _manifest_rel(tree) + hooks_rel = _hooks_rel(tree) + for manifest_path in sorted(dist_root.glob(f"{tree}/*/{manifest_rel}")): + pkg = manifest_path.parent.parent + data = json.loads(manifest_path.read_text(encoding="utf-8")) + has_hook = (pkg / hooks_rel).exists() + enforcing += 1 if has_hook else 0 + posture = words["row"] if has_hook else "advisory" + name = data["name"] + rows.append( + f"| [`{name}`]({CATALOG_DOCS}/{name}/README.md) " + f"| {data.get('version', '-')} | {posture} | {_summary(data.get('description', ''))} |" + ) + + total = len(rows) + summary = words["summary"].format(enforcing=enforcing, advisory=total - enforcing) + lines = [ + "# Published plugins", + "", + "", + "", + f"**{total} policies are published here: {summary}.**", + "", + *words["explain"].splitlines(), + "", + "| plugin | version | in this client | what it does |", + "| :--- | :--- | :--- | :--- |", + *rows, + "", + f"Each name links to its full policy page in the [catalog]({CATALOG_DOCS}): what it", + "solves, how it works, and its honest reach.", + "", + ] + return NEWLINE.join(lines) + + +def catalog_page_differences(dist_root: Path, tree: str = CLAUDE_TREE) -> list[str]: + """Report a catalog page that disagrees with the tree it describes.""" + dest = Path(dist_root) / CATALOG_PAGE + content = render_catalog_page(dist_root, tree) + if not dest.exists(): + return [f"missing: {CATALOG_PAGE}"] + return [] if dest.read_text(encoding="utf-8") == content else [f"differs: {CATALOG_PAGE}"] + + +def index_differences(dist_root: Path, name: str, tree: str = CLAUDE_TREE) -> list[str]: + """Report where the on-disk index files disagree with the plugin tree.""" + content = json.dumps(build_index(dist_root, name, tree), indent=2) + "\n" + differences: list[str] = [] + for rel in TREES[tree]["index_paths"]: + dest = Path(dist_root) / rel + if not dest.exists(): + differences.append(f"missing: {rel.as_posix()}") + elif dest.read_text(encoding="utf-8") != content: + differences.append(f"differs: {rel.as_posix()}") + return differences diff --git a/src/chock/plugin/marketplace_devin.py b/src/chock/plugin/marketplace_devin.py new file mode 100644 index 0000000..0755714 --- /dev/null +++ b/src/chock/plugin/marketplace_devin.py @@ -0,0 +1,40 @@ +"""Devin's marketplace shape: a root meta-plugin, not an index file. + +Kept apart from marketplace.py so neither file crosses the 300-line review budget; this +module depends on marketplace_core, never the reverse, so importing it stays acyclic. +""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any + +from chock.plugin.marketplace_core import collect_entries + +#: Where a Devin marketplace's own meta-plugin manifest lives, at the dist root -- distinct +#: from a per-plugin `devin//.devin-plugin/plugin.json` built by `plugin build --format devin`. +DEVIN_ROOT_MANIFEST_REL = Path(".devin-plugin/plugin.json") + +#: Kept apart from marketplace_core.DESCRIPTION: Devin's text must never claim "enforces" +#: (the vendor's plugin hooks are fail-open by design). +DEVIN_DESCRIPTION = ( + "Chock policies packaged as installable plugins. Generated from the chock-catalog; " + "each plugin states whether it is best-effort in this client or advisory." +) + + +def build_devin_root_manifest(dist_root: Path, name: str, url: str) -> dict[str, Any]: + """Devin's root meta-plugin: `optionalPlugins` git-subdir entries in place of an index file.""" + entries = collect_entries(dist_root, "devin") + plugins = [{"source": "git-subdir", "url": url, "path": e["source"].removeprefix("./")} for e in entries] + return {"name": name, "description": DEVIN_DESCRIPTION, "optionalPlugins": plugins} + + +def devin_root_manifest_differences(dist_root: Path, name: str, url: str) -> list[str]: + """Report a Devin root manifest that is missing, stale, or hand-edited.""" + content = json.dumps(build_devin_root_manifest(dist_root, name, url), indent=2) + "\n" + dest = Path(dist_root) / DEVIN_ROOT_MANIFEST_REL + if not dest.exists(): + return [f"missing: {DEVIN_ROOT_MANIFEST_REL.as_posix()}"] + return [] if dest.read_text(encoding="utf-8") == content else [f"differs: {DEVIN_ROOT_MANIFEST_REL.as_posix()}"] diff --git a/src/chock/plugin/posture.py b/src/chock/plugin/posture.py index f38cd99..17bb5fd 100644 --- a/src/chock/plugin/posture.py +++ b/src/chock/plugin/posture.py @@ -33,6 +33,21 @@ def enforced_codex(*, ledger=None) -> str: ) +def enforced_devin(*, ledger=None) -> str: + """Devin's best-effort posture: the vendor's own fail-open caveat, session-scoped and unwitnessed.""" + return ( + "Best-effort in Devin by a PreToolUse hook shipped with the plugin's hooks.json " + f"({witness_clause('devin', ledger=ledger)}). In the vendor's own words, plugin hooks " + "are 'currently best effort and fail open -- if a hook fails to load or run, the " + "session continues without it -- so don't rely on them for crucial guardrails yet', " + "documented for local Devin sessions (the CLI and Devin Desktop) only. The hook " + "command expands $DEVIN_PLUGIN_ROOT, an environment variable the vendor documents " + "hook commands receive; whether that expansion happens inside a hooks.json command " + "string is documented, not witnessed here. The hook needs python3 on PATH. Repo-wide " + "git-hook and CI coverage still needs `chock sync`." + ) + + def enforced_cursor(*, ledger=None) -> str: """Cursor's enforced posture: the interpreters it needs, and what it does without them.""" return ( diff --git a/src/chock/plugin/store.py b/src/chock/plugin/store.py index 0e213bc..bb90b2c 100644 --- a/src/chock/plugin/store.py +++ b/src/chock/plugin/store.py @@ -29,6 +29,22 @@ def owned_subtrees(store: str) -> tuple[str, ...]: return tuple(data["owned_subtrees"]) +def _owned_candidates(out_dir: Path, sub: str) -> list[Path]: + """Files an owned-subtree entry can name: everything under a directory, or itself. + + Every existing store's entries are directories (`hooks/`, `scripts/`); Devin's native + hooks file sits at the plugin root instead of nested under a directory (`hooks.json`, + not `hooks/hooks.json`), so an entry that is a plain file is checked directly rather + than walked. + """ + target = out_dir / sub + if target.is_dir(): + return sorted(target.rglob("*")) + if target.is_file(): + return [target] + return [] + + def stale_store_files( store: str, files_fn: FilesFn, policy_dir: Path, manifest: dict[str, Any], repo_root: Path, out_dir: Path ) -> list[Path]: @@ -39,7 +55,7 @@ def stale_store_files( expected = set(files_fn(Path(policy_dir), manifest, Path(repo_root))) stale: list[Path] = [] for sub in owned_subtrees(store): - for path in sorted((out_dir / sub).rglob("*")) if (out_dir / sub).is_dir() else []: + for path in _owned_candidates(out_dir, sub): if path.is_file() and path.relative_to(out_dir) not in expected: stale.append(path) return stale diff --git a/tests/fixtures/runtime_goldens/antigravity.py b/tests/fixtures/runtime_goldens/antigravity.py index bfaa5c8..b2c7877 100644 --- a/tests/fixtures/runtime_goldens/antigravity.py +++ b/tests/fixtures/runtime_goldens/antigravity.py @@ -1,4 +1,4 @@ -# Generated by agentseam 0.3.1 -- bundle("antigravity"). Do not hand-edit, except the +# Generated by agentseam 0.3.2 -- bundle("antigravity"). Do not hand-edit, except the # HANDLER block below (between "agentseam handler >>>" and "<<< agentseam handler"), # which is exactly what this file leaves for you to fill in. # @@ -22,7 +22,7 @@ import warnings as _warnings # ------------------------------------------------------------------------------ -# contract (agentseam 0.3.1) +# contract (agentseam 0.3.2) """Canonical event vocabulary, normalized envelope, and decision type.""" diff --git a/tests/fixtures/runtime_goldens/claude_code.py b/tests/fixtures/runtime_goldens/claude_code.py index e4c6207..d8f56f9 100644 --- a/tests/fixtures/runtime_goldens/claude_code.py +++ b/tests/fixtures/runtime_goldens/claude_code.py @@ -1,4 +1,4 @@ -# Generated by agentseam 0.3.1 -- bundle("claude_code"). Do not hand-edit, except the +# Generated by agentseam 0.3.2 -- bundle("claude_code"). Do not hand-edit, except the # HANDLER block below (between "agentseam handler >>>" and "<<< agentseam handler"), # which is exactly what this file leaves for you to fill in. # @@ -23,7 +23,7 @@ import warnings as _warnings # ------------------------------------------------------------------------------ -# contract (agentseam 0.3.1) +# contract (agentseam 0.3.2) """Canonical event vocabulary, normalized envelope, and decision type.""" diff --git a/tests/fixtures/runtime_goldens/codex_cli.py b/tests/fixtures/runtime_goldens/codex_cli.py index ebad293..bdea4c5 100644 --- a/tests/fixtures/runtime_goldens/codex_cli.py +++ b/tests/fixtures/runtime_goldens/codex_cli.py @@ -1,4 +1,4 @@ -# Generated by agentseam 0.3.1 -- bundle("codex_cli"). Do not hand-edit, except the +# Generated by agentseam 0.3.2 -- bundle("codex_cli"). Do not hand-edit, except the # HANDLER block below (between "agentseam handler >>>" and "<<< agentseam handler"), # which is exactly what this file leaves for you to fill in. # @@ -22,7 +22,7 @@ import warnings as _warnings # ------------------------------------------------------------------------------ -# contract (agentseam 0.3.1) +# contract (agentseam 0.3.2) """Canonical event vocabulary, normalized envelope, and decision type.""" diff --git a/tests/fixtures/runtime_goldens/cursor.py b/tests/fixtures/runtime_goldens/cursor.py index 498bab4..a4716f3 100644 --- a/tests/fixtures/runtime_goldens/cursor.py +++ b/tests/fixtures/runtime_goldens/cursor.py @@ -1,4 +1,4 @@ -# Generated by agentseam 0.3.1 -- bundle("cursor"). Do not hand-edit, except the +# Generated by agentseam 0.3.2 -- bundle("cursor"). Do not hand-edit, except the # HANDLER block below (between "agentseam handler >>>" and "<<< agentseam handler"), # which is exactly what this file leaves for you to fill in. # @@ -22,7 +22,7 @@ import warnings as _warnings # ------------------------------------------------------------------------------ -# contract (agentseam 0.3.1) +# contract (agentseam 0.3.2) """Canonical event vocabulary, normalized envelope, and decision type.""" diff --git a/tests/fixtures/runtime_goldens/devin.py b/tests/fixtures/runtime_goldens/devin.py index 4c46897..cb1e666 100644 --- a/tests/fixtures/runtime_goldens/devin.py +++ b/tests/fixtures/runtime_goldens/devin.py @@ -1,4 +1,4 @@ -# Generated by agentseam 0.3.1 -- bundle("devin"). Do not hand-edit, except the +# Generated by agentseam 0.3.2 -- bundle("devin"). Do not hand-edit, except the # HANDLER block below (between "agentseam handler >>>" and "<<< agentseam handler"), # which is exactly what this file leaves for you to fill in. # @@ -22,7 +22,7 @@ import warnings as _warnings # ------------------------------------------------------------------------------ -# contract (agentseam 0.3.1) +# contract (agentseam 0.3.2) """Canonical event vocabulary, normalized envelope, and decision type.""" diff --git a/tests/fixtures/runtime_goldens/gemini_cli.py b/tests/fixtures/runtime_goldens/gemini_cli.py index db789c2..9e671e0 100644 --- a/tests/fixtures/runtime_goldens/gemini_cli.py +++ b/tests/fixtures/runtime_goldens/gemini_cli.py @@ -1,4 +1,4 @@ -# Generated by agentseam 0.3.1 -- bundle("gemini_cli"). Do not hand-edit, except the +# Generated by agentseam 0.3.2 -- bundle("gemini_cli"). Do not hand-edit, except the # HANDLER block below (between "agentseam handler >>>" and "<<< agentseam handler"), # which is exactly what this file leaves for you to fill in. # @@ -22,7 +22,7 @@ import warnings as _warnings # ------------------------------------------------------------------------------ -# contract (agentseam 0.3.1) +# contract (agentseam 0.3.2) """Canonical event vocabulary, normalized envelope, and decision type.""" diff --git a/tests/fixtures/runtime_goldens/grok.py b/tests/fixtures/runtime_goldens/grok.py index 8f09b67..d61208d 100644 --- a/tests/fixtures/runtime_goldens/grok.py +++ b/tests/fixtures/runtime_goldens/grok.py @@ -1,4 +1,4 @@ -# Generated by agentseam 0.3.1 -- bundle("grok"). Do not hand-edit, except the +# Generated by agentseam 0.3.2 -- bundle("grok"). Do not hand-edit, except the # HANDLER block below (between "agentseam handler >>>" and "<<< agentseam handler"), # which is exactly what this file leaves for you to fill in. # @@ -22,7 +22,7 @@ import warnings as _warnings # ------------------------------------------------------------------------------ -# contract (agentseam 0.3.1) +# contract (agentseam 0.3.2) """Canonical event vocabulary, normalized envelope, and decision type.""" diff --git a/tests/fixtures/runtime_goldens/tabnine.py b/tests/fixtures/runtime_goldens/tabnine.py index bcb4148..b29b527 100644 --- a/tests/fixtures/runtime_goldens/tabnine.py +++ b/tests/fixtures/runtime_goldens/tabnine.py @@ -1,4 +1,4 @@ -# Generated by agentseam 0.3.1 -- bundle("tabnine"). Do not hand-edit, except the +# Generated by agentseam 0.3.2 -- bundle("tabnine"). Do not hand-edit, except the # HANDLER block below (between "agentseam handler >>>" and "<<< agentseam handler"), # which is exactly what this file leaves for you to fill in. # @@ -22,7 +22,7 @@ import warnings as _warnings # ------------------------------------------------------------------------------ -# contract (agentseam 0.3.1) +# contract (agentseam 0.3.2) """Canonical event vocabulary, normalized envelope, and decision type.""" diff --git a/tests/fixtures/runtime_goldens/vscode_copilot.py b/tests/fixtures/runtime_goldens/vscode_copilot.py index 6218e70..d0d8d0b 100644 --- a/tests/fixtures/runtime_goldens/vscode_copilot.py +++ b/tests/fixtures/runtime_goldens/vscode_copilot.py @@ -1,4 +1,4 @@ -# Generated by agentseam 0.3.1 -- bundle("vscode_copilot"). Do not hand-edit, except the +# Generated by agentseam 0.3.2 -- bundle("vscode_copilot"). Do not hand-edit, except the # HANDLER block below (between "agentseam handler >>>" and "<<< agentseam handler"), # which is exactly what this file leaves for you to fill in. # @@ -22,7 +22,7 @@ import warnings as _warnings # ------------------------------------------------------------------------------ -# contract (agentseam 0.3.1) +# contract (agentseam 0.3.2) """Canonical event vocabulary, normalized envelope, and decision type.""" diff --git a/tests/fixtures/runtime_goldens/windsurf.py b/tests/fixtures/runtime_goldens/windsurf.py index 90ae182..cd17905 100644 --- a/tests/fixtures/runtime_goldens/windsurf.py +++ b/tests/fixtures/runtime_goldens/windsurf.py @@ -1,4 +1,4 @@ -# Generated by agentseam 0.3.1 -- bundle("windsurf"). Do not hand-edit, except the +# Generated by agentseam 0.3.2 -- bundle("windsurf"). Do not hand-edit, except the # HANDLER block below (between "agentseam handler >>>" and "<<< agentseam handler"), # which is exactly what this file leaves for you to fill in. # @@ -22,7 +22,7 @@ import warnings as _warnings # ------------------------------------------------------------------------------ -# contract (agentseam 0.3.1) +# contract (agentseam 0.3.2) """Canonical event vocabulary, normalized envelope, and decision type.""" diff --git a/tests/test_devin_plugin.py b/tests/test_devin_plugin.py new file mode 100644 index 0000000..8a63471 --- /dev/null +++ b/tests/test_devin_plugin.py @@ -0,0 +1,160 @@ +"""Devin packaging: native layout, root-level hooks.json, and the best-effort posture.""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest +import yaml + +from chock.gate import runtime_bundle +from chock.plugin.cli import main as plugin_main +from chock.plugin.devin import ( + _BESTEFFORT_NOTE_DEVIN, + POSTURE_BESTEFFORT_DEVIN, + build_devin_plugin, + devin_plugin_files, +) + +GUARD_MANIFEST = { + "id": "block-destructive-commands", + "name": "Block Destructive Commands", + "version": "0.0.1", + "description": "Block rm -rf and friends before they run.", + "artifact": "hook", + "enforcement": "block", + "provenance": { + "author": "chock-core", + "license": "Apache-2.0", + "source_repo": "https://github.com/open-coder-ai/chock", + }, +} + +RULE_MANIFEST = { + "id": "code-safety", + "name": "Code Safety Rule", + "version": "0.0.1", + "description": "Advisory rule with no gate.", + "artifact": "rule", + "enforcement": "advise", + "rule": {"text": "never(commit): secrets|keys|tokens"}, +} + +GUARD_BODY = "#!/usr/bin/env bash\nexit 0 # test fixture guard\n" + + +@pytest.fixture +def policy(tmp_path: Path): + def _make(manifest: dict, guard: bool = False) -> Path: + pack = tmp_path / ".agents" / "policies" / manifest["id"] + pack.mkdir(parents=True) + (pack / "manifest.yaml").write_text(yaml.safe_dump(manifest), encoding="utf-8") + if guard: + impl = pack / "implementations" + impl.mkdir() + (impl / f"{manifest['id']}.sh").write_text(GUARD_BODY, encoding="utf-8") + return pack + + return _make + + +def test_devin_guard_policy_layout_and_hook(policy, tmp_path: Path) -> None: + files = devin_plugin_files(policy(GUARD_MANIFEST, guard=True), GUARD_MANIFEST, tmp_path) + assert set(files) == { + Path(".devin-plugin/plugin.json"), + Path("skills/block-destructive-commands/SKILL.md"), + Path("hooks.json"), + Path("scripts/devin.py"), + Path("scripts/block-destructive-commands.sh"), + } + + hooks = json.loads(files[Path("hooks.json")]) + assert set(hooks) == {"hooks"} + entry = hooks["hooks"]["PreToolUse"][0] + assert "matcher" not in entry, "devin's shell-tool vocabulary is unrecorded, never invented" + inner = entry["hooks"][0] + assert inner["type"] == "command" + assert inner["command"] == ( + 'python3 "$DEVIN_PLUGIN_ROOT/scripts/devin.py" --guard "$DEVIN_PLUGIN_ROOT/scripts/block-destructive-commands.sh"' + ) + + +def test_devin_manifest_is_native_layout(policy, tmp_path: Path) -> None: + """`.devin-plugin/plugin.json` only, with the documented fields -- no interface/hooks key.""" + files = devin_plugin_files(policy(GUARD_MANIFEST, guard=True), GUARD_MANIFEST, tmp_path) + assert Path("plugin.json") not in files, "a root plugin.json is the Agent Plugins 1.0 shape" + assert Path(".codex-plugin/plugin.json") not in files + data = json.loads(files[Path(".devin-plugin/plugin.json")]) + assert set(data) == {"name", "version", "description", "skills"}, "only documented manifest fields" + assert data["skills"] == "skills" + assert "hooks" not in data, "devin auto-discovers hooks.json at the plugin root; nothing declares it" + assert Path("assets/icon.svg") not in files, "devin's manifest has no interface/composerIcon field" + + +def test_devin_rule_policy_gets_no_hook(policy, tmp_path: Path) -> None: + files = devin_plugin_files(policy(RULE_MANIFEST), RULE_MANIFEST, tmp_path) + assert set(files) == {Path(".devin-plugin/plugin.json"), Path("skills/code-safety/SKILL.md")} + assert Path("LICENSE") not in files + assert Path("hooks.json") not in files + + +def test_devin_adapter_and_guard_are_verbatim_copies(policy, tmp_path: Path) -> None: + """Byte-identity is the contract: a plugin must not parse payloads differently from `chock sync`.""" + files = devin_plugin_files(policy(GUARD_MANIFEST, guard=True), GUARD_MANIFEST, tmp_path) + assert files[Path("scripts/devin.py")] == runtime_bundle.render("devin") + assert files[Path("scripts/block-destructive-commands.sh")] == GUARD_BODY + + +def test_devin_package_claims_match_the_package(policy, tmp_path: Path) -> None: + """Description, skill frontmatter and closing note must all agree with the hook.""" + files = devin_plugin_files(policy(GUARD_MANIFEST, guard=True), GUARD_MANIFEST, tmp_path) + assert POSTURE_BESTEFFORT_DEVIN in json.loads(files[Path(".devin-plugin/plugin.json")])["description"] + + skill = files[Path("skills/block-destructive-commands/SKILL.md")] + meta = yaml.safe_load(skill.split("---")[1])["metadata"] + assert meta["chock.hooks"] == "hooks.json" + assert "chock.coverage_without_chock" not in meta + assert "advisory: the client reading it has no mechanism to enforce it" not in skill + + +def test_devin_posture_never_claims_enforcement(policy, tmp_path: Path) -> None: + """The vendor's own fail-open caveat, and no 'enforced'/'block' anywhere in the emitted text.""" + for forbidden in ("enforced", "block"): + assert forbidden not in POSTURE_BESTEFFORT_DEVIN + + assert "best effort and fail open" in POSTURE_BESTEFFORT_DEVIN + assert "don't rely on them for crucial guardrails yet" in POSTURE_BESTEFFORT_DEVIN + assert "local Devin sessions (the CLI and Devin Desktop)" in POSTURE_BESTEFFORT_DEVIN + assert "not witnessed" in POSTURE_BESTEFFORT_DEVIN + assert "`chock sync`" in POSTURE_BESTEFFORT_DEVIN + + for forbidden in ("enforced", "enforces", "block"): + assert forbidden not in _BESTEFFORT_NOTE_DEVIN, "the skill's closing note names no enforcement tier" + + +def test_devin_cli_builds_and_refuses_in_place(policy, tmp_path: Path, capsys) -> None: + policy(GUARD_MANIFEST, guard=True) + out = tmp_path / "dist" + + assert plugin_main(["build", "--repo", str(tmp_path), "--format", "devin"]) == 2, "in place must be refused" + capsys.readouterr() + assert plugin_main(["build", "--repo", str(tmp_path), "--format", "devin", "--out-dir", str(out)]) == 0 + capsys.readouterr() + + assert (out / "devin" / "block-destructive-commands" / ".devin-plugin" / "plugin.json").exists() + assert (out / "devin" / "block-destructive-commands" / "hooks.json").exists() + assert plugin_main(["build", "--repo", str(tmp_path), "--format", "devin", "--out-dir", str(out), "--check"]) == 0 + + +def test_devin_losing_a_guard_removes_the_root_hook(policy, tmp_path: Path) -> None: + """`hooks.json` sits at the plugin root; losing the guard must still remove it (store.py's""" + pack = policy(GUARD_MANIFEST, guard=True) + out = tmp_path / "dist" / "devin" / "block-destructive-commands" + build_devin_plugin(pack, GUARD_MANIFEST, tmp_path, out) + assert (out / "hooks.json").exists() + + (pack / "implementations" / "block-destructive-commands.sh").unlink() + build_devin_plugin(pack, GUARD_MANIFEST, tmp_path, out) + assert not (out / "hooks.json").exists() + assert not (out / "scripts").exists() diff --git a/tests/test_marketplace.py b/tests/test_marketplace.py index 128274b..7dc40d2 100644 --- a/tests/test_marketplace.py +++ b/tests/test_marketplace.py @@ -188,3 +188,44 @@ def test_codex_tree_reuses_the_witnessed_legacy_index(dist: Path) -> None: sources = [entry["source"] for entry in index["plugins"]] assert sources and all(s.startswith("./codex/") for s in sources) assert not (dist / ".github" / "plugin" / "marketplace.json").exists() + + +DEVIN_URL = "https://github.com/open-coder-ai/chock-devin-plugins" + + +def test_devin_tree_requires_url(dist: Path, capsys) -> None: + assert marketplace_main(["build", "--dist", str(dist), "--tree", "devin"]) == 2 + assert "requires --url" in capsys.readouterr().err + assert not (dist / ".devin-plugin" / "plugin.json").exists() + + +def test_devin_tree_writes_a_root_meta_plugin_not_an_index_file(dist: Path) -> None: + """`--tree devin` writes a root `.devin-plugin/plugin.json` meta-plugin, no index file.""" + assert ( + marketplace_main(["build", "--dist", str(dist), "--name", "chock-devin", "--tree", "devin", "--url", DEVIN_URL]) + == 0 + ) + manifest = json.loads((dist / ".devin-plugin" / "plugin.json").read_text(encoding="utf-8")) + + assert manifest["name"] == "chock-devin" + assert "enforces" not in manifest["description"] and "block" not in manifest["description"] + assert manifest["optionalPlugins"], "at least one plugin from the built tree" + for plugin in manifest["optionalPlugins"]: + assert set(plugin) == {"source", "url", "path"} + assert plugin["source"] == "git-subdir" + assert plugin["url"] == DEVIN_URL + assert plugin["path"].startswith("devin/") + assert not (dist / ".claude-plugin" / "marketplace.json").exists() + assert not (dist / ".cursor-plugin" / "marketplace.json").exists() + + +def test_devin_tree_check_catches_missing_stale_and_hand_edited(dist: Path, capsys) -> None: + assert marketplace_main(["build", "--dist", str(dist), "--tree", "devin", "--url", DEVIN_URL, "--check"]) == 1 + assert "missing" in capsys.readouterr().out + + marketplace_main(["build", "--dist", str(dist), "--tree", "devin", "--url", DEVIN_URL]) + assert marketplace_main(["build", "--dist", str(dist), "--tree", "devin", "--url", DEVIN_URL, "--check"]) == 0 + + (dist / ".devin-plugin" / "plugin.json").write_text("{}", encoding="utf-8") + assert marketplace_main(["build", "--dist", str(dist), "--tree", "devin", "--url", DEVIN_URL, "--check"]) == 1 + assert "differs" in capsys.readouterr().out From 49f40717107cb51a835c33143553c13827008ecc Mon Sep 17 00:00:00 2001 From: Jothi Mani Rajendran <250249270+jothimani-rajendran@users.noreply.github.com> Date: Tue, 22 Sep 2026 00:23:12 +0000 Subject: [PATCH 2/2] Fix: devin hooks.json must be the bare event map, not the claude wrapper agentseam 0.3.2 records Devin's hook_entry as {bare: true, matcher: true, wrapper: hooks_map}: its own hook_config("devin", ...) returns the bare {"PreToolUse": [...]} map, with no top-level "hooks" key. codex_cli and claude_code carry no `bare` flag, so their hooks.json is wrapped in {"hooks": {...}} -- the shape hooks_map_file() hardcoded for every vendor. Reusing it for devin unconditionally wrapped its native hooks.json too, which risks the hooks silently never loading in Devin, the exact failure mode this plugin format exists to avoid. hooks_map_file() now reads a vendor's own `hook_entry.bare` flag (chock.vendors.hook_entry_bare(), new) and omits the wrapper when set. codex_cli/claude_code/vscode_copilot output is unchanged (verified byte-identical: their tests, and the runtime/emitter-stability goldens, are untouched by this commit). Co-Authored-By: Claude Sonnet 5 Signed-off-by: Jothi Mani Rajendran <250249270+jothimani-rajendran@users.noreply.github.com> --- src/chock/compile/emitters/in_agent.py | 11 +++++++++-- src/chock/vendors.py | 14 ++++++++++++++ tests/test_devin_plugin.py | 21 +++++++++++++++++++-- 3 files changed, 42 insertions(+), 4 deletions(-) diff --git a/src/chock/compile/emitters/in_agent.py b/src/chock/compile/emitters/in_agent.py index 9d5bce0..7d969b0 100644 --- a/src/chock/compile/emitters/in_agent.py +++ b/src/chock/compile/emitters/in_agent.py @@ -98,9 +98,16 @@ def hook_entry(command: str, *, matcher: str | None = None) -> dict[str, Any]: def hooks_map_file(vendor: str, command: str) -> dict[str, Any]: - """A claude-plugin-format hooks file under `vendor`'s own pre-tool event spelling.""" + """A hooks file under `vendor`'s own pre-tool event spelling. + + Wrapped in a top-level `hooks` key (the claude-plugin format) unless `vendor`'s own + hook_entry is bare -- Devin's native `hooks.json` at the plugin root is the event map + itself, with no wrapper, unlike the nested `hooks/hooks.json` every other format here + shares. + """ matcher = vendors.shell_matcher(vendor) - return {"hooks": {vendors.pre_tool_event(vendor): [hook_entry(command, matcher=matcher)]}} + event_map = {vendors.pre_tool_event(vendor): [hook_entry(command, matcher=matcher)]} + return event_map if vendors.hook_entry_bare(vendor) else {"hooks": event_map} def cursor_entry(command: str) -> dict[str, Any]: diff --git a/src/chock/vendors.py b/src/chock/vendors.py index ee5b057..869b3f3 100644 --- a/src/chock/vendors.py +++ b/src/chock/vendors.py @@ -134,6 +134,20 @@ def write_matcher(vendor: str) -> str | None: return "|".join(tools) if tools else None +def hook_entry_bare(vendor: str) -> bool: + """Whether `vendor`'s own hook_config is the bare event map, with no top-level `hooks` key. + + Every vendor with a recorded `hook_entry` wraps its event map in `{"hooks": {...}}`, + except Devin: its native `hooks.json` (unlike the nested `hooks/hooks.json` the Claude + layout writes) is the bare map itself, confirmed by + `agentseam.adapters.get("devin").CONFIG["hook_entry"] == {"bare": True, ...}`. A vendor + with no recorded `hook_entry` (vscode_copilot, witnessed rather than derived) is not bare. + """ + config = getattr(_adapters.get(vendor), "CONFIG", None) + entry = (config or {}).get("hook_entry") if isinstance(config, dict) else None + return bool((entry or {}).get("bare")) + + def pre_tool_hook_config(vendor: str, command: str, matcher: str | None = None) -> dict[str, Any]: """The vendor's complete hook-config document gating pre-tool with `command`.""" return _adapters.get(vendor).hook_config((_contract.PRE_TOOL,), command, matcher) diff --git a/tests/test_devin_plugin.py b/tests/test_devin_plugin.py index 8a63471..94859fb 100644 --- a/tests/test_devin_plugin.py +++ b/tests/test_devin_plugin.py @@ -70,8 +70,8 @@ def test_devin_guard_policy_layout_and_hook(policy, tmp_path: Path) -> None: } hooks = json.loads(files[Path("hooks.json")]) - assert set(hooks) == {"hooks"} - entry = hooks["hooks"]["PreToolUse"][0] + assert set(hooks) == {"PreToolUse"}, "devin's own hook_entry is bare: no top-level `hooks` wrapper" + entry = hooks["PreToolUse"][0] assert "matcher" not in entry, "devin's shell-tool vocabulary is unrecorded, never invented" inner = entry["hooks"][0] assert inner["type"] == "command" @@ -80,6 +80,23 @@ def test_devin_guard_policy_layout_and_hook(policy, tmp_path: Path) -> None: ) +def test_devin_hooks_shape_matches_agentseams_own_hook_config(policy, tmp_path: Path) -> None: + """The emitted file must honour agentseam's `bare` flag, not chock's own wrapping default.""" + from agentseam import adapters, contract + + files = devin_plugin_files(policy(GUARD_MANIFEST, guard=True), GUARD_MANIFEST, tmp_path) + hooks = json.loads(files[Path("hooks.json")]) + command = hooks["PreToolUse"][0]["hooks"][0]["command"] + + upstream = adapters.get("devin").hook_config((contract.PRE_TOOL,), command, None) + assert set(hooks) == set(upstream), "same top-level shape as agentseam's own hook_config for devin" + upstream_inner = upstream["PreToolUse"][0]["hooks"][0] + ours_inner = hooks["PreToolUse"][0]["hooks"][0] + assert {**ours_inner, "timeout": None} == {**upstream_inner, "timeout": None}, ( + "identical modulo chock's own timeout key" + ) + + def test_devin_manifest_is_native_layout(policy, tmp_path: Path) -> None: """`.devin-plugin/plugin.json` only, with the documented fields -- no interface/hooks key.""" files = devin_plugin_files(policy(GUARD_MANIFEST, guard=True), GUARD_MANIFEST, tmp_path)