Skip to content

Design: uphold ships its Claude Code hooks as a plugin from this repository #429

Open

Description

@HackingGate

Goal

A user or repository that runs uphold under Claude Code enables one plugin, pinned to an uphold release, instead of pasting hook JSON from the README. The hooks fail closed when the uphold binary cannot start.

Today

Read at ba56137.

  • README.md and docs/REFERENCE.md ("uphold hook" and "The end of a turn: [stop]") give JSON to paste into a settings file: PreToolUse with matcher mcp__github__.*, SessionStart with async: true, and Stop with timeout: 900.
  • This repository's own .claude/settings.json carries SessionStart and Stop with timeout: 600. That is a second value for one hook, and it already differs from the documented 900.
  • None of the copies sets onFailure. When a command hook cannot start (uphold not on PATH, shell exit 127), Claude Code treats it as a non-blocking error and the tool call proceeds. Every copy fails open.
  • Nothing checks that a pasted copy matches the documented one.

Decision

Ship one Claude Code plugin, uphold, from a marketplace at this repository's root. Its hooks/hooks.json holds the three hook entries, with onFailure: "block" on the two that can block. The plugin carries no skill, no binary and no version field.

Rejected:

  • A skill. A skill is Markdown an agent loads at its own discretion. The enforcement already has a trigger in the hook, and docs/REFERENCE.md is the one place that says how a rule is written. A skill would be a second copy of it.
  • The binary in the plugin's bin/. A plugin is one directory for every platform, while the release builds a binary per target with cargo-dist. The shims and git hooks need the same binary outside Claude Code, so the release's installer installs it once.
  • A version in plugin.json. That would be a fifth copy of the version scripts/bump-version.sh writes, and scripts/tag-release.sh does not check it. Without it, Claude Code versions a relative-path plugin by the commit of the installed directory, and the user pins the release with the marketplace ref.
  • Keeping the pasted JSON. It is three copies that already disagree, and every one of them fails open.

Research: see the first comment.

Design

.claude-plugin/marketplace.json            name "uphold", one plugin, source "./plugins/claude-code"
plugins/claude-code/.claude-plugin/plugin.json   name "uphold", description; no version
plugins/claude-code/hooks/hooks.json       the three entries below

hooks/hooks.json uses the same shape as the hooks object in a settings file:

event matcher command options
PreToolUse mcp__github__.* uphold hook claude-code onFailure: "block"
SessionStart (as docs/REFERENCE.md documents) uphold hook claude-code async: true
Stop uphold hook claude-code timeout: 900, onFailure: "block"

A user installs it once:

/plugin marketplace add HackingGate/uphold@v1.37.0
/plugin install uphold@uphold

A repository enables it for every collaborator in .claude/settings.json, with extraKnownMarketplaces naming this repository at a release tag and enabledPlugins setting "uphold@uphold": true. A relative-path plugin loads from the marketplace itself, so collaborators need no separate install once they trust the folder.

flowchart LR
  release["release tag vX.Y.Z"] --> mkt["marketplace at the tag"]
  mkt --> plugin["plugin: hooks/hooks.json"]
  plugin -->|"PreToolUse, Stop: onFailure block"| bin["uphold hook claude-code"]
  installer["release installer"] --> bin
  bin --> policy["policy/principles.toml, [stop]"]
Loading

Identical handlers are deduplicated only across settings files. A plugin's copy runs separately, so a settings block left beside the plugin runs every hook twice. Enabling the plugin includes removing the pasted block.

Plan

  1. The marketplace, the plugin and hooks/hooks.json, with a test in tests/ that reads hooks.json and refuses an entry that runs any command other than uphold hook claude-code, or a PreToolUse or Stop entry without onFailure: "block". This repository's .claude/settings.json drops its hook block and enables the plugin from its own directory. Stands on Claude Code's plugin format; nothing new in the engine.
  2. README.md and docs/REFERENCE.md replace the Claude Code hook JSON with the two install lines and the project settings.json form. The Codex JSON stays. A content rule in this repository's policy/principles.toml refuses "command": "uphold hook claude-code" in *.md, so the pasted form cannot come back. Stands on a regexp rule.
  3. The release that follows carries the plugin. scripts/bump-version.sh and scripts/tag-release.sh are unchanged, because the plugin has no version field.

Done when

  • .claude-plugin/marketplace.json and plugins/claude-code/hooks/hooks.json exist, and the test from plan item 1 refuses a hooks.json with a missing onFailure or a different command.
  • claude plugin validate . passes. Run by hand and recorded in the pull request, because CI has no Claude Code CLI.
  • This repository's .claude/settings.json carries no hooks block and enables uphold@uphold.
  • The content rule from plan item 2 refuses the pasted hook command in Markdown, with a test in the corpus.
  • scripts/tag-release.sh still checks exactly four version files.

Open

  • onFailure requires Claude Code v2.1.295. Unverified: whether an older version ignores the key or refuses the hook.

    Answered: out of scope, by the owner on 2026-10-27: Claude Code before v2.1.295 is not supported, so its handling of onFailure does not matter.

  • Codex: not researched. The Codex JSON stays in the README until it is.

    Answered: researched in the Codex comment. Codex 0.164.0 installs this plugin unchanged and registers both entries, so the Codex JSON in the README can go the same way the Claude Code JSON does. Codex ignores onFailure and runs the tool when the hook cannot start, so under Codex the hooks still fail open.

  • A cloud session does not add marketplaces from a repository's extraKnownMarketplaces, so the hooks do not reach cloud sessions through project settings. Unchanged from today, where cloud sessions also run no settings hooks from a project's threads.

    Answered: escalated -- this repository's [decide] adopts no records (uphold check --json: adopt is empty), so no adopted record's applies_when holds; and the item states a limit of Claude Code with no options to choose from.

  • Whether uphold check should report a settings hook block that duplicates the plugin, since both run.

    Answered: no uphold check code: plan item 2's regexp rule also covers .claude/settings*.json, and ~/.claude/settings.json, which no repository rule can see, stays README guidance. enforcement-needs-a-trigger L8 "expressed as a decidable predicate over an observable subject"; enforcement-needs-a-trigger L18 "What input is this rule known to match, and is that input checked in?"; least-power L18 "What is the weakest form that expresses every real case here"; economy-of-mechanism L18 "Does a new option belong inside the protected mechanism, or can it live in untrusted code that calls it?"; single-authoritative-source L18 "Is there exactly one writer for this fact, and what stops a second component from writing it?"; precedent: none. Ruled from the whole catalog at v1.38.0, the running binary's, because this repository adopts no [decide] and pins no uphold; three rulers given the options in different orders agreed.

  • The PreToolUse matcher covers the GitHub MCP server only, as the README does today.

    Answered: keep mcp__github__.*, decided by the order psychological-acceptability over complete-mediation ("A hook that refuses routine work has its matcher deleted, and then it mediates nothing."). psychological-acceptability L11 "The control produces failures on legitimate work"; psychological-acceptability L18 "Is the cheapest response to a failure a fix, or turning the check off?". Against it, and outranked: complete-mediation L18 "When a new interface is added, does it inherit the check". The plugin-named GitHub server stays uncovered until Design: the hook refuses only what the shim would, a publishing call to a destination where the rule's scope holds #450 stops the hook refusing reads; this question is asked again then. Precedent: none. Ruled from the records policy/principles.toml adopts under [decide] (59e4705, Adopt ten records for design rulings, with the four orders between them #451) and its orders, at v1.38.0; three rulers given the options in different orders agreed.

Activity

  1. added
    designA Design record: a ruling on what will be built, filed before a pull request
    ruling:pendingDesign record awaiting a ruling
    on Oct 25, 2026
  2. HackingGate commented on Oct 25, 2026

    @HackingGate
    OwnerAuthor

    Read 2026-10-25, Claude Code 2.1.310, against the docs at code.claude.com.

    Plugin hooks (/docs/en/plugins/components): a plugin declares hooks in hooks/hooks.json at the plugin root, "in the same shape as the hooks object in settings.json". They register when a session loads the plugin and fire from then on. Hook processes receive CLAUDE_PLUGIN_ROOT and CLAUDE_PLUGIN_DATA.

    bin/ (same page): "Files in bin/ at the plugin root are on the PATH of the Bash tool's shell while the plugin is enabled", after the user's own PATH entries. claude.ai and Cowork do not install a plugin with a top-level bin/. Rejected for the binary for that reason and the per-platform build.

    Failure (/docs/en/hooks): any exit other than 0 or 2, and a hook that cannot start (shell exit 127), is a non-blocking error and the action proceeds, "except for hooks whose failure blocks the action, for example a hook with onFailure set to "block"". onFailure requires v2.1.295.

    Deduplication (same page): "If you define the same handler in more than one settings file, it runs once. A plugin's or skill's copy of the same handler stays separate."

    Version (/docs/en/plugins/loading): the manifest version comes first, then the marketplace entry's; with neither, a relative path inside a Git-hosted marketplace takes "the commit SHA of the installed directory". A manifest that pins a version keeps users on the cached copy until the string changes.

    Pinning (/docs/en/plugins/marketplace-reference): a github marketplace source takes repo, ref (branch or tag) and path. claude plugin marketplace add accepts owner/repo@ref. A plugin source can pin sha; a marketplace source cannot.

    Project enablement (/docs/en/plugins/loading): a plugin enabled only in a project's .claude/settings.json is not fetched onto a machine where it is not installed, unless its marketplace entry has a relative-path source. Repository plugins load only after the workspace trust check. A cloud session does not add marketplaces a repository lists under extraKnownMarketplaces.

    Reserved names (/docs/en/plugins/marketplace-reference): uphold is not reserved.

    Options considered: a skill; a plugin with hooks only; a plugin with hooks and a bundled binary; a plugin with a pinned version; keeping the pasted JSON. The ruling is the second.

  3. HackingGate commented on Oct 27, 2026

    @HackingGate
    OwnerAuthor

    Codex, tested 2026-10-27 with codex-cli 0.164.0 under an isolated CODEX_HOME, against the docs at learn.chatgpt.com/docs/hooks and developers.openai.com/plugins/build/plugins.

    The same plugin serves Codex. A mock of this issue's layout (.claude-plugin/marketplace.json, plugins/claude-code/.claude-plugin/plugin.json with no version, plugins/claude-code/hooks/hooks.json) installed with codex plugin marketplace add <dir> and codex plugin add uphold@uphold. Codex's /hooks listed both entries with Source: Plugin - uphold@uphold, matcher mcp__github__.*, command uphold hook claude-code. The docs name .claude-plugin/marketplace.json as a legacy-compatible marketplace path; the current ones are .agents/plugins/marketplace.json and a root plugin.json.

    Codex fails open, and there is no switch. A PreToolUse hook whose command does not exist, with onFailure: "block": Codex loaded it without complaint, showed "Hook failed, hook exited with code 127", and ran the tool call. The docs name no fail-closed option. onFailure is ignored, not refused, so one hooks.json serves both harnesses and only Claude Code fails closed.

    Trust. Codex runs a plugin's hooks only after the user trusts them in /hooks; trust is keyed to the hook's hash, so a changed hooks.json in a new release is skipped until trusted again. Enabling the plugin does not trust it.

    Pinning and project enablement. codex plugin marketplace add owner/repo --ref <ref> (or owner/repo@ref). A repository enables it in .codex/config.toml as [plugins."uphold@uphold"] enabled = true, loaded only for a trusted project.

    The command. Codex runs uphold hook claude-code as written. uphold hook gives claude-code and codex one shape (src/hook.rs, SHAPES), so the name differs only in what a report calls the harness.

    Not tested: the Stop gate's continuation under Codex, where decision: "block" continues the turn with the reason as the next prompt.

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    designA Design record: a ruling on what will be built, filed before a pull requestruling:pendingDesign record awaiting a ruling

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions