Skip to content

v0.4.0

Choose a tag to compare

@github-actions github-actions released this 24 Aug 12:29
· 145 commits to main since this release
284d644

0.4.0 — Witnessed enforcement on Cursor and Codex

MINOR: plugin packaging output changes (spec fixes and three new formats), and the
vendored PreToolUse adapter changes -- the deny-dialect and payload-decoding fixes below
mean an adopter's next chock sync rewrites it. Every other compiled enforcement
surface is unchanged.

  • Per-vendor marketplace indexing: chock marketplace build --tree cursor|codex
    indexes a vendor's own format tree with the index file its client actually reads --
    Cursor's .cursor-plugin/marketplace.json in Cursor's schema, and for Codex the
    legacy .claude-plugin/marketplace.json shape it was witnessed consuming from git
    marketplaces -- so each vendor-named distribution repo carries exactly one vendor's
    packages instead of every format tree. Default (--tree claude) is byte-unchanged.

  • cursor and codex plugin formats: chock plugin build --format cursor|codex
    packages a policy for Cursor and OpenAI Codex, with the enforcing hook each vendor
    actually reads. Same guard, same adapter, byte-identical to every other format --
    only the envelope differs. --format all now emits five trees.

    • Cursor (.cursor-plugin/plugin.json + flat hooks/hooks.json) subscribes to
      beforeShellExecution, the shell-scoped event chock sync already installs, so a
      plugin install and a repo install run the identical hook. No matcher is emitted:
      under this event the matcher is a regex over the COMMAND TEXT, not a tool name, so
      the other formats' "Bash" would match almost nothing and silently disable the
      guard. Cursor ignores Agent Plugins hooks entirely, so this is the only format that
      enforces there.
    • Codex (.codex-plugin/plugin.json + nested hooks/hooks.json) subscribes to
      PreToolUse with matcher Bash -- Claude's protocol exactly. The legacy
      .codex-plugin/ manifest is deliberate: Codex's loader DISCARDS hooks from an
      Agent-Plugins-format manifest (codex-rs/core-plugins/src/loader.rs), so shipping
      the Copilot package there would install a plugin whose enforcement is deleted at
      load time while its description still claimed it.
  • Codex packages claim witnessed enforcement -- via JSON deny, not exit codes.
    Probed on a real Codex Desktop install (Windows 11): a trusted PreToolUse hook
    returning the documented exit-2 deny ran the command three times, because Codex wraps
    Windows hook commands in powershell -Command, which collapses exit 2 into 1 -- and
    Codex's parser treats that as a failed hook and FAILS OPEN (its only stdout-parsing
    arm is exit 0). With the deny carried in hookSpecificOutput.permissionDecision JSON
    on a clean exit, the same command was witnessed BLOCKED (2026-08-24). The adapter now
    speaks that dialect to Codex-shaped payloads (turn_id present); Claude Code keeps
    its witnessed exit-2 path. Conditions stated in the package posture: Codex hooks are
    UNTRUSTED on install until a human approves a per-hook trust review, that trust is
    bound to a hash of the hook command so a plugin update silently voids it until
    re-approved, and every hook failure fails open. The emitted hooks.json also carries no
    top-level description: Codex < 0.143.0 rejects the whole file over that one key and
    silently drops every hook in it (openai/codex#30397).

  • Cursor packages DO claim enforcement, witnessed blocking on a real install after the
    two fixes below, with a benign command in the same session still allowed.

  • Two silent fail-opens fixed, both found by probing a real Cursor install (neither
    is visible in any documentation, and each would have shipped a package that advertised
    enforcement and delivered none):

    • Payload decoding. Cursor prefixes its hook payload with a UTF-8 BOM, and
      sys.stdin.read() decoded those bytes with the platform locale (cp1252 on Windows),
      turning the BOM into three stray characters. json.loads then failed, the adapter
      reported "not checked", and returned 0 -- every command ALLOWED. The payload is now
      read as bytes and decoded utf-8-sig, which also stops non-ASCII paths being mangled.
    • Deny signalling. Cursor documents exit 2 as "equivalent to returning
      permission: deny". For plugin hooks that is false: a hook returning exit 2 with the
      reason on stderr was witnessed NOT blocking -- the command ran. The adapter now also
      emits Cursor's stdout response ({"permission": "deny", "user_message", "agent_message"}) for Cursor-shaped payloads only; Claude and Copilot still get
      exit 2 with an empty stdout, asserted by test.
      Witnessed blocking on a real Cursor install after both fixes, with a benign command in
      the same session still allowed.
  • A silent guard can no longer become a silent allow: the PreToolUse adapter now
    guarantees a reason on stderr for every deny. Codex records exit 2 with an empty
    stderr as a FAILED hook ("did not write a blocking reason to stderr",
    codex-rs/hooks/src/events/pre_tool_use.rs) and lets the command run, so a guard that
    denied without explaining itself would have enforced nothing there while every other
    client showed a deny. Harmless elsewhere; load-bearing on Codex.

  • The marketplace lockfile test now derives its expected tree set from FORMATS rather
    than an enumerated list -- the same under-coverage a hand-written list caused once
    before, where a newly added tree escaped the check while it still read as complete.

  • Bundled authoring skills use the same flat metadata: the five shipped skills
    (chock-init, eval, optimize, policy-init, validate) carried the nested chock:
    object the packaged-policy fix removed — two metadata dialects in one project.
    Their frontmatter is now the same flat string map (lists comma-joined, booleans
    as "true"/"false"), and manifest ingestion decodes the typed fields; the derived
    manifests are proven identical. Old nested frontmatter still loads, so
    third-party skills are unaffected.

  • SKILL.md metadata spec fix: the Agent Skills spec (which Agent Plugins 1.0
    defers to for SKILL.md) requires metadata to map string keys to string values;
    the packaged skills nested a chock: object there, which awesome-copilot's vally
    linter rejected ("Metadata values must be strings"). Now a flat map with dotted
    keys (chock.artifact, chock.enforcement, chock.coverage_without_chock) —
    same facts, spec-conformant shape. A parsed-not-substring test pins the constraint.

  • Posture-aware skill frontmatter: a hook-carrying package's SKILL.md used to
    state coverage_without_chock: advisory next to the very hook that enforces —
    one package stating and refuting a claim at once. The frontmatter now swaps the
    advisory claim for the shipped hook's path (chock.hooks), the same substitution
    the hook emitters already made in plugin.json and the closing note.

  • copilot plugin format: chock plugin build --format copilot emits the Agent
    Plugins 1.0 layout — root plugin.json, skills/ — with the enforcing PreToolUse
    hook under com.github.copilot/hooks/hooks.json, the namespace directory VS Code
    documents for Agent Plugins hook bundles and non-Copilot clients must ignore. This
    is the shape spec-validating marketplaces (awesome-copilot) accept; the Claude
    layout, which Copilot also reads, keeps its manifest in .claude-plugin/ and fails
    their intake. Hook command, adapter and guard are byte-identical to the Claude
    package's (asserted in tests): two formats, one enforcement system. The posture is
    scoped to this format's audience — generic Agent Plugins clients are required to
    ignore com.github.copilot, so the description names where the hook enforces
    (documented for VS Code agent mode) and that a namespace-ignoring client gets the
    advisory skill only. Hook-carrying packages replace the coverage_without_chock
    extension claim with the hook's location, and the dangling manifest: manifest.yaml
    pointer (a file this out-of-place format never ships) is dropped — each package
    carries only claims that are true of it. --format all now emits three trees.