Repository navigation
Design: uphold ships its Claude Code hooks as a plugin from this repository #429
Description
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.mdanddocs/REFERENCE.md("uphold hook" and "The end of a turn:[stop]") give JSON to paste into a settings file:PreToolUsewith matchermcp__github__.*,SessionStartwithasync: true, andStopwithtimeout: 900.- This repository's own
.claude/settings.jsoncarriesSessionStartandStopwithtimeout: 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 (upholdnot onPATH, 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.mdis 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
versioninplugin.json. That would be a fifth copy of the versionscripts/bump-version.shwrites, andscripts/tag-release.shdoes 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]"]
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
- The marketplace, the plugin and
hooks/hooks.json, with a test intests/that readshooks.jsonand refuses an entry that runs any command other thanuphold hook claude-code, or aPreToolUseorStopentry withoutonFailure: "block". This repository's.claude/settings.jsondrops its hook block and enables the plugin from its own directory. Stands on Claude Code's plugin format; nothing new in the engine. README.mdanddocs/REFERENCE.mdreplace the Claude Code hook JSON with the two install lines and the projectsettings.jsonform. The Codex JSON stays. A content rule in this repository'spolicy/principles.tomlrefuses"command": "uphold hook claude-code"in*.md, so the pasted form cannot come back. Stands on aregexprule.- The release that follows carries the plugin.
scripts/bump-version.shandscripts/tag-release.share unchanged, because the plugin has no version field.
Done when
-
.claude-plugin/marketplace.jsonandplugins/claude-code/hooks/hooks.jsonexist, and the test from plan item 1 refuses ahooks.jsonwith a missingonFailureor 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.jsoncarries nohooksblock and enablesuphold@uphold. - The content rule from plan item 2 refuses the pasted hook command in Markdown, with a test in the corpus.
-
scripts/tag-release.shstill checks exactly four version files.
Open
-
onFailurerequires 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
onFailuredoes 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
onFailureand 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:adoptis empty), so no adopted record'sapplies_whenholds; and the item states a limit of Claude Code with no options to choose from. -
Whether
uphold checkshould report a settings hook block that duplicates the plugin, since both run.Answered: no
uphold checkcode: 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
PreToolUsematcher 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 recordspolicy/principles.tomladopts 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
HackingGate commentedon Oct 25, 2026
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 inhooks/hooks.jsonat the plugin root, "in the same shape as thehooksobject insettings.json". They register when a session loads the plugin and fire from then on. Hook processes receiveCLAUDE_PLUGIN_ROOTandCLAUDE_PLUGIN_DATA.bin/(same page): "Files inbin/at the plugin root are on thePATHof the Bash tool's shell while the plugin is enabled", after the user's ownPATHentries. claude.ai and Cowork do not install a plugin with a top-levelbin/. 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 withonFailureset to"block"".onFailurerequires 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 manifestversioncomes 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): agithubmarketplace source takesrepo,ref(branch or tag) andpath.claude plugin marketplace addacceptsowner/repo@ref. A plugin source can pinsha; a marketplace source cannot.Project enablement (
/docs/en/plugins/loading): a plugin enabled only in a project's.claude/settings.jsonis 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 underextraKnownMarketplaces.Reserved names (
/docs/en/plugins/marketplace-reference):upholdis 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.HackingGate commentedon Oct 27, 2026
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.jsonwith noversion,plugins/claude-code/hooks/hooks.json) installed withcodex plugin marketplace add <dir>andcodex plugin add uphold@uphold. Codex's/hookslisted both entries withSource: Plugin - uphold@uphold, matchermcp__github__.*, commanduphold hook claude-code. The docs name.claude-plugin/marketplace.jsonas a legacy-compatible marketplace path; the current ones are.agents/plugins/marketplace.jsonand a rootplugin.json.Codex fails open, and there is no switch. A
PreToolUsehook whose command does not exist, withonFailure: "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.onFailureis ignored, not refused, so onehooks.jsonserves 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 changedhooks.jsonin 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>(orowner/repo@ref). A repository enables it in.codex/config.tomlas[plugins."uphold@uphold"] enabled = true, loaded only for a trusted project.The command. Codex runs
uphold hook claude-codeas written.uphold hookgivesclaude-codeandcodexone shape (src/hook.rs,SHAPES), so the name differs only in what a report calls the harness.Not tested: the
Stopgate's continuation under Codex, wheredecision: "block"continues the turn with the reason as the next prompt.Adopt ten records for design rulings, with the four orders between them
Adopt ten records for design rulings, with the four orders between them
Adopt ten records for design rulings, with the four orders between th…
Ask github.com by name, and hold the hook's destination to one name