Skip to content

feat(hook-policy): configurable policyMode + failMode for workflow adaptation - #40

Open
tizerluo wants to merge 6 commits into
mainfrom
feat/hook-policy-mode
Open

feat(hook-policy): configurable policyMode + failMode for workflow adaptation#40
tizerluo wants to merge 6 commits into
mainfrom
feat/hook-policy-mode

Conversation

@tizerluo

@tizerluo tizerluo commented Jul 5, 2026

Copy link
Copy Markdown
Owner

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)

// .agent-loop/config.json
{
  "hookPolicy": "enforce",   // enforce | observe | disable  (default: enforce)
  "hookFailMode": "closed"   // closed | open                (default: closed)
}
field value meaning
hookPolicy enforce Current behavior: block per policy chain (default-deny + allowlist).
observe Record every command but never block — the runtime equivalent of observe-runner, implemented inline so no build/hooks/router changes are needed.
disable Hook fully inert (no recording, no blocking).
hookFailMode closed Current behavior: when storage is unavailable, deny lifecycle commands fail-safe.
open When storage is unavailable, allow lifecycle commands so a broken state DB does not freeze the session. Destructive/shell-control commands remain enforced (they are storage-independent).

One new CLI command

agent-loop hooks disable --repo /path/to/repo   # pause enforcement (binding kept)
agent-loop hooks enable  --repo /path/to/repo   # resume

Uses the existing disabled status on HookBinding (the router already filtered it out) — upsertHookBinding accepted status from the start, it just had no CLI entry point. disable guards against silent upsert when no binding exists.

Why this shape

  • observe is implemented inline in evaluatePreToolUseHook rather than swapping the runner in build: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.
  • fail-open only relaxes storage-gated lifecycle commands. Destructive commands (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.
  • defaults are enforce + closed. Existing configs without these fields behave exactly as before.

Commits (review ordered)

  1. PR1 — plumb hookPolicy / hookFailMode through schema → config defaults + validation → types → HookPolicyInput. No behavior change.
  2. PR2hooks disable/enable CLI subcommands.
  3. PR3hookFailMode=open downgrades storage-gated lifecycle denials in the fail-safe branch.
  4. PR4hookPolicy=observe|disable early-return in evaluatePreToolUseHook (observe records, disable is inert).
  5. fix — addresses code-review + simplifier findings (see below).

Verification

  • tsc --noEmit clean.
  • pnpm test426 / 426 passing.
  • pnpm prepack (build) clean.
  • Note: runs dashboard smoke as structured release-readiness output is flaky on parallel runs due to HTTP port contention; it passes in isolation on both main and 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:

  • (P1) observe + corrupt storage: recordHookDecision could throw and discard the observe allow decision → wrapped in try/catch so observe honors its "never block" contract unconditionally.
  • (P2) isStorageGate allowlist contained two strings (lifecycle_state_gate, publish_prerequisites) that no deny() ever produces — removed, comment added.
  • (refactor) disable/enable CLI branches were near-duplicates — merged (-8 lines).
  • (cleanup) HookPolicyInput.hookPolicy dropped redundant | undefined.
  • (P2) hooks disable no longer silently upserts a disabled binding when none exists.
  • (test) added "observe still allows destructive when storage corrupt" to lock in the P1 fix.

Out of scope (deliberately)

  • generic-loop PR-state-machine exemption — has architectural/review implications (conflicts with docs/trust-and-safety.md, no replacement gate). Tracked separately; not in this PR.
  • Custom allowlist extension (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-hooks and sets the new fields.

tizerluo added 6 commits July 5, 2026 22:39
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.
@tizerluo

tizerluo commented Jul 5, 2026

Copy link
Copy Markdown
Owner Author

Review note from Codex

我同意这个 PR 的方向:现在 hook 太紧,确实需要给工作流留出解绑/观察/故障降级能力。下面两个点不是反对放宽,而是希望这个“关闭/放宽”开关不要让用户产生误判。

  1. hookPolicy=disable 的实际范围需要说清楚

当前实现只让 PreToolUse 变成 allow/no-record,但其他 hook 事件仍会走 runObserveOnlyHook -> observeCodexHook,继续记录 PostToolUseSessionStartStop 等事件。

如果产品定义是“disable = 只关闭 PreToolUse enforcement”,那现在行为可以接受,但 PR 文案里的 “fully inert / no recording” 需要改掉。

如果产品定义是“disable = 所有 HOLO hook 完全 no-op”,那需要在 observe runner 或 observeCodexHook 层也读取 config,hookPolicy=disable 时直接跳过记录,并补一个非 PreToolUse 的回归测试。

  1. agent-loop hooks disable --repo 在多 session binding 下不要假成功

现在无 --session 时,只要该 worktree 有任意 binding,hasHookBinding 就会返回 true;随后 upsertHookBinding({ status: disabled }) 在多个 session-scoped binding 存在时可能新增一个 disabled unscoped binding,而不是关闭已有 active session bindings。

结果是命令看起来成功,但真实 active session binding 还在,hook 仍可能继续生效。这和“我想临时解绑/别再拦我”的目标相反。

建议二选一:

  • --session 时禁用该 repo/worktree 下所有 binding;或
  • 如果存在多个 session binding,就返回明确错误,要求用户指定 --session,不要创建一个不会生效的 disabled binding。

我认为第二点应当在合并前修。第一点至少要改文案;如果目标真的是 fully inert,也应当一起修实现。

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant