Skip to content

Commit f4a0b3d

Browse files
authored
Merge trail: Fix #524: hooks don't short-circuit when disabled
fix(hooks): short-circuit immediately when disabled
2 parents c523f1f + 9e9bde2 commit f4a0b3d

3 files changed

Lines changed: 192 additions & 5 deletions

File tree

cmd/entire/cli/hook_registry.go

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import (
1919
"github.com/entireio/cli/cmd/entire/cli/gitrepo"
2020
"github.com/entireio/cli/cmd/entire/cli/logging"
2121
"github.com/entireio/cli/cmd/entire/cli/paths"
22+
"github.com/entireio/cli/cmd/entire/cli/settings"
2223
"github.com/entireio/cli/cmd/entire/cli/strategy"
2324
"github.com/entireio/cli/cmd/entire/cli/telemetry"
2425
"github.com/entireio/cli/cmd/entire/cli/versioncheck"
@@ -107,9 +108,17 @@ func executeAgentHook(cmd *cobra.Command, agentName types.AgentName, hookName st
107108
return nil
108109
}
109110

110-
// Skip if Entire is not enabled
111-
enabled, err := IsEnabled(cmd.Context())
112-
if err == nil && !enabled {
111+
// Skip if Entire is not set up and enabled. This must fail closed: any
112+
// settings read error (missing file, corrupted JSON, transient I/O
113+
// failure) is treated as disabled so a hook never silently falls through
114+
// to full lifecycle work just because settings couldn't be read. Using
115+
// IsEnabled here previously failed OPEN on error (`err == nil && !enabled`
116+
// only short-circuits when the read succeeded), which meant a corrupted
117+
// or unreadable settings file made every hook invocation pay the full
118+
// dispatch cost instead of exiting fast (#524).
119+
// settings.IsSetUpAndEnabled is the same fail-closed gate the git hooks
120+
// use (see PersistentPreRunE in hooks_git_cmd.go).
121+
if !settings.IsSetUpAndEnabled(cmd.Context()) {
113122
return nil
114123
}
115124

cmd/entire/cli/hook_registry_test.go

Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -231,6 +231,176 @@ func TestExecuteAgentHookSessionStartSkipsCaptureWhenPolicyUnreadable(t *testing
231231
require.True(t, os.IsNotExist(statErr), "session-start must not claim the session when checkpoint policy is unreadable")
232232
}
233233

234+
// TestExecuteAgentHookShortCircuitsWhenDisabled is a regression test for #524:
235+
// a hook must not perform any dispatch/strategy work when Entire is
236+
// disabled. Asserted via the same "session was never claimed" signal the
237+
// checkpoint-policy tests above use, rather than a timing assertion.
238+
func TestExecuteAgentHookShortCircuitsWhenDisabled(t *testing.T) {
239+
setupStopTestRepo(t)
240+
repoRoot := mustGetwd(t)
241+
242+
entireDir := filepath.Join(repoRoot, ".entire")
243+
require.NoError(t, os.MkdirAll(entireDir, 0o750))
244+
require.NoError(t, os.WriteFile(filepath.Join(entireDir, "settings.json"), []byte(`{"enabled":false}`), 0o600))
245+
246+
sessionID := "disabled-session-start"
247+
payload, err := json.Marshal(map[string]string{
248+
"session_id": sessionID,
249+
"transcript_path": filepath.Join(repoRoot, "transcript.jsonl"),
250+
})
251+
require.NoError(t, err)
252+
253+
cmd := &cobra.Command{}
254+
cmd.SetIn(bytes.NewReader(payload))
255+
cmd.SetErr(&bytes.Buffer{})
256+
cmd.SetContext(context.Background())
257+
258+
require.NoError(t, executeAgentHook(cmd, agent.AgentNameClaudeCode, claudecode.HookNameSessionStart, false))
259+
260+
hintPath := filepath.Join(repoRoot, ".git", session.SessionStateDirName, sessionID+".agent")
261+
_, statErr := os.Stat(hintPath)
262+
require.True(t, os.IsNotExist(statErr), "disabled hook must not dispatch or claim the session")
263+
}
264+
265+
// TestExecuteAgentHookShortCircuitsWhenSettingsMissing is a regression test
266+
// for #524: a repo that was never `entire enable`d (no .entire/settings.json)
267+
// must short-circuit rather than falling through to full lifecycle dispatch.
268+
func TestExecuteAgentHookShortCircuitsWhenSettingsMissing(t *testing.T) {
269+
setupStopTestRepo(t)
270+
repoRoot := mustGetwd(t)
271+
// Deliberately do NOT create .entire/settings.json.
272+
273+
sessionID := "missing-settings-session-start"
274+
payload, err := json.Marshal(map[string]string{
275+
"session_id": sessionID,
276+
"transcript_path": filepath.Join(repoRoot, "transcript.jsonl"),
277+
})
278+
require.NoError(t, err)
279+
280+
cmd := &cobra.Command{}
281+
cmd.SetIn(bytes.NewReader(payload))
282+
cmd.SetErr(&bytes.Buffer{})
283+
cmd.SetContext(context.Background())
284+
285+
require.NoError(t, executeAgentHook(cmd, agent.AgentNameClaudeCode, claudecode.HookNameSessionStart, false))
286+
287+
hintPath := filepath.Join(repoRoot, ".git", session.SessionStateDirName, sessionID+".agent")
288+
_, statErr := os.Stat(hintPath)
289+
require.True(t, os.IsNotExist(statErr), "hook must not dispatch when Entire was never enabled in this repo")
290+
}
291+
292+
// TestExecuteAgentHookShortCircuitsWhenSettingsCorrupted is a regression test
293+
// for #524. Before this fix, IsEnabled() failed OPEN on a settings.Load()
294+
// error (the caller's `err == nil && !enabled` check only short-circuited
295+
// when the read succeeded), so a corrupted settings file made every hook
296+
// invocation pay the full dispatch cost — including, for Stop hooks, a
297+
// multi-second wait on the transcript-flush sentinel (see
298+
// ClaudeCodeAgent.ParseHookEvent) — instead of exiting fast. The gate must
299+
// fail closed on any settings read error.
300+
func TestExecuteAgentHookShortCircuitsWhenSettingsCorrupted(t *testing.T) {
301+
setupStopTestRepo(t)
302+
repoRoot := mustGetwd(t)
303+
304+
entireDir := filepath.Join(repoRoot, ".entire")
305+
require.NoError(t, os.MkdirAll(entireDir, 0o750))
306+
require.NoError(t, os.WriteFile(filepath.Join(entireDir, "settings.json"), []byte(`{ enabled: false, not valid json`), 0o600))
307+
308+
sessionID := "corrupted-settings-session-start"
309+
payload, err := json.Marshal(map[string]string{
310+
"session_id": sessionID,
311+
"transcript_path": filepath.Join(repoRoot, "transcript.jsonl"),
312+
})
313+
require.NoError(t, err)
314+
315+
cmd := &cobra.Command{}
316+
cmd.SetIn(bytes.NewReader(payload))
317+
cmd.SetErr(&bytes.Buffer{})
318+
cmd.SetContext(context.Background())
319+
320+
require.NoError(t, executeAgentHook(cmd, agent.AgentNameClaudeCode, claudecode.HookNameSessionStart, false))
321+
322+
hintPath := filepath.Join(repoRoot, ".git", session.SessionStateDirName, sessionID+".agent")
323+
_, statErr := os.Stat(hintPath)
324+
require.True(t, os.IsNotExist(statErr), "hook must fail closed (not dispatch) when settings are unreadable")
325+
}
326+
327+
// TestExecuteAgentHookStopReturnsFastWhenSettingsCorrupted directly
328+
// regression-tests the reported symptom: `entire hooks claude-code stop`
329+
// against a corrupted settings file must return in well under the
330+
// multi-second transcript-flush-sentinel timeout it used to hit, not just
331+
// skip dispatch. The bound is intentionally generous (this repo has no
332+
// other timing-based tests to match precedent against) — it only needs to
333+
// distinguish "short-circuited" from "waited on the sentinel timeout".
334+
func TestExecuteAgentHookStopReturnsFastWhenSettingsCorrupted(t *testing.T) {
335+
setupStopTestRepo(t)
336+
repoRoot := mustGetwd(t)
337+
338+
entireDir := filepath.Join(repoRoot, ".entire")
339+
require.NoError(t, os.MkdirAll(entireDir, 0o750))
340+
require.NoError(t, os.WriteFile(filepath.Join(entireDir, "settings.json"), []byte(`{ enabled: false, not valid json`), 0o600))
341+
342+
transcriptPath := filepath.Join(repoRoot, "transcript.jsonl")
343+
require.NoError(t, os.WriteFile(transcriptPath, []byte(`{"type":"user","message":{"content":"hi"}}`+"\n"), 0o600))
344+
345+
payload, err := json.Marshal(map[string]string{
346+
"session_id": "corrupted-settings-stop",
347+
"transcript_path": transcriptPath,
348+
})
349+
require.NoError(t, err)
350+
351+
cmd := &cobra.Command{}
352+
cmd.SetIn(bytes.NewReader(payload))
353+
cmd.SetErr(&bytes.Buffer{})
354+
cmd.SetContext(context.Background())
355+
356+
start := time.Now()
357+
require.NoError(t, executeAgentHook(cmd, agent.AgentNameClaudeCode, claudecode.HookNameStop, false))
358+
elapsed := time.Since(start)
359+
360+
require.Lessf(t, elapsed, 1*time.Second,
361+
"stop hook took %s against a corrupted settings file; want a fast short-circuit, not the transcript-flush-sentinel timeout path", elapsed)
362+
}
363+
364+
// TestExecuteAgentHookCapturesWhenEnabledViaLocalSettingsOnly guards against a
365+
// regression in the #524 fix: `entire enable --local` writes only
366+
// .entire/settings.local.json and never creates the base .entire/settings.json
367+
// (see determineSettingsTarget in setup.go). The disabled-hook gate must
368+
// recognize that local-only enablement — gating on the base file alone
369+
// (settings.IsSetUp) would silently no-op every agent hook for that repo and
370+
// drop all checkpoint capture. Asserted via the same "session was claimed"
371+
// signal (the .agent hint StoreAgentTypeHint writes during SessionStart
372+
// dispatch) the short-circuit tests above assert the *absence* of.
373+
func TestExecuteAgentHookCapturesWhenEnabledViaLocalSettingsOnly(t *testing.T) {
374+
setupStopTestRepo(t)
375+
repoRoot := mustGetwd(t)
376+
377+
entireDir := filepath.Join(repoRoot, ".entire")
378+
require.NoError(t, os.MkdirAll(entireDir, 0o750))
379+
// Local-only enablement: settings.local.json present, base settings.json absent.
380+
require.NoError(t, os.WriteFile(filepath.Join(entireDir, "settings.local.json"), []byte(`{"enabled":true}`), 0o600))
381+
require.NoFileExists(t, filepath.Join(entireDir, "settings.json"))
382+
383+
transcriptPath := filepath.Join(repoRoot, "transcript.jsonl")
384+
require.NoError(t, os.WriteFile(transcriptPath, []byte(`{"type":"user","message":{"content":"hi"}}`+"\n"), 0o600))
385+
386+
sessionID := "local-only-session-start"
387+
payload, err := json.Marshal(map[string]string{
388+
"session_id": sessionID,
389+
"transcript_path": transcriptPath,
390+
})
391+
require.NoError(t, err)
392+
393+
cmd := &cobra.Command{}
394+
cmd.SetIn(bytes.NewReader(payload))
395+
cmd.SetErr(&bytes.Buffer{})
396+
cmd.SetContext(context.Background())
397+
398+
require.NoError(t, executeAgentHook(cmd, agent.AgentNameClaudeCode, claudecode.HookNameSessionStart, false))
399+
400+
hintPath := filepath.Join(repoRoot, ".git", session.SessionStateDirName, sessionID+".agent")
401+
require.FileExists(t, hintPath, "SessionStart must dispatch and claim the session when Entire is enabled via settings.local.json only")
402+
}
403+
234404
func TestAgentHookPolicyFailsWhenRepoCannotOpen(t *testing.T) {
235405
_, err := agentHookPolicy(context.Background(), filepath.Join(t.TempDir(), "missing"))
236406

cmd/entire/cli/settings/settings.go

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1310,10 +1310,18 @@ func IsSetUpAny(ctx context.Context) bool {
13101310
}
13111311

13121312
// IsSetUpAndEnabled returns true if Entire is both set up and enabled.
1313-
// This checks if .entire/settings.json exists AND has enabled: true.
1313+
// "Set up" spans either scope — .entire/settings.json OR
1314+
// .entire/settings.local.json — so it must check IsSetUpAny, not IsSetUp.
1315+
// `entire enable --local` writes only settings.local.json and never creates the
1316+
// base file; gating on the base file alone would treat such a local-only repo
1317+
// as inactive and make every hook a silent no-op, dropping all checkpoint
1318+
// capture for that documented workflow. The IsSetUpAny guard is still required
1319+
// so a never-enabled repo (no settings file in any scope) is not treated as
1320+
// enabled by Load's default Enabled: true. Any settings read error is treated
1321+
// as disabled (fail closed).
13141322
// Use this for hooks that should be no-ops when Entire is not active.
13151323
func IsSetUpAndEnabled(ctx context.Context) bool {
1316-
if !IsSetUp(ctx) {
1324+
if !IsSetUpAny(ctx) {
13171325
return false
13181326
}
13191327
s, err := Load(ctx)

0 commit comments

Comments
 (0)