feat(hook-policy): configurable policyMode + failMode for workflow adaptation - #40
feat(hook-policy): configurable policyMode + failMode for workflow adaptation#40tizerluo wants to merge 6 commits into
Conversation
Plumb two new config fields end-to-end through schema -> config defaults and validation -> types -> HookPolicyInput. Defaults preserve current behavior (enforce + fail-closed). No behavioral change yet; subsequent PRs consume these fields. - schemas/config.schema.json: declare hookPolicy / hookFailMode enums - core/config.ts: HOOK_POLICY_MODES / HOOK_FAIL_MODES constants, withConfigDefaults defaults, validateConfig enum checks, assertKnownTopLevelKeys allowlist - core/types.ts: AgentLoopConfig interface fields - core/hook-policy.ts: HookPolicyInput accepts hookPolicy / hookFailMode; evaluatePreToolUseHook forwards config values; fail-safe branch best-effort reads hookFailMode via loadHookFailModeSafe
Pause and resume hook enforcement for a repo without deleting the binding. Uses the existing 'disabled' status on HookBinding that the router already filters out — upsertHookBinding accepted status from the start, it just had no CLI entry point. - cli.ts: 'agent-loop hooks disable|enable --repo [--session]' + help text + commands list in three places - cli-run.test.ts: cover disable→list shows disabled→enable→active
When storage is unavailable (corrupt SQLite, missing DB), the fail-safe branch previously denied all lifecycle commands. hookFailMode='open' downgrades storage-gated lifecycle denials to allow, so a broken state DB does not freeze the session. Destructive/shell-control denials are storage-independent and remain enforced regardless of mode. Default stays 'closed' (fail-safe deny) — preserves existing behavior. - core/hook-policy.ts: fail-open branch for storage gates only - tests/hook-policy.test.ts: cover lifecycle-allow + destructive-deny under hookFailMode=open with corrupt storage
When hookPolicy='observe', PreToolUse records the event but never runs the blocking policy chain — equivalent to observe-runner, implemented inline in evaluatePreToolUseHook so no build:hooks/router/file-layout changes are needed. When 'disable', the hook is fully inert (no recording, no blocking). Default stays 'enforce' — preserves existing behavior. This unlocks per-config workflow adaptation: set hookPolicy=observe in .agent-loop/config.json during exploratory/dev phases, switch back to enforce for PR/production. No more 'uninstall hooks to get work done'. - core/hook-policy.ts: early branch after loadConfig for observe/disable - tests/hook-policy.test.ts: observe records+allows, disable inert
Six issues from the post-implementation review (PR1-4 were merged without the double-review gate; this commit restores that gate): A (P1) observe + storage corruption: wrap observe's recordHookDecision in try/catch so a broken state DB cannot discard the observe allow decision. observe now honors its 'never block' contract unconditionally. B (P2) isStorageGate dead strings: drop lifecycle_state_gate and publish_prerequisites from the allowlist — neither is ever produced by deny() and the fail-safe branch has no storage so only storage_required_for_lifecycle is reachable. Comment added. C (refactor) disable/enable CLI: merge the two near-duplicate branches into one (~8 lines saved). D (cleanup) HookPolicyInput.hookPolicy: drop redundant | undefined (config.hookPolicy is non-optional in AgentLoopConfig). E (P2) hooks disable: guard against silent upsert when no binding exists — return 'nothing to disable' instead of creating a disabled binding. enable unchanged (enable-without-bind == bind). F (test) cover observe + corrupt storage so the A fix is locked in. All 426 tests pass (the dashboard smoke test is flaky on parallel runs due to HTTP port contention; passes in isolation). Type-check clean. Build clean.
|
Review note from Codex 我同意这个 PR 的方向:现在 hook 太紧,确实需要给工作流留出解绑/观察/故障降级能力。下面两个点不是反对放宽,而是希望这个“关闭/放宽”开关不要让用户产生误判。
当前实现只让 如果产品定义是“disable = 只关闭 PreToolUse enforcement”,那现在行为可以接受,但 PR 文案里的 “fully inert / no recording” 需要改掉。 如果产品定义是“disable = 所有 HOLO hook 完全 no-op”,那需要在 observe runner 或
现在无 结果是命令看起来成功,但真实 active session binding 还在,hook 仍可能继续生效。这和“我想临时解绑/别再拦我”的目标相反。 建议二选一:
我认为第二点应当在合并前修。第一点至少要改文案;如果目标真的是 fully inert,也应当一起修实现。 |
What
HOLO's hook was an all-or-nothing affair: the only way to adapt to different workflows was to uninstall the hooks entirely, losing the safety net AND the observability. This PR makes the enforcement posture per-config configurable so you can keep HOLO attached while matching the workflow's risk posture.
Closes the "I have to disable hooks to get work done" pain point reported during the multi-agent-workflows integration.
Two new config fields (defaults preserve current behavior)
hookPolicyenforceobservedisablehookFailModeclosedopenOne new CLI command
Uses the existing
disabledstatus onHookBinding(the router already filtered it out) —upsertHookBindingacceptedstatusfrom the start, it just had no CLI entry point.disableguards against silent upsert when no binding exists.Why this shape
evaluatePreToolUseHookrather than swapping the runner inbuild:hooks. Three lines at the entry point achieve the same runtime effect without touching esbuild config, the router, or file layout — far smaller blast radius than the originally-planned build refactor.git reset --hard, force push,gh repo delete) and shell-control operators are storage-independent and stay enforced under every mode. The security floor never moves.enforce+closed. Existing configs without these fields behave exactly as before.Commits (review ordered)
PR1— plumbhookPolicy/hookFailModethrough schema → config defaults + validation → types →HookPolicyInput. No behavior change.PR2—hooks disable/enableCLI subcommands.PR3—hookFailMode=opendowngrades storage-gated lifecycle denials in the fail-safe branch.PR4—hookPolicy=observe|disableearly-return inevaluatePreToolUseHook(observe records, disable is inert).fix— addresses code-review + simplifier findings (see below).Verification
tsc --noEmitclean.pnpm test— 426 / 426 passing.pnpm prepack(build) clean.runs dashboard smoke as structured release-readiness outputis flaky on parallel runs due to HTTP port contention; it passes in isolation on bothmainand this branch, so it is pre-existing and unrelated.Review-gate findings (already addressed in commit 5)
This PR was initially merged-to-branch without the double-review gate. That gate was then run (
code-reviewer+simplifier) and six issues were found and fixed:recordHookDecisioncould throw and discard the observeallowdecision → wrapped in try/catch so observe honors its "never block" contract unconditionally.isStorageGateallowlist contained two strings (lifecycle_state_gate,publish_prerequisites) that nodeny()ever produces — removed, comment added.disable/enableCLI branches were near-duplicates — merged (-8 lines).HookPolicyInput.hookPolicydropped redundant| undefined.hooks disableno longer silently upserts a disabled binding when none exists.Out of scope (deliberately)
docs/trust-and-safety.md, no replacement gate). Tracked separately; not in this PR.customAllowed) — tracked separately.Compatibility
No impact on local Codex installations that do not have HOLO installed. Only takes effect once a user opts in via
agent-loop install-hooksand sets the new fields.