Repository navigation
Design: the hook refuses only what the shim would, a publishing call to a destination where the rule's scope holds #450
Description
Goal
uphold hook refuses a tool call only where the shim would refuse the same act made through gh: a call that publishes, to a destination where the rule's scope holds. A read that names a private repository passes. This settles the conflict in #429's matcher question, where widening the matcher to mcp__.* refuses legitimate reads.
Today
Read at 907eb95 with uphold 1.38.0, from this public checkout with private owners declared in the private-owners file.
uphold hook claude-coderefusesmcp__github__get_file_contentswhoseownerandreponame a private repository, underno-private-repo-namesandno-published-private-repo-names. The call reads a file; nothing is published.- It refuses the same input under
mcp__plugin_engineering_slack__searchandmcp__claude_ai_Claude_Docs__query. - It refuses
mcp__github__create_issueinto a private repository whose body names another private repository. Throughgh issue createtheghtable'sscope = "public-target"stands the private-name rules down for that destination: "A private-name check belongs only where the destination is public" (docs/REFERENCE.mdL2521). - The hook resolves no destination ("Destination ownership and
public-targetchecks remain command-adapter checks", L3205) and judges every call that arrives (L3196;src/hook.rsL19-25). Every rule therefore runs at the hook as if its scope werealways. - L3196 rests on the operator narrowing the matcher to "the publishing tools you use" (L3190). Under Design: uphold ships its Claude Code hooks as a plugin from this repository #429 the matcher is the plugin's, the same for every consumer, so that narrowing is no longer the operator's to make.
Decision (proposed)
A [[hook]] table in policy, shaped like [[shim]], gives the hook the two facts the shim has: which calls publish, and where they go.
[[hook]]
tools = "^mcp__.*github.*__" # regular expression over tool_name
skip = ["__(get|list|search)_"] # calls that read; not judged
owner = "/owner" # JSON pointers into tool_input
repo = "/repo"
target = "forge-repo"
scope = "public-target"
unresolved = "refuse"- A call matching a table's
toolsand none of itsskipis judged. Its destination is read atownerandrepoand resolved the way the shim resolvesforge-repo. Each rule's scope, the table's or the rule's owncommand.scope, decides whether that rule runs, as at the shim. A host-identity rule withcommand.scope = "always"still refuses on a private destination. - A matching call without the destination pointers is unresolved, and
unresolveddecides, as at the shim. - The
private-namesandpublished-textsets ship the GitHub table. A repository adds tables for the servers it uses.
Rejected:
- Narrow the plugin's matcher to publishing tools. One matcher serves every consumer, while which tools publish, and where to, is each repository's policy.
- Infer the destination from argument names in code. L3203 already refuses that: "The hook does not infer a destination host or repository from arbitrary tool argument names." The table declares the pointers instead.
- A
skiplist without a destination. It passes the reads, but still refuses writes into a private repository thatghallows.
Plan
[[hook]]in the config, the JSON Schema anddocs/REFERENCE.md. Load-time refusals mirror[[shim]]: an unknown key, a bad regular expression, andunresolvedon a table that cannot reachpublic-target.- The hook resolves the destination through the shim's
forge-reporesolver and applies scope per rule. This costs one forge request per judged call with a destination, as at the shim. - The
private-namesandpublished-textsets ship the GitHub table. - Tests feed
PreToolUseJSON on stdin:- a read of a private repository passes;
- a write into a public repository naming a private one refuses;
- a write into a private repository naming another passes;
- host identity refuses at a private destination;
- a call matching no table behaves as the ruling below says.
Done when
- Ruled.
- The five cases in plan item 4 pass.
-
docs/REFERENCE.mdL3190-3205 describes the table in place of "adjust the matcher".
Questions for the ruling
-
Which calls are judged.
- (a) Every call matching
tools, except those matching askiplist of read patterns. - (b) Only calls matching a
matchlist of publishing patterns, as[[shim]] matchlistsghverbs. - (c) Every call matching
tools, with no verb filter, so the destination scope alone stands rules down.
Answered: (a). fail-safe-defaults L8 "omission, error, or an unanticipated case yields refusal"; fail-safe-defaults L11 "arrive without the operator noticing"; fail-safe-defaults L18 "Is the permitted set named, or is the forbidden set named?"; psychological-acceptability L11 "The control produces failures on legitimate work". (b) lets a tool nobody listed run unjudged; (c) refuses reads that carry no destination. Precedent: none. Ruled from the records
policy/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. - (a) Every call matching
-
A call that matches no table, such as a Slack search once the matcher is
mcp__.*.- (a) Judged as today, every rule unscoped.
- (b) Its scope is unresolved, and the rule's
unresolveddecides. - (c) Not judged, and stderr says so.
Answered: (b), decided by the order fail-safe-defaults over psychological-acceptability. explicit-unknown L8 "must not be collapsed into success"; fail-safe-defaults L18 "cannot evaluate, does the system permit or refuse"; complete-mediation L12 "mediate at each seam by calling the one rule body". Precedent: none. Ruled from the records
policy/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. -
The default
unresolvedfor a call that matches no table. One ruler escalated this out of question 2.- (a)
refuse, as at the shim. - (b)
run, with stderr saying no checker ran.
Answered: (a), decided by the order fail-safe-defaults over psychological-acceptability ("A scope the shim cannot evaluate refuses by default, and
unresolved = "run"is the named way out."). fail-safe-defaults L18 "cannot evaluate, does the system permit or refuse"; fail-safe-defaults L8 "omission, error, or an unanticipated case yields refusal".runstays the opt-out, with stderr saying no checker ran, by the order explicit-unknown over psychological-acceptability. 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. Citations are unlinked: this repository pins no uphold, soruling-citations-resolvecannot resolve a link (exit 2). - (a)
Activity
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…
uphold hook judges a tool call where gh would judge the same act (#450)…
HackingGate commentedon Oct 28, 2026
Implemented in #453.