Skip to content

Design: the hook refuses only what the shim would, a publishing call to a destination where the rule's scope holds #450

Closed

Description

@HackingGate

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-code refuses mcp__github__get_file_contents whose owner and repo name a private repository, under no-private-repo-names and no-published-private-repo-names. The call reads a file; nothing is published.
  • It refuses the same input under mcp__plugin_engineering_slack__search and mcp__claude_ai_Claude_Docs__query.
  • It refuses mcp__github__create_issue into a private repository whose body names another private repository. Through gh issue create the gh table's scope = "public-target" stands the private-name rules down for that destination: "A private-name check belongs only where the destination is public" (docs/REFERENCE.md L2521).
  • The hook resolves no destination ("Destination ownership and public-target checks remain command-adapter checks", L3205) and judges every call that arrives (L3196; src/hook.rs L19-25). Every rule therefore runs at the hook as if its scope were always.
  • 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 tools and none of its skip is judged. Its destination is read at owner and repo and resolved the way the shim resolves forge-repo. Each rule's scope, the table's or the rule's own command.scope, decides whether that rule runs, as at the shim. A host-identity rule with command.scope = "always" still refuses on a private destination.
  • A matching call without the destination pointers is unresolved, and unresolved decides, as at the shim.
  • The private-names and published-text sets 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 skip list without a destination. It passes the reads, but still refuses writes into a private repository that gh allows.

Plan

  1. [[hook]] in the config, the JSON Schema and docs/REFERENCE.md. Load-time refusals mirror [[shim]]: an unknown key, a bad regular expression, and unresolved on a table that cannot reach public-target.
  2. The hook resolves the destination through the shim's forge-repo resolver and applies scope per rule. This costs one forge request per judged call with a destination, as at the shim.
  3. The private-names and published-text sets ship the GitHub table.
  4. Tests feed PreToolUse JSON 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.md L3190-3205 describes the table in place of "adjust the matcher".

Questions for the ruling

  1. Which calls are judged.

    • (a) Every call matching tools, except those matching a skip list of read patterns.
    • (b) Only calls matching a match list of publishing patterns, as [[shim]] match lists gh verbs.
    • (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.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.

  2. 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 unresolved decides.
    • (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.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.

  3. The default unresolved for 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". run stays the opt-out, with stderr saying no checker ran, by the order explicit-unknown over psychological-acceptability. 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. Citations are unlinked: this repository pins no uphold, so ruling-citations-resolve cannot resolve a link (exit 2).

Metadata

Metadata

Assignees

No one assigned

    Labels

    designA Design record: a ruling on what will be built, filed before a pull requestruling:acceptedDesign record ruled: accepted

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions