diff --git a/cmd/governance_cmd.go b/cmd/governance_cmd.go new file mode 100644 index 00000000..deac39a3 --- /dev/null +++ b/cmd/governance_cmd.go @@ -0,0 +1,189 @@ +package cmd + +import ( + "fmt" + "os" + "sort" + "strings" + + "github.com/GrayCodeAI/hawk/internal/governance" + "github.com/spf13/cobra" +) + +var governancePath string + +// governanceCmd exposes the POLICY ∩ PROFILE permission ceiling. +var governanceCmd = &cobra.Command{ + Use: "governance", + Short: "Inspect and validate the governance policy ceiling", + Long: `Governance is the administrator-set POLICY ceiling layered under the +per-session PROFILE (tightest-wins). Tools are permitted only when both +layers allow them. + + hawk governance Show the managed policy status + hawk governance show Print the effective capability rows + hawk governance validate Validate a policy or profile document + hawk governance explain Evaluate a tool against the policy`, + RunE: func(cmd *cobra.Command, args []string) error { + return runGovernanceStatus(cmd) + }, +} + +var governanceShowCmd = &cobra.Command{ + Use: "show", + Short: "Print the effective policy capability rows", + RunE: func(cmd *cobra.Command, args []string) error { + layer, err := governanceLayerForCLI() + if err != nil { + return err + } + path := governancePath + if path == "" { + path = governance.ManagedPolicyPath() + } + cmd.Printf("Governance layer %q (%s)\n", layer.Name, path) + cmd.Printf("Fail-closed: %t\n", layer.FailClosed) + if len(layer.DeniedTools) > 0 { + cmd.Printf("Denied tools: %s\n", sortedKeys(layer.DeniedTools)) + } + if len(layer.DeniedBash) > 0 { + cmd.Printf("Denied bash patterns: %s\n", strings.Join(layer.DeniedBash, ", ")) + } + if len(layer.SensitivePaths) > 0 { + cmd.Printf("Sensitive paths: %s\n", strings.Join(layer.SensitivePaths, ", ")) + } + if len(layer.Capabilities) == 0 { + cmd.Println("No capability rows.") + return nil + } + cmd.Println("\nCapabilities:") + for _, cap := range layer.Capabilities { + pattern := cap.Pattern + if pattern == "" { + pattern = "*" + } + reason := "" + if cap.Reason != "" { + reason = " (" + cap.Reason + ")" + } + cmd.Printf(" %-8s %-20s %-12s %s\n", cap.Action, cap.Scope, pattern, reason) + } + return nil + }, +} + +var governanceValidateCmd = &cobra.Command{ + Use: "validate ", + Short: "Validate a governance policy or profile document", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + layer, err := governance.LoadLayer("policy", args[0]) + if err != nil { + return err + } + cmd.Printf("valid: %d capability row(s), fail_closed=%t (%s)\n", + len(layer.Capabilities), layer.FailClosed, args[0]) + return nil + }, +} + +var governanceExplainCmd = &cobra.Command{ + Use: "explain [summary]", + Short: "Evaluate a tool call against the policy and show the decision", + Args: cobra.MinimumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + toolName := args[0] + summary := strings.Join(args[1:], " ") + + layer, err := governanceLayerForCLI() + if err != nil { + return err + } + eng := governance.New() + eng.SetPolicy(layer) + + dec := eng.Evaluate(toolName, summary) + scopes := governance.ScopesForTool(toolName) + scoped := "(ungoverned scope)" + if len(scopes) > 0 { + scoped = strings.Join(scopeNames(scopes), ", ") + } + verdict := "DENY" + if dec.Allowed { + verdict = "ALLOW" + } + cmd.Printf("tool: %s\n", toolName) + cmd.Printf("scopes: %s\n", scoped) + if summary != "" { + cmd.Printf("summary: %s\n", summary) + } + cmd.Printf("decision: %s\n", verdict) + cmd.Printf("source: %s\n", dec.Source) + if dec.Scope != "" { + cmd.Printf("scope hit: %s\n", dec.Scope) + } + if dec.Rule != "" { + cmd.Printf("rule: %s\n", dec.Rule) + } + if dec.Reason != "" { + cmd.Printf("reason: %s\n", dec.Reason) + } + return nil + }, +} + +func init() { + governanceShowCmd.Flags().StringVar(&governancePath, "path", "", "policy file to inspect (default: managed policy path)") + governanceExplainCmd.Flags().StringVar(&governancePath, "path", "", "policy file to evaluate against (default: managed policy path)") + governanceCmd.AddCommand(governanceShowCmd) + governanceCmd.AddCommand(governanceValidateCmd) + governanceCmd.AddCommand(governanceExplainCmd) + rootCmd.AddCommand(governanceCmd) +} + +func runGovernanceStatus(cmd *cobra.Command) error { + path := governance.ManagedPolicyPath() + cmd.Printf("Managed policy path: %s\n", path) + if _, err := os.Stat(path); err != nil { + cmd.Println("Status: not installed (governance is fail-open; no ceiling enforced)") + return nil + } + layer, err := governance.LoadLayer("policy", path) + if err != nil { + return fmt.Errorf("managed policy is invalid: %w", err) + } + cmd.Printf("Status: installed — fail_closed=%t, %d capability row(s), %d denied tool(s)\n", + layer.FailClosed, len(layer.Capabilities), len(layer.DeniedTools)) + return nil +} + +func governanceLayerForCLI() (*governance.Layer, error) { + path := governancePath + if path == "" { + path = governance.ManagedPolicyPath() + } + if _, err := os.Stat(path); err != nil { + if os.IsNotExist(err) { + return nil, fmt.Errorf("no governance policy at %s; use --path to point at a policy file", path) + } + return nil, err + } + return governance.LoadLayer("policy", path) +} + +func sortedKeys(m map[string]struct{}) string { + keys := make([]string, 0, len(m)) + for k := range m { + keys = append(keys, k) + } + sort.Strings(keys) + return strings.Join(keys, ", ") +} + +func scopeNames(scopes []governance.ScopeName) []string { + names := make([]string, len(scopes)) + for i, s := range scopes { + names[i] = string(s) + } + return names +} diff --git a/cmd/learn_cmd.go b/cmd/learn_cmd.go new file mode 100644 index 00000000..74b0d20d --- /dev/null +++ b/cmd/learn_cmd.go @@ -0,0 +1,126 @@ +package cmd + +import ( + "fmt" + "strings" + "time" + + "github.com/GrayCodeAI/hawk/internal/engine" + "github.com/spf13/cobra" +) + +var ( + learnWhat string + learnWhy string + learnLesson string + learnCategory string + learnLimit int + learnAll bool +) + +// learnCmd manages the cross-session lesson store. +var learnCmd = &cobra.Command{ + Use: "learn", + Short: "Manage lessons learned across sessions", + Long: `Hawk persists lessons from failures (and manual entries) so future +sessions avoid repeating them. Lessons are injected into the system prompt. + + hawk learn List recent lessons + hawk learn add Add a lesson manually + hawk learn prompt Print the lesson-extraction prompt for a context + hawk learn clear Remove all lessons`, + RunE: func(cmd *cobra.Command, args []string) error { + return runLearnList(cmd) + }, +} + +var learnAddCmd = &cobra.Command{ + Use: "add", + Short: "Add a lesson manually", + RunE: func(cmd *cobra.Command, args []string) error { + if strings.TrimSpace(learnWhat) == "" || strings.TrimSpace(learnLesson) == "" { + return fmt.Errorf("--what and --lesson are required") + } + if learnCategory == "" { + learnCategory = "manual" + } + si := engine.NewSelfImprover() + si.Learn(strings.TrimSpace(learnWhat), strings.TrimSpace(learnWhy), strings.TrimSpace(learnLesson), strings.TrimSpace(learnCategory)) + cmd.Printf("lesson added (category: %s)\n", learnCategory) + return nil + }, +} + +var learnPromptCmd = &cobra.Command{ + Use: "prompt ", + Short: "Print the lesson-extraction prompt for a failure context", + Args: cobra.MinimumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + cmd.Println(engine.LearnPrompt(strings.Join(args, " "))) + return nil + }, +} + +var learnClearCmd = &cobra.Command{ + Use: "clear", + Short: "Remove all lessons", + RunE: func(cmd *cobra.Command, args []string) error { + si := engine.NewSelfImprover() + n := len(si.Lessons("")) + if n == 0 { + cmd.Println("no lessons to clear") + return nil + } + si.Clear() + cmd.Printf("cleared %d lesson(s)\n", n) + return nil + }, +} + +func init() { + learnAddCmd.Flags().StringVar(&learnWhat, "what", "", "what went wrong") + learnAddCmd.Flags().StringVar(&learnWhy, "why", "", "root cause") + learnAddCmd.Flags().StringVar(&learnLesson, "lesson", "", "what to do differently") + learnAddCmd.Flags().StringVar(&learnCategory, "category", "manual", "code, test, design, communication, manual") + learnCmd.Flags().IntVar(&learnLimit, "limit", 20, "max lessons to print (0 = all)") + learnCmd.Flags().BoolVar(&learnAll, "all", false, "include all fields (also shows the why)") + learnCmd.AddCommand(learnAddCmd) + learnCmd.AddCommand(learnPromptCmd) + learnCmd.AddCommand(learnClearCmd) + rootCmd.AddCommand(learnCmd) +} + +func runLearnList(cmd *cobra.Command) error { + si := engine.NewSelfImprover() + lessons := si.Lessons("") + if len(lessons) == 0 { + cmd.Println("No lessons yet. Add one with: hawk learn add --what ... --lesson ...") + return nil + } + + // Count by category. + cats := map[string]int{} + for _, e := range lessons { + cats[e.Category]++ + } + var catSummary []string + for cat, count := range cats { + catSummary = append(catSummary, fmt.Sprintf("%s (%d)", cat, count)) + } + cmd.Printf("Lesson store: %d lesson(s) — %s\n", len(lessons), strings.Join(catSummary, ", ")) + + start := 0 + if learnLimit > 0 && len(lessons) > learnLimit { + start = len(lessons) - learnLimit + } + cmd.Println() + for _, e := range lessons[start:] { + cmd.Printf("[%s] %s\n", e.Category, e.What) + cmd.Printf(" lesson: %s\n", e.Lesson) + if learnAll && e.Why != "" { + cmd.Printf(" why: %s\n", e.Why) + } + cmd.Printf(" learned: %s\n", e.Timestamp.Format(time.RFC3339)) + } + return nil +} diff --git a/cmd/security_verify_governance_cli_test.go b/cmd/security_verify_governance_cli_test.go new file mode 100644 index 00000000..0b539846 --- /dev/null +++ b/cmd/security_verify_governance_cli_test.go @@ -0,0 +1,352 @@ +package cmd + +import ( + "bytes" + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/GrayCodeAI/hawk/internal/securitylog" +) + +// withTempState runs a cli test body with HAWK_STATE_DIR pointed at a temp dir +// so the real user state is never touched. +func withTempState(t *testing.T, body func(stateDir string)) { + t.Helper() + dir := t.TempDir() + t.Setenv("HAWK_STATE_DIR", dir) + body(dir) +} + +func runRoot(args ...string) (string, error) { + // Reset persistent CLI flag vars so test invocations don't bleed values + // across cases (cobra keeps the last value when a flag is omitted). + governancePath = "" + securitylogLimit = 0 + securitylogJSON = false + learnLimit = 20 + learnAll = false + learnWhat, learnWhy, learnLesson = "", "", "" + learnCategory = "manual" + + buf := new(bytes.Buffer) + rootCmd.SetOut(buf) + rootCmd.SetErr(buf) + rootCmd.SetArgs(args) + err := rootCmd.Execute() + return buf.String(), err +} + +func TestGovernanceValidate(t *testing.T) { + dir := t.TempDir() + policy := filepath.Join(dir, "policy.json") + doc := `{ + "version": 1, + "fail_closed": true, + "capabilities": [ + {"scope": "bash", "action": "deny", "pattern": "rm -rf *", "reason": "protect filesystem"} + ], + "denied_tools": ["Bash"] + }` + if err := os.WriteFile(policy, []byte(doc), 0o600); err != nil { + t.Fatal(err) + } + + out, err := runRoot("governance", "validate", policy) + if err != nil { + t.Fatalf("unexpected error: %v\n%s", err, out) + } + if !strings.Contains(out, "valid") { + t.Fatalf("expected validation success, got: %s", out) + } +} + +func TestGovernanceExplainDenied(t *testing.T) { + dir := t.TempDir() + policy := filepath.Join(dir, "policy.json") + doc := `{ + "version": 1, + "fail_closed": false, + "capabilities": [ + {"scope": "filesystem_write", "action": "deny", "pattern": "*.env", "reason": "protect secrets"} + ] + }` + if err := os.WriteFile(policy, []byte(doc), 0o600); err != nil { + t.Fatal(err) + } + + out, err := runRoot("governance", "explain", "Write", "config.local.env", "--path", policy) + if err != nil { + t.Fatalf("unexpected error: %v\n%s", err, out) + } + if !strings.Contains(out, "DENY") { + t.Fatalf("expected DENY for sensitive .env write, got: %s", out) + } +} + +func TestGovernanceExplainAllow(t *testing.T) { + dir := t.TempDir() + policy := filepath.Join(dir, "policy.json") + doc := `{ + "version": 1, + "fail_closed": false, + "capabilities": [ + {"scope": "filesystem_read", "action": "allow", "pattern": "*.go"} + ] + }` + if err := os.WriteFile(policy, []byte(doc), 0o600); err != nil { + t.Fatal(err) + } + + out, err := runRoot("governance", "explain", "Read", "main.go", "--path", policy) + if err != nil { + t.Fatalf("unexpected error: %v\n%s", err, out) + } + if !strings.Contains(out, "ALLOW") { + t.Fatalf("expected ALLOW for reading a .go file, got: %s", out) + } +} + +func TestGovernanceExplainNoPolicy(t *testing.T) { + withTempState(t, func(string) { + // No policy installed and no --path: should error helpfully. + _, err := runRoot("governance", "explain", "Read") + if err == nil { + t.Fatal("expected error when no policy is available") + } + if !strings.Contains(err.Error(), "no governance policy") { + t.Fatalf("expected 'no governance policy' error, got: %v", err) + } + }) +} + +func TestSecuritylogShowsEmpty(t *testing.T) { + withTempState(t, func(stateDir string) { + out, err := runRoot("securitylog") + if err != nil { + t.Fatalf("unexpected error: %v\n%s", err, out) + } + if !strings.Contains(out, "No security events recorded yet") { + t.Fatalf("expected empty-log message, got: %s", out) + } + if !strings.Contains(out, stateDir) { + t.Fatalf("expected log location %q in output, got: %s", stateDir, out) + } + + // Empty JSON should be an array, not null. + jsonOut, err := runRoot("securitylog", "show", "--json") + if err != nil { + t.Fatalf("unexpected error: %v\n%s", err, jsonOut) + } + if strings.TrimSpace(jsonOut) != "[]" { + t.Fatalf("expected empty JSON array, got: %q", jsonOut) + } + }) +} + +func TestSecuritylogAppendVerifyAndShow(t *testing.T) { + withTempState(t, func(stateDir string) { + // Append two events via the public API. + l, err := securitylog.New(securitylog.DefaultDir()) + if err != nil { + t.Fatal(err) + } + if _, err := l.Append(securitylog.SeverityInfo, "tool_exec", "wrote file", "Write", "sess-1"); err != nil { + t.Fatal(err) + } + if _, err := l.Append(securitylog.SeverityWarning, "denied", "blocked", "Bash", "sess-1"); err != nil { + t.Fatal(err) + } + if err := l.Close(); err != nil { + t.Fatal(err) + } + + out, err := runRoot("securitylog", "verify") + if err != nil { + t.Fatalf("verify should pass on a valid chain: %v\n%s", err, out) + } + if !strings.Contains(out, "2 entries verified") { + t.Fatalf("expected verification summary, got: %s", out) + } + + show, err := runRoot("securitylog", "show") + if err != nil { + t.Fatalf("unexpected error: %v\n%s", err, show) + } + if !strings.Contains(show, "tool_exec") || !strings.Contains(show, "denied") { + t.Fatalf("expected both events in show output, got: %s", show) + } + }) +} + +func TestSecuritylogVerifyDetectsTampering(t *testing.T) { + withTempState(t, func(stateDir string) { + l, err := securitylog.New(securitylog.DefaultDir()) + if err != nil { + t.Fatal(err) + } + if _, err := l.Append(securitylog.SeverityInfo, "tool_exec", "original", "Write", ""); err != nil { + t.Fatal(err) + } + if err := l.Close(); err != nil { + t.Fatal(err) + } + + // Tamper with the log file. + path := filepath.Join(stateDir, "securitylog", "security_events.jsonl") + data, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + tampered := strings.Replace(string(data), "original", "TAMPERED", 1) + if err := os.WriteFile(path, []byte(tampered), 0o600); err != nil { + t.Fatal(err) + } + + _, err = runRoot("securitylog", "verify") + if err == nil { + t.Fatal("expected verification to fail after tampering") + } + }) +} + +func TestSecuritylogShowJSON(t *testing.T) { + withTempState(t, func(stateDir string) { + l, err := securitylog.New(securitylog.DefaultDir()) + if err != nil { + t.Fatal(err) + } + if _, err := l.Append(securitylog.SeverityInfo, "tool_exec", "wrote file", "Write", "sess-1"); err != nil { + t.Fatal(err) + } + if err := l.Close(); err != nil { + t.Fatal(err) + } + + out, err := runRoot("securitylog", "show", "--json") + if err != nil { + t.Fatalf("unexpected error: %v\n%s", err, out) + } + var events []securitylog.Event + if err := json.Unmarshal([]byte(strings.TrimSpace(out)), &events); err != nil { + t.Fatalf("expected valid JSON, got parse error: %v\n%s", err, out) + } + if len(events) != 1 { + t.Fatalf("expected 1 event, got %d", len(events)) + } + if events[0].Type != "tool_exec" { + t.Fatalf("unexpected event type: %s", events[0].Type) + } + }) +} + +func TestVerifyCommand(t *testing.T) { + t.Run("passes on empty", func(t *testing.T) { + withTempState(t, func(stateDir string) { + out, err := runRoot("verify") + if err != nil { + t.Fatalf("unexpected error: %v\n%s", err, out) + } + if !strings.Contains(out, "verification passed") { + t.Fatalf("expected 'verification passed', got: %s", out) + } + }) + }) + + t.Run("passes when log intact", func(t *testing.T) { + withTempState(t, func(stateDir string) { + l, err := securitylog.New(securitylog.DefaultDir()) + if err != nil { + t.Fatal(err) + } + if _, err := l.Append(securitylog.SeverityInfo, "tool_exec", "ok", "Write", "s1"); err != nil { + t.Fatal(err) + } + if err := l.Close(); err != nil { + t.Fatal(err) + } + out, err := runRoot("verify") + if err != nil { + t.Fatalf("unexpected error: %v\n%s", err, out) + } + if !strings.Contains(out, "1 entries verified") { + t.Fatalf("expected verified count, got: %s", out) + } + }) + }) +} + +func TestLearnListEmpty(t *testing.T) { + withTempState(t, func(stateDir string) { + out, err := runRoot("learn") + if err != nil { + t.Fatalf("unexpected error: %v\n%s", err, out) + } + if !strings.Contains(out, "No lessons yet") { + t.Fatalf("expected empty-lessons message, got: %s", out) + } + }) +} + +func TestLearnAddListClear(t *testing.T) { + withTempState(t, func(stateDir string) { + out, err := runRoot("learn", "add", + "--what", "write failed", + "--why", "wrong encoding", + "--lesson", "verify encoding", + "--category", "code") + if err != nil { + t.Fatalf("unexpected error: %v\n%s", err, out) + } + if !strings.Contains(out, "lesson added") { + t.Fatalf("expected 'lesson added', got: %s", out) + } + + list, err := runRoot("learn") + if err != nil { + t.Fatalf("unexpected error: %v\n%s", err, list) + } + if !strings.Contains(list, "write failed") || !strings.Contains(list, "code (1)") { + t.Fatalf("expected lesson in list output, got: %s", list) + } + + // Duplicate add is deduped: store stays at 1. + if _, err := runRoot("learn", "add", + "--what", "write failed", + "--why", "wrong encoding", + "--lesson", "verify encoding", + "--category", "code"); err != nil { + t.Fatalf("duplicate add errored: %v", err) + } + list2, err := runRoot("learn") + if err != nil { + t.Fatalf("unexpected error: %v\n%s", err, list2) + } + if !strings.Contains(list2, "Lesson store: 1 lesson") { + t.Fatalf("expected dedup to keep 1 lesson, got: %s", list2) + } + + clear, err := runRoot("learn", "clear") + if err != nil { + t.Fatalf("unexpected error: %v\n%s", err, clear) + } + if !strings.Contains(clear, "cleared 1 lesson") { + t.Fatalf("expected clear summary, got: %s", clear) + } + }) +} + +func TestLearnPrompt(t *testing.T) { + out, err := runRoot("learn", "prompt", "the parser crashed on line 42") + if err != nil { + t.Fatalf("unexpected error: %v\n%s", err, out) + } + if !strings.Contains(out, "WHAT_FAILED") || !strings.Contains(out, "WHY_FAILED") || !strings.Contains(out, "WHAT_TO_DO") { + t.Fatalf("expected labeled fields in prompt, got: %s", out) + } + if !strings.Contains(out, "the parser crashed on line 42") { + t.Fatalf("expected context echoed in prompt, got: %s", out) + } +} diff --git a/cmd/securitylog_cmd.go b/cmd/securitylog_cmd.go new file mode 100644 index 00000000..4614d2b4 --- /dev/null +++ b/cmd/securitylog_cmd.go @@ -0,0 +1,109 @@ +package cmd + +import ( + "encoding/json" + "fmt" + "time" + + "github.com/GrayCodeAI/hawk/internal/securitylog" + "github.com/spf13/cobra" +) + +var ( + securitylogLimit int + securitylogJSON bool +) + +// securitylogCmd inspects the tamper-evident security event log. +var securitylogCmd = &cobra.Command{ + Use: "securitylog", + Short: "Inspect the tamper-evident security event log", + Long: `Hawk records security-relevant events (permission denials, approval +denials) to an append-only, HMAC-chained log. Entries are linked so that +reordering, deletion, or alteration is detectable. + + hawk securitylog Show a summary and recent events + hawk securitylog show List logged events + hawk securitylog verify Verify the hash chain has not been tampered with`, + RunE: func(cmd *cobra.Command, args []string) error { + return runSecuritylogShow(cmd, 20, false) + }, +} + +var securitylogShowCmd = &cobra.Command{ + Use: "show", + Short: "List logged security events", + RunE: func(cmd *cobra.Command, args []string) error { + return runSecuritylogShow(cmd, securitylogLimit, securitylogJSON) + }, +} + +var securitylogVerifyCmd = &cobra.Command{ + Use: "verify", + Short: "Verify the event log hash chain is intact", + RunE: func(cmd *cobra.Command, args []string) error { + dir := securitylog.DefaultDir() + count, err := securitylog.Verify(dir) + if err != nil { + return fmt.Errorf("security log verification FAILED: %w", err) + } + cmd.Printf("security event log OK: %d entries verified (%s)\n", count, dir) + return nil + }, +} + +func init() { + securitylogShowCmd.Flags().IntVar(&securitylogLimit, "limit", 50, "max events to print (0 = all)") + securitylogShowCmd.Flags().BoolVar(&securitylogJSON, "json", false, "output events as JSON") + securitylogCmd.AddCommand(securitylogShowCmd) + securitylogCmd.AddCommand(securitylogVerifyCmd) + rootCmd.AddCommand(securitylogCmd) +} + +func runSecuritylogShow(cmd *cobra.Command, limit int, asJSON bool) error { + dir := securitylog.DefaultDir() + events, err := securitylog.Entries(dir) + if err != nil { + return fmt.Errorf("reading security event log: %w", err) + } + + if asJSON { + if limit > 0 && len(events) > limit { + events = events[len(events)-limit:] + } + if events == nil { + events = []securitylog.Event{} + } + data, err := json.MarshalIndent(events, "", " ") + if err != nil { + return fmt.Errorf("marshaling events: %w", err) + } + cmd.Println(string(data)) + return nil + } + + if len(events) == 0 { + cmd.Println("No security events recorded yet.") + cmd.Printf("Log location: %s\n", dir) + return nil + } + + start := 0 + if limit > 0 && len(events) > limit { + start = len(events) - limit + } + cmd.Printf("Security event log: %d event(s) at %s\n", len(events), dir) + if start > 0 { + cmd.Printf("Showing the most recent %d:\n", len(events)-start) + } + for _, ev := range events[start:] { + cmd.Printf( + "%s %-8s %-20s %s\n", + ev.Timestamp.Format(time.RFC3339), + ev.Severity, + ev.Type, + truncateWithEllipsis(ev.Detail, 60), + ) + } + return nil +} diff --git a/cmd/verify_cmd.go b/cmd/verify_cmd.go new file mode 100644 index 00000000..c7883d41 --- /dev/null +++ b/cmd/verify_cmd.go @@ -0,0 +1,56 @@ +package cmd + +import ( + "fmt" + "os" + + "github.com/GrayCodeAI/hawk/internal/governance" + "github.com/GrayCodeAI/hawk/internal/securitylog" + "github.com/spf13/cobra" +) + +// verifyCmd runs local self-verification: integrity of the security event log +// and validity of the managed governance policy, if one is installed. +var verifyCmd = &cobra.Command{ + Use: "verify", + Short: "Run local self-verification (security log, governance policy)", + Long: `Run hawk's self-verification checks without a model: + 1. The tamper-evident security event log hash chain is intact. + 2. The managed governance policy (if installed) parses and validates. + +Exits non-zero on the first failed check.`, + RunE: func(cmd *cobra.Command, args []string) error { + ok := true + + // 1. Security event log chain integrity. + dir := securitylog.DefaultDir() + count, err := securitylog.Verify(dir) + if err != nil { + ok = false + cmd.Printf("[FAIL] security event log: %v\n", err) + } else { + cmd.Printf("[OK] security event log: %d entries verified (%s)\n", count, dir) + } + + // 2. Managed governance policy validity (only when installed). + policyPath := governance.ManagedPolicyPath() + if _, statErr := os.Stat(policyPath); statErr != nil { + cmd.Printf("[SKIP] governance policy: not installed (%s)\n", policyPath) + } else if _, err := governance.LoadLayer("policy", policyPath); err != nil { + ok = false + cmd.Printf("[FAIL] governance policy: %v\n", err) + } else { + cmd.Printf("[OK] governance policy: valid (%s)\n", policyPath) + } + + if !ok { + return fmt.Errorf("verification failed — see messages above") + } + cmd.Println("verification passed") + return nil + }, +} + +func init() { + rootCmd.AddCommand(verifyCmd) +} diff --git a/internal/engine/execution_graph_observations.go b/internal/engine/execution_graph_observations.go index 6beb3de2..70a41660 100644 --- a/internal/engine/execution_graph_observations.go +++ b/internal/engine/execution_graph_observations.go @@ -16,6 +16,12 @@ import ( ) func (s *Session) recordPolicyObservation(tc types.ToolCall, stage string, allowed bool, reason string) { + // Tamper-evident security event log: record every denial regardless of + // whether graph observation is available, so enforcement history is + // auditable even when the session transcript is gone. + if !allowed { + s.recordSecurityDenial(tc, stage, reason) + } sessionID := s.executionGraphSessionID() if sessionID == "" { return diff --git a/internal/engine/llm_client.go b/internal/engine/llm_client.go index 25cf5a9b..f92023d6 100644 --- a/internal/engine/llm_client.go +++ b/internal/engine/llm_client.go @@ -100,6 +100,8 @@ func parseReflectionEntry(content string, attempt int, goal string) ReflectionEn } // buildReflectionPrompt constructs the reflection prompt from goal, messages, and error. +// The instruction block is delegated to LearnPrompt so the failure-analysis +// template lives in one place. func buildReflectionPrompt(goal string, msgs []types.EyrieMessage, errorContext string) string { var sb strings.Builder sb.WriteString("TASK GOAL: " + goal + "\n\n") @@ -122,7 +124,7 @@ func buildReflectionPrompt(goal string, msgs []types.EyrieMessage, errorContext } } sb.WriteString("\nFINAL ERROR: " + errorContext + "\n\n") - sb.WriteString("Analyze this failure. Respond with exactly:\nWHAT_FAILED: \nWHY_FAILED: \nWHAT_TO_DO: ") + sb.WriteString(LearnPrompt("")) return sb.String() } diff --git a/internal/engine/redaction.go b/internal/engine/redaction.go new file mode 100644 index 00000000..e8c3b06a --- /dev/null +++ b/internal/engine/redaction.go @@ -0,0 +1,31 @@ +package engine + +import ( + "os" +) + +// redactToolResult strips secrets from tool output before the result is fed +// back to the model. It uses the session pipeline's OutputRedactor (hawk's 25+ +// built-in patterns plus registered environment secrets) and collapses the +// user's home directory so absolute paths do not leak. A session without a +// pipeline (tests, zero-value Session) passes output through unchanged. +func (s *Session) redactToolResult(output string) string { + if s == nil { + return output + } + life := s.LifecycleSvc() + if life == nil { + return output + } + pipeline := life.Pipeline() + if pipeline == nil || pipeline.OutputRedactor == nil { + return output + } + // Idempotent: imports values of secret-named environment variables into + // the known-secrets table so tool output that echoes them is redacted. + pipeline.OutputRedactor.RegisterEnvSecrets() + home, _ := os.UserHomeDir() + redacted := pipeline.OutputRedactor.Redact(output) + redacted = pipeline.OutputRedactor.RedactEnvVars(redacted) + return pipeline.OutputRedactor.RedactPaths(redacted, home) +} diff --git a/internal/engine/redaction_test.go b/internal/engine/redaction_test.go new file mode 100644 index 00000000..8a99fd50 --- /dev/null +++ b/internal/engine/redaction_test.go @@ -0,0 +1,54 @@ +package engine + +import ( + "os" + "strings" + "testing" +) + +func TestRedactToolResultRedactsKnownSecrets(t *testing.T) { + s := &Session{life: NewLifecycleService(nil)} + output := "the api key is sk-test12345678901234567890 and all is well" + got := s.redactToolResult(output) + if strings.Contains(got, "sk-test12345678901234567890") { + t.Fatalf("tool result was not redacted: %q", got) + } + if !strings.Contains(got, "[REDACTED:api_key]") { + t.Fatalf("expected redaction placeholder, got: %q", got) + } +} + +func TestRedactToolResultRedactsEnvSecrets(t *testing.T) { + t.Setenv("GITHUB_TOKEN", "ghp_redactenv123456789012345678901234567") + s := &Session{life: NewLifecycleService(nil)} + got := s.redactToolResult("token=ghp_redactenv123456789012345678901234567") + if strings.Contains(got, "ghp_redactenv123456789012345678901234567") { + t.Fatalf("env secret was not redacted: %q", got) + } +} + +func TestRedactToolResultRedactsHomePaths(t *testing.T) { + home, err := os.UserHomeDir() + if err != nil || home == "" { + t.Skip("no home directory available") + } + s := &Session{life: NewLifecycleService(nil)} + got := s.redactToolResult(home + "/secret notes") + if strings.Contains(got, home) { + t.Fatalf("home path not collapsed: %q", got) + } + if !strings.Contains(got, "~/secret notes") { + t.Fatalf("expected ~/ collapse, got: %q", got) + } +} + +func TestRedactToolResultNilSafe(t *testing.T) { + var s *Session + if got := s.redactToolResult("anything"); got != "anything" { + t.Fatalf("nil session should pass through, got %q", got) + } + zero := &Session{} + if got := zero.redactToolResult("anything"); got != "anything" { + t.Fatalf("zero session should pass through, got %q", got) + } +} diff --git a/internal/engine/safety/output_redactor.go b/internal/engine/safety/output_redactor.go index b24ff0a3..c0d88493 100644 --- a/internal/engine/safety/output_redactor.go +++ b/internal/engine/safety/output_redactor.go @@ -2,6 +2,7 @@ package safety import ( "fmt" + "os" "regexp" "strings" "sync" @@ -22,6 +23,32 @@ type RedactStats struct { BytesSaved int } +// secretEnvNames are environment variable names whose values are treated as +// secrets. RegisterEnvSecrets and RedactEnvVars both key off this list so the +// set stays in one place. +var secretEnvNames = []string{ + "AWS_SECRET_ACCESS_KEY", + "AWS_SESSION_TOKEN", + "GITHUB_TOKEN", + "GH_TOKEN", + "OPENAI_API_KEY", + "ANTHROPIC_API_KEY", + "DATABASE_URL", + "REDIS_URL", + "SECRET_KEY", + "API_KEY", + "AUTH_TOKEN", + "ACCESS_TOKEN", + "PRIVATE_KEY", + "NPM_TOKEN", + "SLACK_TOKEN", + "STRIPE_SECRET_KEY", + "SENDGRID_API_KEY", + "TWILIO_AUTH_TOKEN", + "HEROKU_API_KEY", + "DOCKER_PASSWORD", +} + // OutputRedactor strips sensitive information from tool outputs before they reach the LLM. type OutputRedactor struct { Patterns []*RedactPattern @@ -167,6 +194,20 @@ func (r *OutputRedactor) AddKnownSecret(name, value string) { r.KnownSecrets[name] = value } +// RegisterEnvSecrets imports the values of secret-named environment variables +// (see secretEnvNames) into the known-secrets table so tool output that echoes +// them is redacted before it reaches the model. Values shorter than 8 bytes are +// skipped to avoid mangling short, non-secret values. Safe to call repeatedly. +func (r *OutputRedactor) RegisterEnvSecrets() { + r.mu.Lock() + defer r.mu.Unlock() + for _, envName := range secretEnvNames { + if val := strings.TrimSpace(os.Getenv(envName)); len(val) >= 8 { + r.KnownSecrets["env:"+envName] = val + } + } +} + // RedactEnvVars scans the output for values of known environment variables // whose names suggest they contain secrets, and replaces them. func (r *OutputRedactor) RedactEnvVars(output string) string { @@ -176,29 +217,6 @@ func (r *OutputRedactor) RedactEnvVars(output string) string { result := output originalLen := len(output) - secretEnvNames := []string{ - "AWS_SECRET_ACCESS_KEY", - "AWS_SESSION_TOKEN", - "GITHUB_TOKEN", - "GH_TOKEN", - "OPENAI_API_KEY", - "ANTHROPIC_API_KEY", - "DATABASE_URL", - "REDIS_URL", - "SECRET_KEY", - "API_KEY", - "AUTH_TOKEN", - "ACCESS_TOKEN", - "PRIVATE_KEY", - "NPM_TOKEN", - "SLACK_TOKEN", - "STRIPE_SECRET_KEY", - "SENDGRID_API_KEY", - "TWILIO_AUTH_TOKEN", - "HEROKU_API_KEY", - "DOCKER_PASSWORD", - } - for _, envName := range secretEnvNames { val, ok := r.KnownSecrets["env:"+envName] if !ok { diff --git a/internal/engine/safety/output_redactor_test.go b/internal/engine/safety/output_redactor_test.go index b8b9b744..b3159341 100644 --- a/internal/engine/safety/output_redactor_test.go +++ b/internal/engine/safety/output_redactor_test.go @@ -219,6 +219,28 @@ func TestRedactEnvVars(t *testing.T) { } } +func TestRegisterEnvSecrets(t *testing.T) { + t.Setenv("GITHUB_TOKEN", "ghp_registered123456789012345678901234567") + t.Setenv("OPENAI_API_KEY", "sk-registered-token-value-12345") + t.Setenv("SHORT_VAL", "x") // not a secret-named var; ignored by design + t.Setenv("AWS_SECRET_ACCESS_KEY", "") // empty values are skipped + + r := NewOutputRedactor() + r.RegisterEnvSecrets() + + input := "auth via ghp_registered123456789012345678901234567 and sk-registered-token-value-12345" + result := r.RedactEnvVars(input) + if strings.Contains(result, "ghp_registered123456789012345678901234567") { + t.Error("GITHUB_TOKEN value was not registered and redacted") + } + if strings.Contains(result, "sk-registered-token-value-12345") { + t.Error("OPENAI_API_KEY value was not registered and redacted") + } + if !strings.Contains(result, "[REDACTED:env:GITHUB_TOKEN]") || !strings.Contains(result, "[REDACTED:env:OPENAI_API_KEY]") { + t.Errorf("expected env redaction placeholders, got: %s", result) + } +} + func TestRedactPaths(t *testing.T) { r := NewOutputRedactor() input := "Reading /home/user/.config/secrets.json" diff --git a/internal/engine/security_events.go b/internal/engine/security_events.go new file mode 100644 index 00000000..81455991 --- /dev/null +++ b/internal/engine/security_events.go @@ -0,0 +1,67 @@ +package engine + +import ( + "log/slog" + "sync" + + "github.com/GrayCodeAI/hawk/internal/securitylog" + "github.com/GrayCodeAI/hawk/internal/types" +) + +// securityLog is the session's tamper-evident event log. It is opened lazily +// on the first recorded event so sessions that never deny an action do not +// touch the filesystem. A nil log means recording is unavailable (log open +// failed) and events are dropped with a single warning. +type securityLog struct { + mu sync.Mutex + log *securitylog.Log + warn bool +} + +// record opens the log on first use and appends an event. +func (sl *securityLog) record(severity securitylog.EventSeverity, eventType, detail, tool, sessionID string) { + if sl == nil { + return + } + sl.mu.Lock() + defer sl.mu.Unlock() + if sl.log == nil { + l, err := securitylog.New(securitylog.DefaultDir()) + if err != nil { + if !sl.warn { + sl.warn = true + slog.Warn("security event log unavailable; events will be dropped", "error", err) + } + return + } + sl.log = l + } + if _, err := sl.log.Append(severity, eventType, detail, tool, sessionID); err != nil { + if !sl.warn { + sl.warn = true + slog.Warn("security event append failed; events will be dropped", "error", err) + } + } +} + +// recordSecurityDenial records a permission or approval denial to the +// tamper-evident security event log. +func (s *Session) recordSecurityDenial(tc types.ToolCall, stage string, reason string) { + if s == nil { + return + } + s.secLog().record(securitylog.SeverityWarning, "denied", reason, tc.Name, s.executionGraphSessionID()) +} + +// secLog returns the session's security event log, creating it on first use. +func (s *Session) secLog() *securityLog { + if s == nil { + return nil + } + s.mu.Lock() + defer s.mu.Unlock() + if s.sec == nil { + s.sec = &securityLog{} + } + return s.sec +} diff --git a/internal/engine/security_events_test.go b/internal/engine/security_events_test.go new file mode 100644 index 00000000..6b065927 --- /dev/null +++ b/internal/engine/security_events_test.go @@ -0,0 +1,51 @@ +package engine + +import ( + "path/filepath" + "testing" + + "github.com/GrayCodeAI/hawk/internal/securitylog" + "github.com/GrayCodeAI/hawk/internal/types" +) + +func TestSessionRecordsSecurityDenial(t *testing.T) { + dir := t.TempDir() + t.Setenv("HAWK_STATE_DIR", dir) + + s := &Session{life: NewLifecycleService(nil)} + s.recordSecurityDenial(types.ToolCall{Name: "Bash", ID: "tc1"}, "permission", "denied by policy ceiling") + + // DefaultDir honors HAWK_STATE_DIR set in this test. + logDir := securitylog.DefaultDir() + events, err := securitylog.Entries(logDir) + if err != nil { + t.Fatalf("reading events: %v", err) + } + if len(events) != 1 { + t.Fatalf("expected 1 security event, got %d", len(events)) + } + if events[0].Type != "denied" { + t.Fatalf("expected event type %q, got %q", "denied", events[0].Type) + } + if events[0].Tool != "Bash" { + t.Fatalf("expected tool Bash, got %q", events[0].Tool) + } + // Chain must verify. + if count, err := securitylog.Verify(logDir); err != nil || count != 1 { + t.Fatalf("chain verification: %d events, err=%v", count, err) + } +} + +func TestSessionSecurityDenialIsNilSafe(t *testing.T) { + var s *Session + s.recordSecurityDenial(types.ToolCall{Name: "Bash"}, "permission", "") +} + +func TestDefaultDirHonorsStateDir(t *testing.T) { + dir := t.TempDir() + t.Setenv("HAWK_STATE_DIR", dir) + expected := filepath.Join(dir, "securitylog") + if got := securitylog.DefaultDir(); got != expected { + t.Fatalf("expected %q, got %q", expected, got) + } +} diff --git a/internal/engine/self_improve.go b/internal/engine/self_improve.go index ab2f95b5..edeedb3c 100644 --- a/internal/engine/self_improve.go +++ b/internal/engine/self_improve.go @@ -41,12 +41,19 @@ func NewSelfImprover() *SelfImprover { // Learn records a new lesson. It is nil-safe and bounded: oldest entries are // dropped past maxSelfImproveEntries so the store cannot grow without limit. +// Exact duplicates (same what/lesson/category) are skipped so repeated +// failures do not fill the store with identical rows. func (si *SelfImprover) Learn(what, why, lesson, category string) { if si == nil { return } si.mu.Lock() defer si.mu.Unlock() + for _, e := range si.Entries { + if e.What == what && e.Lesson == lesson && e.Category == category { + return + } + } si.Entries = append(si.Entries, SelfImproveEntry{ Timestamp: time.Now(), What: what, @@ -79,6 +86,20 @@ func (si *SelfImprover) Lessons(category string) []SelfImproveEntry { return filtered } +// Clear removes all lessons. +func (si *SelfImprover) Clear() { + if si == nil { + return + } + si.mu.Lock() + defer si.mu.Unlock() + if len(si.Entries) == 0 { + return + } + si.Entries = nil + si.save() +} + // ForPrompt formats recent lessons as context for the system prompt. func (si *SelfImprover) ForPrompt(maxEntries int) string { if si == nil { @@ -114,15 +135,18 @@ func (si *SelfImprover) save() { _ = os.WriteFile(si.Path, data, 0o600) } -// LearnPrompt generates a prompt to extract lessons from a failed interaction. +// LearnPrompt generates a prompt that asks a model to extract a lesson from a +// failed interaction. It is the canonical instruction block used by the +// engine's Reflector (via buildReflectionPrompt) and surfaced by the +// `hawk learn` CLI. The response format matches parseReflectionEntry. func LearnPrompt(context string) string { - return `A task just failed or produced a suboptimal result. Extract a lesson. - -Context: ` + context + ` - -Respond with: -- **What went wrong:** (one sentence) -- **Why:** (root cause) -- **Lesson:** (what to do differently next time) -- **Category:** code | test | design | communication` + prompt := "A task just failed or produced a suboptimal result. Extract a lesson.\n\n" + if context != "" { + prompt += "Context: " + context + "\n\n" + } + prompt += "Respond with exactly three labeled lines:\n" + + "WHAT_FAILED: \n" + + "WHY_FAILED: \n" + + "WHAT_TO_DO: " + return prompt } diff --git a/internal/engine/self_improve_test.go b/internal/engine/self_improve_test.go index 4d5643d5..eeefb9ad 100644 --- a/internal/engine/self_improve_test.go +++ b/internal/engine/self_improve_test.go @@ -1,6 +1,7 @@ package engine import ( + "fmt" "path/filepath" "testing" ) @@ -22,13 +23,24 @@ func TestSelfImproverLearnAndForPrompt(t *testing.T) { func TestSelfImproverBounded(t *testing.T) { si := &SelfImprover{Path: filepath.Join(t.TempDir(), "self-improve.json")} for i := 0; i < maxSelfImproveEntries+50; i++ { - si.Learn("x", "y", "z", "code") + si.Learn(fmt.Sprintf("x%d", i), "y", "z", "code") } if len(si.Entries) != maxSelfImproveEntries { t.Fatalf("expected %d entries, got %d", maxSelfImproveEntries, len(si.Entries)) } } +func TestSelfImproverDeduplicates(t *testing.T) { + si := &SelfImprover{Path: filepath.Join(t.TempDir(), "self-improve.json")} + si.Learn("write failed", "wrong encoding", "verify encoding", "code") + si.Learn("write failed", "wrong encoding", "verify encoding", "code") + si.Learn("write failed", "other cause", "verify encoding", "code") // same what+lesson, different why — deduped + si.Learn("test flaked", "race", "use -race", "test") // distinct — kept + if len(si.Entries) != 2 { + t.Fatalf("expected 2 unique lessons, got %d", len(si.Entries)) + } +} + func TestSelfImproverNilSafe(t *testing.T) { var si *SelfImprover si.Learn("x", "y", "z", "code") // must not panic diff --git a/internal/engine/session.go b/internal/engine/session.go index 1147ac5a..50b252ce 100644 --- a/internal/engine/session.go +++ b/internal/engine/session.go @@ -76,6 +76,9 @@ type Session struct { // cross-session store (e.g. the chat client's SelfImprover). It is a // callback so the engine stays decoupled from storage; nil disables it. learnFn func(what, why, lesson, category string) + // sec is the session's tamper-evident security event log, opened lazily on + // the first recorded event (see security_events.go). + sec *securityLog // GLMThinkingEnabled toggles GLM/Z.ai extended reasoning on outgoing requests // (applied only when provider is zai_payg or zai_coding). nil leaves the model default. diff --git a/internal/engine/stream.go b/internal/engine/stream.go index 242d7024..68585dff 100644 --- a/internal/engine/stream.go +++ b/internal/engine/stream.go @@ -770,12 +770,15 @@ func (s *Session) agentLoop(ctx context.Context, ch chan<- StreamEvent) { Content: assistContent, ToolUse: toolCalls, })) - // Append tool results as proper tool_result messages + // Append tool results as proper tool_result messages. Tool output is + // redacted before it is appended so secrets never reach the model; + // the user-facing stream events already carried the raw output. for _, r := range results { resultContent := r.output if resultContent == "" { resultContent = "(no output)" } + resultContent = s.redactToolResult(resultContent) msg := types.EyrieMessage{ Role: "user", Content: resultContent, diff --git a/internal/governance/governance.go b/internal/governance/governance.go index bac20b11..ee444e5d 100644 --- a/internal/governance/governance.go +++ b/internal/governance/governance.go @@ -174,6 +174,13 @@ func BuildProfile(name string, doc Document) (*Layer, error) { return buildLayer(name, doc) } +// LoadLayer loads and parses a policy or profile document from disk. The name +// is used only for error messages; the layer's behavior is identical for +// policy and profile documents. +func LoadLayer(name, path string) (*Layer, error) { + return loadLayer(name, path) +} + func buildLayer(name string, doc Document) (*Layer, error) { if doc.Version != 1 { return nil, fmt.Errorf("governance: unsupported version %d", doc.Version) diff --git a/internal/securitylog/securitylog.go b/internal/securitylog/securitylog.go index c61b7e1d..5b861676 100644 --- a/internal/securitylog/securitylog.go +++ b/internal/securitylog/securitylog.go @@ -15,6 +15,8 @@ import ( "path/filepath" "sync" "time" + + "github.com/GrayCodeAI/hawk/internal/storage" ) // EventSeverity classifies the impact of a security event. @@ -68,6 +70,12 @@ type Log struct { closed bool } +// DefaultDir returns the default on-disk location for the security event log, +// rooted under hawk's per-user state directory. +func DefaultDir() string { + return filepath.Join(storage.StateDir(), "securitylog") +} + // New opens (or creates) a security event log rooted at dir. The HMAC key is // generated once on first use and reused for subsequent opens, so verification // is stable across processes. @@ -221,6 +229,10 @@ func (l *Log) computeHash(ev Event) string { func Verify(dir string) (int, error) { data, err := os.ReadFile(filepath.Join(dir, logFileName)) if err != nil { + if os.IsNotExist(err) { + // No log to verify yet is vacuously intact. + return 0, nil + } return 0, fmt.Errorf("securitylog: read log: %w", err) } key, err := os.ReadFile(filepath.Join(dir, keyFileName)) @@ -275,6 +287,30 @@ func hashEntry(key []byte, ev Event) string { return hex.EncodeToString(mac.Sum(nil)) } +// Entries reads and decodes every event in the log, oldest first. It does not +// verify the chain (see Verify); it exists for inspection and display. +func Entries(dir string) ([]Event, error) { + data, err := os.ReadFile(filepath.Join(dir, logFileName)) + if err != nil { + if os.IsNotExist(err) { + return nil, nil + } + return nil, fmt.Errorf("securitylog: read log: %w", err) + } + var events []Event + for _, line := range splitLines(data) { + if len(line) == 0 { + continue + } + var ev Event + if err := json.Unmarshal(line, &ev); err != nil { + return events, fmt.Errorf("securitylog: corrupt entry: %w", err) + } + events = append(events, ev) + } + return events, nil +} + // Close flushes and closes the underlying file. func (l *Log) Close() error { l.mu.Lock() diff --git a/internal/securitylog/securitylog_test.go b/internal/securitylog/securitylog_test.go index 965142ce..2ca78c8e 100644 --- a/internal/securitylog/securitylog_test.go +++ b/internal/securitylog/securitylog_test.go @@ -154,3 +154,44 @@ func TestAppendAfterCloseFails(t *testing.T) { t.Fatal("expected append after close to fail") } } + +func TestEntries(t *testing.T) { + dir := t.TempDir() + l, err := New(dir) + if err != nil { + t.Fatal(err) + } + if _, err := l.Append(SeverityInfo, "tool_exec", "wrote file", "Write", "sess-1"); err != nil { + t.Fatal(err) + } + if _, err := l.Append(SeverityWarning, "denied", "blocked", "Bash", "sess-1"); err != nil { + t.Fatal(err) + } + if err := l.Close(); err != nil { + t.Fatal(err) + } + + events, err := Entries(dir) + if err != nil { + t.Fatal(err) + } + if len(events) != 2 { + t.Fatalf("expected 2 entries, got %d", len(events)) + } + if events[0].Type != "tool_exec" || events[1].Type != "denied" { + t.Fatalf("unexpected entry order: %+v", events) + } + if events[0].Seq != 1 || events[1].Seq != 2 { + t.Fatalf("unexpected sequence numbers: %+v", events) + } +} + +func TestEntriesEmptyWhenMissing(t *testing.T) { + events, err := Entries(t.TempDir()) + if err != nil { + t.Fatal(err) + } + if len(events) != 0 { + t.Fatalf("expected no entries for missing log, got %d", len(events)) + } +}