Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,8 @@ Or just ask Copilot to create one — it knows the syntax from the installed ski

When both exist, repo hooks run first and personal hooks automatically defer (via the `--global` flag). This means repo-specific workflows always take priority.

To prevent silent bypass after crashes/restarts, stale `repo-hooks-active` session markers are auto-cleared during `--global` execution. The stale threshold defaults to `24h` and can be configured with `HOOKFLOW_REPO_HOOKS_ACTIVE_STALE_THRESHOLD` (for example, `5m` or `1h`).

### Test and share

```bash
Expand Down
64 changes: 64 additions & 0 deletions cmd/hookflow/cmd_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import (
"runtime"
"strings"
"testing"
"time"

eventpkg "github.com/htekdev/gh-hookflow/internal/event"
"github.com/htekdev/gh-hookflow/internal/schema"
Expand Down Expand Up @@ -4197,6 +4198,69 @@ t.Errorf("Expected allow when repo-hooks-active marker exists in global mode, go
}
}

// TestGlobalFlagClearsStaleRepoHooksMarker verifies stale markers do not keep
// bypassing global mode and are cleaned up automatically.
func TestGlobalFlagClearsStaleRepoHooksMarker(t *testing.T) {
tmpDir, err := os.MkdirTemp("", "hookflow-global-stale-*")
if err != nil {
t.Fatal(err)
}
defer func() { _ = os.RemoveAll(tmpDir) }()

sessionDir := t.TempDir()
t.Setenv("HOOKFLOW_SESSION_DIR", sessionDir)
t.Setenv("HOOKFLOW_REPO_HOOKS_ACTIVE_STALE_THRESHOLD", "1h")

// Create hookflows + hooks.json to satisfy global compliance and dedup checks.
workflowDir := filepath.Join(tmpDir, ".github", "hookflows")
if err := os.MkdirAll(workflowDir, 0755); err != nil {
t.Fatal(err)
}
workflow := "name: allow-all\non:\n file:\n paths: [\"**\"]\nsteps:\n - run: echo ok\n"
if err := os.WriteFile(filepath.Join(workflowDir, "allow.yml"), []byte(workflow), 0644); err != nil {
t.Fatal(err)
}
hooksDir := filepath.Join(tmpDir, ".github", "hooks")
if err := os.MkdirAll(hooksDir, 0755); err != nil {
t.Fatal(err)
}
hooksJSON := `{"version":1,"hooks":{"preToolUse":[{"bash":"gh hookflow run --raw --event-type preToolUse"}]}}`
if err := os.WriteFile(filepath.Join(hooksDir, "hooks.json"), []byte(hooksJSON), 0644); err != nil {
t.Fatal(err)
}

// Create stale marker
markerPath := filepath.Join(sessionDir, "repo-hooks-active")
if err := os.WriteFile(markerPath, []byte(""), 0644); err != nil {
t.Fatal(err)
}
oldTime := time.Now().Add(-2 * time.Hour)
if err := os.Chtimes(markerPath, oldTime, oldTime); err != nil {
t.Fatal(err)
}

oldStdout := os.Stdout
stdoutR, stdoutW, _ := os.Pipe()
os.Stdout = stdoutW

escapedDir := strings.ReplaceAll(tmpDir, `\`, `\\`)
_ = runWithRawInput(tmpDir, `{"toolName":"create","toolArgs":{"path":"test.txt","file_text":"hello"},"cwd":"`+escapedDir+`"}`, "pre", true)

_ = stdoutW.Close()
os.Stdout = oldStdout

var buf bytes.Buffer
_, _ = buf.ReadFrom(stdoutR)
output := buf.String()

if !strings.Contains(output, "allow") {
t.Errorf("Expected command to continue after stale marker cleanup, got: %s", output)
}
if _, statErr := os.Stat(markerPath); !os.IsNotExist(statErr) {
t.Error("Expected stale repo-hooks-active marker to be cleared")
}
}

// TestGlobalFlagProcessesWhenNoRepoHooks tests that --global mode processes events
// when no repo-hooks-active marker exists (global-only mode).
func TestGlobalFlagProcessesWhenNoRepoHooks(t *testing.T) {
Expand Down
84 changes: 59 additions & 25 deletions cmd/hookflow/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,11 @@ import (

var version = "0.1.0"

const (
repoHooksActiveStaleThresholdEnv = "HOOKFLOW_REPO_HOOKS_ACTIVE_STALE_THRESHOLD"
defaultRepoHooksActiveStaleThreshold = 24 * time.Hour
)

func main() {
// Initialize logging (errors are non-fatal)
_ = logging.Init()
Expand Down Expand Up @@ -429,6 +434,21 @@ func runWithRawInput(dir, inputStr, lifecycle string, global bool) error {
if err != nil {
log.Warn("failed to check repo-hooks-active: %v", err)
}
if repoActive {
staleThreshold, thresholdErr := repoHooksActiveStaleThreshold()
if thresholdErr != nil {
log.Warn("invalid %s=%q, using default %s", repoHooksActiveStaleThresholdEnv, os.Getenv(repoHooksActiveStaleThresholdEnv), defaultRepoHooksActiveStaleThreshold)
staleThreshold = defaultRepoHooksActiveStaleThreshold
}
stale, age, staleErr := session.IsRepoHooksActiveStale(staleThreshold)
if staleErr != nil {
log.Warn("failed to evaluate repo-hooks-active staleness: %v", staleErr)
} else if stale {
log.Info("global mode: clearing stale repo-hooks-active marker for session %q (age=%s, threshold=%s)", raw.SessionID, age.Round(time.Second), staleThreshold)
_ = session.ClearRepoHooksActive()
repoActive = false
}
}
if repoActive {
// Verify repo hooks still exist — hooks.json may have been deleted mid-session
repoHooksFile := filepath.Join(dir, ".github", "hooks", "hooks.json")
Expand Down Expand Up @@ -570,6 +590,20 @@ func runWithRawInput(dir, inputStr, lifecycle string, global bool) error {
return err
}

func repoHooksActiveStaleThreshold() (time.Duration, error) {
rawThreshold := strings.TrimSpace(os.Getenv(repoHooksActiveStaleThresholdEnv))
if rawThreshold == "" {
return defaultRepoHooksActiveStaleThreshold, nil
}

threshold, err := time.ParseDuration(rawThreshold)
if err != nil || threshold <= 0 {
return 0, fmt.Errorf("invalid duration")
}

return threshold, nil
}

// primitiveGuards performs critical safety checks on raw hook input before
// any event detection or workflow matching. These are non-negotiable.
func primitiveGuards(input []byte) *schema.WorkflowResult {
Expand Down Expand Up @@ -901,7 +935,7 @@ func runMatchingWorkflowsWithEvent(dir string, evt *schema.Event, global bool) e
func runMatchingWorkflows(dir, eventStr, lifecycle string) error {
// Parse the event
var eventData map[string]interface{}

// Handle stdin input
if eventStr == "-" {
input, err := io.ReadAll(os.Stdin)
Expand All @@ -910,34 +944,34 @@ func runMatchingWorkflows(dir, eventStr, lifecycle string) error {
}
eventStr = string(input)
}

if eventStr == "" {
// No event provided, allow by default
result := schema.NewAllowResult()
return outputWorkflowResult(result)
}

if err := json.Unmarshal([]byte(eventStr), &eventData); err != nil {
return fmt.Errorf("failed to parse event JSON: %w", err)
}

// Convert to Event struct
event := parseEventData(eventData)

// Normalize file path to be relative to dir (for matching against workflow patterns)
if event.File != nil && event.File.Path != "" {
event.File.Path = normalizeFilePath(event.File.Path, dir)
}

// Set lifecycle from CLI flag
event.Lifecycle = lifecycle

// Discover workflows using the discover package
discoveredWFs, err := discover.Discover(dir)
if err != nil {
return fmt.Errorf("failed to discover workflows: %w", err)
}

if len(discoveredWFs) == 0 {
// No workflows found, allow by default
result := schema.NewAllowResult()
Expand All @@ -948,7 +982,7 @@ func runMatchingWorkflows(dir, eventStr, lifecycle string) error {
for _, wf := range discoveredWFs {
workflowFiles = append(workflowFiles, wf.Path)
}

// Load and match workflows
var matchingWorkflows []*schema.Workflow
var hookifyResults []*schema.WorkflowResult
Expand Down Expand Up @@ -983,20 +1017,20 @@ func runMatchingWorkflows(dir, eventStr, lifecycle string) error {
// Skip invalid workflows
continue
}

// Check if workflow matches the event
matcher := trigger.NewMatcher(wf)
if matcher.Match(event) {
matchingWorkflows = append(matchingWorkflows, wf)
}
}

if len(matchingWorkflows) == 0 && len(hookifyResults) == 0 {
// No matching workflows or hookify rules, allow by default
result := schema.NewAllowResult()
return outputWorkflowResult(result)
}

// Aggregate results — deny wins
var finalResult *schema.WorkflowResult
var warnReasons []string
Expand Down Expand Up @@ -1027,7 +1061,7 @@ func runMatchingWorkflows(dir, eventStr, lifecycle string) error {
finalResult = result
}
}

if finalResult == nil {
finalResult = schema.NewAllowResult()
}
Expand All @@ -1043,7 +1077,7 @@ func runMatchingWorkflows(dir, eventStr, lifecycle string) error {
// parseEventData converts raw event data to a schema.Event
func parseEventData(data map[string]interface{}) *schema.Event {
event := &schema.Event{}

// Parse hook event
if hookData, ok := data["hook"].(map[string]interface{}); ok {
event.Hook = &schema.HookEvent{}
Expand All @@ -1063,7 +1097,7 @@ func parseEventData(data map[string]interface{}) *schema.Event {
}
}
}

// Parse tool event
if toolData, ok := data["tool"].(map[string]interface{}); ok {
event.Tool = &schema.ToolEvent{}
Expand All @@ -1077,7 +1111,7 @@ func parseEventData(data map[string]interface{}) *schema.Event {
event.Tool.HookType = hookType
}
}

// Parse file event
if fileData, ok := data["file"].(map[string]interface{}); ok {
event.File = &schema.FileEvent{}
Expand All @@ -1091,7 +1125,7 @@ func parseEventData(data map[string]interface{}) *schema.Event {
event.File.Content = c
}
}

// Parse commit event
if commitData, ok := data["commit"].(map[string]interface{}); ok {
event.Commit = &schema.CommitEvent{}
Expand Down Expand Up @@ -1119,7 +1153,7 @@ func parseEventData(data map[string]interface{}) *schema.Event {
}
}
}

// Parse push event
if pushData, ok := data["push"].(map[string]interface{}); ok {
event.Push = &schema.PushEvent{}
Expand All @@ -1133,15 +1167,15 @@ func parseEventData(data map[string]interface{}) *schema.Event {
event.Push.After = after
}
}

// Parse top-level cwd and timestamp
if cwd, ok := data["cwd"].(string); ok {
event.Cwd = cwd
}
if ts, ok := data["timestamp"].(string); ok {
event.Timestamp = ts
}

return event
}

Expand Down Expand Up @@ -1209,7 +1243,7 @@ func extractPushRef(command string, currentBranch string) string {
if len(matches) >= 2 {
return "refs/tags/" + matches[1]
}

// Default to current branch
return "refs/heads/" + currentBranch
}
Expand Down Expand Up @@ -1372,24 +1406,24 @@ func normalizeFilePath(filePath, dir string) string {
// Normalize path separators for cross-platform compatibility
filePath = strings.ReplaceAll(filePath, "\\", "/")
dir = strings.ReplaceAll(dir, "\\", "/")

// Ensure dir ends with /
if !strings.HasSuffix(dir, "/") {
dir = dir + "/"
}

// If the file path starts with the dir, make it relative
if strings.HasPrefix(filePath, dir) {
return strings.TrimPrefix(filePath, dir)
}

// Also try case-insensitive match (Windows paths)
lowerFilePath := strings.ToLower(filePath)
lowerDir := strings.ToLower(dir)
if strings.HasPrefix(lowerFilePath, lowerDir) {
return filePath[len(dir):]
}

// Return as-is if not under dir
return filePath
}
21 changes: 21 additions & 0 deletions internal/session/sentinel.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package session
import (
"os"
"path/filepath"
"time"
)

const repoHooksActiveFileName = "repo-hooks-active"
Expand Down Expand Up @@ -58,3 +59,23 @@ func IsRepoHooksActive() (bool, error) {
}
return false, err
}

// IsRepoHooksActiveStale returns whether the repo-hooks-active marker is older
// than threshold. If the marker does not exist, stale is false.
func IsRepoHooksActiveStale(threshold time.Duration) (stale bool, age time.Duration, err error) {
path, err := repoHooksActivePath()
if err != nil {
return false, 0, err
}

info, err := os.Stat(path)
if os.IsNotExist(err) {
return false, 0, nil
}
if err != nil {
return false, 0, err
}

age = time.Since(info.ModTime())
return age > threshold, age, nil
}
Loading