feat(miner-governor): PreToolUse-hook-enforced house rules (#2343) - #5031
Conversation
Wraps the existing pure evaluateDenyHooks decision function (deny-hooks.js, interception point CodingAgentDriver sessions register via options.hooks.PreToolUse (driver-factory.ts and agent-sdk-driver.ts already thread an optional `hooks` parameter through for exactly this purpose, explicitly citing #2343 as the attachment point). Per Anthropic's own documented permission-evaluation order, hooks run FIRST -- before deny rules, ask rules, the permission-mode check, and allow rules -- and hook denials are enforced even under permissionMode: bypassPermissions ("Hooks still execute and can block operations if needed"). This module does not implement that guarantee; the SDK does. Its own job is only to return a correctly-shaped, fail-closed deny decision every time. House rules are sourced from the existing single, auditable DEFAULT_DENY_RULES list (or a caller-supplied effective set, e.g. merging in maintainer-approved rules from deny-hook-synthesis.js) -- not duplicated here. Every denial is recorded to the governor ledger with the specific rule's reason; an internal error (including a ledger append failure) fails closed to deny, never silently allows. Also fixes a pre-existing gap: deny-hook-synthesis.js was missing from the miner package's node --check build-validation chain.
|
Superagent didn't find any vulnerabilities or security issues in this PR. |
Deploying with
|
| Status | Name | Latest Commit | Preview URL | Updated (UTC) |
|---|---|---|---|---|
| ✅ Deployment successful! View logs |
gittensory-ui | db87103 | Commit Preview URL Branch Preview URL |
Jul 11 2026, 10:03 AM |
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## main #5031 +/- ##
=======================================
Coverage 94.36% 94.36%
=======================================
Files 458 458
Lines 39255 39255
Branches 14322 14322
=======================================
Hits 37042 37042
Misses 1558 1558
Partials 655 655
Flags with carried forward coverage won't be shown. Click here to find out more. 🚀 New features to boost your workflow:
|
|
Tip 🟩🟩🟩🟩🟩🟩🟩🟩🟩🟩🟩🟩 ✅ Gittensory review result - approve/merge recommendedReview updated: 2026-07-11 10:09:29 UTC
✅ Suggested Action - Approve/Merge
Review summary Blockers
Nits — 6 non-blocking
Review context
Contributor next steps
Signal definitions
🟩 Safe / merged · 🟦 Advisory · 🟨 Held for review · 🟥 Blocked / closed 💰 Earn for open-source contributions like this. Gittensor lets GitHub contributors earn for the work they already do — register to start earning →. Checked by Gittensory, a quiet PR intelligence layer for OSS maintainers.
|
|
An AI reviewer flagged a likely defect, but its confidence was below this repository's configured close-confidence floor, so this is held for a maintainer to confirm instead of closing automatically. Resolve the flagged defect (see the review notes), or ask a maintainer to override. |
…etooluse-hook Measured coverage (vitest v8, scoped include override -- this file sits outside the repo's default coverage.include) showed pretooluse-hook.js at 76.92% branch. Two of the six gaps were real: - The `input && typeof input === "object"` boundary check (input is typed `unknown`) never saw a malformed non-object input exercised. - A custom rule omitting `reason` never exercised the generic-message fallback. - A deny with a non-string tool_name (matcher: "*" still fires -- matcherMatches substitutes "" before testing) never exercised the ledger payload's null-toolName fallback. - The internal-error catch's `error instanceof Error` check was only ever exercised with real Error instances, never the String(error) fallback for a non-Error thrown value. The remaining two (both on `blockedBy?.matcher ?? null`) were dead code, not undertested: ruleMatches gates every match on `typeof rule.matcher === "string"`, so a rule can never become `blockedBy` without a defined string matcher -- the fallback could never fire. Simplified to `verdict.blockedBy.matcher` per the project's "don't add validation for scenarios that can't happen" convention, rather than papering over it with a contrived test. pretooluse-hook.js now measures 100/100/100/100 (stmts/branch/funcs/lines), up from 76.92% branch (24 real branches, down from 26 after removing the two dead ones).
|
Pushed a coverage-only follow-up commit (db87103): measured coverage (vitest v8, scoped include override since this file sits outside the repo's default `coverage.include`) found `pretooluse-hook.js` at 76.92% branch. Four of the six gaps were real and got tests:
The remaining two (both on `blockedBy?.matcher ?? null`) were dead code, not undertested: `ruleMatches` gates every match on `typeof rule.matcher === "string"`, so a rule can never become `blockedBy` without a defined string matcher -- the fallback could never fire. Simplified to `verdict.blockedBy.matcher` rather than writing a contrived test to hit unreachable code. `pretooluse-hook.js` now measures 100/100/100/100 (stmts/branch/funcs/lines). 56/56 tests pass across this file and its siblings (deny-hooks, deny-hook-synthesis, deny-hooks-fixtures). |
Summary
bypassPermissions-configured session ignores interactive approval prompts entirely.packages/gittensory-miner/lib/deny-hooks.js(feat(miner-foundation): PreToolUse-style deny-hook primitives for gittensory-miner #2295) — a pureevaluateDenyHooks(toolCall, rules)decision function plusDEFAULT_DENY_RULES(blocks CI workflows,.env*,.dev.vars,.npmrc, secret-bearing paths, private keys/PEM material, force-push). Its own header comment says: "There is NO live tool-call interception in this phase — a later phase's real coding-agent driver plugs an event source intoevaluateDenyHooks."packages/gittensory-engine/src/miner/agent-sdk-driver.ts(feat(miner-hands): Agent-SDK CodingAgentDriver (query() loop) #4267) already threads anAgentSdkHookspassthrough onto the Claude Agent SDK'squery()options, with a comment naming this exact issue: "callers passhooks(e.g. aPreToolUsematcher, maintainer: PreToolUse-hook-enforced house rules (deny even under bypassPermissions) #2343's stated attachment point) and this driver forwards them verbatim."packages/gittensory-miner/lib/deny-hook-synthesis.js(feat(miner): synthesize PreToolUse deny-hook rules from the review stack's own per-repo close/blocker finding-category history #4522) derives rule proposals from blocker history; its header explicitly says it "Feeds the consumption surface maintainer: PreToolUse-hook-enforced house rules (deny even under bypassPermissions) #2343 will wire intoevaluateDenyHooks— this issue owns derivation + audit, not live hook interception."bypassPermissions" claim against Anthropic's own current documentation (code.claude.com/docs/en/agent-sdk/permissions) rather than assuming: hooks are evaluated first, before deny rules, ask rules, the permission-mode check, and allow rules. Quoting directly: "Bypass permissions mode... Hooks still execute and can block operations if needed." and "Deny rules (disallowed_tools), explicitaskrules, and hooks are evaluated before the mode check and can still block a tool." This module does not implement that guarantee — the SDK does. This module's only job is to return a correctly-shaped, fail-closed deny decision every time.packages/gittensory-miner/lib/pretooluse-hook.jsexportsbuildHouseRulesPreToolUseHook(config, options), which returns a real Agent SDKPreToolUsehook callback: translates the SDK's{ tool_name, tool_input }input shape intoevaluateDenyHooks's{ name, input }shape, returns the exact documentedhookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "deny", permissionDecisionReason }shape on a match (or{}to allow), records every denial to the governor ledger with the specific rule's reason, and fails closed (denies) on any internal error — including a ledger-append failure, which must never downgrade a security decision to allow.DEFAULT_DENY_RULESlist by default, or any caller-supplied effective rule set (e.g.resolveEffectiveDenyRulesfromdeny-hook-synthesis.js, merging in maintainer-approved synthesized rules) — this module composes whatever rule set it's given; it does not own deriving one.deny-hook-synthesis.jswas missing from the miner package'snode --checkbuild-validation chain (added alongside the new file, in the same line I was already touching).Scope
CONTRIBUTING.md.mainand only imports pre-existingdeny-hooks.js/governor-ledger.js.Validation
git diff --checknpm run typecheck— clean.npm run buildinpackages/gittensory-miner(node --check, including the newpretooluse-hook.jsand the now-checkeddeny-hook-synthesis.js) — clean.vitest runon the new suite plus every deny-hooks-adjacent suite (miner-pretooluse-hook,miner-deny-hooks,deny-hooks-fixtures,miner-cli-deny-check,miner-deny-hook-synthesis,miner-governor-ledger) — 64/64 passing.npm run test:coverage,test:workers,build:mcp,test:mcp-pack,ui:*,npm audit— not re-run locally; this PR touches onlypackages/gittensory-miner/**andtest/unit/**, and perCONTRIBUTING.mdonlysrc/**is Codecov-measured. CI runs them authoritatively.Safety
evaluateDenyHookscall (aProxywhosematchergetter throws, rather than a test-only seam added to production code) and confirms a deny; another forces the governor-ledgerappenditself to throw and confirms the deny still happens.hookSpecificOutputshape would silently fail to block anything, which would be far worse than an obviously-broken build.driver-factory.ts's existing optionalhooksparameter is the natural attachment point once that loop exists; forcing a default wiring now would mean editing a file with no real caller yet, which isn't this issue's job.Notes
mainrather than stacked, so it can be reviewed and merged independently.