Skip to content

feat(hooks): add per-hook fail_mode option for fail-closed gating#1906

Open
StressTestor wants to merge 1 commit into
MoonshotAI:mainfrom
StressTestor:feat/hook-fail-mode
Open

feat(hooks): add per-hook fail_mode option for fail-closed gating#1906
StressTestor wants to merge 1 commit into
MoonshotAI:mainfrom
StressTestor:feat/hook-fail-mode

Conversation

@StressTestor

Copy link
Copy Markdown

Related Issue

Resolve #1905

Problem

See linked issue. Hooks fail open in every failure mode, so a hook acting as a security gate silently stops gating when its script breaks, hangs, or fails to spawn. The docs currently warn that hooks "should not be used as the sole security barrier" for exactly this reason.

What changed

[[hooks]] entries accept an optional fail_mode field: "open" (default, today's behavior, existing configs unaffected) or "closed", where any failure to deliver a verdict (spawn failure, runtime error, timeout, death by signal, exit code other than 0 or 2) blocks the operation with an explicit reason. Exit 0/2 and structured stdout decisions are unchanged, and a user interrupt still resolves to allow in both modes, since the turn is being cancelled anyway.

Signal death needed its own handling in agent-core: the close handler coerced code=null to exit 0 via code ?? 0, so a fail-closed hook killed by SIGKILL or crashing with SIGSEGV would have read as a clean allow. Under fail_mode = "closed" a null exit code now blocks; the default path keeps the existing coercion.

Implemented in both agent-core (session/hooks) and agent-core-v2 (agent/externalHooks). The config field is snake_case fail_mode in agent-core's schema because hook arrays reach it untransformed (transformTomlData passes arrays through without key camelization), and camelCase failMode in agent-core-v2's section schema because hooksFromToml camelizes entries before validation; both are covered by tests. Since both cores read the same kimi.plugin.json, the plugin manifest readers accept either spelling and normalize to their core's canonical form, so a manifest written for one core is not silently dropped by the other.

Also fixes hook dedup so identical (cwd, command) entries with different fail modes collapse to the stricter one instead of whichever parses first, and adds test coverage for the previously untested spawn-failure branch in agent-core's runner.

One pre-existing divergence is left untouched: in default fail-open mode, a hook that prints a structured deny and then dies by signal is blocked by agent-core (code ?? 0 coerces to exit 0, so stdout is parsed) but allowed by agent-core-v2 (code ?? -1 skips structured parsing). Under fail_mode = "closed" both cores block, so the new mode is consistent; changing the fail-open behavior felt out of scope for this PR.

Docs updated in en and zh (field table, fail-open section, warning box). Verified end to end against a built CLI: a crashing PreToolUse hook with fail_mode = "closed" blocks the matched Bash call with Hook failed (fail_mode=closed): exit code 1, and the same hook without the field preserves fail-open behavior.

Checklist

  • I have read the CONTRIBUTING document.
  • I have linked a related issue, or explained the problem above.
  • I have added tests that prove my feature works.
  • Ran gen-changesets skill, or this PR needs no changeset.
  • Ran gen-docs skill, or this PR needs no doc update.

By default hooks fail open: spawn failure, crash, timeout, or an
unexpected exit code all resolve to allow. That is the right default
for notifications and lightweight checks, but it means a hook acting
as a security gate silently stops gating when it breaks or times out.

fail_mode = "closed" inverts the failure behavior per hook: any
failure to deliver a verdict (spawn failure, runtime error, timeout,
death by signal, exit code other than 0 or 2) blocks the operation
with an explicit reason instead of allowing it. Exit 0/2 and
structured stdout decisions behave exactly as before, and user
interrupts still resolve to allow in both modes since the turn is
being cancelled anyway.

Implemented in both agent-core (session/hooks) and agent-core-v2
(agent/externalHooks). Note the config field is snake_case fail_mode
in agent-core's schema (hook arrays bypass transformTomlData's key
camelization) and camelCase failMode in agent-core-v2's section
schema (hooksFromToml camelizes entries before validation); plugin
manifests accept either spelling and normalize per core. Hook dedup
now keeps the stricter mode when identical commands collide, and
agent-core's close handler treats signal death (exit code null) as a
failure rather than coercing it to a clean exit 0.

Default remains "open"; existing configs are unaffected.
@changeset-bot

changeset-bot Bot commented Jul 19, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 97f6b5e

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
@moonshot-ai/kimi-code Minor

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

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.

Hooks: opt-in fail_mode = "closed" for hooks that act as security gates

1 participant