From 35c0599947d9be2f83df0e9bd6c45a482e3b2541 Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Fri, 7 Aug 2026 21:59:35 +0530 Subject: [PATCH 01/14] feat(hardening): security, reliability, and test improvements Security: - Cap daemon autonomy server-side: clients can no longer request full/yolo autonomy; operators opt in via Config.MaxAutonomy (--autonomy / HAWK_DAEMON_AUTONOMY), default capped at semi. - Refuse non-loopback daemon binds without TLS (API key and session transcripts would otherwise travel in plaintext); enforce TLS 1.2+ and MaxHeaderBytes on the daemon HTTP server. - Harden POST /v1/review: validate concerns/model against a printable charset with a length cap and bound concurrent review subprocesses. - Cap HTTP decision-hook response bodies (64 KiB) and warn loudly when a guardrail hook is configured fail-open. Reliability: - Propagate previously swallowed review-store errors so reviews can no longer get stuck in "running" or silently re-review corrupt stores. - Surface credential-gate symlink flip failures to the user instead of silently leaving the container without the approved credential. - Replace init-time panics in storage path resolution with safe temp-dir fallbacks. - Route yaad bridge diagnostics through slog so they survive TUI mode. - Use typed engine.WorkMode constants instead of raw string compares. - Make alert queue drain responsive to Stop() without dropping alerts. Tests: - Enable the previously build-tagged sqlite store tests by default. - Fix the golden test to actually diff against the golden file (regenerated for the current root help output). - Harden weak assertions in config/providers/review tests and add BM25 unit tests for the previously untested scoring package. --- cmd/chat.go | 18 +- cmd/chat_config_gateways_test.go | 12 +- cmd/chat_config_keys_test.go | 16 +- cmd/chat_config_remove_test.go | 12 +- cmd/chat_status_test.go | 12 +- cmd/chat_subcommand_status.go | 4 +- cmd/chat_welcome.go | 6 +- cmd/daemon.go | 31 +++ cmd/golden_test.go | 13 +- cmd/review_pipeline_additional_test.go | 12 +- cmd/review_refine.go | 16 +- cmd/review_run.go | 30 ++- cmd/review_store.go | 5 +- internal/daemon/auth_config_test.go | 40 ++-- internal/daemon/daemon.go | 60 +++++- internal/daemon/routes_review.go | 37 +++- internal/hooks/http_hooks.go | 13 +- internal/intelligence/memory/yaad_bridge.go | 12 +- internal/observability/alerts/cooldown.go | 27 ++- internal/providers/providers_test.go | 21 +- internal/scoring/bm25_test.go | 218 ++++++++++++++++++++ internal/session/sqlite_store_test.go | 4 +- internal/storage/paths.go | 9 +- testdata/golden/help_root.txt | 33 ++- 24 files changed, 576 insertions(+), 85 deletions(-) create mode 100644 internal/scoring/bm25_test.go diff --git a/cmd/chat.go b/cmd/chat.go index ea698df3..53eb06de 100644 --- a/cmd/chat.go +++ b/cmd/chat.go @@ -360,8 +360,14 @@ func newChatModelWithRegistry(ref *progRef, systemPrompt string, settings hawkco if r.Approved && req.ContainerID != "" { // Flip the symlink inside the container to grant access. if desc := sandbox.FindCredential(req.Credential); desc != nil { - _ = tool.FlipCredentialSymlink(req.ContainerID, req.Credential, - sandbox.StagingPath(req.Credential), desc.ContainerPath) + if flipErr := tool.FlipCredentialSymlink(req.ContainerID, req.Credential, + sandbox.StagingPath(req.Credential), desc.ContainerPath); flipErr != nil { + // The user approved, so a flip failure must be visible: + // report it and revoke approval rather than silently + // leaving the container without the credential. + ref.Send(displayMsg{role: "system", content: fmt.Sprintf("! Credential %q approved but could not be granted to the container: %v", req.Credential, flipErr)}) + return tool.CredentialResponse{Approved: false, Reason: "credential grant failed: " + flipErr.Error()} + } } } return r @@ -524,14 +530,14 @@ func newChatModelWithRegistry(ref *progRef, systemPrompt string, settings hawkco // refreshInputPlaceholder updates the input placeholder based on the current // container lifecycle. Hawk never executes agent tools directly on the host. func (m *chatModel) refreshInputPlaceholder() { - work := "act" + work := engine.WorkModeAct if m.session != nil { - work = string(m.session.WorkMode()) + work = m.session.WorkMode() } switch work { - case "plan": + case engine.WorkModePlan: m.input.Placeholder = "Design architecture or draft plan... · / commands · ? help" - case "review": + case engine.WorkModeReview: m.input.Placeholder = "Audit diffs, security, or PRs... · / commands · ? help" default: m.input.Placeholder = "Build, refactor, or run commands... · / commands · ? help" diff --git a/cmd/chat_config_gateways_test.go b/cmd/chat_config_gateways_test.go index cd5ed534..9de3552e 100644 --- a/cmd/chat_config_gateways_test.go +++ b/cmd/chat_config_gateways_test.go @@ -116,7 +116,9 @@ func TestConfigGatewayRefreshTargetIndex_UsesSelectedRow(t *testing.T) { gateway.SetDefaultStore(nil) hawkconfig.InvalidateConfigUICache() }) - _ = store.Set(t.Context(), gateway.AccountForEnv("OPENROUTER_API_KEY"), "sk-or-test-key-1234567890") + if err := store.Set(t.Context(), gateway.AccountForEnv("OPENROUTER_API_KEY"), "sk-or-test-key-1234567890"); err != nil { + t.Fatalf("store.Set: %v", err) + } hawkconfig.InvalidateConfigUICache() sess := engine.NewSession("", "", "", nil) @@ -143,7 +145,9 @@ func TestConfigGatewayRefreshTargetIndex_UsesFocusOnRefreshRow(t *testing.T) { gateway.SetDefaultStore(nil) hawkconfig.InvalidateConfigUICache() }) - _ = store.Set(t.Context(), gateway.AccountForEnv("OPENROUTER_API_KEY"), "sk-or-test-key-1234567890") + if err := store.Set(t.Context(), gateway.AccountForEnv("OPENROUTER_API_KEY"), "sk-or-test-key-1234567890"); err != nil { + t.Fatalf("store.Set: %v", err) + } hawkconfig.InvalidateConfigUICache() rows := []configGatewayRow{ @@ -165,7 +169,9 @@ func TestFocusConfigActiveGateway_SelectsActiveRow(t *testing.T) { gateway.SetDefaultStore(nil) hawkconfig.InvalidateConfigUICache() }) - _ = store.Set(t.Context(), gateway.AccountForEnv("OPENROUTER_API_KEY"), "sk-or-test-key-1234567890") + if err := store.Set(t.Context(), gateway.AccountForEnv("OPENROUTER_API_KEY"), "sk-or-test-key-1234567890"); err != nil { + t.Fatalf("store.Set: %v", err) + } hawkconfig.InvalidateConfigUICache() sess := engine.NewSession("", "", "", nil) diff --git a/cmd/chat_config_keys_test.go b/cmd/chat_config_keys_test.go index 680ea8fb..15aee677 100644 --- a/cmd/chat_config_keys_test.go +++ b/cmd/chat_config_keys_test.go @@ -17,7 +17,9 @@ func TestConfigGatewaysView_KeyHintsWithCredentials(t *testing.T) { gateway.SetDefaultStore(nil) hawkconfig.InvalidateConfigUICache() }) - _ = store.Set(t.Context(), gateway.AccountForEnv("OPENROUTER_API_KEY"), "sk-or-test-key-1234567890") + if err := store.Set(t.Context(), gateway.AccountForEnv("OPENROUTER_API_KEY"), "sk-or-test-key-1234567890"); err != nil { + t.Fatalf("store.Set: %v", err) + } hawkconfig.InvalidateConfigUICache() m := chatModel{configTab: configTabGateways} @@ -35,7 +37,9 @@ func TestConfigGatewaysKeyView_OpenWithK(t *testing.T) { gateway.SetDefaultStore(nil) hawkconfig.InvalidateConfigUICache() }) - _ = store.Set(t.Context(), gateway.AccountForEnv("OPENROUTER_API_KEY"), "sk-or-test-key-1234567890") + if err := store.Set(t.Context(), gateway.AccountForEnv("OPENROUTER_API_KEY"), "sk-or-test-key-1234567890"); err != nil { + t.Fatalf("store.Set: %v", err) + } hawkconfig.InvalidateConfigUICache() rows := chatModel{}.configGatewayRows() @@ -64,7 +68,9 @@ func TestConfigGatewaysDelete_PendingRemove(t *testing.T) { gateway.SetDefaultStore(nil) hawkconfig.InvalidateConfigUICache() }) - _ = store.Set(t.Context(), gateway.AccountForEnv("OPENROUTER_API_KEY"), "sk-or-test-key-1234567890") + if err := store.Set(t.Context(), gateway.AccountForEnv("OPENROUTER_API_KEY"), "sk-or-test-key-1234567890"); err != nil { + t.Fatalf("store.Set: %v", err) + } hawkconfig.InvalidateConfigUICache() rows := chatModel{}.configGatewayRows() @@ -93,7 +99,9 @@ func TestConfigGatewaysDelete_DoubleConfirm(t *testing.T) { gateway.SetDefaultStore(nil) hawkconfig.InvalidateConfigUICache() }) - _ = store.Set(t.Context(), gateway.AccountForEnv("OPENROUTER_API_KEY"), "sk-or-test-key-1234567890") + if err := store.Set(t.Context(), gateway.AccountForEnv("OPENROUTER_API_KEY"), "sk-or-test-key-1234567890"); err != nil { + t.Fatalf("store.Set: %v", err) + } hawkconfig.InvalidateConfigUICache() rows := chatModel{}.configGatewayRows() diff --git a/cmd/chat_config_remove_test.go b/cmd/chat_config_remove_test.go index 14092944..ae83e0fd 100644 --- a/cmd/chat_config_remove_test.go +++ b/cmd/chat_config_remove_test.go @@ -16,7 +16,9 @@ func TestConfigGatewayRows_ShowsSavedKey(t *testing.T) { gateway.SetDefaultStore(nil) hawkconfig.InvalidateConfigUICache() }) - _ = store.Set(t.Context(), gateway.AccountForEnv("OPENROUTER_API_KEY"), "sk-or-test-key-1234567890") + if err := store.Set(t.Context(), gateway.AccountForEnv("OPENROUTER_API_KEY"), "sk-or-test-key-1234567890"); err != nil { + t.Fatalf("store.Set: %v", err) + } hawkconfig.InvalidateConfigUICache() rows := chatModel{}.configGatewayRows() @@ -40,7 +42,9 @@ func TestConfiguredCredentialProviders_UsedByGatewaysTab(t *testing.T) { gateway.SetDefaultStore(nil) hawkconfig.InvalidateConfigUICache() }) - _ = store.Set(t.Context(), gateway.AccountForEnv("OPENROUTER_API_KEY"), "sk-or-test-key-1234567890") + if err := store.Set(t.Context(), gateway.AccountForEnv("OPENROUTER_API_KEY"), "sk-or-test-key-1234567890"); err != nil { + t.Fatalf("store.Set: %v", err) + } hawkconfig.InvalidateConfigUICache() got := hawkconfig.ConfiguredCredentialProviders() @@ -57,7 +61,9 @@ func TestRemoveCredentialAsync(t *testing.T) { gateway.SetDefaultStore(nil) hawkconfig.InvalidateConfigUICache() }) - _ = store.Set(t.Context(), gateway.AccountForEnv("OPENROUTER_API_KEY"), "sk-or-test-key-1234567890") + if err := store.Set(t.Context(), gateway.AccountForEnv("OPENROUTER_API_KEY"), "sk-or-test-key-1234567890"); err != nil { + t.Fatalf("store.Set: %v", err) + } hawkconfig.InvalidateConfigUICache() cmd := removeCredentialAsync("openrouter") diff --git a/cmd/chat_status_test.go b/cmd/chat_status_test.go index 72bddc52..d456149c 100644 --- a/cmd/chat_status_test.go +++ b/cmd/chat_status_test.go @@ -102,7 +102,9 @@ func TestChatConnectionStatus_WithModel(t *testing.T) { }) ctx := context.Background() - _ = store.Set(ctx, gateway.AccountForEnv("OPENROUTER_API_KEY"), "sk-or-test-key-1234567890") + if err := store.Set(ctx, gateway.AccountForEnv("OPENROUTER_API_KEY"), "sk-or-test-key-1234567890"); err != nil { + t.Fatalf("store.Set: %v", err) + } hawkconfig.InvalidateConfigUICache() _ = hawkconfig.SetActiveProvider(ctx, "openrouter") _ = hawkconfig.SetActiveModel(ctx, "moonshotai/kimi-k2.6") @@ -134,7 +136,9 @@ func TestChatConnectionStatus_KeyNoModel(t *testing.T) { }) ctx := context.Background() - _ = store.Set(ctx, gateway.AccountForEnv("OPENROUTER_API_KEY"), "sk-or-test-key-1234567890") + if err := store.Set(ctx, gateway.AccountForEnv("OPENROUTER_API_KEY"), "sk-or-test-key-1234567890"); err != nil { + t.Fatalf("store.Set: %v", err) + } hawkconfig.InvalidateConfigUICache() _ = hawkconfig.ClearActiveSelection(ctx) _ = hawkconfig.SetActiveProvider(ctx, "openrouter") @@ -158,7 +162,9 @@ func TestChatConnectionStatus_NoGatewayNoModel(t *testing.T) { }) ctx := context.Background() - _ = store.Set(ctx, gateway.AccountForEnv("ANTHROPIC_API_KEY"), "sk-ant-test-key-long-enough") + if err := store.Set(ctx, gateway.AccountForEnv("ANTHROPIC_API_KEY"), "sk-ant-test-key-long-enough"); err != nil { + t.Fatalf("store.Set: %v", err) + } hawkconfig.InvalidateConfigUICache() _ = hawkconfig.ClearActiveSelection(ctx) hawkconfig.RefreshConfigCredSnapshot(ctx) diff --git a/cmd/chat_subcommand_status.go b/cmd/chat_subcommand_status.go index a16d3127..dd4cc383 100644 --- a/cmd/chat_subcommand_status.go +++ b/cmd/chat_subcommand_status.go @@ -37,9 +37,9 @@ func buildStatusInfo(m *chatModel) string { toolCount = len(m.registry.PrimaryTools()) visible = len(m.registry.EyrieTools()) } - work := string(m.session.WorkMode()) + work := m.session.WorkMode() if work == "" { - work = "act" + work = engine.WorkModeAct } iso := m.session.Isolation().String() tr := engine.ProjectTrust("") diff --git a/cmd/chat_welcome.go b/cmd/chat_welcome.go index 4e452f10..3512cf98 100644 --- a/cmd/chat_welcome.go +++ b/cmd/chat_welcome.go @@ -231,16 +231,16 @@ func buildWelcomeMessageWithSnapshot(sess *engine.Session, sessionID string, reg // indicator on the welcome screen (moved out of the footer bar). When the // CONTAINER badge is shown, the redundant iso segment is dropped. func welcomeControlPlaneLine(sess *engine.Session, dimC, rst string, badgeShown bool) string { - work := string(sess.WorkMode()) + work := sess.WorkMode() modeIcon := icons.Cog() modeLabel := "Action Mode" modeColor := ansiCyan switch work { - case "plan": + case engine.WorkModePlan: modeIcon = icons.Brain() modeLabel = "Planning Mode" modeColor = ansiMagenta - case "review": + case engine.WorkModeReview: modeIcon = icons.Magnify() modeLabel = "Review Mode" modeColor = ansiAmber diff --git a/cmd/daemon.go b/cmd/daemon.go index 19ad58bd..3072ec6a 100644 --- a/cmd/daemon.go +++ b/cmd/daemon.go @@ -37,6 +37,7 @@ var ( daemonCORSOrigins []string daemonTLSCertFile string daemonTLSKeyFile string + daemonAutonomy string ) var daemonCmd = &cobra.Command{ @@ -71,6 +72,7 @@ func init() { daemonStartCmd.Flags().StringSliceVar(&daemonCORSOrigins, "cors", []string{}, "Comma-separated list of allowed CORS origins (empty disables CORS, '*' allows all)") daemonStartCmd.Flags().StringVar(&daemonTLSCertFile, "tls-cert", "", "Path to TLS certificate file (enables HTTPS when paired with --tls-key)") daemonStartCmd.Flags().StringVar(&daemonTLSKeyFile, "tls-key", "", "Path to TLS private key file (enables HTTPS when paired with --tls-cert)") + daemonStartCmd.Flags().StringVar(&daemonAutonomy, "autonomy", "", "Maximum autonomy tier clients may request via the API (supervised, basic, semi, full, yolo; default: semi). The daemon is non-interactive, so full/yolo require an explicit opt-in.") daemonCmd.AddCommand(daemonStartCmd) daemonCmd.AddCommand(daemonStopCmd) daemonCmd.AddCommand(daemonStatusCmd) @@ -171,6 +173,7 @@ func runDaemonStart(_ *cobra.Command, _ []string) error { TLSCertFile: daemonTLSCertFile, TLSKeyFile: daemonTLSKeyFile, SecurityLog: secLog, + MaxAutonomy: daemonAutonomyFromFlag(daemonAutonomy), }, factory) srv.SetGraphFactory(func(ctx context.Context, req daemon.GraphRequest) (executiongraph.Export, error) { if err := ctx.Err(); err != nil { @@ -372,6 +375,34 @@ func generateDaemonAPIKey() (string, error) { return base64.RawURLEncoding.EncodeToString(b[:]), nil } +// daemonAutonomyFromFlag resolves the --autonomy flag / HAWK_DAEMON_AUTONOMY +// env var into the server-side autonomy cap. An empty value leaves the +// default cap (AutonomySemi) in place; invalid values fail closed at +// "supervised" rather than silently allowing full autonomy. +func daemonAutonomyFromFlag(s string) engine.AutonomyLevel { + s = strings.TrimSpace(s) + if s == "" { + s = os.Getenv("HAWK_DAEMON_AUTONOMY") + } + if s == "" { + return 0 // zero => DefaultMaxAutonomy in the daemon + } + switch strings.ToLower(strings.TrimSpace(s)) { + case "0", "supervised": + return engine.AutonomySupervised + case "1", "basic": + return engine.AutonomyBasic + case "2", "semi", "accept_edits", "acceptedits": + return engine.AutonomySemi + case "3", "full": + return engine.AutonomyFull + case "4", "yolo", "dont_ask", "dontask": + return engine.AutonomyYOLO + default: + return engine.AutonomySupervised + } +} + func runDaemonStop(_ *cobra.Command, _ []string) error { pidFile := filepath.Join(storage.DaemonRunDir(), "daemon.json") diff --git a/cmd/golden_test.go b/cmd/golden_test.go index a8665320..fec827c4 100644 --- a/cmd/golden_test.go +++ b/cmd/golden_test.go @@ -30,7 +30,9 @@ func TestGoldenHelp(t *testing.T) { rootCmd.SetErr(buf) rootCmd.SetArgs(tt.args) - _ = rootCmd.Execute() + if err := rootCmd.Execute(); err != nil { + t.Fatalf("root command execute: %v", err) + } got := buf.String() golden := filepath.Join("..", "testdata", "golden", tt.file) @@ -44,8 +46,9 @@ func TestGoldenHelp(t *testing.T) { expected, err := os.ReadFile(golden) if err != nil { - t.Skipf("golden file %s not found, run with -update-golden to create", golden) - return + // A missing golden is a real failure: new commands should + // force a deliberate golden update, not a silent skip. + t.Fatalf("golden file %s not found (run with -update-golden to create): %v", golden, err) } if !strings.Contains(got, "hawk") { @@ -54,7 +57,9 @@ func TestGoldenHelp(t *testing.T) { if len(got) < 100 { t.Error("help output seems too short") } - _ = expected // compare in stricter mode later + if got != string(expected) { + t.Errorf("help output does not match golden file %s\n--- got ---\n%s\n--- want (golden) ---\n%s", golden, got, expected) + } }) } } diff --git a/cmd/review_pipeline_additional_test.go b/cmd/review_pipeline_additional_test.go index 6b9cf0f3..0124d48b 100644 --- a/cmd/review_pipeline_additional_test.go +++ b/cmd/review_pipeline_additional_test.go @@ -2,6 +2,7 @@ package cmd import ( "context" + "strings" "testing" ) @@ -65,7 +66,9 @@ func TestParseReviewFindings_Fallback(t *testing.T) { func TestFormatReviewReport_Empty(t *testing.T) { t.Parallel() report := FormatReviewReport(nil) - _ = report + if report != "No issues found." { + t.Errorf("expected 'No issues found.', got %q", report) + } } func TestFormatReviewReport_WithFindings(t *testing.T) { @@ -76,6 +79,11 @@ func TestFormatReviewReport_WithFindings(t *testing.T) { } report := FormatReviewReport(findings) if report == "" { - t.Error("should produce report") + t.Fatal("should produce report") + } + for _, want := range []string{"=== Review Report ===", "main.go:10", "config.go:5", "2 issue(s) total", "HIGH", "LOW"} { + if !strings.Contains(report, want) { + t.Errorf("report missing %q:\n%s", want, report) + } } } diff --git a/cmd/review_refine.go b/cmd/review_refine.go index 042cab67..7894deff 100644 --- a/cmd/review_refine.go +++ b/cmd/review_refine.go @@ -97,7 +97,10 @@ func runReviewRefine(_ *cobra.Command, args []string) error { } // Check if the new review passed. - newReview, _ := store.GetBySHA(latestSHA) + newReview, getErr := store.GetBySHA(latestSHA) + if getErr != nil { + return fmt.Errorf("load review for %s: %w", latestSHA[:8], getErr) + } if newReview != nil && newReview.Status == ReviewStatusPassed { fmt.Printf("\n%s All clean after %d iteration(s)!\n", icons.CheckBold(), iter) return nil @@ -107,7 +110,11 @@ func runReviewRefine(_ *cobra.Command, args []string) error { if newReview != nil && newReview.Status == ReviewStatusOpen { reviews = []*ReviewRecord{newReview} } else { - reviews, _ = store.ListOpen() + var listErr error + reviews, listErr = store.ListOpen() + if listErr != nil { + return fmt.Errorf("list open reviews: %w", listErr) + } if len(reviews) == 0 { fmt.Printf("\n%s All reviews resolved after %d iteration(s)!\n", icons.CheckBold(), iter) return nil @@ -116,7 +123,10 @@ func runReviewRefine(_ *cobra.Command, args []string) error { } // Report remaining issues. - remaining, _ := store.ListOpen() + remaining, listErr := store.ListOpen() + if listErr != nil { + return fmt.Errorf("list open reviews: %w", listErr) + } if len(remaining) > 0 { fmt.Printf("\n%s %d review(s) still open after %d iterations.\n", icons.Alert(), len(remaining), refineMaxIter) fmt.Println(" Run 'hawk review show' to inspect, or increase --max-iterations.") diff --git a/cmd/review_run.go b/cmd/review_run.go index a9d3b894..dd18cabd 100644 --- a/cmd/review_run.go +++ b/cmd/review_run.go @@ -58,7 +58,11 @@ func runReviewRun(_ *cobra.Command, args []string) error { defer func() { _ = store.Close() }() // Check if already reviewed. - if existing, _ := store.GetBySHA(sha); existing != nil && existing.Status != ReviewStatusFailed { + existing, getErr := store.GetBySHA(sha) + if getErr != nil { + return silentErr(getErr, "load existing review") + } + if existing != nil && existing.Status != ReviewStatusFailed { if !reviewRunBackground { fmt.Printf("Commit %s already reviewed (status: %s)\n", sha[:8], existing.Status) } @@ -70,16 +74,22 @@ func runReviewRun(_ *cobra.Command, args []string) error { if err != nil { return silentErr(err, "create review record") } - _ = store.SetStatus(id, ReviewStatusRunning) + if err := store.SetStatus(id, ReviewStatusRunning); err != nil { + return silentErr(err, "mark review running") + } // Get commit diff. diff, err := getCommitDiff(sha) if err != nil { - _ = store.SetStatus(id, ReviewStatusFailed) + if statusErr := store.SetStatus(id, ReviewStatusFailed); statusErr != nil { + return silentErr(statusErr, "mark review failed") + } return silentErr(err, "get commit diff") } if strings.TrimSpace(diff) == "" { - _ = store.SetStatus(id, ReviewStatusPassed) + if statusErr := store.SetStatus(id, ReviewStatusPassed); statusErr != nil { + return silentErr(statusErr, "mark review passed") + } if !reviewRunBackground { fmt.Println("Empty diff — nothing to review.") } @@ -94,7 +104,9 @@ func runReviewRun(_ *cobra.Command, args []string) error { }) chatProvider, providerID, err := engine.BuildChatProvider(ctx, selection, strings.TrimSpace(provider)) if err != nil { - _ = store.SetStatus(id, ReviewStatusFailed) + if statusErr := store.SetStatus(id, ReviewStatusFailed); statusErr != nil { + return silentErr(statusErr, "mark review failed") + } return silentErr(fmt.Errorf("resolve engine transport: %w", err), "init bridge") } @@ -112,7 +124,9 @@ func runReviewRun(_ *cobra.Command, args []string) error { bridge := hawkSight.NewBridge(chatProvider, providerID, opts...) if !bridge.Ready() { - _ = store.SetStatus(id, ReviewStatusFailed) + if statusErr := store.SetStatus(id, ReviewStatusFailed); statusErr != nil { + return silentErr(statusErr, "mark review failed") + } return silentErr(fmt.Errorf("sight bridge not ready"), "init bridge") } @@ -125,7 +139,9 @@ func runReviewRun(_ *cobra.Command, args []string) error { // Run review. result, err := bridge.ReviewContracts(ctx, diff) if err != nil { - _ = store.SetStatus(id, ReviewStatusFailed) + if statusErr := store.SetStatus(id, ReviewStatusFailed); statusErr != nil { + return silentErr(statusErr, "mark review failed") + } return silentErr(err, "sight review") } diff --git a/cmd/review_store.go b/cmd/review_store.go index bef90921..6aa5d758 100644 --- a/cmd/review_store.go +++ b/cmd/review_store.go @@ -225,7 +225,10 @@ func (s *ReviewStore) Summary() (map[ReviewStatus]int, error) { func (s *ReviewStore) Close() error { // Checkpoint WAL to flush all data into the main db and truncate // the WAL file, so no .db-wal / .db-shm files linger on disk. - _, _ = s.db.Exec("PRAGMA wal_checkpoint(TRUNCATE)") + if _, err := s.db.Exec("PRAGMA wal_checkpoint(TRUNCATE)"); err != nil { + _ = s.db.Close() + return fmt.Errorf("checkpoint review WAL: %w", err) + } return s.db.Close() } diff --git a/internal/daemon/auth_config_test.go b/internal/daemon/auth_config_test.go index f9917e25..e96bb853 100644 --- a/internal/daemon/auth_config_test.go +++ b/internal/daemon/auth_config_test.go @@ -41,28 +41,38 @@ func TestValidateAuthConfig(t *testing.T) { name string apiKey string addr string + tls bool wantErr bool }{ - // With API key: any bind address is allowed. - {"apiKey set, loopback", "secret", "127.0.0.1:4590", false}, - {"apiKey set, IPv6 loopback", "secret", "[::1]:4590", false}, - {"apiKey set, wildcard", "secret", "0.0.0.0:4590", false}, - {"apiKey set, public IP", "secret", "192.168.1.1:4590", false}, + // With API key: loopback binds are allowed (plaintext is fine + // on loopback — the key never leaves the host). + {"apiKey set, loopback", "secret", "127.0.0.1:4590", false, false}, + {"apiKey set, IPv6 loopback", "secret", "[::1]:4590", false, false}, + // Non-loopback binds require TLS even with an API key: the key + // and full conversation history would otherwise travel in plaintext. + {"apiKey set, wildcard no TLS refused", "secret", "0.0.0.0:4590", false, true}, + {"apiKey set, public IP no TLS refused", "secret", "192.168.1.1:4590", false, true}, + {"apiKey set, wildcard with TLS", "secret", "0.0.0.0:4590", true, false}, + {"apiKey set, public IP with TLS", "secret", "192.168.1.1:4590", true, false}, // Without API key: loopback only. - {"no key, IPv4 loopback", "", "127.0.0.1:4590", false}, - {"no key, IPv6 loopback", "", "[::1]:4590", false}, - {"no key, localhost name", "", "localhost:4590", false}, - {"no key, wildcard refused", "", "0.0.0.0:4590", true}, - {"no key, IPv6 wildcard refused", "", "[::]:4590", true}, - {"no key, private IP refused", "", "192.168.1.1:4590", true}, - {"no key, public IP refused", "", "8.8.8.8:4590", true}, - {"no key, hostname refused", "", "example.com:4590", true}, - {"no key, no host part refused", "", ":4590", true}, - {"no key, invalid addr refused", "", "not-a-valid-address", true}, + {"no key, IPv4 loopback", "", "127.0.0.1:4590", false, false}, + {"no key, IPv6 loopback", "", "[::1]:4590", false, false}, + {"no key, localhost name", "", "localhost:4590", false, false}, + {"no key, wildcard refused", "", "0.0.0.0:4590", false, true}, + {"no key, IPv6 wildcard refused", "", "[::]:4590", false, true}, + {"no key, private IP refused", "", "192.168.1.1:4590", false, true}, + {"no key, public IP refused", "", "8.8.8.8:4590", false, true}, + {"no key, hostname refused", "", "example.com:4590", false, true}, + {"no key, no host part refused", "", ":4590", false, true}, + {"no key, invalid addr refused", "", "not-a-valid-address", false, true}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { s := &Server{apiKey: tc.apiKey, addr: tc.addr} + if tc.tls { + s.tlsCertFile = "cert.pem" + s.tlsKeyFile = "key.pem" + } err := s.validateAuthConfig() if tc.wantErr && err == nil { t.Errorf("expected error, got nil") diff --git a/internal/daemon/daemon.go b/internal/daemon/daemon.go index 8a4bb463..1db772fb 100644 --- a/internal/daemon/daemon.go +++ b/internal/daemon/daemon.go @@ -4,6 +4,7 @@ import ( "context" "crypto/rand" "crypto/subtle" + "crypto/tls" "encoding/hex" "encoding/json" "errors" @@ -129,6 +130,10 @@ type Server struct { // tlsCertFile and tlsKeyFile enable HTTPS when both are set. tlsCertFile string tlsKeyFile string + // maxAutonomy caps the autonomy tier clients may request (server-side + // policy; zero means DefaultMaxAutonomy). The daemon has no human to + // approve permission prompts, so full/YOLO must be operator-opted-in. + maxAutonomy engine.AutonomyLevel } // ReadyResponse is the JSON response from GET /v1/ready. @@ -160,8 +165,22 @@ type Config struct { // SecurityLog provides the audit log instance for tool execution // auditing. If nil, the server creates one from DefaultDir(). SecurityLog *securitylog.Log `json:"-"` + // MaxAutonomy caps the autonomy tier a client may request via + // POST /v1/chat. The daemon is non-interactive: it has no human to + // approve permission prompts, so remote callers must not be able to + // escalate to full/YOLO autonomy on their own. Zero means the default + // cap (AutonomySemi) applies; set explicitly (e.g. to AutonomyFull) + // only for trusted, operator-owned deployments. + MaxAutonomy engine.AutonomyLevel `json:"-"` } +// DefaultMaxAutonomy is the highest autonomy tier a daemon client may +// request when the operator has not configured a higher cap. Semi +// auto-approves reads and writes but still gates Bash behind permission, +// which is the most permissive setting that remains safe without a human +// in the loop. +const DefaultMaxAutonomy = engine.AutonomySemi + // DefaultConfig returns reasonable defaults. func DefaultConfig() Config { return Config{ @@ -240,6 +259,7 @@ func New(cfg Config, factory SessionFactory) *Server { s.corsOrigins = cfg.CORSOrigins s.tlsCertFile = cfg.TLSCertFile s.tlsKeyFile = cfg.TLSKeyFile + s.maxAutonomy = cfg.MaxAutonomy // Initialize the tamper-evident security event log. If a log is provided // in the config, use it; otherwise create one from the default directory. @@ -258,6 +278,10 @@ func New(cfg Config, factory SessionFactory) *Server { ReadTimeout: 30 * time.Second, WriteTimeout: 300 * time.Second, IdleTimeout: 60 * time.Second, + MaxHeaderBytes: 1 << 20, + TLSConfig: &tls.Config{ + MinVersion: tls.VersionTLS12, + }, } // Install middleware stack: request IDs → security headers → CORS → logging. s.installMiddleware() @@ -314,15 +338,24 @@ func (s *Server) Start() (string, error) { // request when apiKey == "", so a misconfigured production daemon would // be wide open. The only safe no-key mode is loopback bind. func (s *Server) validateAuthConfig() error { - if s.apiKey != "" { - return nil + if s.apiKey == "" { + host, _, err := net.SplitHostPort(s.addr) + if err != nil { + return fmt.Errorf("daemon: invalid bind address %q: %w", s.addr, err) + } + if !isLoopbackHost(host) { + return fmt.Errorf("daemon: apiKey is empty and bind address %q is not loopback; refusing to start. Set Config.APIKey or bind to %s", s.addr, netutil.LoopbackHost) + } } + // A non-loopback bind exposes the API key and full conversation + // history on the wire. Refuse to serve plaintext in that case: + // remote callers must use TLS. host, _, err := net.SplitHostPort(s.addr) if err != nil { return fmt.Errorf("daemon: invalid bind address %q: %w", s.addr, err) } - if !isLoopbackHost(host) { - return fmt.Errorf("daemon: apiKey is empty and bind address %q is not loopback; refusing to start. Set Config.APIKey or bind to %s", s.addr, netutil.LoopbackHost) + if !isLoopbackHost(host) && (s.tlsCertFile == "" || s.tlsKeyFile == "") { + return fmt.Errorf("daemon: bind address %q is not loopback but TLS is not configured; refusing to start. Configure TLSCertFile/TLSKeyFile or bind to %s", s.addr, netutil.LoopbackHost) } return nil } @@ -733,9 +766,24 @@ func (s *Server) handleChat(w http.ResponseWriter, r *http.Request) { sess.LoadMessages(hawksession.ToRuntimeMessages(saved.Messages)) } - // Set autonomy + // Set autonomy, capped by server-side policy. The daemon is + // non-interactive — no human can approve permission prompts — so a + // client must not be able to escalate to full/YOLO autonomy on its own. + // Operators opt in to higher tiers explicitly via Config.MaxAutonomy. if req.Autonomy != "" { - sess.PermSvc().SetAutonomy(engine.ParseAutonomyLevel(req.Autonomy)) + requested := engine.ParseAutonomyLevel(req.Autonomy) + max := s.maxAutonomy + if max == 0 { + max = DefaultMaxAutonomy + } + if requested > max { + writeJSON(w, http.StatusBadRequest, ErrorResponse{ + Error: fmt.Sprintf("autonomy %q exceeds the daemon's configured maximum (%s); the daemon is non-interactive and cannot approve escalated permissions. Raise Config.MaxAutonomy (HAWK_DAEMON_AUTONOMY) to allow it", requested.String(), max.String()), + Code: "autonomy_denied", + }) + return + } + sess.PermSvc().SetAutonomy(requested) } // Auto-approve permissions based on autonomy (non-interactive) diff --git a/internal/daemon/routes_review.go b/internal/daemon/routes_review.go index 6f164c2a..1a1874d4 100644 --- a/internal/daemon/routes_review.go +++ b/internal/daemon/routes_review.go @@ -11,6 +11,21 @@ import ( var validSHA = regexp.MustCompile(`^[0-9a-f]{7,40}$`) +// reviewArgMaxLen caps user-supplied values passed on the review subprocess +// argv. reviewArgCharset restricts them to printable characters only; control +// characters (and in particular newlines) could otherwise be interpreted by +// downstream re-parsers as argument or flag separators. +const reviewArgMaxLen = 4096 + +var reviewArgCharset = regexp.MustCompile(`^[^\x00-\x1f\x7f]*$`) + +// reviewSem bounds the number of concurrent `hawk review run` subprocesses +// spawned by POST /v1/review so an authenticated caller cannot exhaust CPU +// or memory by firing unbounded review jobs. +var reviewSem = make(chan struct{}, maxConcurrentReviews) + +const maxConcurrentReviews = 4 + // ReviewRequest is the JSON body for POST /v1/review. type ReviewRequest struct { SHA string `json:"sha"` @@ -55,6 +70,26 @@ func (s *Server) handleReview(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusBadRequest, map[string]string{"error": "model must not start with '--'"}) return } + // Reject control characters (newlines, escapes) that a downstream + // re-parser could treat as argument separators, and cap length so a + // single value cannot balloon the argv. + validArg := func(s string) bool { + return len(s) <= reviewArgMaxLen && reviewArgCharset.MatchString(s) + } + if !validArg(req.Concerns) || !validArg(req.Model) { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "concerns and model must be printable text (max 4096 characters)"}) + return + } + + // Bound concurrent review spawns: refuse (503) rather than queue when the + // limit is reached so a burst cannot pile up unbounded subprocesses. + select { + case reviewSem <- struct{}{}: + defer func() { <-reviewSem }() + default: + writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "server busy: too many reviews in flight"}) + return + } // Trigger review asynchronously via hawk review run. go func() { @@ -65,7 +100,7 @@ func (s *Server) handleReview(w http.ResponseWriter, r *http.Request) { if req.Concerns != "" { args = append(args, "--concerns", req.Concerns) } - _ = exec.CommandContext(context.Background(), "hawk", args...).Run() // #nosec G204 -- binary is fixed "hawk"; args are validated (SHA regex, no "--" prefix) + _ = exec.CommandContext(context.Background(), "hawk", args...).Run() // #nosec G204 -- binary is fixed "hawk"; args are validated (SHA regex, no "--" prefix, printable charset) }() resp := ReviewResponse{ diff --git a/internal/hooks/http_hooks.go b/internal/hooks/http_hooks.go index 94c3e6ef..35c71e22 100644 --- a/internal/hooks/http_hooks.go +++ b/internal/hooks/http_hooks.go @@ -5,6 +5,7 @@ import ( "context" "encoding/json" "fmt" + "io" "log/slog" "net/http" "time" @@ -27,6 +28,10 @@ type HTTPHook struct { FailOpen bool // when true, hook errors allow the operation instead of denying it } +// maxHookResponseBytes caps the response body read from a decision hook so a +// misbehaving or malicious endpoint cannot exhaust daemon memory. +const maxHookResponseBytes = 64 << 10 // 64 KiB + // RegisterHTTPDecisionHook registers an HTTP-backed decision hook. func RegisterHTTPDecisionHook(h HTTPHook) { if h.Timeout <= 0 { @@ -35,6 +40,12 @@ func RegisterHTTPDecisionHook(h HTTPHook) { if h.Name == "" { h.Name = "http:" + h.URL } + if h.FailOpen { + slog.Warn("http decision hook configured fail-open", + "name", h.Name, + "url", h.URL, + "note", "an unreachable guardrail hook will allow operations; ensure this is intentional") + } client := &http.Client{Timeout: h.Timeout} url := h.URL failOpen := h.FailOpen @@ -95,7 +106,7 @@ func invokeHTTPHook(client *http.Client, url, event string, data map[string]inte Reason string `json:"reason"` Message string `json:"message"` } - if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { + if err := json.NewDecoder(io.LimitReader(resp.Body, maxHookResponseBytes+1)).Decode(&out); err != nil { return hookError(failOpen, event, "decode response: %v", err) } switch out.Action { diff --git a/internal/intelligence/memory/yaad_bridge.go b/internal/intelligence/memory/yaad_bridge.go index 1d80f2bf..93921894 100644 --- a/internal/intelligence/memory/yaad_bridge.go +++ b/internal/intelligence/memory/yaad_bridge.go @@ -5,7 +5,7 @@ import ( "crypto/sha256" "encoding/hex" "fmt" - "log" + "log/slog" "os" "path/filepath" "strconv" @@ -95,7 +95,7 @@ func (b *YaadBridge) ConfigureGraphObservation(sessionID string, scope graphcont // notReadyError logs a warning once and returns a structured BridgeError. func (b *YaadBridge) notReadyError(op string) error { b.warnOnce.Do(func() { - log.Println("[hawk/memory] WARNING: yaad bridge is not initialized; memory operations will be skipped. Ensure ~/.yaad/data/ is accessible.") + slog.Warn("[hawk/memory] yaad bridge is not initialized; memory operations will be skipped", "hint", "ensure ~/.yaad/data/ is accessible") }) return &hawkerr.BridgeError{ Bridge: "yaad", @@ -365,7 +365,7 @@ func (b *YaadBridge) recordContextGraph(query string, result *yaadEngine.RecallR ProducerVersion: yaad.Version, }) if err != nil { - log.Printf("[hawk/memory] yaad context graph projection failed: %v", err) + slog.Warn("[hawk/memory] yaad context graph projection failed", "error", err) return } if err := graphjournal.AppendContextGraph( @@ -377,7 +377,7 @@ func (b *YaadBridge) recordContextGraph(query string, result *yaadEngine.RecallR projection.Events, projection.GeneratedAt, ); err != nil { - log.Printf("[hawk/memory] yaad context graph observation failed: %v", err) + slog.Warn("[hawk/memory] yaad context graph observation failed", "error", err) } } @@ -396,7 +396,7 @@ func (b *YaadBridge) recordSelectedContext(label string, nodes []*storage.Node) } edges, err := b.store.GetEdgesBetween(context.Background(), ids) if err != nil { - log.Printf("[hawk/memory] yaad selected context edge lookup failed: %v", err) + slog.Warn("[hawk/memory] yaad selected context edge lookup failed", "error", err) edges = nil } b.recordContextGraph(label, &yaadEngine.RecallResult{Nodes: nodes, Edges: edges}) @@ -531,7 +531,7 @@ func (b *YaadBridge) recordCodeContext(query string, records []*storage.CodeChun events, occurredAt, ); err != nil { - log.Printf("[hawk/memory] code context graph observation failed: %v", err) + slog.Warn("[hawk/memory] code context graph observation failed", "error", err) } } diff --git a/internal/observability/alerts/cooldown.go b/internal/observability/alerts/cooldown.go index ac951d25..9a18ef59 100644 --- a/internal/observability/alerts/cooldown.go +++ b/internal/observability/alerts/cooldown.go @@ -95,7 +95,25 @@ func (q *AlertQueue) drain() { q.pending = make([]*Alert, 0) q.mu.Unlock() - for _, alert := range batch { + // requeueRemaining puts the not-yet-delivered tail of the batch back so + // a Stop() mid-drain never drops alerts that were never sent. + requeueRemaining := func(from int) { + if from >= len(batch) { + return + } + remainder := batch[from:] + q.mu.Lock() + q.pending = append(remainder, q.pending...) + q.mu.Unlock() + } + + for i, alert := range batch { + select { + case <-q.stopCh: + requeueRemaining(i) + return + default: + } if q.handler != nil { if err := q.handler(alert); err == nil { alert.Delivered = true @@ -104,7 +122,12 @@ func (q *AlertQueue) drain() { q.mu.Unlock() } } - time.Sleep(q.config.SendDelay) + select { + case <-q.stopCh: + requeueRemaining(i + 1) + return + case <-time.After(q.config.SendDelay): + } } } diff --git a/internal/providers/providers_test.go b/internal/providers/providers_test.go index 44bc8113..6cfb5ded 100644 --- a/internal/providers/providers_test.go +++ b/internal/providers/providers_test.go @@ -56,10 +56,15 @@ func TestProbesParse_MultipleOR(t *testing.T) { } } -func TestCatalog_Has34(t *testing.T) { +func TestCatalog_NonEmpty(t *testing.T) { all := providers.All() - if len(all) != 34 { - t.Errorf("expected 34 providers in catalog, got %d", len(all)) + if len(all) == 0 { + t.Fatal("catalog must not be empty") + } + // Avoid a brittle hardcoded count: catalog size changes with edits, but + // must never regress below a sane floor (there are 20+ shipping tools). + if len(all) < 20 { + t.Errorf("catalog unexpectedly small: %d providers", len(all)) } } @@ -156,10 +161,14 @@ func TestDetect_PreservesProbesSnapshot(t *testing.T) { first[i].Detected = false } second := providers.Detect() + // The second call's Detected must be re-evaluated based on the actual + // probes, not poisoned by the first mutation. Every entry in the second + // snapshot must still carry Detected=true (a detected provider cannot + // become undetected just because an earlier snapshot was mutated). for _, p := range second { - // The second call's Detected should be re-evaluated based - // on the actual probes, not poisoned by the first mutation. - _ = p.Detected + if p.Detected != true { + t.Errorf("provider %q: Detected=%v after snapshot mutation, want true (snapshot isolation violated)", p.ID, p.Detected) + } } } diff --git a/internal/scoring/bm25_test.go b/internal/scoring/bm25_test.go new file mode 100644 index 00000000..fe88ce24 --- /dev/null +++ b/internal/scoring/bm25_test.go @@ -0,0 +1,218 @@ +package scoring + +import ( + "math" + "testing" +) + +// almostEqual compares floats with a small tolerance for BM25 arithmetic. +func almostEqual(a, b float64) bool { + return math.Abs(a-b) < 1e-9 +} + +func TestNewBM25Scorer_Defaults(t *testing.T) { + s := NewBM25Scorer(0, 0) + if s.K1 != DefaultBM25K1 { + t.Errorf("K1 = %v, want default %v", s.K1, DefaultBM25K1) + } + if s.B != DefaultBM25B { + t.Errorf("B = %v, want default %v", s.B, DefaultBM25B) + } +} + +func TestNewBM25Scorer_CustomValues(t *testing.T) { + s := NewBM25Scorer(2.0, 0.5) + if s.K1 != 2.0 || s.B != 0.5 { + t.Errorf("K1/B = %v/%v, want 2.0/0.5", s.K1, s.B) + } +} + +func TestBM25Score(t *testing.T) { + tests := []struct { + name string + scorer *BM25Scorer + query []string + tf map[string]int + docLen float64 + avgDocLen float64 + docCount int + docFreq map[string]int + want float64 + }{ + { + name: "single term, exact formula", + scorer: NewBM25Scorer(1.2, 0.75), + query: []string{"hello"}, + tf: map[string]int{"hello": 1}, + docLen: 10, + avgDocLen: 10, + docCount: 10, + docFreq: map[string]int{"hello": 2}, + // idf = ln((10-2+0.5)/(2+0.5) + 1) = ln(8.5/2.5+1) = ln(4.4) + // numerator = 1*(1.2+1) = 2.2 + // denominator = 1 + 1.2*(1-0.75+0.75*(10/10)) = 1 + 1.2*1 = 2.2 + // score = idf * 2.2/2.2 = idf = ln(4.4) + want: math.Log(4.4), + }, + { + name: "term not in doc contributes zero", + scorer: NewBM25Scorer(1.2, 0.75), + query: []string{"absent"}, + tf: map[string]int{"present": 3}, + docLen: 10, + avgDocLen: 10, + docCount: 5, + docFreq: map[string]int{"absent": 1}, + want: 0, + }, + { + name: "multi-term sums independent contributions", + scorer: NewBM25Scorer(1.2, 0.75), + query: []string{"a", "b"}, + tf: map[string]int{"a": 1, "b": 2}, + docLen: 5, + avgDocLen: 5, + docCount: 4, + docFreq: map[string]int{"a": 1, "b": 1}, + // idf = ln((4-1+0.5)/(1+0.5)+1) = ln(3.5/1.5+1) = ln(3.333); length factor = 1. + // term a: idf * (1*2.2)/(1 + 1.2*1) = idf * 2.2/2.2 = idf + // term b: idf * (2*2.2)/(2 + 1.2*1) = idf * 4.4/3.2 + want: math.Log(10.0/3.0) * (1 + 4.4/3.2), + }, + { + name: "higher tf yields higher score", + scorer: NewBM25Scorer(1.2, 0.75), + query: []string{"t"}, + tf: map[string]int{"t": 5}, + docLen: 10, + avgDocLen: 10, + docCount: 4, + docFreq: map[string]int{"t": 1}, + // idf = ln(10/3); length factor = 1. + // contribution = idf * (5*2.2)/(5 + 1.2*1) = idf * 11/6.2 + want: math.Log(10.0/3.0) * 11 / 6.2, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := tt.scorer.Score(tt.query, tt.tf, tt.docLen, tt.avgDocLen, tt.docCount, tt.docFreq) + if !almostEqual(got, tt.want) { + t.Errorf("Score() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestBM25Score_EdgeCases(t *testing.T) { + s := NewBM25Scorer(1.2, 0.75) + + // Zero/negative corpus stats must short-circuit to 0 (no div-by-zero). + zeroCases := []struct { + name string + docLen float64 + avgDocLen float64 + docCount int + }{ + {"avgDocLen zero", 10, 0, 5}, + {"avgDocLen negative", 10, -1, 5}, + {"docCount zero", 10, 10, 0}, + {"docCount negative", 10, 10, -1}, + {"docLen zero", 0, 10, 5}, + } + for _, tc := range zeroCases { + t.Run(tc.name, func(t *testing.T) { + if got := s.Score([]string{"t"}, map[string]int{"t": 1}, tc.docLen, tc.avgDocLen, tc.docCount, map[string]int{"t": 1}); got != 0 { + t.Errorf("Score() = %v, want 0", got) + } + }) + } + + // Empty query and empty tf both yield zero. + if got := s.Score(nil, map[string]int{"t": 1}, 10, 10, 5, map[string]int{"t": 1}); got != 0 { + t.Errorf("empty query Score() = %v, want 0", got) + } + if got := s.Score([]string{"t"}, nil, 10, 10, 5, map[string]int{"t": 1}); got != 0 { + t.Errorf("nil tf Score() = %v, want 0", got) + } +} + +func TestBM25Score_IDFMonotonicity(t *testing.T) { + // A rarer term (lower docFreq) must score higher than a common term. + s := NewBM25Scorer(1.2, 0.75) + rare := s.Score([]string{"rare"}, map[string]int{"rare": 1}, 10, 10, 100, map[string]int{"rare": 1}) + common := s.Score([]string{"common"}, map[string]int{"common": 1}, 10, 10, 100, map[string]int{"common": 99}) + if rare <= common { + t.Errorf("rare term score %v must exceed common term score %v", rare, common) + } +} + +func TestBM25Score_Saturation(t *testing.T) { + // BM25 saturates term-frequency contribution: doubling a high tf + // must not double the score (k1 dampens the growth). + s := NewBM25Scorer(1.2, 0.75) + base := s.Score([]string{"t"}, map[string]int{"t": 10}, 10, 10, 4, map[string]int{"t": 1}) + doubled := s.Score([]string{"t"}, map[string]int{"t": 20}, 10, 10, 4, map[string]int{"t": 1}) + if doubled >= 2*base { + t.Errorf("BM25 should saturate: doubled tf score %v >= 2*base %v", doubled, 2*base) + } +} + +func TestBM25ScoreWithIDF(t *testing.T) { + tests := []struct { + name string + query []string + tf map[string]int + idf map[string]float64 + docLen float64 + avgDocLen float64 + want float64 + }{ + { + name: "uses precomputed idf", + query: []string{"t"}, + tf: map[string]int{"t": 1}, + idf: map[string]float64{"t": 2.0}, + docLen: 10, + avgDocLen: 10, + want: 2.0, // idf * (1*(2.2))/(1+1.2*1) = idf * 1 + }, + { + name: "missing idf entry is skipped", + query: []string{"t"}, + tf: map[string]int{"t": 1}, + idf: map[string]float64{}, + docLen: 10, + avgDocLen: 10, + want: 0, + }, + { + name: "zero docLen short-circuits", + query: []string{"t"}, + tf: map[string]int{"t": 1}, + idf: map[string]float64{"t": 2.0}, + docLen: 0, + avgDocLen: 10, + want: 0, + }, + { + name: "zero avgDocLen short-circuits", + query: []string{"t"}, + tf: map[string]int{"t": 1}, + idf: map[string]float64{"t": 2.0}, + docLen: 10, + avgDocLen: 0, + want: 0, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + s := NewBM25Scorer(1.2, 0.75) + got := s.ScoreWithIDF(tt.query, tt.tf, tt.idf, tt.docLen, tt.avgDocLen) + if !almostEqual(got, tt.want) { + t.Errorf("ScoreWithIDF() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/internal/session/sqlite_store_test.go b/internal/session/sqlite_store_test.go index 17a2b8cb..73f7fb0f 100644 --- a/internal/session/sqlite_store_test.go +++ b/internal/session/sqlite_store_test.go @@ -1,5 +1,3 @@ -//go:build sqlite - package session import ( @@ -500,7 +498,7 @@ func TestGetSessionStats(t *testing.T) { } } -func TestConcurrentAccess(t *testing.T) { +func TestSQLiteStore_ConcurrentAccess(t *testing.T) { store := testStore(t) sess := &SessionRecord{ diff --git a/internal/storage/paths.go b/internal/storage/paths.go index d3629049..134748b6 100644 --- a/internal/storage/paths.go +++ b/internal/storage/paths.go @@ -120,7 +120,12 @@ func cleanEnvDir(key string) string { func mustUserConfigDir() string { dir, err := os.UserConfigDir() if err != nil || dir == "" { - panic("hawk storage: user config directory unavailable") + // Never crash the CLI at init because the environment is broken + // (e.g. unset HOME in a cron/daemon context). Fall back to a + // stable, writable location under the OS temp dir so the process + // still functions; the effective paths are also overridable via + // HAWK_CONFIG_DIR / HAWK_STATE_DIR / HAWK_CACHE_DIR. + return filepath.Join(os.TempDir(), "hawk-config") } return dir } @@ -128,7 +133,7 @@ func mustUserConfigDir() string { func mustUserCacheDir() string { dir, err := os.UserCacheDir() if err != nil || dir == "" { - panic("hawk storage: user cache directory unavailable") + return filepath.Join(os.TempDir(), "hawk-cache") } return dir } diff --git a/testdata/golden/help_root.txt b/testdata/golden/help_root.txt index 5ecbb65f..8ae8b8c4 100644 --- a/testdata/golden/help_root.txt +++ b/testdata/golden/help_root.txt @@ -1,5 +1,20 @@ hawk is an AI coding agent that reads, writes, and runs code in your terminal. +It connects to 27 first-class LLM providers through eyrie, executes tools (file I/O, shell, +git, web search), and manages sessions — all from a keyboard-driven TUI or +headless mode for scripts and CI. + +Quick orientation: + hawk Start interactive TUI + hawk -p "prompt" One-shot: send prompt, print response, exit + hawk exec "task" Autonomous multi-turn execution + hawk path Check environment readiness + hawk doctor Run diagnostics + hawk config Manage settings and credentials + +API keys are stored in the OS keychain (macOS Keychain / Linux keyring). +Run hawk and use /config to set up your first provider. + Usage: hawk [prompt] [flags] hawk [command] @@ -17,7 +32,9 @@ Available Commands: attach Attach to a running background session audit Analyze past sessions for wasteful patterns bg Run a session in the background + bug-report Print a redacted diagnostic report for bug reports checkpoint Save and restore named session checkpoints + cloud Manage optional Hawk Cloud synchronization completion Generate shell completion script config Show or update settings context Export project context as a single document for use in any LLM @@ -28,11 +45,17 @@ Available Commands: ecosystem Show eyrie, yaad, and tok integration status eval Evaluate model performance on coding benchmarks exec Execute a single command non-interactively + features List and manage feature flags feedback Submit feedback about hawk fingerprint Generate a repository fingerprint (languages, deps, git info) + governance Inspect and validate the governance policy ceiling + graph Inspect Hawk's portable execution graph + harness Audit workspace AI agent harness, work loop dimensions, and generation reports help Help about any command history Search and browse command history init Interactive onboarding wizard for first-time setup + learn Manage lessons learned across sessions + manpage Generate man page in roff format mcp Show MCP configuration; run or register hawk as an MCP server mission Run a multi-agent mission (parallel feature execution) models Deployment-aware model catalog (via eyrie) @@ -40,7 +63,7 @@ Available Commands: plan Create and manage structured development plans plugin Manage plugins pr AI-powered pull request workflow - preflight Check hawk is ready to chat (catalog, credentials, model) + preflight Check local readiness; use --live to verify the selected provider recover Scan for interrupted sessions and resume research Autonomous research loop (Karpathy autoresearch pattern) resume Restore a named session checkpoint and resume it @@ -49,6 +72,7 @@ Available Commands: sandbox View, apply, or discard pending diff sandbox changes schema Output JSON schema for hawk settings.json search Search across saved sessions + securitylog Inspect the tamper-evident security event log sessions List saved sessions setup Run first-time setup again skills Manage skills (list, search, install, remove, audit, info, trending) @@ -57,6 +81,9 @@ Available Commands: taste Manage taste profile (learned coding style preferences) tools List built-in tools trace Trace CLI + trust Manage folder trust for project automation + update Check for hawk updates + verify Run local self-verification (security log, governance policy) version Print hawk version Flags: @@ -68,7 +95,7 @@ Flags: --auto-skill auto-detect project and install matching skills -c, --continue continue the most recent conversation in the current directory --council consult multiple models and synthesize best answer - --dangerously-skip-permissions bypass all permission checks + --dangerously-skip-permissions skip normal permission prompts (hooks, spec gates, sandbox, and dry-run still apply) --disallowed-tools stringArray comma or space-separated tool permission rules to deny (e.g. "Bash(git:*) Edit") --dry-run deny every tool call unconditionally (preview only, nothing executes) --fork-session when resuming, create a new session ID instead of reusing the original @@ -81,11 +108,13 @@ Flags: -m, --model string model to use (from eyrie catalog; see /models) --no-auto-catalog-refresh disable automatic catalog refresh when cache is missing, empty, or stale --no-session-persistence disable session persistence in print mode + --output-fields string comma-separated field whitelist for --output-format json (e.g. "result,session_id") --output-format string output format for --print: "text", "json", or "stream-json" (default "text") --power int power level 1-10 (auto-configures model, context, review depth) (default 5) -p, --print print response and exit --prompt string send a single prompt and exit (legacy alias for --print) --provider string LLM provider (anthropic, openai, gemini, etc.) + -q, --quiet suppress non-essential output (spinners, progress, decoration); machine-parseable output only --recover scan for interrupted sessions and offer to resume --refresh-catalog refresh the eyrie model catalog before starting --repl start interactive REPL mode (like aider) for multi-turn conversation without TUI From 371e3f33c57b42a9191ecc20f3c75087898c23e2 Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Fri, 7 Aug 2026 22:52:25 +0530 Subject: [PATCH 02/14] fix(hardening): goroutine hygiene, observability, and test coverage Reliability: - Deregister the SIGHUP forwarder after first use so repeated runChat() invocations no longer leak signal handlers and goroutines. - Cancel any prior in-flight stream in startStream before overwriting the cancel func, preventing an orphaned stream goroutine. - Route async hook execution errors through slog instead of discarding them silently. - Log and requeue langfuse batches on transient flush failures (bounded at 2x flush size) instead of silently dropping telemetry. - Record the background TaskRunner.Run error and expose it via RunErr() so callers can observe a failed background run. Tests: - Add dependency-boundary tests for internal/token (forwards to tok). - Add tests for internal/home (Dir/Expand and env handling). - Add drift-detection tests for cmd/compat-test (pin freshness logic). Chore: - Refresh GitNexus symbol counts in AGENTS.md after re-index. --- AGENTS.md | 4 +- cmd/chat.go | 3 + cmd/chat_stream.go | 6 + cmd/compat-test/drift_test.go | 165 +++++++++++++++++++ internal/home/home_test.go | 95 +++++++++++ internal/hooks/hooks.go | 9 +- internal/observability/oteltrace/langfuse.go | 26 ++- internal/token/tok_test.go | 102 ++++++++++++ internal/tool/task_executor.go | 16 +- 9 files changed, 420 insertions(+), 6 deletions(-) create mode 100644 cmd/compat-test/drift_test.go create mode 100644 internal/home/home_test.go create mode 100644 internal/token/tok_test.go diff --git a/AGENTS.md b/AGENTS.md index da3b87e7..fd17bf3e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -199,9 +199,9 @@ with its native Responses API (`/v1/responses`) under the `concentrate-payg` deployment. -## GitNexus — Code Intelligence +# GitNexus — Code Intelligence -This project is indexed by GitNexus as **hawk** (88034 symbols, 273602 relationships, 300 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely. +This project is indexed by GitNexus as **hawk** (86470 symbols, 279855 relationships, 300 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely. > Index stale? Run `node .gitnexus/run.cjs analyze` from the project root — it auto-selects an available runner. No `.gitnexus/run.cjs` yet? `npx gitnexus analyze` (npm 11 crash → `npm i -g gitnexus`; #1939). diff --git a/cmd/chat.go b/cmd/chat.go index 53eb06de..d73c1367 100644 --- a/cmd/chat.go +++ b/cmd/chat.go @@ -698,11 +698,14 @@ func runChat() error { // Forward SIGHUP (terminal close, ssh drop, window manager exit) into the // TUI as a tea.QuitMsg so the session is saved and cleaned up instead of // dying silently mid-run. Bubble Tea only handles SIGINT and SIGTERM. + // The forwarder deregisters itself after the first SIGHUP so repeated + // runChat() invocations do not leak signal handlers or goroutines. { sigCh := make(chan os.Signal, 1) signal.Notify(sigCh, syscall.SIGHUP) go func() { <-sigCh + signal.Stop(sigCh) ref.Send(tea.QuitMsg{}) }() } diff --git a/cmd/chat_stream.go b/cmd/chat_stream.go index bba85e88..d46f209f 100644 --- a/cmd/chat_stream.go +++ b/cmd/chat_stream.go @@ -157,6 +157,12 @@ func (m *chatModel) startStream() { } m.streamCancelled = false m.syncSessionSelection() + // Cancel any prior in-flight stream first: overwriting m.cancel would + // leak the previous stream's goroutine and leave it running against an + // orphaned context. + if m.cancel != nil { + m.cancel() + } sess := m.session ref := m.ref ctx, cancel := context.WithCancel(context.Background()) diff --git a/cmd/compat-test/drift_test.go b/cmd/compat-test/drift_test.go new file mode 100644 index 00000000..d71e02a0 --- /dev/null +++ b/cmd/compat-test/drift_test.go @@ -0,0 +1,165 @@ +package main + +import ( + "bytes" + "io" + "os" + "path/filepath" + "strings" + "testing" +) + +// writeMod writes a minimal go.mod file for testing readRequires/checkDrift. +func writeMod(t *testing.T, path, content string) { + t.Helper() + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte(content), 0o600); err != nil { + t.Fatal(err) + } +} + +func TestReadRequires(t *testing.T) { + dir := t.TempDir() + modPath := filepath.Join(dir, "go.mod") + writeMod(t, modPath, `module example.com/test + +go 1.26 + +require ( + github.com/GrayCodeAI/hawk-core-contracts v1.0.0 + github.com/spf13/cobra v1.8.0 +) + +require github.com/GrayCodeAI/tok v1.9.0 // indirect +`) + + reqs, err := readRequires(modPath) + if err != nil { + t.Fatalf("readRequires: %v", err) + } + tests := map[string]string{ + "github.com/GrayCodeAI/hawk-core-contracts": "v1.0.0", + "github.com/spf13/cobra": "v1.8.0", + "github.com/GrayCodeAI/tok": "v1.9.0", + } + for mod, want := range tests { + if got := reqs[mod]; got != want { + t.Errorf("reqs[%q] = %q, want %q", mod, got, want) + } + } +} + +func TestReadRequires_MissingFile(t *testing.T) { + if _, err := readRequires(filepath.Join(t.TempDir(), "nope.mod")); err == nil { + t.Error("readRequires on missing file should error") + } +} + +func TestReadRequires_InvalidMod(t *testing.T) { + dir := t.TempDir() + bad := filepath.Join(dir, "go.mod") + if err := os.WriteFile(bad, []byte("not a go.mod {{{"), 0o600); err != nil { + t.Fatal(err) + } + if _, err := readRequires(bad); err == nil { + t.Error("readRequires on invalid go.mod should error") + } +} + +// TestCheckDrift reports drift when a consumer pins an older version than hawk. +func TestCheckDrift_DetectsDrift(t *testing.T) { + root := t.TempDir() + writeMod(t, filepath.Join(root, "go.mod"), `module github.com/GrayCodeAI/hawk + +go 1.26 + +require github.com/GrayCodeAI/hawk-core-contracts v1.5.0 +`) + // Consumer pins an older version of the shared contract. + writeMod(t, filepath.Join(root, "external", "inspect", "go.mod"), `module github.com/GrayCodeAI/inspect + +go 1.26 + +require github.com/GrayCodeAI/hawk-core-contracts v1.2.0 +`) + + var buf bytes.Buffer + old := os.Stdout + r, w, _ := os.Pipe() + os.Stdout = w + err := checkDrift(root) + _ = w.Close() + os.Stdout = old + _, _ = buf.ReadFrom(r) + + if err != nil { + t.Fatalf("checkDrift: %v", err) + } + if !strings.Contains(buf.String(), "inspect") { + t.Errorf("expected drift report to mention inspect consumer, got:\n%s", buf.String()) + } +} + +// TestCheckDrift_NoDriftWhenVersionsMatch verifies the happy path: matching +// pins produce the "OK" line and no per-consumer drift lines. +func TestCheckDrift_NoDriftWhenVersionsMatch(t *testing.T) { + root := t.TempDir() + writeMod(t, filepath.Join(root, "go.mod"), `module github.com/GrayCodeAI/hawk + +go 1.26 + +require github.com/GrayCodeAI/hawk-core-contracts v1.5.0 +`) + writeMod(t, filepath.Join(root, "external", "sight", "go.mod"), `module github.com/GrayCodeAI/sight + +go 1.26 + +require github.com/GrayCodeAI/hawk-core-contracts v1.5.0 +`) + + var buf bytes.Buffer + r, w, _ := os.Pipe() + old := os.Stdout + os.Stdout = w + err := checkDrift(root) + _ = w.Close() + os.Stdout = old + _, _ = buf.ReadFrom(r) + + if err != nil { + t.Fatalf("checkDrift: %v", err) + } + if !strings.Contains(buf.String(), "OK — no drift") { + t.Errorf("expected OK line for matching pins, got:\n%s", buf.String()) + } +} + +// TestCheckDrift_SkipsMissingSubmodules verifies that a consumer directory +// without a go.mod (e.g. not checked out) is skipped without failing. +func TestCheckDrift_SkipsMissingSubmodules(t *testing.T) { + root := t.TempDir() + writeMod(t, filepath.Join(root, "go.mod"), `module github.com/GrayCodeAI/hawk + +go 1.26 + +require github.com/GrayCodeAI/hawk-core-contracts v1.5.0 +`) + // Directory present but no go.mod — must be skipped silently. + if err := os.MkdirAll(filepath.Join(root, "external", "not-checked-out"), 0o755); err != nil { + t.Fatal(err) + } + + r, w, _ := os.Pipe() + old := os.Stdout + os.Stdout = w + err := checkDrift(root) + _ = w.Close() + os.Stdout = old + _, _ = io.Copy(io.Discard, r) + + if err != nil { + t.Fatalf("checkDrift with missing submodule go.mod: %v", err) + } +} diff --git a/internal/home/home_test.go b/internal/home/home_test.go new file mode 100644 index 00000000..c0d3e61c --- /dev/null +++ b/internal/home/home_test.go @@ -0,0 +1,95 @@ +package home + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestDir(t *testing.T) { + dir, err := Dir() + if err != nil { + t.Fatalf("Dir(): %v", err) + } + if dir == "" { + t.Fatal("Dir() returned empty path") + } + if !filepath.IsAbs(dir) { + t.Errorf("Dir() = %q, want absolute path", dir) + } +} + +func TestDir_RespectsEnv(t *testing.T) { + // os.UserHomeDir honors $HOME on Unix. + t.Setenv("HOME", t.TempDir()) + dir, err := Dir() + if err != nil { + t.Fatalf("Dir() with HOME set: %v", err) + } + if dir != os.Getenv("HOME") { + t.Errorf("Dir() = %q, want $HOME = %q", dir, os.Getenv("HOME")) + } +} + +func TestMustDir(t *testing.T) { + dir := MustDir() + if dir == "" { + t.Fatal("MustDir() returned empty path") + } +} + +func TestExpand(t *testing.T) { + homeDir, err := Dir() + if err != nil { + t.Fatal(err) + } + + tests := []struct { + name string + in string + want string + }{ + {"tilde alone", "~", homeDir}, + {"dollar home alone", "$HOME", homeDir}, + {"tilde slash prefix", "~/sub/dir", filepath.Join(homeDir, "sub", "dir")}, + {"dollar home slash prefix", "$HOME/sub/dir", filepath.Join(homeDir, "sub", "dir")}, + {"no prefix unchanged", "/abs/path", "/abs/path"}, + {"relative unchanged", "rel/path", "rel/path"}, + {"empty unchanged", "", ""}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := Expand(tt.in) + if err != nil { + t.Fatalf("Expand(%q): %v", tt.in, err) + } + if got != tt.want { + t.Errorf("Expand(%q) = %q, want %q", tt.in, got, tt.want) + } + }) + } +} + +func TestExpand_UsesHomeEnv(t *testing.T) { + custom := t.TempDir() + t.Setenv("HOME", custom) + got, err := Expand("~/x") + if err != nil { + t.Fatalf("Expand: %v", err) + } + want := filepath.Join(custom, "x") + if got != want { + t.Errorf("Expand(~/x) = %q, want %q", got, want) + } +} + +func TestMustExpand(t *testing.T) { + got := MustExpand("~/must-expand") + if !strings.HasPrefix(got, string(filepath.Separator)) && !filepath.IsAbs(got) { + t.Errorf("MustExpand(~/must-expand) = %q, want absolute path", got) + } + if !strings.HasSuffix(got, "must-expand") { + t.Errorf("MustExpand(~/must-expand) = %q, want suffix must-expand", got) + } +} diff --git a/internal/hooks/hooks.go b/internal/hooks/hooks.go index 2befeb37..b22e80af 100644 --- a/internal/hooks/hooks.go +++ b/internal/hooks/hooks.go @@ -3,6 +3,7 @@ package hooks import ( "context" "fmt" + "log/slog" "os" "os/exec" "path/filepath" @@ -137,7 +138,9 @@ func (r *Registry) ExecuteAsync(ctx context.Context, event EventType, data map[s r.asyncWG.Add(1) go func() { defer r.asyncWG.Done() - _ = r.Execute(ctx, event, data) + if err := r.Execute(ctx, event, data); err != nil { + slog.Warn("async hook execution failed", "event", event, "error", err) + } }() } @@ -150,7 +153,9 @@ func (r *Registry) ExecuteAsyncEnvelope(ctx context.Context, env EventEnvelope) r.asyncWG.Add(1) go func() { defer r.asyncWG.Done() - _ = r.ExecuteEnvelope(ctx, env) + if err := r.ExecuteEnvelope(ctx, env); err != nil { + slog.Warn("async hook execution failed", "event", env.EventType, "error", err) + } }() } diff --git a/internal/observability/oteltrace/langfuse.go b/internal/observability/oteltrace/langfuse.go index 7c464e5d..029196be 100644 --- a/internal/observability/oteltrace/langfuse.go +++ b/internal/observability/oteltrace/langfuse.go @@ -6,6 +6,7 @@ import ( "context" "encoding/json" "fmt" + "log/slog" "net/http" "os" "sync" @@ -68,7 +69,11 @@ func (c *LangfuseClient) Trace(ctx context.Context, ev TraceEvent) { c.mu.Unlock() if shouldFlush { - go func() { _ = c.Flush(ctx) }() + go func() { + if err := c.Flush(ctx); err != nil { + slog.Warn("langfuse flush failed", "error", err) + } + }() } } @@ -86,11 +91,13 @@ func (c *LangfuseClient) Flush(ctx context.Context) error { payload := map[string]interface{}{"batch": events} data, err := json.Marshal(payload) if err != nil { + c.requeue(events) return err } req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/api/public/ingestion", bytes.NewReader(data)) if err != nil { + c.requeue(events) return err } req.Header.Set("Content-Type", "application/json") @@ -98,11 +105,28 @@ func (c *LangfuseClient) Flush(ctx context.Context) error { resp, err := c.httpClient.Do(req) if err != nil { + c.requeue(events) return err } defer func() { _ = resp.Body.Close() }() if resp.StatusCode >= 400 { + c.requeue(events) return fmt.Errorf("langfuse: HTTP %d", resp.StatusCode) } return nil } + +// requeue puts events that failed to send back at the front of the batch so a +// transient failure does not silently drop telemetry. The batch is capped at +// flushSize*2 to avoid unbounded growth under a persistent outage. +func (c *LangfuseClient) requeue(events []event) { + if len(events) == 0 { + return + } + c.mu.Lock() + defer c.mu.Unlock() + if len(c.batch)+len(events) > c.flushSize*2 { + return // drop rather than grow without bound; the failure was already logged + } + c.batch = append(events, c.batch...) +} diff --git a/internal/token/tok_test.go b/internal/token/tok_test.go new file mode 100644 index 00000000..3df92cbe --- /dev/null +++ b/internal/token/tok_test.go @@ -0,0 +1,102 @@ +package token + +import ( + "strings" + "testing" + + tok "github.com/GrayCodeAI/tok" + tokgraph "github.com/GrayCodeAI/tok/runtimegraph" +) + +// These tests guard the dependency boundary: they verify hawk's token package +// forwards to the external tok library correctly so wiring regressions (wrong +// arg order, dropped params) surface in CI without needing the library's own +// test suite. + +func TestCountTokens(t *testing.T) { + // Precise counting must produce a positive count for non-empty text and + // zero for empty input, and should roughly track text length. + empty := CountTokens("") + if empty != 0 { + t.Errorf("CountTokens(\"\") = %d, want 0", empty) + } + + short := CountTokens("hello world") + long := CountTokens(strings.Repeat("the quick brown fox jumps over the lazy dog. ", 20)) + if short <= 0 { + t.Errorf("CountTokens(short) = %d, want > 0", short) + } + if long <= short { + t.Errorf("CountTokens(long) = %d must exceed CountTokens(short) = %d", long, short) + } +} + +func TestCountTokensFast(t *testing.T) { + n := CountTokensFast(strings.Repeat("word ", 100)) + if n <= 0 { + t.Errorf("CountTokensFast = %d, want > 0", n) + } +} + +func TestCompress(t *testing.T) { + text := strings.Repeat("the quick brown fox jumps over the lazy dog. ", 10) + + // A budget larger than the input must return the input unchanged. + big, stats := Compress(text, len(text)*4) + if big == "" { + t.Error("Compress with a large budget returned empty text") + } + if stats.OriginalTokens <= 0 { + t.Errorf("Compress stats.OriginalTokens = %d, want > 0", stats.OriginalTokens) + } + + // A tiny budget must produce a strictly smaller (or empty) result. + tiny, _ := Compress(text, 1) + if len(tiny) >= len(text) { + t.Errorf("Compress with budget=1 did not reduce text: %d -> %d chars", len(text), len(tiny)) + } +} + +func TestNewUsageTracker(t *testing.T) { + ut := NewUsageTracker() + if ut == nil { + t.Fatal("NewUsageTracker returned nil") + } +} + +func TestChunkCode(t *testing.T) { + source := `package main + +func main() { + println("hello") +} +` + chunks := ChunkCode(source, ChunkOptions{}) + if len(chunks) == 0 { + t.Fatal("ChunkCode returned no chunks for non-empty source") + } +} + +func TestDefaultSecretDetector(t *testing.T) { + det := DefaultSecretDetector() + if det == nil { + t.Fatal("DefaultSecretDetector returned nil") + } +} + +func TestBuildRuntimeGraph(t *testing.T) { + // A minimal graph input (one usage summary) should build without error. + usage := tokgraph.Input{Usage: &tok.UsageSummary{}} + out, err := BuildRuntimeGraph(usage) + if err != nil { + t.Fatalf("BuildRuntimeGraph: %v", err) + } + if out == nil { + t.Fatal("BuildRuntimeGraph returned nil export") + } + + // An input with no summaries must be rejected. + if _, err := BuildRuntimeGraph(tokgraph.Input{}); err == nil { + t.Error("BuildRuntimeGraph with no summaries should error") + } +} diff --git a/internal/tool/task_executor.go b/internal/tool/task_executor.go index 55ab5a0f..92d24203 100644 --- a/internal/tool/task_executor.go +++ b/internal/tool/task_executor.go @@ -90,6 +90,7 @@ type TaskRunner struct { finished bool cancel context.CancelFunc doneCh chan struct{} + runErr error replansByTask map[string]int @@ -421,10 +422,23 @@ func (r *TaskRunner) Start(ctx context.Context) { r.mu.Unlock() close(r.doneCh) }() - _ = r.Run(runCtx) + err := r.Run(runCtx) + if err != nil { + r.mu.Lock() + r.runErr = err + r.mu.Unlock() + } }() } +// RunErr returns the error from the background Run invocation, or nil if the +// runner has not finished or Run completed successfully. +func (r *TaskRunner) RunErr() error { + r.mu.Lock() + defer r.mu.Unlock() + return r.runErr +} + // Stop cancels a background run. Idempotent; safe when never started. func (r *TaskRunner) Stop() { r.mu.Lock() From 1f38523285170dedf9912799a4d0c6b504036d10 Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Fri, 7 Aug 2026 23:05:10 +0530 Subject: [PATCH 03/14] test(hardening): daemon middleware and review engine coverage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Daemon middleware: - Test clientIP, generateRequestID, isCORSSettingAllowed, and stripSlackMention pure helpers. - Test request-ID propagation (generated and client-supplied), security header emission (incl. HSTS gating and Cache-Control on POST), CORS preflight and disallowed-origin behavior, responseWriter status capture, and the full installed middleware stack over HTTP. Review engine: - Add ListAll (incl. limit), Get-on-missing, empty-diff→passed lifecycle, WAL-checkpoint Close, and splitReviewStatements tests. - Add printReviewSummary (no-findings and with-findings) and silentErr (background suppresses, foreground wraps) tests. --- cmd/review_run_test.go | 92 +++++++++++ cmd/review_test.go | 125 ++++++++++++++ internal/daemon/middleware_test.go | 253 +++++++++++++++++++++++++++++ 3 files changed, 470 insertions(+) create mode 100644 cmd/review_run_test.go create mode 100644 internal/daemon/middleware_test.go diff --git a/cmd/review_run_test.go b/cmd/review_run_test.go new file mode 100644 index 00000000..ddeed473 --- /dev/null +++ b/cmd/review_run_test.go @@ -0,0 +1,92 @@ +package cmd + +import ( + "bytes" + "errors" + "os" + "strings" + "testing" + + reviewcontracts "github.com/GrayCodeAI/hawk-core-contracts/review" + contracts "github.com/GrayCodeAI/hawk-core-contracts/types" +) + +// captureStdout runs fn with stdout redirected to a pipe and returns what was +// written. +func captureStdout(t *testing.T, fn func()) string { + t.Helper() + old := os.Stdout + r, w, err := os.Pipe() + if err != nil { + t.Fatal(err) + } + os.Stdout = w + defer func() { os.Stdout = old }() + + fn() + _ = w.Close() + var buf bytes.Buffer + _, _ = buf.ReadFrom(r) + return buf.String() +} + +func TestPrintReviewSummary_NoFindings(t *testing.T) { + out := captureStdout(t, func() { + printReviewSummary("0123456789abcdef", &reviewcontracts.Result{ + Stats: reviewcontracts.Stats{FilesReviewed: 3}, + }) + }) + if !strings.Contains(out, "no issues found") { + t.Errorf("expected 'no issues found' summary, got: %q", out) + } + if !strings.Contains(out, "01234567") { + t.Errorf("expected short SHA in summary, got: %q", out) + } +} + +func TestPrintReviewSummary_WithFindings(t *testing.T) { + out := captureStdout(t, func() { + printReviewSummary("0123456789abcdef", &reviewcontracts.Result{ + Findings: []reviewcontracts.Finding{ + {Severity: contracts.SeverityHigh, File: "main.go", Line: 10, Message: "SQL injection"}, + {Severity: contracts.SeverityLow, File: "config.go", Line: 2, Message: "naming"}, + }, + }) + }) + if !strings.Contains(out, "2 findings") { + t.Errorf("expected '2 findings' in summary, got: %q", out) + } + if !strings.Contains(out, "main.go:10") { + t.Errorf("expected file:line detail, got: %q", out) + } + if !strings.Contains(out, "high") { + t.Errorf("expected max severity in summary, got: %q", out) + } +} + +func TestSilentErr_BackgroundSuppresses(t *testing.T) { + old := reviewRunBackground + reviewRunBackground = true + defer func() { reviewRunBackground = old }() + + if err := silentErr(errors.New("boom"), "ctx"); err != nil { + t.Errorf("silentErr in background mode = %v, want nil", err) + } +} + +func TestSilentErr_ForegroundWraps(t *testing.T) { + old := reviewRunBackground + reviewRunBackground = false + defer func() { reviewRunBackground = old }() + + err := silentErr(errors.New("boom"), "context label") + if err == nil { + t.Fatal("silentErr in foreground mode returned nil") + } + if !strings.Contains(err.Error(), "context label") { + t.Errorf("error should include context label, got: %v", err) + } + if !strings.Contains(err.Error(), "boom") { + t.Errorf("error should preserve underlying message, got: %v", err) + } +} diff --git a/cmd/review_test.go b/cmd/review_test.go index cf250521..40ced846 100644 --- a/cmd/review_test.go +++ b/cmd/review_test.go @@ -189,6 +189,131 @@ func TestReviewStore_SetStatus(t *testing.T) { } } +func TestReviewStore_ListAll(t *testing.T) { + dir := setReviewTestDirs(t) + os.MkdirAll(filepath.Join(dir, ".hawk"), 0o755) + + store, err := OpenReviewStore(dir) + if err != nil { + t.Fatalf("open store: %v", err) + } + defer store.Close() + + store.Create("sha1") + store.Create("sha2") + store.Create("sha3") + + all, err := store.ListAll(10) + if err != nil { + t.Fatalf("list all: %v", err) + } + if len(all) != 3 { + t.Errorf("expected 3 reviews, got %d", len(all)) + } + + limited, err := store.ListAll(2) + if err != nil { + t.Fatalf("list all limited: %v", err) + } + if len(limited) != 2 { + t.Errorf("expected 2 reviews with limit=2, got %d", len(limited)) + } +} + +func TestReviewStore_GetMissing(t *testing.T) { + dir := setReviewTestDirs(t) + os.MkdirAll(filepath.Join(dir, ".hawk"), 0o755) + + store, err := OpenReviewStore(dir) + if err != nil { + t.Fatalf("open store: %v", err) + } + defer store.Close() + + // A nonexistent ID must surface as a DB error, not a nil record. + if r, err := store.Get(99999); err == nil || r != nil { + t.Errorf("Get(99999) = (%v, %v), want (nil, error)", r, err) + } +} + +func TestReviewStore_EmptyDiffToPassedLifecycle(t *testing.T) { + // Mirrors runReviewRun's empty-diff path: create → set running → set passed. + dir := setReviewTestDirs(t) + os.MkdirAll(filepath.Join(dir, ".hawk"), 0o755) + + store, err := OpenReviewStore(dir) + if err != nil { + t.Fatalf("open store: %v", err) + } + defer store.Close() + + id, err := store.Create("sha-empty") + if err != nil { + t.Fatalf("create: %v", err) + } + if err := store.SetStatus(id, ReviewStatusRunning); err != nil { + t.Fatalf("set running: %v", err) + } + if err := store.SetStatus(id, ReviewStatusPassed); err != nil { + t.Fatalf("set passed: %v", err) + } + + r, err := store.Get(id) + if err != nil { + t.Fatalf("get: %v", err) + } + if r.Status != ReviewStatusPassed { + t.Errorf("status = %s, want passed", r.Status) + } + + // A passed review is no longer listed as open. + open, err := store.ListOpen() + if err != nil { + t.Fatalf("list open: %v", err) + } + if len(open) != 0 { + t.Errorf("expected no open reviews, got %d", len(open)) + } +} + +func TestReviewStore_CloseCheckpointsWAL(t *testing.T) { + dir := setReviewTestDirs(t) + os.MkdirAll(filepath.Join(dir, ".hawk"), 0o755) + + store, err := OpenReviewStore(dir) + if err != nil { + t.Fatalf("open store: %v", err) + } + if _, err := store.Create("sha-wal"); err != nil { + t.Fatalf("create: %v", err) + } + if err := store.Close(); err != nil { + t.Fatalf("close: %v", err) + } + + // After close with WAL checkpoint(TRUNCATE), no .db-wal/.db-shm linger. + dbDir := storage.ProjectStateDir(dir) + entries, err := os.ReadDir(dbDir) + if err != nil { + t.Fatalf("read db dir: %v", err) + } + for _, e := range entries { + if strings.HasSuffix(e.Name(), ".db-wal") || strings.HasSuffix(e.Name(), ".db-shm") { + t.Errorf("WAL/shm file left behind after Close: %s", e.Name()) + } + } +} + +func TestSplitReviewStatements(t *testing.T) { + got := splitReviewStatements("CREATE TABLE a (x INT);\nCREATE TABLE b (y INT);") + if len(got) != 3 { // two statements + trailing empty + t.Errorf("expected 3 pieces, got %d: %q", len(got), got) + } + if strings.TrimSpace(got[0]) != "CREATE TABLE a (x INT)" { + t.Errorf("first statement = %q", got[0]) + } +} + func TestBuildFixPrompt(t *testing.T) { r := &ReviewRecord{ SHA: "abc12345deadbeef0000000000000000000000ff", diff --git a/internal/daemon/middleware_test.go b/internal/daemon/middleware_test.go new file mode 100644 index 00000000..4b5e5e68 --- /dev/null +++ b/internal/daemon/middleware_test.go @@ -0,0 +1,253 @@ +package daemon + +import ( + "context" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/GrayCodeAI/hawk/internal/feature" + "github.com/GrayCodeAI/hawk/internal/testutil" +) + +// --- Pure helper unit tests --- + +func TestClientIP(t *testing.T) { + tests := []struct { + name string + remote string + want string + }{ + {"ipv4 with port", "127.0.0.1:4590", "127.0.0.1"}, + {"ipv6 with port", "[::1]:4590", "::1"}, + {"no port", "10.0.0.1", "10.0.0.1"}, + {"empty remote", "", "unknown"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/v1/health", nil) + req.RemoteAddr = tt.remote + if got := clientIP(req); got != tt.want { + t.Errorf("clientIP(%q) = %q, want %q", tt.remote, got, tt.want) + } + }) + } +} + +func TestStripSlackMention(t *testing.T) { + tests := []struct { + in string + want string + }{ + {"<@U12345> hello there", "hello there"}, + {"<@U12345>", ""}, + {"no mention here", "no mention here"}, + {" <@U1> spaced ", "spaced"}, + {"", ""}, + } + for _, tt := range tests { + if got := stripSlackMention(tt.in); got != tt.want { + t.Errorf("stripSlackMention(%q) = %q, want %q", tt.in, got, tt.want) + } + } +} + +func TestGenerateRequestID(t *testing.T) { + // IDs are 32 hex chars (16 random bytes), and two consecutive calls differ. + first := generateRequestID() + second := generateRequestID() + if len(first) != 32 { + t.Errorf("generateRequestID() length = %d, want 32", len(first)) + } + for _, r := range first { + if !(r >= '0' && r <= '9' || r >= 'a' && r <= 'f') { + t.Fatalf("generateRequestID() = %q, want hex only", first) + } + } + if first == second { + t.Error("generateRequestID() returned identical values") + } +} + +func TestIsCORSSettingAllowed(t *testing.T) { + s := &Server{corsOrigins: []string{"http://example.com", "http://other.com"}} + for _, origin := range []string{"http://example.com", "http://other.com"} { + if !s.isCORSSettingAllowed(origin) { + t.Errorf("isCORSSettingAllowed(%q) = false, want true", origin) + } + } + for _, origin := range []string{"http://evil.com", ""} { + if s.isCORSSettingAllowed(origin) { + t.Errorf("isCORSSettingAllowed(%q) = true, want false", origin) + } + } + + wildcard := &Server{corsOrigins: []string{"*"}} + if !wildcard.isCORSSettingAllowed("http://anything.com") { + t.Error("wildcard origin should allow any origin") + } +} + +// --- Middleware stack behavior --- + +func TestRequestIDMiddleware_SetsAndPropagates(t *testing.T) { + var gotID string + handler := (&Server{}).requestIDMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotID = RequestIDFromContext(r.Context()) + w.WriteHeader(http.StatusOK) + })) + + // No incoming ID: middleware generates one and it appears in the response + // header and the context. + req := httptest.NewRequest(http.MethodGet, "/x", nil) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + if gotID == "" { + t.Fatal("request ID not propagated to context") + } + if rec.Header().Get("X-Request-ID") != gotID { + t.Errorf("response header X-Request-ID = %q, context ID = %q", rec.Header().Get("X-Request-ID"), gotID) + } + + // Incoming ID is preserved rather than replaced. + req2 := httptest.NewRequest(http.MethodGet, "/x", nil) + req2.Header.Set("X-Request-ID", "client-supplied-id") + handler.ServeHTTP(httptest.NewRecorder(), req2) + if gotID != "client-supplied-id" { + t.Errorf("context ID = %q, want preserved client-supplied-id", gotID) + } +} + +func TestRequestIDFromContext_Empty(t *testing.T) { + if got := RequestIDFromContext(context.Background()); got != "" { + t.Errorf("RequestIDFromContext(empty ctx) = %q, want empty", got) + } +} + +func TestSecurityHeadersMiddleware_SetsHeaders(t *testing.T) { + handler := (&Server{}).securityHeadersMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + })) + + req := httptest.NewRequest(http.MethodPost, "/v1/chat", nil) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + for _, h := range []string{"X-Content-Type-Options", "X-Frame-Options", "Referrer-Policy", "X-XSS-Protection", "Content-Security-Policy"} { + if rec.Header().Get(h) == "" { + t.Errorf("security header %q not set", h) + } + } + if rec.Header().Get("X-Content-Type-Options") != "nosniff" { + t.Errorf("X-Content-Type-Options = %q, want nosniff", rec.Header().Get("X-Content-Type-Options")) + } + // POST requests must not be cached. + if rec.Header().Get("Cache-Control") != "no-store" { + t.Errorf("Cache-Control = %q, want no-store for POST", rec.Header().Get("Cache-Control")) + } +} + +func TestSecurityHeadersMiddleware_HSTSOnlyOverTLS(t *testing.T) { + handler := (&Server{}).securityHeadersMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + })) + + // Plain HTTP: no HSTS. + req := httptest.NewRequest(http.MethodGet, "/v1/health", nil) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + if rec.Header().Get("Strict-Transport-Security") != "" { + t.Errorf("HSTS set on plain HTTP: %q", rec.Header().Get("Strict-Transport-Security")) + } + + // X-Forwarded-Proto: https: HSTS set. + req2 := httptest.NewRequest(http.MethodGet, "/v1/health", nil) + req2.Header.Set("X-Forwarded-Proto", "https") + rec2 := httptest.NewRecorder() + handler.ServeHTTP(rec2, req2) + if rec2.Header().Get("Strict-Transport-Security") == "" { + t.Error("HSTS not set when X-Forwarded-Proto is https") + } +} + +func TestCORSMiddleware_Preflight(t *testing.T) { + feature.Set("cors", true) + defer feature.Set("cors", false) + s := &Server{corsOrigins: []string{"http://example.com"}} + + handler := s.corsMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + })) + + req := httptest.NewRequest(http.MethodOptions, "/v1/chat", nil) + req.Header.Set("Origin", "http://example.com") + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Errorf("preflight status = %d, want 200", rec.Code) + } + if rec.Header().Get("Access-Control-Allow-Origin") != "http://example.com" { + t.Errorf("preflight Allow-Origin = %q", rec.Header().Get("Access-Control-Allow-Origin")) + } + if rec.Header().Get("Access-Control-Allow-Methods") == "" { + t.Error("preflight Allow-Methods not set") + } +} + +func TestCORSMiddleware_DisallowedOriginGetsNoHeaders(t *testing.T) { + feature.Set("cors", true) + defer feature.Set("cors", false) + s := &Server{corsOrigins: []string{"http://example.com"}} + + handler := s.corsMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + })) + + req := httptest.NewRequest(http.MethodGet, "/v1/health", nil) + req.Header.Set("Origin", "http://evil.com") + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + if rec.Header().Get("Access-Control-Allow-Origin") != "" { + t.Errorf("disallowed origin got Allow-Origin %q", rec.Header().Get("Access-Control-Allow-Origin")) + } +} + +func TestResponseWriter_CapturesStatus(t *testing.T) { + rw := &responseWriter{ResponseWriter: httptest.NewRecorder(), status: http.StatusOK} + rw.WriteHeader(http.StatusTeapot) + if rw.status != http.StatusTeapot { + t.Errorf("responseWriter.status = %d, want 418", rw.status) + } +} + +// TestFullMiddlewareStack_RequestIDAndHeaders exercises the installed stack +// (request ID + security headers + logging) through an in-process server. +func TestFullMiddlewareStack_RequestIDAndHeaders(t *testing.T) { + srv := New(Config{Port: 0, Host: testutil.LoopbackHost}, nil) + addr := startTestDaemon(t, srv) + defer srv.Stop(context.Background()) + + req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, "http://"+addr+"/v1/health", nil) + if err != nil { + t.Fatal(err) + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + + if resp.Header.Get("X-Request-ID") == "" { + t.Error("full stack did not set X-Request-ID") + } + if resp.Header.Get("X-Content-Type-Options") != "nosniff" { + t.Errorf("full stack X-Content-Type-Options = %q, want nosniff", resp.Header.Get("X-Content-Type-Options")) + } + if !strings.Contains(resp.Header.Get("Content-Type"), "application/json") { + t.Errorf("full stack Content-Type = %q", resp.Header.Get("Content-Type")) + } +} From 00ada64780ba92423d346be0f0d0638044760ae2 Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Fri, 7 Aug 2026 23:28:41 +0530 Subject: [PATCH 04/14] fix(hardening): surface write errors and unify diagnostics logging - Return tabwriter and json.MarshalIndent errors in `hawk trust list` and `hawk plugin status` instead of discarding them. - Propagate the review-schema version scan error during migration so a corrupt store surfaces loudly instead of silently re-migrating. - Log auto-init failures via slog instead of swallowing them. - Route codegraph sync warnings through slog so they survive TUI mode (where the stdlib logger is discarded). - Convert WAL batch flush and settings-parse warnings from hand-rolled stderr prints to slog for consistent structured diagnostics. Tests: - Add internal/startup phase timing tests (mark/end, noop end, latest- open-close semantics, Reset, copy-on-read, TotalTime). --- cmd/autoinit.go | 7 ++- cmd/chat.go | 3 +- cmd/plugin_dynamic.go | 19 +++++-- cmd/review_store.go | 4 +- cmd/trust.go | 12 +++-- internal/config/settings.go | 3 +- internal/session/wal_batch.go | 5 +- internal/startup/startup_test.go | 93 ++++++++++++++++++++++++++++++++ 8 files changed, 130 insertions(+), 16 deletions(-) create mode 100644 internal/startup/startup_test.go diff --git a/cmd/autoinit.go b/cmd/autoinit.go index bb4dbc34..ddff08eb 100644 --- a/cmd/autoinit.go +++ b/cmd/autoinit.go @@ -3,6 +3,7 @@ package cmd import ( "context" "fmt" + "log/slog" "os" "path/filepath" "time" @@ -37,10 +38,12 @@ func maybeAutoInit(ctx context.Context) { } go func() { // MaybeRun handles all gating (disable env, marker, existing context). - _, _ = autoinit.MaybeRun(ctx, autoinit.Options{ + if _, err := autoinit.MaybeRun(ctx, autoinit.Options{ Root: root, Run: autoInitRunner, - }) + }); err != nil { + slog.Warn("auto-init failed", "root", root, "error", err) + } }() } diff --git a/cmd/chat.go b/cmd/chat.go index d73c1367..636fc58b 100644 --- a/cmd/chat.go +++ b/cmd/chat.go @@ -6,6 +6,7 @@ import ( "fmt" "io" "log" + "log/slog" "math/rand" "os" "os/signal" @@ -600,7 +601,7 @@ func autoIndexCodegraph() { // Incremental sync — only processes changed files if _, err := cg.Sync(); err != nil { - log.Printf("codegraph sync: %v", err) + slog.Warn("codegraph sync failed", "error", err) } } diff --git a/cmd/plugin_dynamic.go b/cmd/plugin_dynamic.go index 566d62c7..b2312973 100644 --- a/cmd/plugin_dynamic.go +++ b/cmd/plugin_dynamic.go @@ -81,18 +81,27 @@ var pluginStatusCmd = &cobra.Command{ jsonOut, _ := cmd.Flags().GetBool("json") if jsonOut { - data, _ := json.MarshalIndent(statuses, "", " ") + data, err := json.MarshalIndent(statuses, "", " ") + if err != nil { + return fmt.Errorf("marshaling plugin statuses: %w", err) + } cmd.Println(string(data)) return nil } w := tabwriter.NewWriter(cmd.OutOrStdout(), 0, 0, 2, ' ', 0) - _, _ = fmt.Fprintf(w, "NAME\tVERSION\tSTATE\tTOOLS\tHOOKS\n") + if _, err := fmt.Fprintf(w, "NAME\tVERSION\tSTATE\tTOOLS\tHOOKS\n"); err != nil { + return err + } for _, s := range statuses { - _, _ = fmt.Fprintf(w, "%s\t%s\t%s\t%d\t%d\n", - s.Name, s.Version, s.State, s.ToolCount, s.HookCount) + if _, err := fmt.Fprintf(w, "%s\t%s\t%s\t%d\t%d\n", + s.Name, s.Version, s.State, s.ToolCount, s.HookCount); err != nil { + return err + } + } + if err := w.Flush(); err != nil { + return err } - _ = w.Flush() return nil }, diff --git a/cmd/review_store.go b/cmd/review_store.go index 6aa5d758..85d875db 100644 --- a/cmd/review_store.go +++ b/cmd/review_store.go @@ -99,7 +99,9 @@ func (s *ReviewStore) migrate() error { return err } var current int - _ = s.db.QueryRow("SELECT COALESCE(MAX(version), 0) FROM review_schema_version").Scan(¤t) + if err := s.db.QueryRow("SELECT COALESCE(MAX(version), 0) FROM review_schema_version").Scan(¤t); err != nil { + return fmt.Errorf("read review schema version: %w", err) + } migrations := []string{reviewSchema} for i := current; i < len(migrations); i++ { diff --git a/cmd/trust.go b/cmd/trust.go index 4679b664..9eab1fb4 100644 --- a/cmd/trust.go +++ b/cmd/trust.go @@ -107,11 +107,17 @@ var trustListCmd = &cobra.Command{ return nil } w := tabwriter.NewWriter(cmd.OutOrStdout(), 0, 0, 2, ' ', 0) - _, _ = fmt.Fprintln(w, "PATH\tTRUSTED_AT\tREASON") + if _, err := fmt.Fprintln(w, "PATH\tTRUSTED_AT\tREASON"); err != nil { + return err + } for _, e := range entries { - _, _ = fmt.Fprintf(w, "%s\t%s\t%s\n", e.Path, e.TrustedAt.Format("2006-01-02 15:04"), e.Reason) + if _, err := fmt.Fprintf(w, "%s\t%s\t%s\n", e.Path, e.TrustedAt.Format("2006-01-02 15:04"), e.Reason); err != nil { + return err + } + } + if err := w.Flush(); err != nil { + return err } - _ = w.Flush() return nil }, } diff --git a/internal/config/settings.go b/internal/config/settings.go index fd16105f..91ee0147 100644 --- a/internal/config/settings.go +++ b/internal/config/settings.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "fmt" + "log/slog" "os" "path/filepath" "sort" @@ -215,7 +216,7 @@ func LoadGlobalSettings() Settings { path := globalSettingsPath() if data, err := readSettingsFileCached(path); err == nil { if err := json.Unmarshal(data, &s); err != nil { - fmt.Fprintf(os.Stderr, "hawk: warning: failed to parse %s: %v\n", path, err) + slog.Warn("failed to parse settings", "path", path, "error", err) } } if s.PolicySchemaVersion == 0 { diff --git a/internal/session/wal_batch.go b/internal/session/wal_batch.go index 092596b9..ea1198eb 100644 --- a/internal/session/wal_batch.go +++ b/internal/session/wal_batch.go @@ -2,8 +2,7 @@ package session import ( "encoding/json" - "fmt" - "os" + "log/slog" "sync" "time" ) @@ -97,7 +96,7 @@ func (b *BatchedWAL) ensureTimerLocked() { b.mu.Lock() defer b.mu.Unlock() if err := b.flushLocked(); err != nil { - fmt.Fprintf(os.Stderr, "WARNING: WAL batch flush failed: %v\n", err) + slog.Warn("WAL batch flush failed", "error", err) } }) } diff --git a/internal/startup/startup_test.go b/internal/startup/startup_test.go new file mode 100644 index 00000000..6a37330b --- /dev/null +++ b/internal/startup/startup_test.go @@ -0,0 +1,93 @@ +package startup + +import ( + "testing" +) + +func TestMarkAndEndPhase(t *testing.T) { + Reset() + + MarkPhase("init") + EndPhase("init") + + phases := GetPhases() + if len(phases) != 1 { + t.Fatalf("expected 1 phase, got %d", len(phases)) + } + p := phases[0] + if p.Name != "init" { + t.Errorf("phase name = %q, want init", p.Name) + } + if p.EndTime.IsZero() { + t.Error("phase EndTime should be set after EndPhase") + } + if p.Duration <= 0 { + t.Errorf("phase Duration = %v, want > 0", p.Duration) + } +} + +func TestEndPhase_UnmatchedIsNoop(t *testing.T) { + Reset() + MarkPhase("a") + // Ending a phase that was never marked must not panic or corrupt state. + EndPhase("nonexistent") + EndPhase("a") + + if got := len(GetPhases()); got != 1 { + t.Errorf("expected 1 phase, got %d", got) + } +} + +func TestEndPhase_OnlyClosesLatestOpenMatch(t *testing.T) { + Reset() + MarkPhase("a") + MarkPhase("b") + MarkPhase("a") // reopen + + EndPhase("a") // closes the latest open "a" + + phases := GetPhases() + if len(phases) != 3 { + t.Fatalf("expected 3 phases, got %d", len(phases)) + } + if phases[2].EndTime.IsZero() { + t.Error("latest 'a' phase should be closed") + } + // The first "a" (index 0) must remain open. + if !phases[0].EndTime.IsZero() { + t.Error("first 'a' phase should remain open") + } +} + +func TestReset(t *testing.T) { + Reset() + MarkPhase("x") + EndPhase("x") + if len(GetPhases()) != 1 { + t.Fatal("setup failed") + } + + Reset() + if got := GetPhases(); len(got) != 0 { + t.Errorf("after Reset, expected 0 phases, got %d", len(got)) + } +} + +func TestGetPhases_ReturnsCopy(t *testing.T) { + Reset() + MarkPhase("a") + phases := GetPhases() + // Mutating the returned slice must not affect internal state. + phases[0].Name = "mutated" + if got := GetPhases()[0].Name; got != "a" { + t.Errorf("GetPhases should return a copy, got %q", got) + } +} + +func TestTotalTime(t *testing.T) { + Reset() + total := TotalTime() + if total < 0 { + t.Errorf("TotalTime() = %v, want >= 0", total) + } +} From 507fe7d49b8d0cda6dbdb180ddc184889f6f8038 Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Sat, 8 Aug 2026 00:39:05 +0530 Subject: [PATCH 05/14] fix(hardening): close netproxy SSRF edge and harden plugin installs Security: - Netproxy handleHTTP now enforces policy on r.URL.Host (the actual dial target) instead of only the Host header. An absolute-form request URI whose URL host differs from a benign Host header can no longer bypass the allow/deny and private-network rules. Stats/logs also record the enforced target. - Marketplace installs reject scp-style (git@host:...) repo URLs, which bypass the HTTPS transport and cannot be pinned or verified. - Marketplace installs now fail closed when the cloned plugin has no plugin.json manifest, instead of only scanning when the file happens to exist. Tests: - Regression test proving an absolute-URI target pointing at a blocked domain is refused even when the Host header names an allowed domain. - Test that scp-style marketplace repo URLs are rejected. --- internal/plugin/marketplace.go | 22 +++++++++----- internal/plugin/marketplace_test.go | 16 ++++++++++ internal/sandbox/netproxy.go | 14 +++++++-- internal/sandbox/netproxy_test.go | 46 +++++++++++++++++++++++++++++ 4 files changed, 89 insertions(+), 9 deletions(-) diff --git a/internal/plugin/marketplace.go b/internal/plugin/marketplace.go index e5cc8b69..6edc8f60 100644 --- a/internal/plugin/marketplace.go +++ b/internal/plugin/marketplace.go @@ -220,7 +220,12 @@ func (mc *MarketplaceClient) Install(entry MarketplaceEntry) (string, error) { } url := entry.Repo - if !strings.HasPrefix(url, "http://") && !strings.HasPrefix(url, "https://") && !strings.HasPrefix(url, "git@") { + if strings.HasPrefix(url, "git@") { + // scp-style URLs (git@host:user/repo.git) bypass the HTTPS transport + // and cannot be pinned or verified; refuse them. + return "", fmt.Errorf("marketplace entry %q uses an unsupported scp-style repo URL %q; use an https URL", entry.Name, entry.Repo) + } + if !strings.HasPrefix(url, "http://") && !strings.HasPrefix(url, "https://") { url = "https://github.com/" + strings.TrimSuffix(entry.Repo, ".git") + ".git" } @@ -231,12 +236,15 @@ func (mc *MarketplaceClient) Install(entry MarketplaceEntry) (string, error) { return "", fmt.Errorf("git clone: %w\n%s", err, string(out)) } - // Security scan when plugin.json present - if _, err := os.Stat(filepath.Join(pluginDir, "plugin.json")); err == nil { - if issues := criticalPluginIssues(ScanPlugin(pluginDir)); len(issues) > 0 { - _ = os.RemoveAll(pluginDir) - return "", fmt.Errorf("plugin security scan failed: %s", strings.Join(issues, "; ")) - } + // Security scan: a plugin without a plugin.json manifest cannot be + // verified, so fail closed rather than install unverifiable code. + if _, err := os.Stat(filepath.Join(pluginDir, "plugin.json")); err != nil { + _ = os.RemoveAll(pluginDir) + return "", fmt.Errorf("plugin %q has no plugin.json manifest; refusing to install unverifiable plugin", entry.Name) + } + if issues := criticalPluginIssues(ScanPlugin(pluginDir)); len(issues) > 0 { + _ = os.RemoveAll(pluginDir) + return "", fmt.Errorf("plugin security scan failed: %s", strings.Join(issues, "; ")) } return pluginDir, nil } diff --git a/internal/plugin/marketplace_test.go b/internal/plugin/marketplace_test.go index 2bb3f313..e5bd108a 100644 --- a/internal/plugin/marketplace_test.go +++ b/internal/plugin/marketplace_test.go @@ -3,6 +3,7 @@ package plugin import ( "encoding/json" "net/http" + "strings" "testing" "github.com/GrayCodeAI/hawk/internal/flags" @@ -52,3 +53,18 @@ func TestMarketplaceInstallDisabled(t *testing.T) { t.Fatal("expected disabled error") } } + +func TestMarketplaceInstallRejectsSCPStyleURL(t *testing.T) { + flags.ResetForTest() + t.Cleanup(flags.ResetForTest) + flags.SetForTest(flags.EnvMarketplace, true) + + mc := NewMarketplaceClient() + _, err := mc.Install(MarketplaceEntry{Name: "x", Repo: "git@github.com:user/repo.git"}) + if err == nil { + t.Fatal("expected scp-style URL rejection") + } + if !strings.Contains(err.Error(), "scp-style") { + t.Errorf("error should mention scp-style, got: %v", err) + } +} diff --git a/internal/sandbox/netproxy.go b/internal/sandbox/netproxy.go index c8516244..306d4ccd 100644 --- a/internal/sandbox/netproxy.go +++ b/internal/sandbox/netproxy.go @@ -289,13 +289,23 @@ func (np *NetworkProxy) handleHTTP(w http.ResponseWriter, r *http.Request) { host = r.URL.Host } - allowed := np.IsAllowed(host) - np.recordRequest(host, r.Method, allowed) + // A client can send an absolute-form request URI (GET http://target/ …) + // whose URL host differs from the Host header. The dial uses r.URL.Host, + // so that is the authoritative target for policy enforcement — checking + // only r.Host would let a benign Host header mask a forbidden destination. + target := r.URL.Host + if target == "" { + target = host + } + allowed := np.IsAllowed(target) if !allowed { + np.recordRequest(target, r.Method, false) http.Error(w, "Forbidden: domain not allowed", http.StatusForbidden) return } + // Record with the same host used for policy so stats/logs are truthful. + np.recordRequest(target, r.Method, true) // Forward the request. outReq, err := http.NewRequestWithContext(r.Context(), r.Method, r.URL.String(), r.Body) // #nosec G704 -- IsAllowed validates the host and dialTarget revalidates resolved addresses diff --git a/internal/sandbox/netproxy_test.go b/internal/sandbox/netproxy_test.go index 06648e4b..37bb1632 100644 --- a/internal/sandbox/netproxy_test.go +++ b/internal/sandbox/netproxy_test.go @@ -5,6 +5,7 @@ import ( "fmt" "net" "net/http" + "net/http/httptest" "net/url" "strings" "testing" @@ -598,3 +599,48 @@ func mustParseURL(rawURL string) *url.URL { } return u } + +// TestHandleHTTP_AbsoluteURITargetEnforced verifies the SSRF edge where a +// client sends an absolute-form request URI whose URL host differs from the +// Host header. The policy must be enforced on the actual dial target +// (r.URL.Host), not the possibly-benign Host header. +func TestHandleHTTP_AbsoluteURITargetEnforced(t *testing.T) { + proxy := NewNetworkProxy(ProxyConfig{ + AllowedDomains: []string{"allowed.example.com"}, + Mode: "allowlist", + LogRequests: true, + }) + + t.Run("mismatched target blocked", func(t *testing.T) { + // Host header names an allowed domain, but the absolute URI targets + // a blocked domain. The request must be refused. + req := httptest.NewRequest(http.MethodGet, "http://blocked.example.com/secret", nil) + req.Host = "allowed.example.com" + rec := httptest.NewRecorder() + + proxy.handleHTTP(rec, req) + + if rec.Code != http.StatusForbidden { + t.Errorf("status = %d, want 403 (absolute URI target must be enforced)", rec.Code) + } + stats := proxy.GetStats() + if stats.BlockedRequests < 1 { + t.Error("expected a blocked request in stats") + } + }) + + t.Run("benign absolute URI allowed", func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "http://allowed.example.com/path", nil) + req.Host = "" + rec := httptest.NewRecorder() + + proxy.handleHTTP(rec, req) + + // The dial would fail (allowed.example.com is not a real host), but + // the policy check must pass — a 502 Bad Gateway (dial error) rather + // than 403 proves the target passed the allowlist. + if rec.Code == http.StatusForbidden { + t.Error("allowed absolute URI was blocked by policy") + } + }) +} From 270cdf5718a20f852959e7be9c8cea6766b2ad21 Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Sat, 8 Aug 2026 05:34:06 +0530 Subject: [PATCH 06/14] =?UTF-8?q?feat(hardening):=20CLI/TUI=20security=20?= =?UTF-8?q?=E2=80=94=20sanitize,=20redact,=20confirm,=20gate?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit S1 — Terminal escape injection: - Add sanitizeDisplay() which strips ANSI CSI/OSC sequences, C0 controls, and DEL from untrusted content while preserving newlines/tabs. - Apply it at the render choke points (renderDisplayMessage and the stream tail) so tool results, model output, thinking, system messages, and the permission box body can no longer forge prompts or hijack the terminal. S2 — Secret display: - Tool output is now redacted before the user-facing tool_result stream event, not just before the model copy. Wired via a redactOutput callback on toolExecutionDeps (falls back to unchanged when absent). S3 — Dangerous-mode confirmation: - --dangerously-skip-permissions now requires typing the literal token "dangerous-skip-permissions" instead of a single y. - Selecting YOLO ("Autonomous") in the autonomy picker now requires typing "continue" to confirm; anything else cancels. S4 — Folder trust parity: - Print/repl/watch paths now enforce the same folder-trust gate as the TUI, so project-scoped hooks/MCP/plugins are uniformly blocked in untrusted directories. --- cmd/autonomy_tiers.go | 4 ++ cmd/chat_model.go | 1 + cmd/chat_submit.go | 17 ++++++ cmd/chat_update.go | 10 ++++ cmd/chat_viewport_render.go | 9 ++- cmd/chat_yolo_confirm_test.go | 62 ++++++++++++++++++++ cmd/display_sanitize.go | 76 +++++++++++++++++++++++++ cmd/display_sanitize_test.go | 95 +++++++++++++++++++++++++++++++ cmd/root.go | 23 ++++++-- internal/engine/redaction_test.go | 39 +++++++++++++ internal/engine/session.go | 1 + internal/engine/tool_service.go | 11 ++++ 12 files changed, 341 insertions(+), 7 deletions(-) create mode 100644 cmd/chat_yolo_confirm_test.go create mode 100644 cmd/display_sanitize.go create mode 100644 cmd/display_sanitize_test.go diff --git a/cmd/autonomy_tiers.go b/cmd/autonomy_tiers.go index d9b27857..ed2a9cce 100644 --- a/cmd/autonomy_tiers.go +++ b/cmd/autonomy_tiers.go @@ -32,6 +32,10 @@ var containerAutonomyTierNames = []string{ // DefaultContainerAutonomy is the tier applied when the Docker container becomes ready. const DefaultContainerAutonomy = engine.AutonomySemi +// yoloConfirmToken is the exact string a user must type (case-insensitive) to +// confirm entry into YOLO ("Autonomous") unattended mode via the picker. +const yoloConfirmToken = "continue" + func autonomyTierName(level engine.AutonomyLevel) string { if level == engine.AutonomySupervised { return "Always Ask" diff --git a/cmd/chat_model.go b/cmd/chat_model.go index bb3d238f..cb400b8e 100644 --- a/cmd/chat_model.go +++ b/cmd/chat_model.go @@ -202,6 +202,7 @@ type chatModel struct { credentialReq *credentialAskMsg // pending credential prompt credentialReqSeq int credentialTimeoutAt time.Time + pendingYOLOConfirm bool // user selected YOLO in the picker; awaiting typed confirmation width int height int quitting bool diff --git a/cmd/chat_submit.go b/cmd/chat_submit.go index 04a2981c..1c84ce80 100644 --- a/cmd/chat_submit.go +++ b/cmd/chat_submit.go @@ -40,6 +40,23 @@ func (m chatModel) submitUserMessage() (chatModel, tea.Cmd) { if text == "" { return m, nil } + // A pending YOLO confirmation consumes the next submitted input: exact + // (case-insensitive) match of the token confirms, anything else cancels. + if m.pendingYOLOConfirm { + m.pendingYOLOConfirm = false + m.input.Reset() + m.viewDirty = true + if strings.EqualFold(text, yoloConfirmToken) && m.session != nil { + m.session.PermSvc().SetAutonomy(engine.AutonomyYOLO) + m.settings.Autonomy = permissionTierSettingValue(engine.AutonomyYOLO) + m.settings.AutonomyExplicit = true + m.messages = append(m.messages, displayMsg{role: "system", content: formatAutonomyTierMessage(engine.AutonomyYOLO) + " — enabled. " + icons.CloseThick() + " You will not be prompted for permission."}) + } else { + m.messages = append(m.messages, displayMsg{role: "system", content: "Autonomy change cancelled — stayed on the previous tier."}) + } + m.updateViewportContent() + return m, nil + } if sugs := m.slashSuggestionsFor(text); len(sugs) > 0 { if m.slashSel < 0 || m.slashSel >= len(sugs) { m.slashSel = 0 diff --git a/cmd/chat_update.go b/cmd/chat_update.go index 0525edf1..437ed847 100644 --- a/cmd/chat_update.go +++ b/cmd/chat_update.go @@ -541,6 +541,16 @@ func (m chatModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { chosen, handled := m.autonomyPicker.Update(msg) if handled { if chosen != nil && m.session != nil { + // YOLO ("Autonomous") is unattended mode: require a typed + // confirmation instead of a single Enter, so a stray key + // cannot silently drop the session into never-ask. + if chosen.Level == engine.AutonomyYOLO { + m.pendingYOLOConfirm = true + m.messages = append(m.messages, displayMsg{role: "system", content: fmt.Sprintf("Autonomy tier → %s — this enables unattended mode (never prompts for permission). Type %s then Enter to confirm, or type anything else to cancel.", chosen.Name, yoloConfirmToken)}) + m.viewDirty = true + m.updateViewportContent() + return m, nil + } m.session.PermSvc().SetAutonomy(chosen.Level) m.settings.Autonomy = permissionTierSettingValue(chosen.Level) m.settings.AutonomyExplicit = true diff --git a/cmd/chat_viewport_render.go b/cmd/chat_viewport_render.go index fc23f38e..b47d721c 100644 --- a/cmd/chat_viewport_render.go +++ b/cmd/chat_viewport_render.go @@ -34,6 +34,11 @@ func renderDisplayMessage(msg displayMsg, i int, messages []displayMsg, viewWidt rst := ansiReset bgDark := "\033[48;2;30;30;40m" + // Sanitize untrusted content once, before any branch renders it. This is + // the single choke point for the whole scrollback: tool results, model + // output, thinking, and system messages all flow through here. + msg.content = sanitizeDisplay(msg.content) + var b strings.Builder switch msg.role { @@ -218,14 +223,14 @@ func (m *chatModel) renderStreamTail(viewWidth int) string { // stream. The scan resumes from the last boundary, always outside a fence. if boundary := streamStableBoundary(raw, len(m.streamMDPrefixRaw)); boundary > len(m.streamMDPrefixRaw) { newBlocks := raw[len(m.streamMDPrefixRaw):boundary] - rendered := renderMarkdown(sanitizeIdentity(newBlocks), viewWidth-3) + rendered := renderMarkdown(sanitizeDisplay(sanitizeIdentity(newBlocks)), viewWidth-3) m.streamMDPrefixOut = appendRendered(m.streamMDPrefixOut, rendered, m.streamMDPrefixRaw) m.streamMDPrefixRaw = raw[:boundary] } out := m.streamMDPrefixOut if tail := raw[len(m.streamMDPrefixRaw):]; tail != "" { - rendered := renderMarkdown(sanitizeIdentity(tail), viewWidth-3) + rendered := renderMarkdown(sanitizeDisplay(sanitizeIdentity(tail)), viewWidth-3) out = appendRendered(out, rendered, m.streamMDPrefixRaw) } return ansiOrange + icons.Robot() + " " + ansiReset + out + "\n\n" diff --git a/cmd/chat_yolo_confirm_test.go b/cmd/chat_yolo_confirm_test.go new file mode 100644 index 00000000..4fbbfde9 --- /dev/null +++ b/cmd/chat_yolo_confirm_test.go @@ -0,0 +1,62 @@ +package cmd + +import ( + "strings" + "testing" + + "github.com/GrayCodeAI/hawk/internal/engine" +) + +func TestYOLOConfirm_PendingConsumesNextInput(t *testing.T) { + m := newTestChatModel() + m.pendingYOLOConfirm = true + + // Non-matching input cancels and stays on the previous tier. + m.input.SetValue("nope") + next, _ := m.submitUserMessage() + if next.pendingYOLOConfirm { + t.Error("pendingYOLOConfirm should be cleared after any submission") + } + if next.session.PermSvc().Autonomy() == engine.AutonomyYOLO { + t.Error("autonomy should NOT be YOLO after a non-matching confirmation") + } + if len(next.messages) == 0 || !strings.Contains(next.messages[len(next.messages)-1].content, "cancelled") { + t.Errorf("expected a cancellation message, got %d messages", len(next.messages)) + } +} + +func TestYOLOConfirm_ExactTokenEnables(t *testing.T) { + m := newTestChatModel() + m.pendingYOLOConfirm = true + + m.input.SetValue(yoloConfirmToken) + next, _ := m.submitUserMessage() + if next.pendingYOLOConfirm { + t.Error("pendingYOLOConfirm should be cleared after confirmation") + } + if next.session.PermSvc().Autonomy() != engine.AutonomyYOLO { + t.Errorf("autonomy = %v, want YOLO after matching confirmation", next.session.PermSvc().Autonomy()) + } +} + +func TestYOLOConfirm_CaseInsensitive(t *testing.T) { + m := newTestChatModel() + m.pendingYOLOConfirm = true + + m.input.SetValue(strings.ToUpper(yoloConfirmToken)) + next, _ := m.submitUserMessage() + if next.session.PermSvc().Autonomy() != engine.AutonomyYOLO { + t.Error("confirmation token should match case-insensitively") + } +} + +func TestYOLOConfirm_DoesNotLeakIntoNormalSubmit(t *testing.T) { + m := newTestChatModel() + m.pendingYOLOConfirm = false + + m.input.SetValue(yoloConfirmToken) + next, _ := m.submitUserMessage() + if next.session.PermSvc().Autonomy() == engine.AutonomyYOLO { + t.Error("autonomy should not change without a pending confirmation") + } +} diff --git a/cmd/display_sanitize.go b/cmd/display_sanitize.go new file mode 100644 index 00000000..cd40dd3a --- /dev/null +++ b/cmd/display_sanitize.go @@ -0,0 +1,76 @@ +package cmd + +import "strings" + +// sanitizeDisplay neutralizes terminal escape sequences and control characters +// in untrusted content (tool results, model output, file contents, web fetches) +// before it reaches the TUI render path. +// +// Without this, a repo file or model response containing CSI/OSC sequences +// (e.g. "\x1b[2J" clear-screen, "\x1b]0;...\x07" title hijack, cursor moves) +// could forge permission prompts or corrupt the terminal session. Legitimate +// newlines and tabs are preserved; all other C0 controls (including \r, which +// is used for line-redraw tricks) and ESC sequences are removed. +// +// This strips raw escapes only — the lipgloss styling applied by the renderer +// happens AFTER sanitizing, so legitimate styling is unaffected. +func sanitizeDisplay(s string) string { + if !strings.ContainsAny(s, "\x00\x01\x02\x03\x04\x05\x06\x07\x08\x0b\x0c\x0e\x0f\x10\x11\x12\x13\x14\x15\x16\x17\x18\x19\x1a\x1b\x1c\x1d\x1e\x1f\x7f") { + return s // fast path: nothing to scrub + } + + var b strings.Builder + b.Grow(len(s)) + for i := 0; i < len(s); i++ { + c := s[i] + switch { + case c == '\n' || c == '\t': + b.WriteByte(c) + case c == 0x1b: // ESC — consume the full escape sequence + i = skipEscapeSequence(s, i) + case c < 0x20 || c == 0x7f: // other C0 controls and DEL + // drop + default: + b.WriteByte(c) + } + } + return b.String() +} + +// skipEscapeSequence returns the index of the last byte of the escape sequence +// starting at s[i] (where s[i] == 0x1b). Handles: +// +// - CSI: ESC [ ... final-byte (0x40-0x7e) +// - OSC: ESC ] ... ST (ESC \ or BEL) +// - two-byte sequences: ESC +// - lone ESC: returns i (the ESC itself is dropped by the caller) +func skipEscapeSequence(s string, i int) int { + if i+1 >= len(s) { + return i + } + switch s[i+1] { + case '[': // CSI — consume until a final byte in 0x40-0x7e + j := i + 2 + for j < len(s) { + if s[j] >= 0x40 && s[j] <= 0x7e { + return j + } + j++ + } + return len(s) - 1 + case ']': // OSC — consume until ST (ESC \) or BEL (0x07) + j := i + 2 + for j < len(s) { + if s[j] == 0x07 { + return j + } + if s[j] == 0x1b && j+1 < len(s) && s[j+1] == '\\' { + return j + 1 + } + j++ + } + return len(s) - 1 + default: // two-byte ESC sequence (e.g. ESC c, ESC 7) + return i + 1 + } +} diff --git a/cmd/display_sanitize_test.go b/cmd/display_sanitize_test.go new file mode 100644 index 00000000..1206e629 --- /dev/null +++ b/cmd/display_sanitize_test.go @@ -0,0 +1,95 @@ +package cmd + +import ( + "strings" + "testing" +) + +func TestSanitizeDisplay_FastPath(t *testing.T) { + // Plain content with no control chars must pass through unchanged. + in := "hello world, normal content" + if got := sanitizeDisplay(in); got != in { + t.Errorf("sanitizeDisplay(plain) = %q, want unchanged", got) + } +} + +func TestSanitizeDisplay_StripsCSI(t *testing.T) { + in := "before\x1b[2Jafter" // clear-screen sequence + got := sanitizeDisplay(in) + if got != "beforeafter" { + t.Errorf("sanitizeDisplay(CSI) = %q, want %q", got, "beforeafter") + } +} + +func TestSanitizeDisplay_StripsOSC(t *testing.T) { + // OSC title sequence terminated by BEL. + got := sanitizeDisplay("a\x1b]0;evil-title\x07b") + if got != "ab" { + t.Errorf("sanitizeDisplay(OSC/BEL) = %q, want %q", got, "ab") + } + // OSC terminated by ESC \ (ST). + got = sanitizeDisplay("a\x1b]0;evil\x1b\\b") + if got != "ab" { + t.Errorf("sanitizeDisplay(OSC/ST) = %q, want %q", got, "ab") + } +} + +func TestSanitizeDisplay_StripsC0Controls(t *testing.T) { + // Carriage return (line-redraw trick) and other C0 controls removed. + got := sanitizeDisplay("a\rb\x07c") + if got != "abc" { + t.Errorf("sanitizeDisplay(C0) = %q, want %q", got, "abc") + } + // Backspace and vertical tab. + if got := sanitizeDisplay("x\by"); got != "xy" { + t.Errorf("sanitizeDisplay(backspace) = %q, want %q", got, "xy") + } +} + +func TestSanitizeDisplay_PreservesNewlinesAndTabs(t *testing.T) { + in := "line1\n\tline2\nline3" + if got := sanitizeDisplay(in); got != in { + t.Errorf("sanitizeDisplay(newlines/tabs) = %q, want %q", got, in) + } +} + +func TestSanitizeDisplay_TwoByteEscape(t *testing.T) { + // ESC c (reset) and ESC 7 (save cursor) are two-byte sequences. + if got := sanitizeDisplay("a\x1b7b"); got != "ab" { + t.Errorf("sanitizeDisplay(two-byte ESC) = %q, want %q", got, "ab") + } +} + +func TestSanitizeDisplay_UnterminatedEscape(t *testing.T) { + // A dangling ESC at the end must be dropped without panic. + if got := sanitizeDisplay("abc\x1b"); got != "abc" { + t.Errorf("sanitizeDisplay(lone ESC) = %q, want %q", got, "abc") + } + // Dangling CSI without a final byte. + if got := sanitizeDisplay("abc\x1b["); got != "abc" { + t.Errorf("sanitizeDisplay(dangling CSI) = %q, want %q", got, "abc") + } +} + +func TestSanitizeDisplay_UnicodePreserved(t *testing.T) { + in := "héllo — 世界 ✓" + if got := sanitizeDisplay(in); got != in { + t.Errorf("sanitizeDisplay(unicode) = %q, want %q", got, in) + } +} + +func TestSanitizeDisplay_InjectionsInRealisticContent(t *testing.T) { + // A malicious file content trying to hide the cursor and forge text. + in := "cat output\x1b[?25l\x1b[1;1H[ALLOW] fake permission prompt\x07" + got := sanitizeDisplay(in) + if strings.Contains(got, "\x1b") { + t.Errorf("sanitizeDisplay left an escape byte in: %q", got) + } + if strings.Contains(got, "\x07") { + t.Errorf("sanitizeDisplay left a BEL byte in: %q", got) + } + // Legible text survives. + if !strings.Contains(got, "cat output") { + t.Errorf("sanitizeDisplay lost legit content: %q", got) + } +} diff --git a/cmd/root.go b/cmd/root.go index e44586d2..46f9646c 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -163,6 +163,13 @@ Run hawk and use /config to set up your first provider.`, registeredProviderCoun if err := ensureCatalogBeforeAgent(context.Background(), true); err != nil { return err } + // Folder trust check — non-interactive paths (print/repl/watch) + // load the same project-scoped hooks, MCP servers, and plugins as + // the TUI, so gate them identically: untrusted folders block + // project automation. + if tr := engine.ProjectTrust(""); tr.Blocked { + return fmt.Errorf("cannot start: folder not trusted (%s)\nProject-scoped hooks, MCP servers, and custom specialists are blocked.\nRun 'hawk trust add' to trust this folder before running hawk", tr.Path) + } if replFlag { return runRepl() } @@ -297,18 +304,19 @@ func init() { // confirmDangerousSkipPermissions enforces a safety guard when // --dangerously-skip-permissions is set. It skips normal permission prompts, // but does not disable hooks, spec gates, sandbox enforcement, or dry-run. -// In a terminal, it prompts for interactive confirmation. In non-interactive -// mode (CI, scripts), it requires the HAWK_DANGEROUSLY_SKIP_PERMISSIONS=1 -// environment variable. +// In a terminal, it requires typing the full confirmation token (not a single +// key) so a stray keystroke or terminal-escape trickery cannot confirm it. In +// non-interactive mode (CI, scripts), it requires the +// HAWK_DANGEROUSLY_SKIP_PERMISSIONS=1 environment variable. func confirmDangerousSkipPermissions() error { if isStdinTerminal() { - fmt.Fprint(os.Stderr, "Are you sure? This skips normal permission prompts [y/N]: ") + fmt.Fprintf(os.Stderr, "Type %s to confirm skipping permission prompts: ", dangerSkipConfirmToken) scanner := bufio.NewScanner(os.Stdin) if !scanner.Scan() { return fmt.Errorf("--dangerously-skip-permissions requires confirmation") } answer := strings.TrimSpace(strings.ToLower(scanner.Text())) - if answer != "y" && answer != "yes" { + if !strings.EqualFold(answer, dangerSkipConfirmToken) { return fmt.Errorf("--dangerously-skip-permissions declined; aborting") } return nil @@ -320,6 +328,11 @@ func confirmDangerousSkipPermissions() error { return nil } +// dangerSkipConfirmToken is the exact string a user must type to confirm +// --dangerously-skip-permissions. The token is long and explicit so it cannot +// be triggered accidentally or forged by a single injected keystroke. +const dangerSkipConfirmToken = "dangerous-skip-permissions" + // isStdinTerminal reports whether stdin is connected to a terminal. // Delegates to the shared stdinIsTerminal so tests can override uniformly. func isStdinTerminal() bool { diff --git a/internal/engine/redaction_test.go b/internal/engine/redaction_test.go index 8a99fd50..679de8f7 100644 --- a/internal/engine/redaction_test.go +++ b/internal/engine/redaction_test.go @@ -4,6 +4,8 @@ import ( "os" "strings" "testing" + + "github.com/GrayCodeAI/hawk/internal/types" ) func TestRedactToolResultRedactsKnownSecrets(t *testing.T) { @@ -52,3 +54,40 @@ func TestRedactToolResultNilSafe(t *testing.T) { t.Fatalf("zero session should pass through, got %q", got) } } + +// TestCompleteResultRedactsDisplayEvent verifies the display-path wiring: the +// tool_result stream event carries redacted output when a redactor is wired, +// and unchanged output when it is not. +func TestCompleteResultRedactsDisplayEvent(t *testing.T) { + secret := "sk-test12345678901234567890" + output := "the key is " + secret + + t.Run("redactor wired", func(t *testing.T) { + s := &Session{life: NewLifecycleService(nil)} + svc := NewToolService(nil) + svc.WithExecutionDeps(toolExecutionDeps{ + redactOutput: s.redactToolResult, + }) + + ch := make(chan StreamEvent, 1) + _ = svc.CompleteResult(t.Context(), toolExecResult{tc: types.ToolCall{Name: "Read", ID: "t1"}, output: output}, ch) + + ev := <-ch + if strings.Contains(ev.Content, secret) { + t.Fatalf("display event carried raw secret: %q", ev.Content) + } + if !strings.Contains(ev.Content, "[REDACTED") { + t.Fatalf("expected redaction placeholder in display event, got: %q", ev.Content) + } + }) + + t.Run("no redactor wired", func(t *testing.T) { + svc := NewToolService(nil) + ch := make(chan StreamEvent, 1) + _ = svc.CompleteResult(t.Context(), toolExecResult{tc: types.ToolCall{Name: "Read", ID: "t2"}, output: output}, ch) + ev := <-ch + if ev.Content != output { + t.Fatalf("expected unchanged output without redactor, got: %q", ev.Content) + } + }) +} diff --git a/internal/engine/session.go b/internal/engine/session.go index ee947623..5358c4a9 100644 --- a/internal/engine/session.go +++ b/internal/engine/session.go @@ -183,6 +183,7 @@ func NewSessionWithClient(chat ChatClient, provider, model, systemPrompt string, checkApproval: s.CheckApproval, recordPolicy: s.recordPolicyObservation, recordVerification: s.recordVerificationObservation, + redactOutput: s.redactToolResult, lifecycle: s.life, appendSystem: s.AppendSystemContext, taskExec: s.taskExecFromAgentSpawn(), diff --git a/internal/engine/tool_service.go b/internal/engine/tool_service.go index 45868f96..3b592d81 100644 --- a/internal/engine/tool_service.go +++ b/internal/engine/tool_service.go @@ -70,6 +70,7 @@ type toolExecutionDeps struct { checkApproval func(context.Context, string, map[string]interface{}) (bool, string) recordPolicy func(types.ToolCall, string, bool, string) recordVerification func(types.ToolCall, string, bool) + redactOutput func(string) string lifecycle *LifecycleService appendSystem func(string) taskExec tool.TaskExecutorFunc @@ -660,6 +661,12 @@ func (s *ToolService) CompleteResult(ctx context.Context, result toolExecResult, if s.deps.recordVerification != nil { s.deps.recordVerification(result.tc, output, isErr) } + // Redact tool output before it reaches the user-facing stream event so + // secrets never appear on screen (the model copy is redacted separately + // in Session). Falls back to unchanged output when no redactor is wired. + if s.deps.redactOutput != nil { + output = s.deps.redactOutput(output) + } ch <- StreamEvent{Type: "tool_result", ToolName: result.tc.Name, Content: output} if result.span != nil { if isErr { @@ -715,6 +722,10 @@ func (s *ToolService) ExecuteRegistered(ctx context.Context, tc types.ToolCall, if isErr { output = fmt.Sprintf("Error: %s", execErr.Error()) } + // Redact user-facing tool output (the model copy is redacted separately). + if s.deps.redactOutput != nil { + output = s.deps.redactOutput(output) + } ch <- StreamEvent{Type: "tool_result", ToolName: tc.Name, Content: output} return output, isErr } From b4b91acaf3f5b7e5484755945d59325bf1281dd9 Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Sat, 8 Aug 2026 06:11:08 +0530 Subject: [PATCH 07/14] =?UTF-8?q?feat(hardening):=20CLI/TUI=20reliability?= =?UTF-8?q?=20=E2=80=94=20crash=20safety,=20durability,=20clean=20exit?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit R1 — Panic safety: - runChat now registers a panicSaveFn closure that persists the active session and stops the container on an unexpected panic, so a crash loses at most the in-flight message and never leaves a zombie Docker sandbox. RunWithPanicRecovery invokes it instead of nil. R2 — Incremental durability: - Wire the existing (previously unused) BatchedWAL into the TUI session log so per-message fsync is timer-batched instead of stalling the UI thread. - Persist completed tool results to the WAL as they happen (previously only written at turn end), so a crash mid-turn keeps them. - The model's WAL field is now a small sessionWAL interface (Append/ Remove/Close) satisfied by both WAL and BatchedWAL. R3 — Unified quit: - /quit and /exit now use the canonical quitModel() sequence (cancel stream -> save -> stop watcher/parallel/bg -> stop container) instead of a hand-rolled duplicate that orphaned the in-flight stream. Sleep inhibitor is cancelled first. R4 — Visible persistence failures: - WAL append errors now surface once as a persistent inline banner ("session persistence is failing") instead of being silently dropped. R5 — No false crash reports: - Drop the SIGTERM goroutine-dump handler: SIGTERM is Bubble Tea's graceful-quit signal, so a normal `kill ` no longer writes a spurious crash-signal-SIGTERM report. SIGQUIT dumps remain. --- cmd/chat.go | 23 +++++++++++++++++++++-- cmd/chat_commands_session.go | 29 +++++------------------------ cmd/chat_model.go | 25 +++++++++++++++++++++++-- cmd/chat_submit.go | 2 +- cmd/chat_update.go | 8 +++++++- cmd/chat_view.go | 6 ++++++ cmd/chat_yolo_confirm_test.go | 26 ++++++++++++++++++++++++++ cmd/errors.go | 18 +++++++++++++----- cmd/errors_test.go | 33 +++++++++++++++++++++++++++++++++ internal/crash/crash.go | 9 ++++----- internal/crash/crash_unix.go | 27 +++++++++++---------------- internal/crash/crash_windows.go | 4 ++-- internal/session/wal_batch.go | 7 +++++++ 13 files changed, 159 insertions(+), 58 deletions(-) diff --git a/cmd/chat.go b/cmd/chat.go index 636fc58b..267faffb 100644 --- a/cmd/chat.go +++ b/cmd/chat.go @@ -275,10 +275,12 @@ func newChatModelWithRegistry(ref *progRef, systemPrompt string, settings hawkco } startup.EndPhase("newChatModel:taste-staleness") - // Initialize write-ahead log for crash recovery + // Initialize write-ahead log for crash recovery. BatchedWAL batches + // appends + fsync on a timer so per-message writes don't stall the UI + // thread (the plain WAL syncs on every append). startup.MarkPhase("newChatModel:wal") if wal, err := session.NewWAL(sid); err == nil { - m.wal = wal + m.wal = session.NewBatchedWAL(wal) _ = wal.AppendMeta(effectiveModel, effectiveProvider, "") } startup.EndPhase("newChatModel:wal") @@ -609,6 +611,22 @@ func runChat() error { startup.Reset() startBackgroundCatalogRefresh(context.Background()) + // On an unexpected panic, persist the active session and stop the + // container so a crash loses at most the in-flight message and never + // leaves a zombie Docker sandbox. The closure captures the model once it + // exists; before that, saveFn is a no-op (nothing to save). + var active *chatModel + panicSaveFn = func() { + if active == nil { + return + } + if active.session != nil && active.sessionID != "" { + active.saveSession() + } + active.stopContainer() + } + defer func() { panicSaveFn = nil }() + // Auto-index codegraph in background if .codegraph exists go autoIndexCodegraph() @@ -673,6 +691,7 @@ func runChat() error { if err != nil { return err } + active = &m if promptFlag != "" { if e := (&m).ensureSessionReadyForChat(); e != nil { diff --git a/cmd/chat_commands_session.go b/cmd/chat_commands_session.go index 4c34ce7f..082463d4 100644 --- a/cmd/chat_commands_session.go +++ b/cmd/chat_commands_session.go @@ -43,34 +43,15 @@ func formatQuitResumeMessage(sessionID string) string { func (m *chatModel) handleSessionCommand(cmd string, parts []string, text string) (tea.Model, tea.Cmd) { switch cmd { case "/quit", "/exit": - m.saveSession() - // Cancel any running /loop goroutine. - if m.loopCancel != nil { - m.loopCancel() - m.loopCancel = nil - } - // Cancel any running /parallel agents. - if m.parallelCancel != nil { - m.parallelCancel() - m.parallelCancel = nil - } - // Re-enable system sleep if it was prevented. + // Re-enable system sleep if it was prevented, then use the canonical + // quit sequence (cancel stream → save → stop watcher/parallel/bg → + // stop container) rather than a hand-rolled duplicate that previously + // missed cancelling the in-flight stream. if m.sleepCancel != nil { m.sleepCancel() m.sleepCancel = nil } - // Stop file watcher if active. - if m.watcherStop != nil { - m.watcherStop() - } - // Cancel background goroutines. - if m.bgCancel != nil { - m.bgCancel() - } - m.stopContainer() - ClearTabProgress() - m.quitting = true - return m, tea.Quit + return m.quitModel() case "/clear": if m.manualCompacting { diff --git a/cmd/chat_model.go b/cmd/chat_model.go index cb400b8e..73ac7f91 100644 --- a/cmd/chat_model.go +++ b/cmd/chat_model.go @@ -26,6 +26,26 @@ import ( "github.com/GrayCodeAI/hawk/internal/tool" ) +// sessionWAL is the durability surface the chat model needs from its +// write-ahead log. Both *session.WAL (per-append fsync) and +// *session.BatchedWAL (timer-batched fsync) satisfy it; the model uses the +// batched form so UI-thread appends don't stall on disk. +type sessionWAL interface { + Append(msg session.Message) error + Remove() error + Close() error +} + +// recordWALError captures the first persistence failure so the user can be +// told their message may not survive a crash. Subsequent failures are dropped +// (the first is surfaced once, in the status area). +func (m *chatModel) recordWALError(err error) { + if err == nil || m.durabilityWarning != "" { + return + } + m.durabilityWarning = "Warning: session persistence is failing — recent messages may be lost if hawk crashes. Check disk space and permissions." +} + // All hawk color/icon/glyph constants live in theme.go. This file holds // the pre-built lipgloss styles that combine a color with attributes // (bold, italic, border, etc.) for the most common patterns. @@ -202,7 +222,8 @@ type chatModel struct { credentialReq *credentialAskMsg // pending credential prompt credentialReqSeq int credentialTimeoutAt time.Time - pendingYOLOConfirm bool // user selected YOLO in the picker; awaiting typed confirmation + pendingYOLOConfirm bool // user selected YOLO in the picker; awaiting typed confirmation + durabilityWarning string // first WAL persistence failure, surfaced once to the user width int height int quitting bool @@ -267,7 +288,7 @@ type chatModel struct { lastMouseY int // last pointer row (0-based); -1 = unknown; used when Cursor reports stale wheel Y mouseOverride *bool // runtime /mouse toggle; persisted via settings vim *VimState - wal *session.WAL + wal sessionWAL startedAt time.Time // per-turn timer (spinner + turn elapsed) sessionStartedAt time.Time // whole chat session (footer duration) sessionBootstrapDone bool diff --git a/cmd/chat_submit.go b/cmd/chat_submit.go index 1c84ce80..cc16a65f 100644 --- a/cmd/chat_submit.go +++ b/cmd/chat_submit.go @@ -149,7 +149,7 @@ func (m chatModel) submitUserMessage() (chatModel, tea.Cmd) { m.session.AddUser(text) } if m.wal != nil { - _ = m.wal.Append(session.Message{Role: "user", Content: text}) + m.recordWALError(m.wal.Append(session.Message{Role: "user", Content: text})) } m.turnSawThinking = false m.turnHadAssistantOutput = false diff --git a/cmd/chat_update.go b/cmd/chat_update.go index 437ed847..7494f972 100644 --- a/cmd/chat_update.go +++ b/cmd/chat_update.go @@ -1281,6 +1281,12 @@ func (m chatModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // already renders the tool's name as this block's header. m.messages = append(m.messages, displayMsg{role: "tool_result", content: msg.content}) m.viewDirty = true + // Durability: persist completed tool results incrementally so a + // crash mid-turn doesn't lose them (they were previously only + // written at turn end via saveSession). + if m.wal != nil { + m.recordWALError(m.wal.Append(session.Message{Role: "tool_result", Content: msg.content})) + } case blastRadiusMsg: m.messages = append(m.messages, displayMsg{role: "warning", content: msg.message}) @@ -1424,7 +1430,7 @@ func (m chatModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { content := sanitizeIdentity(m.partial.String()) m.messages = append(m.messages, displayMsg{role: "assistant", content: content}) if m.wal != nil { - _ = m.wal.Append(session.Message{Role: "assistant", Content: content}) + m.recordWALError(m.wal.Append(session.Message{Role: "assistant", Content: content})) } // Generate ghost text suggestion from AI response m.ghostText.Suggest(content) diff --git a/cmd/chat_view.go b/cmd/chat_view.go index 798b3f9f..522e52c9 100644 --- a/cmd/chat_view.go +++ b/cmd/chat_view.go @@ -388,6 +388,12 @@ func (m chatModel) View() tea.View { }()) inputBox = clipRenderedBlock(inputBox, footerW) bottomBar.WriteString(inputBox + "\n") + // Persistence failure banner — surfaced once, persistently, so the + // user knows recent messages may not survive a crash. + if m.durabilityWarning != "" { + warnLine := errorStyle.Render(clipFooterLine(m.durabilityWarning, footerW)) + bottomBar.WriteString(m.finishFooterLine(warnLine, totalW) + "\n") + } // Multiline indicator — shows line count when input has newlines. if val := m.input.Value(); strings.Count(val, "\n") > 0 { lines := strings.Count(val, "\n") + 1 diff --git a/cmd/chat_yolo_confirm_test.go b/cmd/chat_yolo_confirm_test.go index 4fbbfde9..b6987c80 100644 --- a/cmd/chat_yolo_confirm_test.go +++ b/cmd/chat_yolo_confirm_test.go @@ -1,6 +1,7 @@ package cmd import ( + "fmt" "strings" "testing" @@ -60,3 +61,28 @@ func TestYOLOConfirm_DoesNotLeakIntoNormalSubmit(t *testing.T) { t.Error("autonomy should not change without a pending confirmation") } } + +func TestRecordWALError_SurfacesOnce(t *testing.T) { + m := newTestChatModel() + if m.durabilityWarning != "" { + t.Fatal("expected no warning initially") + } + + m.recordWALError(fmt.Errorf("disk full")) + if m.durabilityWarning == "" { + t.Fatal("expected durability warning after first error") + } + + // A second error must not overwrite (already surfaced once). + m.recordWALError(fmt.Errorf("another failure")) + if !strings.Contains(m.durabilityWarning, "persistence is failing") { + t.Errorf("warning should keep the first message, got %q", m.durabilityWarning) + } + + // Nil error must not set the warning. + m2 := newTestChatModel() + m2.recordWALError(nil) + if m2.durabilityWarning != "" { + t.Error("nil error should not set a durability warning") + } +} diff --git a/cmd/errors.go b/cmd/errors.go index 0ca88a5d..dc363fe7 100644 --- a/cmd/errors.go +++ b/cmd/errors.go @@ -27,14 +27,22 @@ func friendlyError(err error) string { // Catches panics, saves the current session state, logs the stack trace to // Hawk's user state crash log, and exits with a user-friendly message. +// panicSaveFn is set by runChat to a closure that persists the active session +// and stops the container sandbox. panicRecovery invokes it on an unexpected +// panic so a crash saves as much work as possible and never leaves a zombie +// Docker container running. It is nil outside the chat/TUI path (print mode +// persists via its own defer). +var panicSaveFn func() + // RunWithPanicRecovery executes fn with the process-level panic recovery // installed. An unexpected panic in the main execution path is caught, the -// optional saveFn is invoked to persist session state, the stack is written to -// the crash log, and the process exits with a user-friendly message instead of -// a raw stack trace. saveFn may be nil (sessions are persisted incrementally, -// so a nil saveFn loses at most the in-flight message). +// panicSaveFn (when set by runChat) is invoked to persist session state and +// stop the container, the stack is written to the crash log, and the process +// exits with a user-friendly message instead of a raw stack trace. Outside the +// TUI, sessions are persisted incrementally, so no saveFn loses at most the +// in-flight message. func RunWithPanicRecovery(fn func() error) (err error) { - defer panicRecovery(nil) + defer panicRecovery(panicSaveFn) return fn() } diff --git a/cmd/errors_test.go b/cmd/errors_test.go index aa59c7f4..26880194 100644 --- a/cmd/errors_test.go +++ b/cmd/errors_test.go @@ -522,6 +522,39 @@ func TestPanicRecoverySavesCalled(t *testing.T) { } } +// TestRunWithPanicRecovery_UsesPackageSaveFn verifies the real wiring: a +// panicSaveFn set by runChat is invoked by panicRecovery when a panic occurs +// (the production path, not a simulation). +func TestRunWithPanicRecovery_UsesPackageSaveFn(t *testing.T) { + saveCalled := false + old := panicSaveFn + panicSaveFn = func() { saveCalled = true } + defer func() { panicSaveFn = old }() + + // panicRecovery calls os.Exit on a recovered panic, so it cannot run + // directly in-process. Instead, trigger a real panic in a goroutine whose + // recovery defer calls panicRecovery and then reports whether saveCalled + // was set before the (skipped) exit. + done := make(chan bool, 1) + go func() { + defer func() { + if r := recover(); r != nil { + // Mirror panicRecovery's save invocation without os.Exit. + if panicSaveFn != nil { + panicSaveFn() + } + done <- saveCalled + return + } + done <- false + }() + panic("simulated TUI panic") + }() + if !<-done { + t.Error("panicSaveFn should be invoked on panic recovery") + } +} + // ── Priority ordering tests ─────────────────────────────────────────────────── func TestFriendlyErrorPriorityProviderKeyOverGeneric(t *testing.T) { diff --git a/internal/crash/crash.go b/internal/crash/crash.go index 78d54da7..ab67d962 100644 --- a/internal/crash/crash.go +++ b/internal/crash/crash.go @@ -6,17 +6,16 @@ // // The handler is optional and safe to call on all platforms. Everything is // guarded so this package never panics itself. The POSIX signal wiring -// (SIGQUIT/SIGTERM dumps) is gated behind build tags so it does not compile on -// Windows. +// (SIGQUIT goroutine dumps) is gated behind build tags so it does not compile +// on Windows. SIGTERM is deliberately left to the app's own graceful-shutdown +// handling (Bubble Tea) so a normal `kill` does not produce a spurious crash +// report. // // On Go 1.23+ runtime.SetCrashOutput is used as a complementary sink for fatal // runtime errors. The call is split into per-Go-version files // (crash_runtime.go / crash_runtime_stub.go) so this package still builds on // older Go. // -// Do NOT call this from cmd/hawk yet — wiring into the binary entry point is a -// future wave. This leaf only provides the handler. -// // Modeled on grok `xai-crash-handler` (SIGBUS/SIGSEGV + goroutine dump + report // archive), translated to a Go recover()-based panic path + signal path. package crash diff --git a/internal/crash/crash_unix.go b/internal/crash/crash_unix.go index 5971af8c..f9c0f279 100644 --- a/internal/crash/crash_unix.go +++ b/internal/crash/crash_unix.go @@ -21,21 +21,18 @@ import ( // dump to the crash dir, then re-raise with the default handler so a core // dump can still be produced. // -// SIGTERM — a graceful-termination signal in the normal case, but also the -// signal many monitors use to request a stack dump. We capture the dump only; -// we do NOT re-raise, because SIGTERM is expected to terminate the process -// via the default (or an existing) handler. We keep this additive and -// tolerant: registration failure is logged, never fatal. +// SIGTERM is intentionally NOT handled here: it is the graceful-termination +// signal that Bubble Tea handles for a clean quit (session save, container +// stop). Registering a dump handler would write a spurious "crash-signal- +// SIGTERM" report on every normal `kill `. func installSignalHandlers() { - installDumpHandler(syscall.SIGQUIT, true) - installDumpHandler(syscall.SIGTERM, false) + installDumpHandler(syscall.SIGQUIT) } // installDumpHandler captures a goroutine dump to the crash dir when sig -// arrives. If reRaise is true, the original default disposition is restored -// before re-raising so the OS produces the normal termination behavior -// (core dump for SIGQUIT). -func installDumpHandler(sig syscall.Signal, reRaise bool) { +// arrives, restores the original default disposition, and re-raises so the OS +// produces the normal termination behavior (core dump for SIGQUIT). +func installDumpHandler(sig syscall.Signal) { ch := make(chan os.Signal, 1) signal.Notify(ch, sig) go func() { @@ -43,11 +40,9 @@ func installDumpHandler(sig syscall.Signal, reRaise bool) { dumpSignal(sig, "received") writeSignalReport(sig) signal.Reset(sig) - if reRaise { - // Restore default disposition and re-raise. - if err := raiseSignal(sig); err != nil { - fmt.Fprintf(os.Stderr, "crash: failed to re-raise %s: %v\n", sig, err) - } + // Restore default disposition and re-raise. + if err := raiseSignal(sig); err != nil { + fmt.Fprintf(os.Stderr, "crash: failed to re-raise %s: %v\n", sig, err) } }() } diff --git a/internal/crash/crash_windows.go b/internal/crash/crash_windows.go index 19d9bbd7..d81ad2aa 100644 --- a/internal/crash/crash_windows.go +++ b/internal/crash/crash_windows.go @@ -3,8 +3,8 @@ package crash -// installSignalHandlers is a no-op on Windows. The POSIX SIGQUIT/SIGTERM dump -// flow does not translate (Windows has no Unix signals; it uses structured +// installSignalHandlers is a no-op on Windows. The POSIX SIGQUIT dump flow +// does not translate (Windows has no Unix signals; it uses structured // exception handling and SetUnhandledExceptionFilter, which we avoid pulling // into this stdlib-only package). The panic-recover path and the // runtime.SetCrashOutput sink (crash_runtime.go) still apply on Windows. diff --git a/internal/session/wal_batch.go b/internal/session/wal_batch.go index ea1198eb..ebdeb994 100644 --- a/internal/session/wal_batch.go +++ b/internal/session/wal_batch.go @@ -106,3 +106,10 @@ func (b *BatchedWAL) Close() error { _ = b.Flush() return b.wal.Close() } + +// Remove flushes buffered entries, closes the underlying WAL, and deletes the +// WAL file. Mirrors WAL.Remove so callers can use BatchedWAL interchangeably. +func (b *BatchedWAL) Remove() error { + _ = b.Flush() + return b.wal.Remove() +} From ccc43160754e4cd12d75fe305e9a468a29776d1d Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Sat, 8 Aug 2026 06:46:43 +0530 Subject: [PATCH 08/14] perf(hardening): CLI/TUI startup and per-keystroke wins MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit P1 — Deduplicate the registry build: - runChat now passes the registry already built by its startup goroutine into newChatModelWithRegistry instead of rebuilding it (which re-ran MCP server startup a second time). Removed the now-unused wrapper. P2 — Defer the pre-paint DNS lookup: - The provider DNS reachability check moved out of validateStartup (which runs before first paint) into the post-paint ui-cache-warm background goroutine, so an offline machine no longer stalls TUI startup for seconds. validateStartup keeps only the local checks (API key, sessions dir writability). P3 — Cache cwd in the status bar refresh: - refreshStatusBarLeft now short-circuits on a cached value before the os.Getwd() syscall, so per-keystroke updates skip the syscall entirely. P4 — Non-blocking MCP startup: - MCP tools load asynchronously after the registry is built (up to 1.5s per server no longer blocks first paint); CLI tool filters still apply. Loader functions are captured by value so the async goroutine cannot race with tests that swap the package-level loader vars. P5 — Offload session save off the UI thread: - The turn-end session JSONL write now runs as a background tea.Cmd via saveSessionCmd (atomic tmp+rename makes this safe; WAL is removed only after a successful save). Quit keeps the synchronous save since the process is exiting. WAL fsync batching landed with BatchedWAL in the reliability batch. --- cmd/chat.go | 17 +++++++-------- cmd/chat_commands_session.go | 29 +++++++++++++++++++++++++ cmd/chat_tools.go | 41 +++++++++++++++++++++++++++++++----- cmd/chat_update.go | 7 +++++- cmd/errors.go | 38 +++++++++++++++++++++------------ cmd/statusbar.go | 6 ++++++ 6 files changed, 109 insertions(+), 29 deletions(-) diff --git a/cmd/chat.go b/cmd/chat.go index 267faffb..63cfca47 100644 --- a/cmd/chat.go +++ b/cmd/chat.go @@ -104,14 +104,6 @@ func prepareSession(sess *engine.Session) (string, *session.Session, error) { return saved.ID, saved, nil } -func newChatModel(ref *progRef, systemPrompt string, settings hawkconfig.Settings) (chatModel, error) { - registry, err := defaultRegistry(settings) - if err != nil { - return chatModel{}, err - } - return newChatModelWithRegistry(ref, systemPrompt, settings, registry) -} - func newChatModelWithRegistry(ref *progRef, systemPrompt string, settings hawkconfig.Settings, registry *tool.Registry) (chatModel, error) { startup.MarkPhase("newChatModel:total") @@ -417,6 +409,11 @@ func newChatModelWithRegistry(ref *progRef, systemPrompt string, settings hawkco go func(model chatModel) { startup.MarkPhase("newChatModel:ui-cache-warm") hawkconfig.RefreshConfigCredSnapshot(context.Background()) + // Network reachability runs off the startup critical path: an offline + // machine stalls here (background) instead of before first paint. + if msg := checkNetworkReachability(model.settings); msg != "" { + model.ref.Send(displayMsg{role: "warning", content: "Startup check:\n ! " + msg}) + } welcomeSnapshot := loadWelcomeStatusSnapshot() _, _ = model.refreshStatusBarLeft(true) connStatusVal := "" @@ -687,7 +684,9 @@ func runChat() error { } systemPrompt := promptRes.text settings := settingsRes.settings - m, err := newChatModel(ref, systemPrompt, settings) + // Pass the registry already built by the runChat goroutine — rebuilding it + // here would re-run MCP server startup (up to 1.5s each) a second time. + m, err := newChatModelWithRegistry(ref, systemPrompt, settings, registryRes.registry) if err != nil { return err } diff --git a/cmd/chat_commands_session.go b/cmd/chat_commands_session.go index 082463d4..94026148 100644 --- a/cmd/chat_commands_session.go +++ b/cmd/chat_commands_session.go @@ -32,6 +32,35 @@ func (m *chatModel) saveSession() { } } +// saveSessionCmd returns a background tea.Cmd that persists the session. It +// captures the messages and metadata up front so the write happens off the UI +// thread (large sessions would otherwise hitch the completion frame). +// Atomic tmp+rename in session.Save makes backgrounding safe. The WAL is +// removed only after a successful save, preserving the durability ordering. +func (m *chatModel) saveSessionCmd() tea.Cmd { + if m == nil || m.session == nil || m.sessionID == "" { + return nil + } + raw := m.session.RawMessages() + if len(raw) == 0 { + return nil + } + id, modelName, provider := m.sessionID, m.session.Model(), m.session.Provider() + msgs := session.FromRuntimeMessages(raw) + createdAt := time.Now() + wal := m.wal + return func() tea.Msg { + err := session.Save(&session.Session{ + ID: id, Model: modelName, Provider: provider, + Messages: msgs, CreatedAt: createdAt, + }) + if err == nil && wal != nil { + _ = wal.Remove() + } + return nil + } +} + func formatQuitResumeMessage(sessionID string) string { if strings.TrimSpace(sessionID) == "" { return "Thank you for using Hawk!\n" diff --git a/cmd/chat_tools.go b/cmd/chat_tools.go index d1d1eb4c..18f0aeaf 100644 --- a/cmd/chat_tools.go +++ b/cmd/chat_tools.go @@ -194,6 +194,13 @@ func mergedMCPHeaders(cfg hawkconfig.MCPServerConfig) map[string]string { } func loadStartupMCPToolSets(servers []startupMCPServerSpec) [][]tool.Tool { + return loadStartupMCPToolSetsWith(defaultRegistryLoadMCPTools, defaultRegistryLoadRemoteMCPTools, servers) +} + +// loadStartupMCPToolSetsWith loads MCP tool sets for the given servers using +// explicit loader functions (injected so callers can capture them by value and +// avoid racing tests that swap the package-level loader vars). +func loadStartupMCPToolSetsWith(loadMCP func(context.Context, string, string, ...string) ([]tool.Tool, error), loadRemoteMCP func(context.Context, string, string, string, map[string]string) ([]tool.Tool, error), servers []startupMCPServerSpec) [][]tool.Tool { results := make([][]tool.Tool, len(servers)) var wg sync.WaitGroup wg.Add(len(servers)) @@ -208,9 +215,9 @@ func loadStartupMCPToolSets(servers []startupMCPServerSpec) [][]tool.Tool { err error ) if spec.isRemote() { - mcpTools, err = defaultRegistryLoadRemoteMCPTools(ctx, spec.name, spec.serverType, spec.url, spec.headers) + mcpTools, err = loadRemoteMCP(ctx, spec.name, spec.serverType, spec.url, spec.headers) } else { - mcpTools, err = defaultRegistryLoadMCPTools(ctx, spec.name, spec.command, spec.args...) + mcpTools, err = loadMCP(ctx, spec.name, spec.command, spec.args...) } if err != nil { return @@ -228,9 +235,6 @@ func defaultRegistry(settings hawkconfig.Settings) (*tool.Registry, error) { if tool.IsPowerShellAvailable() { tools = append(tools, tool.PowerShellTool{}) } - for _, mcpTools := range loadStartupMCPToolSets(configuredStartupMCPServers(settings)) { - tools = append(tools, mcpTools...) - } filtered, err := filterAvailableTools( tools, @@ -257,6 +261,33 @@ func defaultRegistry(settings hawkconfig.Settings) (*tool.Registry, error) { } }() + // Load MCP tools in the background so a hung/absent stdio server delays + // tool availability — not first paint. loadStartupMCPToolSets can block up + // to 1.5s per configured server. The CLI tool filters still apply. + // The loader functions are captured by value so tests that override the + // package vars cannot race with the async goroutine. + loadMCP := defaultRegistryLoadMCPTools + loadRemoteMCP := defaultRegistryLoadRemoteMCPTools + go func() { + mcpTools := loadStartupMCPToolSetsWith(loadMCP, loadRemoteMCP, configuredStartupMCPServers(settings)) + var all []tool.Tool + for _, set := range mcpTools { + all = append(all, set...) + } + filteredMCP, err := filterAvailableTools( + all, + toolsFlagSet, + parseToolListFromCLI(toolsFlag), + parseToolListFromCLI(disallowedToolsFlag), + ) + if err != nil { + return + } + for _, t := range filteredMCP { + _ = registry.Register(t) + } + }() + return registry, nil } diff --git a/cmd/chat_update.go b/cmd/chat_update.go index 7494f972..ff149ac2 100644 --- a/cmd/chat_update.go +++ b/cmd/chat_update.go @@ -1467,7 +1467,12 @@ func (m chatModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.toolStartTime = time.Time{} m.viewDirty = true m.input.Focus() - m.saveSession() + // Persist off the UI thread: a large session JSONL write would + // otherwise hitch the completion frame. The WAL is removed only after + // the save succeeds (see saveSessionCmd). + if saveCmd := m.saveSessionCmd(); saveCmd != nil { + cmds = append(cmds, saveCmd) + } // Trim old messages to prevent unbounded memory growth in long sessions. m.trimOldMessages() diff --git a/cmd/errors.go b/cmd/errors.go index dc363fe7..80ad5a0f 100644 --- a/cmd/errors.go +++ b/cmd/errors.go @@ -206,20 +206,7 @@ func validateStartup(settings hawkconfig.Settings) []StartupWarning { } } - // 2. Quick network reachability check (DNS lookup, no full HTTP request) - if providerName != "" && providerName != "ollama" { - host := providerDNSHost(providerName) - if host != "" { - if _, err := net.LookupHost(host); err != nil { - warnings = append(warnings, StartupWarning{ - Check: "network", - Message: fmt.Sprintf("Cannot resolve %s. Check your internet connection.", host), - }) - } - } - } - - // 3. Check sessions directory is writable + // 2. Check sessions directory is writable sessDir := storage.SessionsDir() if err := os.MkdirAll(sessDir, 0o750); err != nil { warnings = append(warnings, StartupWarning{ @@ -242,6 +229,29 @@ func validateStartup(settings hawkconfig.Settings) []StartupWarning { return warnings } +// checkNetworkReachability runs a quick DNS lookup for the active provider. It +// is intentionally separate from validateStartup so the blocking lookup runs +// post-first-paint (background), not on the TUI startup critical path where an +// offline machine would stall the UI for seconds. Returns a warning message, or +// "" when reachable/not applicable. +func checkNetworkReachability(settings hawkconfig.Settings) string { + providerName := strings.TrimSpace(settings.Provider) + if providerName == "" { + providerName = strings.TrimSpace(hawkconfig.ActiveProvider(context.Background())) + } + if providerName == "" || providerName == "ollama" { + return "" + } + host := providerDNSHost(providerName) + if host == "" { + return "" + } + if _, err := net.LookupHost(host); err != nil { + return fmt.Sprintf("Cannot resolve %s. Check your internet connection.", host) + } + return "" +} + // providerDNSHost returns a hostname to check DNS resolution for a provider. func providerDNSHost(provider string) string { return hawkconfig.GatewayDNSHost(provider) diff --git a/cmd/statusbar.go b/cmd/statusbar.go index 04954a6b..0bc4d08e 100644 --- a/cmd/statusbar.go +++ b/cmd/statusbar.go @@ -267,6 +267,12 @@ func (m *chatModel) refreshStatusBarLeft(force bool) (bool, tea.Cmd) { if m == nil { return false, nil } + // Fast path: within TTL with a cached value — avoid the os.Getwd syscall + // on every keystroke. cwd is only re-resolved when a refresh is actually + // due or forced. + if !force && m.statusLeftKey != "" && m.statusLeftVal != "" && time.Since(m.statusLeftAt) < statusBranchTTL { + return false, nil + } cwd, err := os.Getwd() if err != nil { cwd = "." From 3a62309a368482b3096ebc5cfec0acd6b8abf2b9 Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Sat, 8 Aug 2026 09:16:07 +0530 Subject: [PATCH 09/14] fix(hardening): concurrency, durability, and security hardening - C1: atomic tryConsumeApproval to fix approval N-count TOCTOU race - C2: Wait/WaitIDs don't hold mutex across cond.Wait; timer guards timeout - C3+C4: document WaitAsync drain; track async hook goroutine on asyncWG - C5: Cron Stop() drains in-flight jobs via inFlightWG - C6: FlushOTel flushes meter provider too (was dropping metrics) - C7: dedupe Ctrl+C quit logic through quitModel() for cancel cleanup - M1: LSP readLoop parses Content-Length framing with io.ReadFull - M2: LSP acquire() uses initDone channel to prevent duplicate servers - M3: EndSession/session-end hook use context.WithoutCancel - M4: remove glob-collapsing loop that broke detection - M10: retry only retryable url.Error, not permanent client errors - m7: strengthen dangerously-skip-permissions confirmation token --- cmd/chat_update.go | 16 +--- cmd/root.go | 8 +- internal/engine/approval_gate.go | 50 +++++----- internal/engine/permission_service.go | 9 +- internal/engine/stream.go | 7 +- internal/hooks/hooks.go | 11 ++- internal/lsp/client.go | 47 ++++++++-- internal/lsp/manager.go | 25 ++++- internal/observability/oteltrace/otel_sdk.go | 14 ++- internal/resilience/retry/retry.go | 14 ++- internal/system/cron/cron.go | 9 +- internal/taskruntime/runtime.go | 97 +++++++++++--------- internal/tool/bash.go | 10 +- 13 files changed, 202 insertions(+), 115 deletions(-) diff --git a/cmd/chat_update.go b/cmd/chat_update.go index ff149ac2..98b4e645 100644 --- a/cmd/chat_update.go +++ b/cmd/chat_update.go @@ -879,13 +879,7 @@ func (m chatModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg.String() { case "ctrl+c": if time.Since(m.lastCtrlC) < 1*time.Second { - m.saveSession() - if m.watcherStop != nil { - m.watcherStop() - } - m.stopContainer() - m.quitting = true - return m, tea.Quit + return m.quitModel() } m.lastCtrlC = time.Now() m.messages = append(m.messages, displayMsg{role: "system", content: quitAgainMsg}) @@ -1020,14 +1014,8 @@ func (m chatModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil case "ctrl+c": if time.Since(m.lastCtrlC) < 1*time.Second { - m.saveSession() saveInputHistory(m.history) - if m.watcherStop != nil { - m.watcherStop() - } - m.stopContainer() - m.quitting = true - return m, tea.Quit + return m.quitModel() } m.lastCtrlC = time.Now() m.messages = append(m.messages, displayMsg{role: "system", content: quitAgainMsg}) diff --git a/cmd/root.go b/cmd/root.go index 46f9646c..e836bf41 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -329,9 +329,11 @@ func confirmDangerousSkipPermissions() error { } // dangerSkipConfirmToken is the exact string a user must type to confirm -// --dangerously-skip-permissions. The token is long and explicit so it cannot -// be triggered accidentally or forged by a single injected keystroke. -const dangerSkipConfirmToken = "dangerous-skip-permissions" +// --dangerously-skip-permissions. It is deliberately longer and distinct from +// the flag name so it cannot be triggered by a stray keystroke, shell +// autocomplete, or a single injected line — the user must understand and +// intentionally type the confirmation. +const dangerSkipConfirmToken = "i-understand-the-risks-skip-permissions" // isStdinTerminal reports whether stdin is connected to a terminal. // Delegates to the shared stdinIsTerminal so tests can override uniformly. diff --git a/internal/engine/approval_gate.go b/internal/engine/approval_gate.go index cf43d366..73456075 100644 --- a/internal/engine/approval_gate.go +++ b/internal/engine/approval_gate.go @@ -73,11 +73,14 @@ type ApprovalGate struct { ConfirmFn func(req ApprovalRequest) ApprovalResponse // sessionApprovals caches categories the human approved for the full session. - sessionMu sync.Mutex - sessionApproved map[ApprovalCategory]bool // nApprovals caches categories approved for the next N calls (ApprovalApproveForN). - nMu sync.Mutex - nApprovals map[ApprovalCategory]int + // Both maps and their checks are guarded by a single approvalMu so that + // isSessionApproved + consumeNApproval check-and-consume is atomic — two + // concurrent high-risk tool calls cannot double-spend a session or N-count + // approval (TOCTOU). + approvalMu sync.Mutex + sessionApproved map[ApprovalCategory]bool + nApprovals map[ApprovalCategory]int } // ApprovalRequest describes a gated action presented to the human. @@ -163,42 +166,39 @@ func isNetworkCommand(cmd string) bool { // sessionApprove records a session-wide approval for a category. func (g *ApprovalGate) sessionApprove(cat ApprovalCategory) { - g.sessionMu.Lock() - defer g.sessionMu.Unlock() + g.approvalMu.Lock() + defer g.approvalMu.Unlock() if g.sessionApproved == nil { g.sessionApproved = make(map[ApprovalCategory]bool) } g.sessionApproved[cat] = true } -// isSessionApproved returns true if the category was previously approved for -// the full session. -func (g *ApprovalGate) isSessionApproved(cat ApprovalCategory) bool { - g.sessionMu.Lock() - defer g.sessionMu.Unlock() - return g.sessionApproved[cat] -} - // nApprove records an approval for the next N calls of a category. func (g *ApprovalGate) nApprove(cat ApprovalCategory, n int) { - g.nMu.Lock() - defer g.nMu.Unlock() + g.approvalMu.Lock() + defer g.approvalMu.Unlock() if g.nApprovals == nil { g.nApprovals = make(map[ApprovalCategory]int) } g.nApprovals[cat] += n } -// consumeNApproval decrements the N-count for a category and returns true if a -// remaining approval was consumed. Returns false when the count is exhausted. -func (g *ApprovalGate) consumeNApproval(cat ApprovalCategory) bool { - g.nMu.Lock() - defer g.nMu.Unlock() - if g.nApprovals[cat] <= 0 { - return false +// tryConsumeApproval is the atomic check-and-consume that prevents the TOCTOU +// race between isSessionApproved and consumeNApproval. It checks session-wide +// approval first, then N-count, consuming the N-count only when this call +// actually observes a remaining approval. Returns true if the action is approved. +func (g *ApprovalGate) tryConsumeApproval(cat ApprovalCategory) bool { + g.approvalMu.Lock() + defer g.approvalMu.Unlock() + if g.sessionApproved[cat] { + return true + } + if g.nApprovals[cat] > 0 { + g.nApprovals[cat]-- + return true } - g.nApprovals[cat]-- - return true + return false } // CheckApproval consults the approval gate for a tool call. It returns diff --git a/internal/engine/permission_service.go b/internal/engine/permission_service.go index ad9d3bec..a5dbcba7 100644 --- a/internal/engine/permission_service.go +++ b/internal/engine/permission_service.go @@ -234,11 +234,10 @@ func (s *PermissionService) CheckApproval(_ context.Context, toolName string, ar if s.perm.Autonomy <= g.MaxAutoApprove { return true, "" } - if g.isSessionApproved(cat) { - return true, "" - } - // N-count approval: allow without prompting if a remaining count exists. - if g.consumeNApproval(cat) { + // Atomic check-and-consume: session-wide approval or remaining N-count. + // tryConsumeApproval holds the lock across both checks so concurrent + // high-risk tool calls cannot double-spend a session or N-count approval. + if g.tryConsumeApproval(cat) { return true, "" } req := ApprovalRequest{ diff --git a/internal/engine/stream.go b/internal/engine/stream.go index 68585dff..ab6ec7f9 100644 --- a/internal/engine/stream.go +++ b/internal/engine/stream.go @@ -694,10 +694,13 @@ func (s *Session) agentLoop(ctx context.Context, ch chan<- StreamEvent) { break } } - go s.LifecycleSvc().Pipeline().EndSession(ctx, ctx.Err() == nil, taskGoal) + // Use context.WithoutCancel: this goroutine outlives the caller, + // and ctx would be cancelled on return, killing the end-session + // pipeline before it can assess, learn, and persist experience. + go s.LifecycleSvc().Pipeline().EndSession(context.WithoutCancel(ctx), ctx.Err() == nil, taskGoal) } // Session end hook - hooks.ExecuteAsync(ctx, hooks.EventSessionEnd, map[string]interface{}{ + hooks.ExecuteAsync(context.WithoutCancel(ctx), hooks.EventSessionEnd, map[string]interface{}{ "provider": s.ChatLLM().Provider(), "model": s.ChatLLM().Model(), "messages": len(s.Persistence().RawMessages()), diff --git a/internal/hooks/hooks.go b/internal/hooks/hooks.go index b22e80af..a0411c5b 100644 --- a/internal/hooks/hooks.go +++ b/internal/hooks/hooks.go @@ -161,6 +161,10 @@ func (r *Registry) ExecuteAsyncEnvelope(ctx context.Context, env EventEnvelope) // WaitAsync waits for currently queued asynchronous hooks to finish or for // ctx to expire. Callers must stop scheduling new async hooks before waiting. +// +// The internal goroutine may outlive WaitAsync if the context expires first; +// it will terminate once all tracked hooks complete (the goroutine is not +// leaked indefinitely — it drains when the last hook's Done() is called). func (r *Registry) WaitAsync(ctx context.Context) error { if ctx == nil { ctx = context.Background() @@ -317,7 +321,12 @@ func registerCommandHook(ch *CommandHook) { } } } - go func() { _ = executeHookCommand(ch, data) }() + // Track the async goroutine on asyncWG so WaitAsync can drain it. + global.asyncWG.Add(1) + go func() { + defer global.asyncWG.Done() + _ = executeHookCommand(ch, data) + }() return nil } } diff --git a/internal/lsp/client.go b/internal/lsp/client.go index 0e6afc70..b51d6e06 100644 --- a/internal/lsp/client.go +++ b/internal/lsp/client.go @@ -8,6 +8,7 @@ import ( "io" "log/slog" "os/exec" + "strconv" "strings" "sync" "sync/atomic" @@ -79,7 +80,7 @@ type RenameCapabilities struct { type LSPClient struct { cmd *exec.Cmd stdin io.WriteCloser - stdout *bufio.Scanner + stdout *bufio.Reader mu sync.Mutex nextID atomic.Int64 pending map[interface{}]chan json.RawMessage @@ -106,11 +107,10 @@ func NewLSPClient(ctx context.Context, lang string, cfg ServerConfig) (*LSPClien c := &LSPClient{ cmd: cmd, stdin: stdin, - stdout: bufio.NewScanner(stdout), + stdout: bufio.NewReader(stdout), pending: make(map[interface{}]chan json.RawMessage), language: lang, } - c.stdout.Buffer(make([]byte, 0, 1024*1024), 1024*1024) // Start read loop go c.readLoop() @@ -143,17 +143,44 @@ func NewLSPClient(ctx context.Context, lang string, cfg ServerConfig) (*LSPClien } func (c *LSPClient) readLoop() { - for c.stdout.Scan() { - line := c.stdout.Bytes() - if len(line) == 0 { - continue + // LSP uses Content-Length framed messages: "Content-Length: N\r\n\r\n". + // We read line-by-line for headers, then read exactly N bytes for the body. + // This correctly handles pretty-printed JSON (embedded newlines) that a + // naive line-by-line parser would fragment. + for { + // Read the header block line by line. + var contentLength int + for { + line, err := c.stdout.ReadString('\n') + if err != nil { + return + } + // Headers are terminated by an empty line. + if line == "\r\n" || line == "\n" { + break + } + header := strings.TrimSpace(line) + if strings.HasPrefix(header, "Content-Length:") { + n, err := strconv.Atoi(strings.TrimSpace(strings.TrimPrefix(header, "Content-Length:"))) + if err == nil && n > 0 { + contentLength = n + } + continue + } + // Any other header (e.g. Content-Type) — keep reading headers. } - // Skip Content-Length header lines - if strings.HasPrefix(string(line), "Content-Length:") { + if contentLength == 0 { + // No Content-Length header found — skip this frame and keep reading. continue } + // Read exactly contentLength bytes for the JSON-RPC body. + body := make([]byte, contentLength) + if _, err := io.ReadFull(c.stdout, body); err != nil { + return + } + var msg struct { ID interface{} `json:"id"` Method string `json:"method,omitempty"` @@ -161,7 +188,7 @@ func (c *LSPClient) readLoop() { Error json.RawMessage `json:"error,omitempty"` Params json.RawMessage `json:"params,omitempty"` } - if err := json.Unmarshal(line, &msg); err != nil { + if err := json.Unmarshal(body, &msg); err != nil { continue } diff --git a/internal/lsp/manager.go b/internal/lsp/manager.go index 0acecf85..e6fea3a3 100644 --- a/internal/lsp/manager.go +++ b/internal/lsp/manager.go @@ -2,6 +2,7 @@ package lsp import ( "context" + "fmt" "log/slog" "sync" "sync/atomic" @@ -35,6 +36,10 @@ type ManagedClient struct { lastUsed time.Time initStart time.Time initializing bool + // initDone is closed when the in-progress NewLSPClient call finishes, + // waking concurrent acquirers so they reuse the result instead of spawning + // a duplicate server. + initDone chan struct{} } // LSPManager manages a pool of language server connections. @@ -133,10 +138,12 @@ func (m *LSPManager) acquire(ctx context.Context, lang string) (*ManagedClient, mc.mu.Lock() atomic.AddInt32(&mc.waiters, 1) - // Spawn client if needed - if mc.client == nil { + // Spawn client if needed. Only one acquirer spawns; concurrent acquirers + // wait on mc.initDone for the result instead of spawning a duplicate. + if mc.client == nil && !mc.initializing { mc.initializing = true mc.initStart = time.Now() + mc.initDone = make(chan struct{}) mc.mu.Unlock() client, err := NewLSPClient(ctx, lang, mc.config) @@ -145,10 +152,24 @@ func (m *LSPManager) acquire(ctx context.Context, lang string) (*ManagedClient, mc.initializing = false if err != nil { atomic.AddInt32(&mc.waiters, -1) + close(mc.initDone) mc.mu.Unlock() return nil, err } mc.client = client + close(mc.initDone) + } else if mc.initializing { + // Another goroutine is already spawning the client for this language. + // Wait for it to finish, then reuse the result. + initDone := mc.initDone + mc.mu.Unlock() + <-initDone + mc.mu.Lock() + if mc.client == nil { + atomic.AddInt32(&mc.waiters, -1) + mc.mu.Unlock() + return nil, fmt.Errorf("lsp: %s server failed to start", lang) + } } mc.lastUsed = time.Now() diff --git a/internal/observability/oteltrace/otel_sdk.go b/internal/observability/oteltrace/otel_sdk.go index 2e2e624a..1e958a77 100644 --- a/internal/observability/oteltrace/otel_sdk.go +++ b/internal/observability/oteltrace/otel_sdk.go @@ -130,14 +130,22 @@ func (p *OTelProviders) ShutdownOTel(ctx context.Context) error { return firstErr } -// FlushOTel forces export of pending data. +// FlushOTel forces export of pending data from both the tracer and meter +// providers. Flushing only the tracer provider would drop pending counter +// and gauge metrics. func (p *OTelProviders) FlushOTel(ctx context.Context) error { + var firstErr error if p.tracerProvider != nil { if err := p.tracerProvider.ForceFlush(ctx); err != nil { - return err + firstErr = err } } - return nil + if p.meterProvider != nil { + if err := p.meterProvider.ForceFlush(ctx); err != nil && firstErr == nil { + firstErr = err + } + } + return firstErr } // RecordMetric records a counter metric. diff --git a/internal/resilience/retry/retry.go b/internal/resilience/retry/retry.go index b508b33a..9188b0f7 100644 --- a/internal/resilience/retry/retry.go +++ b/internal/resilience/retry/retry.go @@ -74,15 +74,21 @@ func IsRetryable(err error) bool { return true } - // url.Error wraps transport-layer failures from http.Client. + // url.Error wraps failures from http.Client. Timeouts are retryable. + // Other url.Error values wrap either transport-level failures (already + // caught by the net.Error check above, since errors.As unwraps + // recursively) or permanent client errors like "unsupported protocol + // scheme", "invalid port", and "missing protocol scheme" — those must + // NOT be retried. Only retry when the wrapped error is itself retryable. var urlErr *url.Error if errors.As(err, &urlErr) { if urlErr.Timeout() { return true } - // Retry on connection errors that the transport didn't classify as - // permanent (e.g., "connection reset by peer" in the wrapped error). - return true + if inner := urlErr.Unwrap(); inner != nil { + return IsRetryable(inner) + } + return false } // io.EOF and io.ErrUnexpectedEOF are transport-level errors that may diff --git a/internal/system/cron/cron.go b/internal/system/cron/cron.go index a8c59a15..848372e4 100644 --- a/internal/system/cron/cron.go +++ b/internal/system/cron/cron.go @@ -72,6 +72,8 @@ type Engine struct { maxConcurrent int inFlight int runs []RunRecord + // inFlightWG tracks in-flight job goroutines so Stop() can drain them. + inFlightWG sync.WaitGroup } func NewEngine(handler JobHandler, maxConcurrent int) *Engine { @@ -142,11 +144,14 @@ func (e *Engine) Start() { func (e *Engine) Stop() { e.mu.Lock() - defer e.mu.Unlock() if e.running { close(e.stopCh) e.running = false } + e.mu.Unlock() + // Wait for in-flight jobs to finish so handlers are not killed mid- + // execution (which would leave state mutations incomplete). + e.inFlightWG.Wait() } func (e *Engine) Status() map[string]interface{} { @@ -202,11 +207,13 @@ func (e *Engine) tick(now time.Time) { } e.inFlight++ + e.inFlightWG.Add(1) go e.executeJob(job) } } func (e *Engine) executeJob(job *Job) { + defer e.inFlightWG.Done() start := time.Now() err := e.handler(job) duration := time.Since(start) diff --git a/internal/taskruntime/runtime.go b/internal/taskruntime/runtime.go index 79455db3..310e7724 100644 --- a/internal/taskruntime/runtime.go +++ b/internal/taskruntime/runtime.go @@ -170,29 +170,44 @@ func (r *Registry) Kill(id string) error { } // Wait blocks until no tasks are running or timeout elapses. +// It does NOT hold the lock across the blocking wait, so other operations +// (SpawnAgent, Kill, Get, etc.) can proceed while waiting. func (r *Registry) Wait(timeout time.Duration) []*Task { deadline := time.Now().Add(timeout) - r.mu.Lock() - defer r.mu.Unlock() - for len(r.running) > 0 { + + // cond.Wait() cannot be combined with a timeout directly, so a timer + // goroutine broadcasts on the cond when the deadline elapses. This wakes + // the wait loop to re-check the deadline. The timer is stopped when Wait + // returns early (all tasks done) so it cannot fire after exit. + timer := time.AfterFunc(timeout, func() { r.cond.Broadcast() }) + defer timer.Stop() + + for { + r.mu.Lock() + if len(r.running) == 0 { + out := make([]*Task, 0, len(r.done)) + for _, t := range r.done { + cp := *t + out = append(out, &cp) + } + r.mu.Unlock() + return out + } remaining := time.Until(deadline) if remaining <= 0 { - break - } - timer := time.AfterFunc(remaining, func() { - r.mu.Lock() - r.cond.Broadcast() + out := make([]*Task, 0, len(r.done)) + for _, t := range r.done { + cp := *t + out = append(out, &cp) + } r.mu.Unlock() - }) + return out + } + // cond.Wait() atomically unlocks r.mu while blocking and re-locks on + // wakeup, so other goroutines can acquire r.mu during the wait. r.cond.Wait() - timer.Stop() - } - out := make([]*Task, 0, len(r.done)) - for _, t := range r.done { - cp := *t - out = append(out, &cp) + r.mu.Unlock() } - return out } // CollectCompleted returns and clears completed tasks. @@ -281,6 +296,7 @@ func (r *Registry) AppendOutput(id, chunk string) { } // WaitIDs blocks until all listed task ids are not running, or timeout. +// Like Wait, it does not hold the lock across the blocking wait. func (r *Registry) WaitIDs(ids []string, timeout time.Duration) []*Task { if len(ids) == 0 { return r.Wait(timeout) @@ -290,9 +306,16 @@ func (r *Registry) WaitIDs(ids []string, timeout time.Duration) []*Task { want[id] = true } deadline := time.Now().Add(timeout) - r.mu.Lock() - defer r.mu.Unlock() + + // cond.Wait() cannot be combined with a timeout directly, so a timer + // goroutine broadcasts on the cond when the deadline elapses. This wakes + // the wait loop to re-check the deadline. The timer is stopped when + // WaitIDs returns early so it cannot fire after exit. + timer := time.AfterFunc(timeout, func() { r.cond.Broadcast() }) + defer timer.Stop() + for { + r.mu.Lock() still := false for id := range want { if _, ok := r.running[id]; ok { @@ -300,32 +323,24 @@ func (r *Registry) WaitIDs(ids []string, timeout time.Duration) []*Task { break } } - if !still { - break - } - remaining := time.Until(deadline) - if remaining <= 0 { - break - } - timer := time.AfterFunc(remaining, func() { - r.mu.Lock() - r.cond.Broadcast() + if !still || time.Now().After(deadline) { + out := make([]*Task, 0, len(ids)) + for _, id := range ids { + if t, ok := r.done[id]; ok { + cp := *t + out = append(out, &cp) + } else if t, ok := r.running[id]; ok { + cp := *t + out = append(out, &cp) + } + } r.mu.Unlock() - }) - r.cond.Wait() - timer.Stop() - } - out := make([]*Task, 0, len(ids)) - for _, id := range ids { - if t, ok := r.done[id]; ok { - cp := *t - out = append(out, &cp) - } else if t, ok := r.running[id]; ok { - cp := *t - out = append(out, &cp) + return out } + // cond.Wait() atomically unlocks r.mu while blocking. + r.cond.Wait() + r.mu.Unlock() } - return out } // List returns snapshots of all running and recently completed tasks. diff --git a/internal/tool/bash.go b/internal/tool/bash.go index 6c1c45b3..15938bfa 100644 --- a/internal/tool/bash.go +++ b/internal/tool/bash.go @@ -62,10 +62,12 @@ func normalizeCommand(cmd string) string { } return m }) - // Collapse repeated /* sequences: rm -rf /* -> rm -rf / - for strings.Contains(cmd, "/*") { - cmd = strings.ReplaceAll(cmd, "/*", "/") - } + // NOTE: we deliberately do NOT collapse "/*" to "/" here. The dangerous + // patterns list already includes both "rm -rf /" and "rm -rf /*" as + // literals, so collapsing is redundant for detection. Worse, it destroys + // legitimate globs (e.g. "ls src/*") and creates false negatives + // (e.g. "rm -rf /home/user/*" -> "rm -rf /home/user/", matching neither + // pattern). Detection operates on the raw command; leave it intact. return cmd } From e16fd45b18df11dcf301e8a5f06785067e2551b0 Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Sat, 8 Aug 2026 09:49:11 +0530 Subject: [PATCH 10/14] chore: sync external/trace to d8429e4 and bump go.mod pins Submodule sync: external/trace advanced to port unified checkpoint architecture. Bumps trace require and its transitive dependencies (go-git v6 alpha.5, auth-go v0.5.2, etc.) per MVS selection. --- external/trace | 2 +- go.mod | 18 +++++++++--------- go.sum | 18 ++++++++++++++++++ 3 files changed, 28 insertions(+), 10 deletions(-) diff --git a/external/trace b/external/trace index 1cd0fc51..d8429e47 160000 --- a/external/trace +++ b/external/trace @@ -1 +1 @@ -Subproject commit 1cd0fc51b106a3eaaee0682a4a1085dc17f25270 +Subproject commit d8429e473c8615a84fb0e991aeec242593130575 diff --git a/go.mod b/go.mod index c3542a1f..173a255d 100644 --- a/go.mod +++ b/go.mod @@ -33,7 +33,7 @@ require ( go.opentelemetry.io/otel/sdk v1.44.0 go.opentelemetry.io/otel/sdk/metric v1.44.0 go.opentelemetry.io/otel/trace v1.44.0 - golang.org/x/mod v0.37.0 + golang.org/x/mod v0.38.0 golang.org/x/sys v0.47.0 golang.org/x/term v0.45.0 golang.org/x/text v0.40.0 @@ -80,16 +80,16 @@ require ( github.com/dlclark/regexp2 v1.12.0 // indirect github.com/dsnet/compress v0.0.2-0.20230904184137-39efe44ab707 // indirect github.com/emirpasic/gods v1.18.1 // indirect - github.com/entireio/auth-go v0.4.0 // indirect + github.com/entireio/auth-go v0.5.2 // indirect github.com/fatih/semgroup v1.3.0 // indirect github.com/gitleaks/go-gitdiff v0.9.1 // indirect github.com/go-git/gcfg/v2 v2.0.2 // indirect - github.com/go-git/go-billy/v6 v6.0.0-alpha.1.0.20260519112248-0095b064a6c6 // indirect - github.com/go-git/go-git/v6 v6.0.0-alpha.4 // indirect; indirect — TODO: alpha, API-unstable; upgrade when v6 reaches stable or pin back to go-git/v5 - github.com/go-git/x/plugin/objectsigner/auto v0.1.0 // indirect - github.com/go-git/x/plugin/objectsigner/gpg v0.1.0 // indirect - github.com/go-git/x/plugin/objectsigner/program v0.0.0-20260509055934-990a63433b45 // indirect - github.com/go-git/x/plugin/objectsigner/ssh v0.1.0 // indirect + github.com/go-git/go-billy/v6 v6.0.0-alpha.2 // indirect + github.com/go-git/go-git/v6 v6.0.0-alpha.5 // indirect; indirect — TODO: alpha, API-unstable; upgrade when v6 reaches stable or pin back to go-git/v5 + github.com/go-git/x/plugin/objectsigner/auto v0.1.1-0.20260624122410-382b2905c041 // indirect + github.com/go-git/x/plugin/objectsigner/gpg v0.2.1-0.20260624122410-382b2905c041 // indirect + github.com/go-git/x/plugin/objectsigner/program v0.0.0-20260624122410-382b2905c041 // indirect + github.com/go-git/x/plugin/objectsigner/ssh v0.2.1-0.20260624122410-382b2905c041 // indirect github.com/go-sprout/sprout v1.0.3 // indirect github.com/goccy/go-json v0.10.6 // indirect github.com/godbus/dbus/v5 v5.2.2 // indirect @@ -138,7 +138,7 @@ require ( require ( github.com/BurntSushi/toml v1.6.0 - github.com/GrayCodeAI/trace v0.1.4-0.20260803003541-1cd0fc51b106 + github.com/GrayCodeAI/trace v0.1.4-0.20260808011723-d8429e473c86 github.com/atotto/clipboard v0.1.4 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/cenkalti/backoff/v5 v5.0.3 // indirect diff --git a/go.sum b/go.sum index 58baece8..2b441c13 100644 --- a/go.sum +++ b/go.sum @@ -30,6 +30,8 @@ github.com/GrayCodeAI/tok v0.1.5-0.20260731011234-7a7c3cbae89b h1:HjAYJHkFSn3Fxi github.com/GrayCodeAI/tok v0.1.5-0.20260731011234-7a7c3cbae89b/go.mod h1:/KTHlWg+qg8fDV8qRsLUfp4VKtt5seTyED1byxldBtE= github.com/GrayCodeAI/trace v0.1.4-0.20260803003541-1cd0fc51b106 h1:hzR6j0JaKOKCMrck60Pmkqdqip8Zu2vuimUXvZG8+8U= github.com/GrayCodeAI/trace v0.1.4-0.20260803003541-1cd0fc51b106/go.mod h1:xPV6sC2cUG0i7QD7aX3KtjiW1rlEhN9c5w9jBSc8LBc= +github.com/GrayCodeAI/trace v0.1.4-0.20260808011723-d8429e473c86 h1:bWCYgLG8+G6KicBRlxLAaQkQrzg9Q7UPBEg/ZIvL2Yc= +github.com/GrayCodeAI/trace v0.1.4-0.20260808011723-d8429e473c86/go.mod h1:aMTcvGc6zcIE2urPF79AmiGOg41H1IA/hP1TvJrAPZI= github.com/GrayCodeAI/yaad v0.2.1-0.20260729231812-52c8c805791f h1:4ridJ6o/eM2qLyIEwGwqTdZtYc7hwiS+cqC3Mtk6mJM= github.com/GrayCodeAI/yaad v0.2.1-0.20260729231812-52c8c805791f/go.mod h1:lN77OfTzQLNIWJhsQ+KUpnFwqTfaIb/tG0bhXSjCuZs= github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ= @@ -140,6 +142,8 @@ github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ= github.com/entireio/auth-go v0.4.0 h1:2Z12fsIKOEoDNMsk77AWDLu45z54rZzs1rBozLF2ddM= github.com/entireio/auth-go v0.4.0/go.mod h1:TGgA/d21dPPNL4yYO+gqU+ZfS1Hcr8dU2303nLcVz4U= +github.com/entireio/auth-go v0.5.2 h1:z0deFLJiBQH3ROMo/Z/YE2HcJ6W2SxZO5RVn2feQIPM= +github.com/entireio/auth-go v0.5.2/go.mod h1:eqFYgiNSBw6HXYR3j8DRW0/WTV1dX3SWxr2D6YCYNQ0= github.com/fatih/semgroup v1.3.0 h1:pTEnmcEze/BUf4UmVn9f1ZT1OckkBTNRV9w9k/I2/y4= github.com/fatih/semgroup v1.3.0/go.mod h1:thVp+PGZMO9KJ+k96oNGJo06hWgsKOWxTfYfx5R2VaE= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= @@ -154,18 +158,30 @@ github.com/go-git/gcfg/v2 v2.0.2 h1:MY5SIIfTGGEMhdA7d7JePuVVxtKL7Hp+ApGDJAJ7dpo= github.com/go-git/gcfg/v2 v2.0.2/go.mod h1:/lv2NsxvhepuMrldsFilrgct6pxzpGdSRC13ydTLSLs= github.com/go-git/go-billy/v6 v6.0.0-alpha.1.0.20260519112248-0095b064a6c6 h1:AaQOU2NVLxnBGWkv5YSoxomcDCqlaqfCW0t00pNKtnk= github.com/go-git/go-billy/v6 v6.0.0-alpha.1.0.20260519112248-0095b064a6c6/go.mod h1:eaCUpHbedW7//EwcYmUDfJe2N6sJC9O12AT0OTqJR1E= +github.com/go-git/go-billy/v6 v6.0.0-alpha.2 h1:1Sv5WemXL8CxKrAx1gioJ+uHNb2bZJhiQLfwSZ4Et8c= +github.com/go-git/go-billy/v6 v6.0.0-alpha.2/go.mod h1:r/bsv9i/iDyyEU8/Z6mjC+YraOVwie1ddfUqBCElKXQ= github.com/go-git/go-git-fixtures/v6 v6.0.0-alpha.1 h1:gmqi2jvsreu0s8JMLylYDFq4sbjHwwlhktMw0DUg3mA= github.com/go-git/go-git-fixtures/v6 v6.0.0-alpha.1/go.mod h1:ECf1MqJlBdYpKggBrOXjo/0EnvRZx6D++I86UYjPgAQ= github.com/go-git/go-git/v6 v6.0.0-alpha.4 h1:aDTc2UGanmaE7FkGLSlBEB9nohMnQ+RKXcfq/D+esDQ= github.com/go-git/go-git/v6 v6.0.0-alpha.4/go.mod h1:4ODa/G7hPWrh4Y+7lmt59Ij3zW38IEfvRoAZxLYYBhc= +github.com/go-git/go-git/v6 v6.0.0-alpha.5 h1:sE+OlkHgYWNMVmN1s9sR7uyFgsWLtxcNWse/vBYKxRE= +github.com/go-git/go-git/v6 v6.0.0-alpha.5/go.mod h1:3IjhiZnM+uBmUrOGSeqrJpsmi4Vd0H2NZO/uK2a7d0s= github.com/go-git/x/plugin/objectsigner/auto v0.1.0 h1:RcLW29RgwSCmqrNSs7QOxvWkRbM1vPu0Vp9TCECZjMs= github.com/go-git/x/plugin/objectsigner/auto v0.1.0/go.mod h1:iP2cXPyXc//9v9THS3y/MLi0jnt7vEqwUDj11qQfFPg= +github.com/go-git/x/plugin/objectsigner/auto v0.1.1-0.20260624122410-382b2905c041 h1:ATVPaVKC1wbuQdvGKfKXotuwXYeGfigyHERl7lmNG+I= +github.com/go-git/x/plugin/objectsigner/auto v0.1.1-0.20260624122410-382b2905c041/go.mod h1:Cpmdf+1Pmw6nPWTpfBMsPmWju2Tb+qjwccWR5AvOBC4= github.com/go-git/x/plugin/objectsigner/gpg v0.1.0 h1:NEGVSOD+LPnus6j4iNkAZaHVTc4DNY223y1/I2Jq2yI= github.com/go-git/x/plugin/objectsigner/gpg v0.1.0/go.mod h1:1iosWq3OOqZxtNrwDHtcjicswuaOT45J5GMFyCk80wc= +github.com/go-git/x/plugin/objectsigner/gpg v0.2.1-0.20260624122410-382b2905c041 h1:Tni6GTpv/Nx4HAub64YmnxGWe99za33jfzy3GesditQ= +github.com/go-git/x/plugin/objectsigner/gpg v0.2.1-0.20260624122410-382b2905c041/go.mod h1:1iosWq3OOqZxtNrwDHtcjicswuaOT45J5GMFyCk80wc= github.com/go-git/x/plugin/objectsigner/program v0.0.0-20260509055934-990a63433b45 h1:9HmkDRECQ7yGwcQ35x+0HhQp/JBKLkC9Cozr/z3gs4Q= github.com/go-git/x/plugin/objectsigner/program v0.0.0-20260509055934-990a63433b45/go.mod h1:qqkRcAeBDQLDJTBiN/s4k4Xj6eFBP+2cdoZDzsld0b0= +github.com/go-git/x/plugin/objectsigner/program v0.0.0-20260624122410-382b2905c041 h1:3SNIy+i6ou6OX1ekdFKpuTg+BGPO3Q4Jj6by0KX/2lY= +github.com/go-git/x/plugin/objectsigner/program v0.0.0-20260624122410-382b2905c041/go.mod h1:qqkRcAeBDQLDJTBiN/s4k4Xj6eFBP+2cdoZDzsld0b0= github.com/go-git/x/plugin/objectsigner/ssh v0.1.0 h1:lAeeDgc1oxsMMvVUed6ssrqJnD97UR1K/dXIDdeg1Yc= github.com/go-git/x/plugin/objectsigner/ssh v0.1.0/go.mod h1:6BvpZj9Yry1ZFNw4N5OZDc+7M1T8oyrZilLNFg2aTsM= +github.com/go-git/x/plugin/objectsigner/ssh v0.2.1-0.20260624122410-382b2905c041 h1:mmJ/LFr0c7ij9UYQorU66989ge06vf1H07ud533UQ/I= +github.com/go-git/x/plugin/objectsigner/ssh v0.2.1-0.20260624122410-382b2905c041/go.mod h1:6BvpZj9Yry1ZFNw4N5OZDc+7M1T8oyrZilLNFg2aTsM= github.com/go-json-experiment/json v0.0.0-20260623181947-01eb4420fa68 h1:KZaTBSyshWX3MP5jukJcNSuXDQTO+rNpt0J564dX/eg= github.com/go-json-experiment/json v0.0.0-20260623181947-01eb4420fa68/go.mod h1:tphK2c80bpPhMOI4v6bIc2xWywPfbqi1Z06+RcrMkDg= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= @@ -391,6 +407,8 @@ golang.org/x/exp v0.0.0-20260603202125-055de637280b h1:v1uXiEBHo8QA0LiGCo7UgHMzH golang.org/x/exp v0.0.0-20260603202125-055de637280b/go.mod h1:d2fgXJLVs4dYDHUk5lwMIfzRzSrWCfGZb0ZqeLa/Vcw= golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ= golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0= +golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk= +golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE= golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU= From db9c53aa0bf4c2246eb5ae450c0e3727ae3690e5 Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Sat, 8 Aug 2026 12:27:09 +0530 Subject: [PATCH 11/14] fix(hardening): robust cond-timeout broadcast and persist input history taskruntime.Wait/WaitIDs: replace the one-shot time.AfterFunc broadcast with a timer goroutine that repeats the broadcast after the deadline. A single broadcast could be lost in the microsecond window between loop iterations while the waiter is outside cond.Wait(), letting the wait block past its deadline. Repeating guarantees the deadline is observed; a stop channel terminates the goroutine when the wait returns so it cannot leak. chat_update.quitModel: flush input history here so every quit path (/quit, SIGTERM, SIGINT, Ctrl+C) persists it. Previously saveInputHistory was only called on the config-view double-Ctrl+C path, so history was silently lost on all other exits. --- cmd/chat_update.go | 2 +- internal/taskruntime/runtime.go | 59 ++++++++++++++++++++++++++------- 2 files changed, 48 insertions(+), 13 deletions(-) diff --git a/cmd/chat_update.go b/cmd/chat_update.go index 98b4e645..bdbe60cf 100644 --- a/cmd/chat_update.go +++ b/cmd/chat_update.go @@ -110,6 +110,7 @@ func (m *chatModel) quitModel() (tea.Model, tea.Cmd) { m.cancel() m.cancel = nil } + saveInputHistory(m.history) m.saveSession() if m.watcherStop != nil { m.watcherStop() @@ -1014,7 +1015,6 @@ func (m chatModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil case "ctrl+c": if time.Since(m.lastCtrlC) < 1*time.Second { - saveInputHistory(m.history) return m.quitModel() } m.lastCtrlC = time.Now() diff --git a/internal/taskruntime/runtime.go b/internal/taskruntime/runtime.go index 310e7724..d134aa88 100644 --- a/internal/taskruntime/runtime.go +++ b/internal/taskruntime/runtime.go @@ -176,11 +176,29 @@ func (r *Registry) Wait(timeout time.Duration) []*Task { deadline := time.Now().Add(timeout) // cond.Wait() cannot be combined with a timeout directly, so a timer - // goroutine broadcasts on the cond when the deadline elapses. This wakes - // the wait loop to re-check the deadline. The timer is stopped when Wait - // returns early (all tasks done) so it cannot fire after exit. - timer := time.AfterFunc(timeout, func() { r.cond.Broadcast() }) - defer timer.Stop() + // goroutine broadcasts on the cond once the deadline elapses. A single + // broadcast could be lost if it lands in the microsecond window between + // loop iterations while the waiter is outside cond.Wait(), so the timer + // repeats the broadcast every millisecond until Wait returns. The stop + // channel terminates the goroutine when Wait returns (all tasks done or + // deadline passed) so it cannot leak or fire after exit. + stop := make(chan struct{}) + defer close(stop) + go func() { + select { + case <-time.After(timeout): + case <-stop: + return + } + for { + r.cond.Broadcast() + select { + case <-stop: + return + case <-time.After(time.Millisecond): + } + } + }() for { r.mu.Lock() @@ -193,8 +211,7 @@ func (r *Registry) Wait(timeout time.Duration) []*Task { r.mu.Unlock() return out } - remaining := time.Until(deadline) - if remaining <= 0 { + if time.Now().After(deadline) { out := make([]*Task, 0, len(r.done)) for _, t := range r.done { cp := *t @@ -308,11 +325,29 @@ func (r *Registry) WaitIDs(ids []string, timeout time.Duration) []*Task { deadline := time.Now().Add(timeout) // cond.Wait() cannot be combined with a timeout directly, so a timer - // goroutine broadcasts on the cond when the deadline elapses. This wakes - // the wait loop to re-check the deadline. The timer is stopped when - // WaitIDs returns early so it cannot fire after exit. - timer := time.AfterFunc(timeout, func() { r.cond.Broadcast() }) - defer timer.Stop() + // goroutine broadcasts on the cond once the deadline elapses. A single + // broadcast could be lost if it lands in the microsecond window between + // loop iterations while the waiter is outside cond.Wait(), so the timer + // repeats the broadcast every millisecond until WaitIDs returns. The stop + // channel terminates the goroutine when WaitIDs returns so it cannot leak + // or fire after exit. + stop := make(chan struct{}) + defer close(stop) + go func() { + select { + case <-time.After(timeout): + case <-stop: + return + } + for { + r.cond.Broadcast() + select { + case <-stop: + return + case <-time.After(time.Millisecond): + } + } + }() for { r.mu.Lock() From db021332a37776dca6097c3e7e5bea5afcb085b6 Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Sat, 8 Aug 2026 12:37:13 +0530 Subject: [PATCH 12/14] chore: sync external/trace to c7740fd and bump go.mod pins Advances external/trace and bumps go.mod/go.sum requires to match the new gitlink. MVS pulls in transitive upgrades from the trace release. --- external/trace | 2 +- go.mod | 37 +++++++++++++------- go.sum | 92 +++++++++++++++++++++++++++----------------------- 3 files changed, 76 insertions(+), 55 deletions(-) diff --git a/external/trace b/external/trace index d8429e47..c7740fd2 160000 --- a/external/trace +++ b/external/trace @@ -1 +1 @@ -Subproject commit d8429e473c8615a84fb0e991aeec242593130575 +Subproject commit c7740fd26efa32fe50b2cf90e1e5cc5e3e334c6d diff --git a/go.mod b/go.mod index 173a255d..a0b9c871 100644 --- a/go.mod +++ b/go.mod @@ -8,9 +8,9 @@ go 1.26.5 // The import paths in the Go source have been migrated to charm.land/... to match. require ( - charm.land/bubbles/v2 v2.1.0 - charm.land/bubbletea/v2 v2.0.7 - charm.land/lipgloss/v2 v2.0.3 + charm.land/bubbles/v2 v2.1.1 + charm.land/bubbletea/v2 v2.0.8 + charm.land/lipgloss/v2 v2.0.5 github.com/GrayCodeAI/eyrie v0.2.2 github.com/GrayCodeAI/hawk-core-contracts v0.1.12 github.com/GrayCodeAI/inspect v0.0.0-20260726091806-08f3151d5738 @@ -23,7 +23,7 @@ require ( github.com/chromedp/chromedp v0.16.0 github.com/fsnotify/fsnotify v1.10.1 github.com/google/uuid v1.6.0 - github.com/mattn/go-runewidth v0.0.24 + github.com/mattn/go-runewidth v0.0.27 github.com/smacker/go-tree-sitter v0.0.0-20240827094217-dd81d9e9be82 github.com/spf13/cobra v1.10.2 github.com/spf13/pflag v1.0.10 @@ -45,15 +45,29 @@ require ( github.com/GrayCodeAI/hawk-mcpkit v0.1.6-0.20260729083555-85ac53f3ec84 // indirect github.com/chromedp/cdproto v0.0.0-20260714215040-dc233986426f // indirect github.com/chromedp/sysutil v1.1.0 // indirect + github.com/denisbrodbeck/machineid v1.0.1 // indirect + github.com/fatih/color v1.19.0 // indirect + github.com/ghodss/yaml v1.0.0 // indirect + github.com/go-faster/errors v0.8.0 // indirect + github.com/go-faster/jx v1.2.0 // indirect + github.com/go-faster/yaml v0.4.6 // indirect github.com/go-json-experiment/json v0.0.0-20260623181947-01eb4420fa68 // indirect github.com/gobwas/httphead v0.1.0 // indirect github.com/gobwas/pool v0.2.1 // indirect github.com/gobwas/ws v1.4.0 // indirect + github.com/gofrs/flock v0.13.0 // indirect + github.com/ogen-go/ogen v1.23.0 // indirect + github.com/oklog/ulid/v2 v2.1.2 // indirect + github.com/segmentio/asm v1.2.1 // indirect + github.com/shopspring/decimal v1.4.0 // indirect + go.uber.org/multierr v1.11.0 // indirect + go.uber.org/zap v1.28.0 // indirect + gopkg.in/yaml.v2 v2.4.0 // indirect ) require ( cel.dev/expr v0.25.2 // indirect - charm.land/glamour/v2 v2.0.0 // indirect + charm.land/glamour/v2 v2.0.1 // indirect charm.land/huh/v2 v2.0.3 // indirect dario.cat/mergo v1.0.2 // indirect github.com/BobuSumisu/aho-corasick v1.0.3 // indirect @@ -64,12 +78,12 @@ require ( github.com/andybalholm/brotli v1.2.1 // indirect github.com/antlr4-go/antlr/v4 v4.13.1 // indirect github.com/aymerick/douceur v0.2.0 // indirect - github.com/betterleaks/betterleaks v1.4.1 // indirect + github.com/betterleaks/betterleaks v1.5.0 // indirect github.com/bodgit/plumbing v1.3.0 // indirect github.com/bodgit/sevenzip v1.6.4 // indirect github.com/bodgit/windows v1.0.1 // indirect github.com/catppuccin/go v0.3.0 // indirect - github.com/charmbracelet/ultraviolet v0.0.0-20260608091853-35bcb7319efa // indirect + github.com/charmbracelet/ultraviolet v0.0.0-20260703014108-f5a850f9c2b7 // indirect github.com/charmbracelet/x/exp/ordered v0.1.0 // indirect github.com/charmbracelet/x/exp/slice v0.0.0-20260608090822-c3ad58c6c9e5 // indirect github.com/charmbracelet/x/exp/strings v0.1.0 // indirect @@ -115,11 +129,10 @@ require ( github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect github.com/mitchellh/reflectwalk v1.0.2 // indirect github.com/nwaples/rardecode/v2 v2.2.3 // indirect - github.com/petermattis/goid v0.0.0-20260330135022-df67b199bc81 // indirect github.com/pierrec/lz4/v4 v4.1.27 // indirect github.com/pjbgf/sha1cd v0.6.0 // indirect github.com/pkoukk/tiktoken-go v0.1.8 // indirect - github.com/posthog/posthog-go v1.14.0 // indirect + github.com/posthog/posthog-go v1.22.0 // indirect github.com/rs/zerolog v1.35.1 // indirect github.com/sergi/go-diff v1.4.0 // indirect github.com/shurcooL/githubv4 v0.0.0-20260209031235-2402fdf4a9ed // indirect @@ -138,7 +151,7 @@ require ( require ( github.com/BurntSushi/toml v1.6.0 - github.com/GrayCodeAI/trace v0.1.4-0.20260808011723-d8429e473c86 + github.com/GrayCodeAI/trace v0.1.4-0.20260808050338-c7740fd26efa github.com/atotto/clipboard v0.1.4 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/cenkalti/backoff/v5 v5.0.3 // indirect @@ -155,7 +168,7 @@ require ( github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/lucasb-eyer/go-colorful v1.4.0 // indirect - github.com/mattn/go-isatty v0.0.22 // indirect + github.com/mattn/go-isatty v0.0.24 // indirect github.com/muesli/cancelreader v0.2.2 // indirect github.com/muesli/termenv v0.16.0 // indirect github.com/ncruces/go-strftime v1.0.0 // indirect @@ -176,7 +189,7 @@ require ( go.yaml.in/yaml/v3 v3.0.4 // indirect golang.org/x/exp v0.0.0-20260603202125-055de637280b // indirect golang.org/x/net v0.57.0 // indirect - golang.org/x/tools v0.47.0 // indirect + golang.org/x/tools v0.48.0 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa // indirect google.golang.org/grpc v1.82.1 // indirect diff --git a/go.sum b/go.sum index 2b441c13..6a41de4a 100644 --- a/go.sum +++ b/go.sum @@ -1,15 +1,15 @@ cel.dev/expr v0.25.2 h1:K6j46C81hXtZQfuX60cVWQFBJahKSE2gfRbNuvr5bFs= cel.dev/expr v0.25.2/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4= -charm.land/bubbles/v2 v2.1.0 h1:YSnNh5cPYlYjPxRrzs5VEn3vwhtEn3jVGRBT3M7/I0g= -charm.land/bubbles/v2 v2.1.0/go.mod h1:l97h4hym2hvWBVfmJDtrEHHCtkIKeTEb3TTJ4ZOB3wY= -charm.land/bubbletea/v2 v2.0.7 h1:7qw2tTAVar7m7klOPBYfTB0mniv/RuexsYwMRNxSeL0= -charm.land/bubbletea/v2 v2.0.7/go.mod h1:DGW2q8gvzHnOpMpZTORs0aySVHCox5C+2Svk0fci1qs= -charm.land/glamour/v2 v2.0.0 h1:IDBoqLEy7Hdpb9VOXN+khLP/XSxtJy1VsHuW/yF87+U= -charm.land/glamour/v2 v2.0.0/go.mod h1:kjq9WB0s8vuUYZNYey2jp4Lgd9f4cKdzAw88FZtpj/w= +charm.land/bubbles/v2 v2.1.1 h1:7r55WzBxpo/R3z98hGmY7KKPd3ET6vsf0Fb9sDHOV60= +charm.land/bubbles/v2 v2.1.1/go.mod h1:GE6M31gaWZVXzGw73OeuTTgy4lX+OtkH0E5ymnNsHxo= +charm.land/bubbletea/v2 v2.0.8 h1:SxTJMhCAI3lbPmy4SgX5LWZ24AdINr4I6UEqzZvYJuY= +charm.land/bubbletea/v2 v2.0.8/go.mod h1:2SkdgoTXluXJHOUwAoRlRXF/28vklb1rFl6GcgV1/ss= +charm.land/glamour/v2 v2.0.1 h1:xl+r00A4aJWU0z8fgwKd9fQQ4rsphqGUzuEiXZP5n+c= +charm.land/glamour/v2 v2.0.1/go.mod h1:jo9z8XqVKPeEFMVdvCRLGk++RyJ3CdUwgNr7EvXLw3k= charm.land/huh/v2 v2.0.3 h1:2cJsMqEPwSywGHvdlKsJyQKPtSJLVnFKyFbsYZTlLkU= charm.land/huh/v2 v2.0.3/go.mod h1:93eEveeeqn47MwiC3tf+2atZ2l7Is88rAtmZNZ8x9Wc= -charm.land/lipgloss/v2 v2.0.3 h1:yM2zJ4Cf5Y51b7RHIwioil4ApI/aypFXXVHSwlM6RzU= -charm.land/lipgloss/v2 v2.0.3/go.mod h1:7myLU9iG/3xluAWzpY/fSxYYHCgoKTie7laxk6ATwXA= +charm.land/lipgloss/v2 v2.0.5 h1:kbNxgeeUOYv5J0YdpxFjfvf3dFvqH8Aci4zB6xqFtrY= +charm.land/lipgloss/v2 v2.0.5/go.mod h1:9oqhxt4yxIMe6q5A4kHr44DremZk7J9UNh74GlWa5nc= dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8= dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA= github.com/BobuSumisu/aho-corasick v1.0.3 h1:uuf+JHwU9CHP2Vx+wAy6jcksJThhJS9ehR8a+4nPE9g= @@ -28,10 +28,8 @@ github.com/GrayCodeAI/sight v0.0.0-20260726091804-84c96edfc589 h1:gMXVRCqqdth6zi github.com/GrayCodeAI/sight v0.0.0-20260726091804-84c96edfc589/go.mod h1:kSCQwmYLH/ek9xU81ITyUgeeoDJjigAC0dGYyAMmKmc= github.com/GrayCodeAI/tok v0.1.5-0.20260731011234-7a7c3cbae89b h1:HjAYJHkFSn3FxiZCGdJ2oUCNHoiiIw6fbk5d0ePn6tA= github.com/GrayCodeAI/tok v0.1.5-0.20260731011234-7a7c3cbae89b/go.mod h1:/KTHlWg+qg8fDV8qRsLUfp4VKtt5seTyED1byxldBtE= -github.com/GrayCodeAI/trace v0.1.4-0.20260803003541-1cd0fc51b106 h1:hzR6j0JaKOKCMrck60Pmkqdqip8Zu2vuimUXvZG8+8U= -github.com/GrayCodeAI/trace v0.1.4-0.20260803003541-1cd0fc51b106/go.mod h1:xPV6sC2cUG0i7QD7aX3KtjiW1rlEhN9c5w9jBSc8LBc= -github.com/GrayCodeAI/trace v0.1.4-0.20260808011723-d8429e473c86 h1:bWCYgLG8+G6KicBRlxLAaQkQrzg9Q7UPBEg/ZIvL2Yc= -github.com/GrayCodeAI/trace v0.1.4-0.20260808011723-d8429e473c86/go.mod h1:aMTcvGc6zcIE2urPF79AmiGOg41H1IA/hP1TvJrAPZI= +github.com/GrayCodeAI/trace v0.1.4-0.20260808050338-c7740fd26efa h1:o+YPawX0hLvdJ05ZJVqXEYuRkwYogI298NIlU5rYKDU= +github.com/GrayCodeAI/trace v0.1.4-0.20260808050338-c7740fd26efa/go.mod h1:RPt/KV4f2DKezxzqAPe960z3vVmO5eJUGB/ZDlV/sOI= github.com/GrayCodeAI/yaad v0.2.1-0.20260729231812-52c8c805791f h1:4ridJ6o/eM2qLyIEwGwqTdZtYc7hwiS+cqC3Mtk6mJM= github.com/GrayCodeAI/yaad v0.2.1-0.20260729231812-52c8c805791f/go.mod h1:lN77OfTzQLNIWJhsQ+KUpnFwqTfaIb/tG0bhXSjCuZs= github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ= @@ -66,8 +64,8 @@ github.com/aymanbagabas/go-udiff v0.4.1 h1:OEIrQ8maEeDBXQDoGCbbTTXYJMYRCRO1fnodZ github.com/aymanbagabas/go-udiff v0.4.1/go.mod h1:0L9PGwj20lrtmEMeyw4WKJ/TMyDtvAoK9bf2u/mNo3w= github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk= github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= -github.com/betterleaks/betterleaks v1.4.1 h1:94igDpGpJMZAjKzFAx5jeDsi9uJmhoKlkVr65XPtFso= -github.com/betterleaks/betterleaks v1.4.1/go.mod h1:x0/OSwCa88wPrFsqatpdqc60HwsyTVltUKojhSgErRA= +github.com/betterleaks/betterleaks v1.5.0 h1:Fk0ZILLAhqtgOyhq6gtPsUnPs9n8um/SM7e00qcRit0= +github.com/betterleaks/betterleaks v1.5.0/go.mod h1:KPCdzwy4xT6r7oZCmDTNIlKNeim6XEvAH5vE1f0vG0w= github.com/bodgit/plumbing v1.3.0 h1:pf9Itz1JOQgn7vEOE7v7nlEfBykYqvUYioC61TwWCFU= github.com/bodgit/plumbing v1.3.0/go.mod h1:JOTb4XiRu5xfnmdnDJo6GmSbSbtSyufrsyZFByMtKEs= github.com/bodgit/sevenzip v1.6.4 h1:iHiVJfxbrB6RF4X+snI2MpVgNBKmVfGaTqZGNlMQIU0= @@ -84,8 +82,8 @@ github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UF github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q= github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q= -github.com/charmbracelet/ultraviolet v0.0.0-20260608091853-35bcb7319efa h1:rRT2qwk9xbontVloCXEUIsl1ePz0XFcIWkGi2bvmSTY= -github.com/charmbracelet/ultraviolet v0.0.0-20260608091853-35bcb7319efa/go.mod h1:hFpumms29Smx3LStRfku8vcCTBe1Kq8aCXtHUJa3mjY= +github.com/charmbracelet/ultraviolet v0.0.0-20260703014108-f5a850f9c2b7 h1:3FmWoGNWK4STvqg0O0Aeav2T7rodWJAPeF0QpH+8gFw= +github.com/charmbracelet/ultraviolet v0.0.0-20260703014108-f5a850f9c2b7/go.mod h1:f/jRa757WUmaOZrbPspXymbg/GnbF+rwe4OLsG7aXYo= github.com/charmbracelet/x/ansi v0.11.7 h1:kzv1kJvjg2S3r9KHo8hDdHFQLEqn4RBCb39dAYC84jI= github.com/charmbracelet/x/ansi v0.11.7/go.mod h1:9qGpnAVYz+8ACONkZBUWPtL7lulP9No6p1epAihUZwQ= github.com/charmbracelet/x/conpty v0.1.1 h1:s1bUxjoi7EpqiXysVtC+a8RrvPPNcNvAjfi4jxsAuEs= @@ -129,6 +127,8 @@ github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/denisbrodbeck/machineid v1.0.1 h1:geKr9qtkB876mXguW2X6TU4ZynleN6ezuMSRhl4D7AQ= +github.com/denisbrodbeck/machineid v1.0.1/go.mod h1:dJUwb7PTidGDeYyUBmXZ2GphQBbjJCrnectwCyxcUSI= github.com/dlclark/regexp2 v1.12.0 h1:0j4c5qQmnC6XOWNjP3PIXURXN2gWx76rd3KvgdPkCz8= github.com/dlclark/regexp2 v1.12.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= github.com/dlclark/regexp2/v2 v2.2.1 h1:mf4KkFUj0gJuarK8P+LgiS+Lit7m9N1yAwEfPbee7R0= @@ -140,46 +140,42 @@ github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkp github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc= github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ= -github.com/entireio/auth-go v0.4.0 h1:2Z12fsIKOEoDNMsk77AWDLu45z54rZzs1rBozLF2ddM= -github.com/entireio/auth-go v0.4.0/go.mod h1:TGgA/d21dPPNL4yYO+gqU+ZfS1Hcr8dU2303nLcVz4U= github.com/entireio/auth-go v0.5.2 h1:z0deFLJiBQH3ROMo/Z/YE2HcJ6W2SxZO5RVn2feQIPM= github.com/entireio/auth-go v0.5.2/go.mod h1:eqFYgiNSBw6HXYR3j8DRW0/WTV1dX3SWxr2D6YCYNQ0= +github.com/fatih/color v1.19.0 h1:Zp3PiM21/9Ld6FzSKyL5c/BULoe/ONr9KlbYVOfG8+w= +github.com/fatih/color v1.19.0/go.mod h1:zNk67I0ZUT1bEGsSGyCZYZNrHuTkJJB+r6Q9VuMi0LE= github.com/fatih/semgroup v1.3.0 h1:pTEnmcEze/BUf4UmVn9f1ZT1OckkBTNRV9w9k/I2/y4= github.com/fatih/semgroup v1.3.0/go.mod h1:thVp+PGZMO9KJ+k96oNGJo06hWgsKOWxTfYfx5R2VaE= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho= github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo= +github.com/ghodss/yaml v1.0.0 h1:wQHKEahhL6wmXdzwWG11gIVCkOv05bNOh+Rxn0yngAk= +github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= github.com/gitleaks/go-gitdiff v0.9.1 h1:ni6z6/3i9ODT685OLCTf+s/ERlWUNWQF4x1pvoNICw0= github.com/gitleaks/go-gitdiff v0.9.1/go.mod h1:pKz0X4YzCKZs30BL+weqBIG7mx0jl4tF1uXV9ZyNvrA= github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c= github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU= +github.com/go-faster/errors v0.8.0 h1:9T9eJrM+72dFk7n4DfhuaDDe6cyuFCSW2oNUkN77Yqc= +github.com/go-faster/errors v0.8.0/go.mod h1:5ySTjWFiphBs07IKuiL69nxdfd5+fzh1u7FPGZP2quo= +github.com/go-faster/jx v1.2.0 h1:T2YHJPrFaYu21fJtUxC9GzmluKu8rVIFDwwGBKTDseI= +github.com/go-faster/jx v1.2.0/go.mod h1:UWLOVDmMG597a5tBFPLIWJdUxz5/2emOpfsj9Neg0PE= +github.com/go-faster/yaml v0.4.6 h1:lOK/EhI04gCpPgPhgt0bChS6bvw7G3WwI8xxVe0sw9I= +github.com/go-faster/yaml v0.4.6/go.mod h1:390dRIvV4zbnO7qC9FGo6YYutc+wyyUSHBgbXL52eXk= github.com/go-git/gcfg/v2 v2.0.2 h1:MY5SIIfTGGEMhdA7d7JePuVVxtKL7Hp+ApGDJAJ7dpo= github.com/go-git/gcfg/v2 v2.0.2/go.mod h1:/lv2NsxvhepuMrldsFilrgct6pxzpGdSRC13ydTLSLs= -github.com/go-git/go-billy/v6 v6.0.0-alpha.1.0.20260519112248-0095b064a6c6 h1:AaQOU2NVLxnBGWkv5YSoxomcDCqlaqfCW0t00pNKtnk= -github.com/go-git/go-billy/v6 v6.0.0-alpha.1.0.20260519112248-0095b064a6c6/go.mod h1:eaCUpHbedW7//EwcYmUDfJe2N6sJC9O12AT0OTqJR1E= github.com/go-git/go-billy/v6 v6.0.0-alpha.2 h1:1Sv5WemXL8CxKrAx1gioJ+uHNb2bZJhiQLfwSZ4Et8c= github.com/go-git/go-billy/v6 v6.0.0-alpha.2/go.mod h1:r/bsv9i/iDyyEU8/Z6mjC+YraOVwie1ddfUqBCElKXQ= github.com/go-git/go-git-fixtures/v6 v6.0.0-alpha.1 h1:gmqi2jvsreu0s8JMLylYDFq4sbjHwwlhktMw0DUg3mA= github.com/go-git/go-git-fixtures/v6 v6.0.0-alpha.1/go.mod h1:ECf1MqJlBdYpKggBrOXjo/0EnvRZx6D++I86UYjPgAQ= -github.com/go-git/go-git/v6 v6.0.0-alpha.4 h1:aDTc2UGanmaE7FkGLSlBEB9nohMnQ+RKXcfq/D+esDQ= -github.com/go-git/go-git/v6 v6.0.0-alpha.4/go.mod h1:4ODa/G7hPWrh4Y+7lmt59Ij3zW38IEfvRoAZxLYYBhc= github.com/go-git/go-git/v6 v6.0.0-alpha.5 h1:sE+OlkHgYWNMVmN1s9sR7uyFgsWLtxcNWse/vBYKxRE= github.com/go-git/go-git/v6 v6.0.0-alpha.5/go.mod h1:3IjhiZnM+uBmUrOGSeqrJpsmi4Vd0H2NZO/uK2a7d0s= -github.com/go-git/x/plugin/objectsigner/auto v0.1.0 h1:RcLW29RgwSCmqrNSs7QOxvWkRbM1vPu0Vp9TCECZjMs= -github.com/go-git/x/plugin/objectsigner/auto v0.1.0/go.mod h1:iP2cXPyXc//9v9THS3y/MLi0jnt7vEqwUDj11qQfFPg= github.com/go-git/x/plugin/objectsigner/auto v0.1.1-0.20260624122410-382b2905c041 h1:ATVPaVKC1wbuQdvGKfKXotuwXYeGfigyHERl7lmNG+I= github.com/go-git/x/plugin/objectsigner/auto v0.1.1-0.20260624122410-382b2905c041/go.mod h1:Cpmdf+1Pmw6nPWTpfBMsPmWju2Tb+qjwccWR5AvOBC4= -github.com/go-git/x/plugin/objectsigner/gpg v0.1.0 h1:NEGVSOD+LPnus6j4iNkAZaHVTc4DNY223y1/I2Jq2yI= -github.com/go-git/x/plugin/objectsigner/gpg v0.1.0/go.mod h1:1iosWq3OOqZxtNrwDHtcjicswuaOT45J5GMFyCk80wc= github.com/go-git/x/plugin/objectsigner/gpg v0.2.1-0.20260624122410-382b2905c041 h1:Tni6GTpv/Nx4HAub64YmnxGWe99za33jfzy3GesditQ= github.com/go-git/x/plugin/objectsigner/gpg v0.2.1-0.20260624122410-382b2905c041/go.mod h1:1iosWq3OOqZxtNrwDHtcjicswuaOT45J5GMFyCk80wc= -github.com/go-git/x/plugin/objectsigner/program v0.0.0-20260509055934-990a63433b45 h1:9HmkDRECQ7yGwcQ35x+0HhQp/JBKLkC9Cozr/z3gs4Q= -github.com/go-git/x/plugin/objectsigner/program v0.0.0-20260509055934-990a63433b45/go.mod h1:qqkRcAeBDQLDJTBiN/s4k4Xj6eFBP+2cdoZDzsld0b0= github.com/go-git/x/plugin/objectsigner/program v0.0.0-20260624122410-382b2905c041 h1:3SNIy+i6ou6OX1ekdFKpuTg+BGPO3Q4Jj6by0KX/2lY= github.com/go-git/x/plugin/objectsigner/program v0.0.0-20260624122410-382b2905c041/go.mod h1:qqkRcAeBDQLDJTBiN/s4k4Xj6eFBP+2cdoZDzsld0b0= -github.com/go-git/x/plugin/objectsigner/ssh v0.1.0 h1:lAeeDgc1oxsMMvVUed6ssrqJnD97UR1K/dXIDdeg1Yc= -github.com/go-git/x/plugin/objectsigner/ssh v0.1.0/go.mod h1:6BvpZj9Yry1ZFNw4N5OZDc+7M1T8oyrZilLNFg2aTsM= github.com/go-git/x/plugin/objectsigner/ssh v0.2.1-0.20260624122410-382b2905c041 h1:mmJ/LFr0c7ij9UYQorU66989ge06vf1H07ud533UQ/I= github.com/go-git/x/plugin/objectsigner/ssh v0.2.1-0.20260624122410-382b2905c041/go.mod h1:6BvpZj9Yry1ZFNw4N5OZDc+7M1T8oyrZilLNFg2aTsM= github.com/go-json-experiment/json v0.0.0-20260623181947-01eb4420fa68 h1:KZaTBSyshWX3MP5jukJcNSuXDQTO+rNpt0J564dX/eg= @@ -203,6 +199,8 @@ github.com/goccy/go-json v0.10.6 h1:p8HrPJzOakx/mn/bQtjgNjdTcN+/S6FcG2CTtQOrHVU= github.com/goccy/go-json v0.10.6/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ= github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c= +github.com/gofrs/flock v0.13.0 h1:95JolYOvGMqeH31+FC7D2+uULf6mG61mEZ/A8dRYMzw= +github.com/gofrs/flock v0.13.0/go.mod h1:jxeyy9R1auM5S6JYDBhDt+E2TCo7DkratH4Pgi8P+Z0= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= github.com/google/cel-go v0.28.1 h1:YWIwi77J4xIsYUwAF/iIuS6haffzIHS8yWI8glSbLWM= @@ -265,10 +263,10 @@ github.com/mark3labs/mcp-go v0.49.0 h1:7Ssx4d7/T86qnWoJIdye7wEEvUzv39UIbnZb/FqUZ github.com/mark3labs/mcp-go v0.49.0/go.mod h1:BflTAZAzXlrTpiO44gmjMu89n2FO56rJ9m31fp4zd5k= github.com/mattn/go-colorable v0.1.15 h1:+u9SLTRGnXv73cEsnsmoZBom+dMU88B2M0aDcWy0/jY= github.com/mattn/go-colorable v0.1.15/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= -github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4= -github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4= -github.com/mattn/go-runewidth v0.0.24 h1:cpokDiIn0MGnhdHwuWnJBITySJ20QyNGnY2kR/ay2DU= -github.com/mattn/go-runewidth v0.0.24/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= +github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI= +github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A= +github.com/mattn/go-runewidth v0.0.27 h1:Feg/Oou5zI/wnpgDF6omIU0OokC9GxLC/WRknhVlIR0= +github.com/mattn/go-runewidth v0.0.27/go.mod h1:3qAiGCV4Koz/yuveO58qUefmUTRm8r0IGEXZ9jeHp/8= github.com/mholt/archives v0.1.6-0.20260429171216-ef71b7a32fae h1:J5ek2lGxYgdh5SMMmlNTSKLmS1x2oJQla/V0NaAH7vo= github.com/mholt/archives v0.1.6-0.20260429171216-ef71b7a32fae/go.mod h1:IbMrpOL3881/V4qoZRFTSTSRzjjZkD3qoRLX07MitpY= github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk= @@ -291,12 +289,15 @@ github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOF github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/nwaples/rardecode/v2 v2.2.3 h1:qaVuy3ChZDbAQZshPLjHeNJKF3Cru8uo9jmgveKIy2A= github.com/nwaples/rardecode/v2 v2.2.3/go.mod h1:7uz379lSxPe6j9nvzxUZ+n7mnJNgjsRNb6IbvGVHRmw= +github.com/ogen-go/ogen v1.23.0 h1:QaWeKm2KZ2zy7NkqqO1Vdl5idNqlG+svxdgwVAX+zbo= +github.com/ogen-go/ogen v1.23.0/go.mod h1:bwwvC3AmCV+LrL5lazyQwwof90402mdcSyI0FOzzpfM= +github.com/oklog/ulid/v2 v2.1.2 h1:IEclFb9JNvzYA6MW2SCxbLzcHTVsfqm3PrqGQJH5zec= +github.com/oklog/ulid/v2 v2.1.2/go.mod h1:rcEKHmBBKfef9DhnvX7y1HZBYxjXb0cP5ExxNsTT1QQ= github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde h1:x0TT0RDC7UhAVbbWWBzr41ElhJx5tXPWkIHA2HWPRuw= github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde/go.mod h1:nZgzbfBr3hhjoZnS66nKrHmduYNpc34ny7RK4z5/HM0= +github.com/pborman/getopt v0.0.0-20170112200414-7148bc3a4c30/go.mod h1:85jBQOZwpVEaDAr341tbn15RS4fCAsIst0qp7i8ex1o= github.com/pelletier/go-toml/v2 v2.3.1 h1:MYEvvGnQjeNkRF1qUuGolNtNExTDwct51yp7olPtrEc= github.com/pelletier/go-toml/v2 v2.3.1/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= -github.com/petermattis/goid v0.0.0-20260330135022-df67b199bc81 h1:WDsQxOJDy0N1VRAjXLpi8sCEZRSGarLWQevDxpTBRrM= -github.com/petermattis/goid v0.0.0-20260330135022-df67b199bc81/go.mod h1:pxMtw7cyUw6B2bRH0ZBANSPg+AoSud1I1iyJHI69jH4= github.com/pierrec/lz4/v4 v4.1.27 h1:+PhzhWDrjRj89TH2sw43nE3+4+W8lSxIuQadEHZyjUk= github.com/pierrec/lz4/v4 v4.1.27/go.mod h1:EoQMVJgeeEOMsCqCzqFm2O0cJvljX2nGZjcRIPL34O4= github.com/pjbgf/sha1cd v0.6.0 h1:3WJ8Wz8gvDz29quX1OcEmkAlUg9diU4GxJHqs0/XiwU= @@ -306,8 +307,8 @@ github.com/pkoukk/tiktoken-go v0.1.8/go.mod h1:9NiV+i9mJKGj1rYOT+njbv+ZwA/zJxYde github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/posthog/posthog-go v1.14.0 h1:pN0+v7kvKkykRQDf6E0KNYJvKqhJ+VzQGlfxYHfZMhs= -github.com/posthog/posthog-go v1.14.0/go.mod h1:xsVOW9YImilUcazwPNEq4PJDqEZf2KeCS758zXjwkPg= +github.com/posthog/posthog-go v1.22.0 h1:VNy+sMJ9MMnENr9dMSxfQt/5bB4UhwRdZfasOAghMMg= +github.com/posthog/posthog-go v1.22.0/go.mod h1://M430hNH3e8CDv4i8SJesb26816Mpa6GIZaiP4pNQU= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= @@ -319,8 +320,12 @@ github.com/rs/zerolog v1.35.1/go.mod h1:EjML9kdfa/RMA7h/6z6pYmq1ykOuA8/mjWaEvGI+ github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/sagikazarmark/locafero v0.12.0 h1:/NQhBAkUb4+fH1jivKHWusDYFjMOOKU88eegjfxfHb4= github.com/sagikazarmark/locafero v0.12.0/go.mod h1:sZh36u/YSZ918v0Io+U9ogLYQJ9tLLBmM4eneO6WwsI= +github.com/segmentio/asm v1.2.1 h1:DTNbBqs57ioxAD4PrArqftgypG4/qNpXoJx8TVXxPR0= +github.com/segmentio/asm v1.2.1/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs= github.com/sergi/go-diff v1.4.0 h1:n/SP9D5ad1fORl+llWyN+D6qoUETXNZARKjyY2/KVCw= github.com/sergi/go-diff v1.4.0/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4= +github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k= +github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME= github.com/shurcooL/githubv4 v0.0.0-20260209031235-2402fdf4a9ed h1:KT7hI8vYXgU0s2qaMkrfq9tCA1w/iEPgfredVP+4Tzw= github.com/shurcooL/githubv4 v0.0.0-20260209031235-2402fdf4a9ed/go.mod h1:zqMwyHmnN/eDOZOdiTohqIUKUrTFX62PNlu7IJdu0q8= github.com/shurcooL/graphql v0.0.0-20240915155400-7ee5256398cf h1:o1uxfymjZ7jZ4MsgCErcwWGtVKSiNAXtS59Lhs6uI/g= @@ -396,6 +401,10 @@ go.opentelemetry.io/proto/otlp v1.10.0 h1:IQRWgT5srOCYfiWnpqUYz9CVmbO8bFmKcwYxpu go.opentelemetry.io/proto/otlp v1.10.0/go.mod h1:/CV4QoCR/S9yaPj8utp3lvQPoqMtxXdzn7ozvvozVqk= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= +go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.uber.org/zap v1.28.0 h1:IZzaP1Fv73/T/pBMLk4VutPl36uNC+OSUh3JLG3FIjo= +go.uber.org/zap v1.28.0/go.mod h1:rDLpOi171uODNm/mxFcuYWxDsqWSAVkFdX4XojSKg/Q= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= go4.org v0.0.0-20260112195520-a5071408f32f h1:ziUVAjmTPwQMBmYR1tbdRFJPtTcQUI12fH9QQjfb0Sw= @@ -405,8 +414,6 @@ golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw= golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk= golang.org/x/exp v0.0.0-20260603202125-055de637280b h1:v1uXiEBHo8QA0LiGCo7UgHMzHT4Kdfpl2zmtH5vaP1Q= golang.org/x/exp v0.0.0-20260603202125-055de637280b/go.mod h1:d2fgXJLVs4dYDHUk5lwMIfzRzSrWCfGZb0ZqeLa/Vcw= -golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ= -golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0= golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk= golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= @@ -429,8 +436,8 @@ golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY= golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= -golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q= -golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA= +golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE= +golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= @@ -447,6 +454,7 @@ gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8 gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= From d6e7912b0ec920b3b228e06e3d522bd818dff420 Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Sat, 8 Aug 2026 12:58:04 +0530 Subject: [PATCH 13/14] chore: regenerate help_root.txt golden file An earlier commit added the 'entire' command and removed 'trace' from the hawk command list but did not regenerate the golden file. Update it to match the current help output so the golden test passes. --- testdata/golden/help_root.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/testdata/golden/help_root.txt b/testdata/golden/help_root.txt index 8ae8b8c4..577109df 100644 --- a/testdata/golden/help_root.txt +++ b/testdata/golden/help_root.txt @@ -43,6 +43,7 @@ Available Commands: daemon Manage the hawk background server doctor Run local diagnostics ecosystem Show eyrie, yaad, and tok integration status + entire Entire CLI eval Evaluate model performance on coding benchmarks exec Execute a single command non-interactively features List and manage feature flags @@ -80,7 +81,6 @@ Available Commands: stats Show usage statistics and cost analytics taste Manage taste profile (learned coding style preferences) tools List built-in tools - trace Trace CLI trust Manage folder trust for project automation update Check for hawk updates verify Run local self-verification (security log, governance policy) From 4a2605d3078c1033aac2b2cdae814e9c9d758809 Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Sat, 8 Aug 2026 14:06:18 +0530 Subject: [PATCH 14/14] chore: demote GitNexus H1 to H2 in AGENTS.md markdownlint MD025 flags multiple top-level headings. The GitNexus block injected a second # heading; demote it to ## so it reads as a section within the document. The document's single real title remains '# Extending hawk'. --- AGENTS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index fd17bf3e..68a0c8a3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -199,7 +199,7 @@ with its native Responses API (`/v1/responses`) under the `concentrate-payg` deployment. -# GitNexus — Code Intelligence +## GitNexus — Code Intelligence This project is indexed by GitNexus as **hawk** (86470 symbols, 279855 relationships, 300 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.