From b42999c9ebc1e2ef75861437d176d1a2a07d953d Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Wed, 5 Aug 2026 21:52:40 +0530 Subject: [PATCH 1/4] feat(securitylog): hash-chained tamper-evident security event log Adds internal/securitylog with an HMAC-SHA256 hash-chained event log and a head-pointer file to catch tail truncation. Verify() detects tampering or missing entries; the chain key is generated on first use and stored beside the log. Includes append/verify/reopen/tamper/truncation tests. --- internal/securitylog/securitylog.go | 307 +++++++++++++++++++++++ internal/securitylog/securitylog_test.go | 156 ++++++++++++ 2 files changed, 463 insertions(+) create mode 100644 internal/securitylog/securitylog.go create mode 100644 internal/securitylog/securitylog_test.go diff --git a/internal/securitylog/securitylog.go b/internal/securitylog/securitylog.go new file mode 100644 index 00000000..c61b7e1d --- /dev/null +++ b/internal/securitylog/securitylog.go @@ -0,0 +1,307 @@ +// Package securitylog implements a tamper-evident, append-only security +// event log. Every entry is chained to the previous one with HMAC-SHA256 so +// that reordering, deletion, or alteration of any historical event is +// detectable during verification. +package securitylog + +import ( + "crypto/hmac" + "crypto/rand" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "os" + "path/filepath" + "sync" + "time" +) + +// EventSeverity classifies the impact of a security event. +type EventSeverity string + +const ( + SeverityInfo EventSeverity = "info" + SeverityWarning EventSeverity = "warning" + SeverityCritical EventSeverity = "critical" +) + +// Event describes a single security event. Hash and PrevHash are the chain +// linkage and are computed at write time; callers supply the rest. +type Event struct { + Seq uint64 `json:"seq"` + Timestamp time.Time `json:"timestamp"` + Severity EventSeverity `json:"severity"` + Type string `json:"type"` + Detail string `json:"detail,omitempty"` + Tool string `json:"tool,omitempty"` + SessionID string `json:"session_id,omitempty"` + PrevHash string `json:"prev_hash"` + Hash string `json:"hash"` +} + +const ( + keyFileName = "sel.key" + logFileName = "security_events.jsonl" + headFileName = "sel.head" + keySize = 32 + genesisHash = "" // the first entry links against the empty string +) + +// headPointer is a separate file recording the expected tail of the chain. +// It makes truncation detectable: a removed tail no longer matches the head. +type headPointer struct { + Seq uint64 `json:"seq"` + Hash string `json:"hash"` +} + +// Log is the append-only hash-chained security event log. +type Log struct { + mu sync.Mutex + dir string + path string + keyPath string + key []byte + f *os.File + seq uint64 + last string + closed bool +} + +// 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. +func New(dir string) (*Log, error) { + if dir == "" { + return nil, fmt.Errorf("securitylog: empty directory") + } + if err := os.MkdirAll(dir, 0o750); err != nil { + return nil, fmt.Errorf("securitylog: create dir: %w", err) + } + + l := &Log{ + dir: dir, + path: filepath.Join(dir, logFileName), + keyPath: filepath.Join(dir, keyFileName), + } + + if err := l.loadKey(); err != nil { + return nil, err + } + if err := l.loadChain(); err != nil { + return nil, err + } + + f, err := os.OpenFile(l.path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600) // #nosec G304 -- path is derived from the state dir, not external input + if err != nil { + return nil, fmt.Errorf("securitylog: open log: %w", err) + } + l.f = f + return l, nil +} + +// loadKey reads an existing HMAC key or generates a fresh one. +func (l *Log) loadKey() error { + data, err := os.ReadFile(l.keyPath) + if err == nil && len(data) == keySize { + l.key = data + return nil + } + key := make([]byte, keySize) + if _, err := rand.Read(key); err != nil { + return fmt.Errorf("securitylog: generate key: %w", err) + } + if err := os.WriteFile(l.keyPath, key, 0o600); err != nil { + return fmt.Errorf("securitylog: persist key: %w", err) + } + l.key = key + return nil +} + +// loadChain scans the existing log to recover the current sequence number and +// tail hash so appends continue the chain without gaps. It also reads the head +// pointer so a truncated tail is caught at append time, not silently extended. +func (l *Log) loadChain() error { + data, err := os.ReadFile(l.path) + if err != nil { + if os.IsNotExist(err) { + return nil + } + return fmt.Errorf("securitylog: read log: %w", err) + } + var ev Event + for _, line := range splitLines(data) { + if len(line) == 0 { + continue + } + if err := json.Unmarshal(line, &ev); err != nil { + return fmt.Errorf("securitylog: corrupt entry: %w", err) + } + l.seq = ev.Seq + l.last = ev.Hash + } + + if head, err := l.readHead(); err == nil && head.Seq != 0 { + if head.Seq != l.seq || head.Hash != l.last { + return fmt.Errorf("securitylog: log tail does not match head pointer (truncated or tampered)") + } + } + return nil +} + +func (l *Log) readHead() (headPointer, error) { + var head headPointer + data, err := os.ReadFile(filepath.Join(l.dir, headFileName)) + if err != nil { + return head, err + } + err = json.Unmarshal(data, &head) + return head, err +} + +// writeHead persists the current chain tail so truncation is detectable. +func (l *Log) writeHead() error { + head := headPointer{Seq: l.seq, Hash: l.last} + data, err := json.Marshal(head) + if err != nil { + return err + } + return os.WriteFile(filepath.Join(l.dir, headFileName), data, 0o600) +} + +// Append records a new event and returns the linked entry. It is safe for +// concurrent use. The returned event carries the computed chain linkage. +func (l *Log) Append(severity EventSeverity, eventType, detail, tool, sessionID string) (Event, error) { + l.mu.Lock() + defer l.mu.Unlock() + if l.closed { + return Event{}, fmt.Errorf("securitylog: log closed") + } + + l.seq++ + ev := Event{ + Seq: l.seq, + Timestamp: time.Now().UTC(), + Severity: severity, + Type: eventType, + Detail: detail, + Tool: tool, + SessionID: sessionID, + PrevHash: l.last, + } + ev.Hash = l.computeHash(ev) + + line, err := json.Marshal(ev) + if err != nil { + return Event{}, fmt.Errorf("securitylog: marshal: %w", err) + } + if _, err := l.f.Write(append(line, '\n')); err != nil { + return Event{}, fmt.Errorf("securitylog: append: %w", err) + } + l.last = ev.Hash + if err := l.writeHead(); err != nil { + return Event{}, fmt.Errorf("securitylog: write head: %w", err) + } + return ev, nil +} + +// computeHash HMACs the canonical serialization of the event with the chain +// key. The hash covers every field except Hash itself. +func (l *Log) computeHash(ev Event) string { + ev.Hash = "" + payload, _ := json.Marshal(ev) + mac := hmac.New(sha256.New, l.key) + mac.Write(payload) + return hex.EncodeToString(mac.Sum(nil)) +} + +// Verify replays the log and confirms every entry's hash matches its contents +// and each entry chains to the previous one. It returns the number of entries +// verified and an error naming the first break in the chain. +func Verify(dir string) (int, error) { + data, err := os.ReadFile(filepath.Join(dir, logFileName)) + if err != nil { + return 0, fmt.Errorf("securitylog: read log: %w", err) + } + key, err := os.ReadFile(filepath.Join(dir, keyFileName)) + if err != nil { + return 0, fmt.Errorf("securitylog: read key: %w", err) + } + if len(key) != keySize { + return 0, fmt.Errorf("securitylog: invalid key size %d", len(key)) + } + + count := 0 + prev := genesisHash + for _, line := range splitLines(data) { + if len(line) == 0 { + continue + } + var ev Event + if err := json.Unmarshal(line, &ev); err != nil { + return count, fmt.Errorf("securitylog: entry %d corrupt: %w", count, err) + } + if ev.PrevHash != prev { + return count, fmt.Errorf("securitylog: entry %d breaks chain: expected prev %q got %q", count, prev, ev.PrevHash) + } + computed := hashEntry(key, ev) + if computed != ev.Hash { + return count, fmt.Errorf("securitylog: entry %d hash mismatch", count) + } + prev = ev.Hash + count++ + } + + // Confirm the chain tail matches the recorded head pointer, catching + // truncation of the tail. + var head headPointer + if data, err := os.ReadFile(filepath.Join(dir, headFileName)); err == nil { + _ = json.Unmarshal(data, &head) + } + if head.Seq != 0 { + if uint64(count) != head.Seq || prev != head.Hash { + return count, fmt.Errorf("securitylog: tail does not match head pointer (truncated)") + } + } + return count, nil +} + +// hashEntry recomputes an event's chain hash without mutating it. +func hashEntry(key []byte, ev Event) string { + ev.Hash = "" + payload, _ := json.Marshal(ev) + mac := hmac.New(sha256.New, key) + mac.Write(payload) + return hex.EncodeToString(mac.Sum(nil)) +} + +// Close flushes and closes the underlying file. +func (l *Log) Close() error { + l.mu.Lock() + defer l.mu.Unlock() + if l.closed { + return nil + } + l.closed = true + if l.f != nil { + return l.f.Close() + } + return nil +} + +func splitLines(data []byte) [][]byte { + var lines [][]byte + start := 0 + for i, b := range data { + if b == '\n' { + if i > start { + lines = append(lines, data[start:i]) + } + start = i + 1 + } + } + if start < len(data) { + lines = append(lines, data[start:]) + } + return lines +} diff --git a/internal/securitylog/securitylog_test.go b/internal/securitylog/securitylog_test.go new file mode 100644 index 00000000..965142ce --- /dev/null +++ b/internal/securitylog/securitylog_test.go @@ -0,0 +1,156 @@ +package securitylog + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestAppendAndVerify(t *testing.T) { + dir := t.TempDir() + l, err := New(dir) + if err != nil { + t.Fatal(err) + } + + ev1, err := l.Append(SeverityInfo, "tool_exec", "wrote file", "Write", "sess-1") + if err != nil { + t.Fatal(err) + } + ev2, err := l.Append(SeverityCritical, "denied", "blocked sensitive path", "Bash", "sess-1") + if err != nil { + t.Fatal(err) + } + if err := l.Close(); err != nil { + t.Fatal(err) + } + + if ev1.Seq != 1 || ev2.Seq != 2 { + t.Fatalf("seq mismatch: %d %d", ev1.Seq, ev2.Seq) + } + if ev1.PrevHash != "" { + t.Fatalf("first entry should link to genesis, got %q", ev1.PrevHash) + } + if ev2.PrevHash != ev1.Hash { + t.Fatalf("second entry must chain to first: got %q want %q", ev2.PrevHash, ev1.Hash) + } + + count, err := Verify(dir) + if err != nil { + t.Fatal(err) + } + if count != 2 { + t.Fatalf("expected 2 verified entries, got %d", count) + } +} + +func TestReopenContinuesChain(t *testing.T) { + dir := t.TempDir() + l, err := New(dir) + if err != nil { + t.Fatal(err) + } + if _, err := l.Append(SeverityInfo, "test", "first", "", ""); err != nil { + t.Fatal(err) + } + if err := l.Close(); err != nil { + t.Fatal(err) + } + + l2, err := New(dir) + if err != nil { + t.Fatal(err) + } + ev, err := l2.Append(SeverityWarning, "test", "second", "", "") + if err != nil { + t.Fatal(err) + } + if err := l2.Close(); err != nil { + t.Fatal(err) + } + + if ev.Seq != 2 { + t.Fatalf("expected seq 2 after reopen, got %d", ev.Seq) + } + if _, err := Verify(dir); err != nil { + t.Fatal(err) + } +} + +func TestVerifyDetectsTampering(t *testing.T) { + dir := t.TempDir() + l, err := New(dir) + if err != nil { + t.Fatal(err) + } + if _, err := l.Append(SeverityInfo, "tool_exec", "original", "Write", ""); err != nil { + t.Fatal(err) + } + if _, err := l.Append(SeverityInfo, "tool_exec", "second", "Write", ""); err != nil { + t.Fatal(err) + } + if err := l.Close(); err != nil { + t.Fatal(err) + } + + path := filepath.Join(dir, logFileName) + data, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + tampered := strings.Replace(string(data), "second", "TAMPERED", 1) + if err := os.WriteFile(path, []byte(tampered), 0o600); err != nil { + t.Fatal(err) + } + + if _, err := Verify(dir); err == nil { + t.Fatal("expected verification to fail after tampering") + } +} + +func TestVerifyDetectsTruncation(t *testing.T) { + dir := t.TempDir() + l, err := New(dir) + if err != nil { + t.Fatal(err) + } + if _, err := l.Append(SeverityInfo, "tool_exec", "one", "Write", ""); err != nil { + t.Fatal(err) + } + if _, err := l.Append(SeverityInfo, "tool_exec", "two", "Write", ""); err != nil { + t.Fatal(err) + } + if err := l.Close(); err != nil { + t.Fatal(err) + } + + path := filepath.Join(dir, logFileName) + data, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + // Keep only the first line — the chain now dangles. + lines := strings.SplitN(string(data), "\n", 2) + if err := os.WriteFile(path, []byte(lines[0]+"\n"), 0o600); err != nil { + t.Fatal(err) + } + + if _, err := Verify(dir); err == nil { + t.Fatal("expected verification to fail after truncation") + } +} + +func TestAppendAfterCloseFails(t *testing.T) { + dir := t.TempDir() + l, err := New(dir) + if err != nil { + t.Fatal(err) + } + if err := l.Close(); err != nil { + t.Fatal(err) + } + if _, err := l.Append(SeverityInfo, "test", "x", "", ""); err == nil { + t.Fatal("expected append after close to fail") + } +} From 6821e80132ea5cf3d4879e28fd2a7b36c697bfa4 Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Wed, 5 Aug 2026 21:52:47 +0530 Subject: [PATCH 2/4] =?UTF-8?q?feat(governance):=20POLICY=20=E2=88=A9=20PR?= =?UTF-8?q?OFILE=20tightest-wins=20permission=20ceiling?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds internal/governance: a two-layer permission model where the admin-set POLICY (loaded from a platform trust-root) can only be narrowed, never loosened, by the session PROFILE. Evaluated before hooks, spec stage, rules, autonomy, and the bypass kill-switch, so nothing an agent or user grants at a lower layer can override the org ceiling. Includes fail-closed policy files, flat denied-tool lists, sensitive-path floors, and a scope catalog. Wired into PermissionEngine as an un-disableable pre-gate and auto-loaded from the managed path at session bootstrap. --- internal/engine/permission_service.go | 29 +- internal/engine/safety/permission_engine.go | 19 + .../permission_engine_governance_test.go | 67 ++++ internal/governance/governance.go | 349 ++++++++++++++++++ internal/governance/governance_test.go | 190 ++++++++++ 5 files changed, 653 insertions(+), 1 deletion(-) create mode 100644 internal/engine/safety/permission_engine_governance_test.go create mode 100644 internal/governance/governance.go create mode 100644 internal/governance/governance_test.go diff --git a/internal/engine/permission_service.go b/internal/engine/permission_service.go index d11af543..cab3bbdb 100644 --- a/internal/engine/permission_service.go +++ b/internal/engine/permission_service.go @@ -3,10 +3,12 @@ package engine import ( "context" "fmt" + "os" "strings" "sync" "github.com/GrayCodeAI/hawk/internal/engine/safety" + "github.com/GrayCodeAI/hawk/internal/governance" "github.com/GrayCodeAI/hawk/internal/observability/logger" "github.com/GrayCodeAI/hawk/internal/permissions" "github.com/GrayCodeAI/hawk/internal/sandbox" @@ -59,7 +61,7 @@ func NewPermissionService(log *logger.Logger) *PermissionService { log = logger.Default() } pe := NewPermissionEngine() - return &PermissionService{ + s := &PermissionService{ perm: pe, memory: pe.Memory, autoMode: pe.AutoMode, @@ -67,6 +69,31 @@ func NewPermissionService(log *logger.Logger) *PermissionService { bypassKill: pe.BypassKill, log: log, } + s.loadManagedGovernance() + return s +} + +// loadManagedGovernance attempts to install the administrator-set POLICY +// ceiling from the platform trust-root (see governance.ManagedPolicyPath). +// A missing file is the normal unmanaged case and leaves the engine +// fail-open; a malformed file is logged loudly so misconfiguration is +// visible without hard-failing every session. +func (s *PermissionService) loadManagedGovernance() { + if s == nil || s.perm == nil || s.perm.Governance == nil { + return + } + path := governance.ManagedPolicyPath() + if path == "" { + return + } + if err := s.perm.Governance.LoadPolicy(path); err != nil { + if !os.IsNotExist(err) { + s.log.Error("governance: failed to load managed policy", map[string]interface{}{ + "path": path, + "error": err.Error(), + }) + } + } } // WithEngine replaces the underlying PermissionEngine. Used by tests diff --git a/internal/engine/safety/permission_engine.go b/internal/engine/safety/permission_engine.go index 6b919cae..fa038f02 100644 --- a/internal/engine/safety/permission_engine.go +++ b/internal/engine/safety/permission_engine.go @@ -11,6 +11,7 @@ import ( "time" contracts "github.com/GrayCodeAI/hawk-core-contracts/policy" + "github.com/GrayCodeAI/hawk/internal/governance" "github.com/GrayCodeAI/hawk/internal/hooks" "github.com/GrayCodeAI/hawk/internal/permissions" "github.com/GrayCodeAI/hawk/internal/sandbox" @@ -74,6 +75,11 @@ type PermissionEngine struct { Phases int // total number of phases detected from tasks.md convergeChecked bool // whether convergence has been checked this session PromptFn func(PermissionRequest) // callback to ask user + // Governance is the POLICY ∩ PROFILE ceiling. It is evaluated before + // every other gate (hooks, spec stage, rules, autonomy, bypass) so no + // agent state or user-granted bypass can loosen an administrator-set + // ceiling. Nil means fail-open (no governance policy installed). + Governance *governance.Engine } // DecisionOutcome is the result of evaluating a tool request. @@ -104,6 +110,7 @@ const ( ReasonClassifiedSafe DecisionReason = "classified_safe" ReasonUserPrompt DecisionReason = "user_prompt" ReasonPromptUnavailable DecisionReason = "prompt_unavailable" + ReasonGovernance DecisionReason = "governance" ) // Decision is the structured result of a permission evaluation. @@ -155,6 +162,7 @@ func NewPermissionEngine() *PermissionEngine { AutoMode: permissions.NewAutoModeState(), Classifier: permissions.NewClassifier(), BypassKill: permissions.NewBypassKillswitch(), + Governance: governance.New(), } } @@ -221,6 +229,17 @@ func (pe *PermissionEngine) evaluateToolDecision(ctx context.Context, tc ToolCal return Decision{Outcome: DecisionDeny, Reason: ReasonDryRun, Message: "dry-run: tool execution disabled"} } + // Governance ceiling — evaluated first so no later gate (hooks, spec + // stage, remembered rules, autonomy, bypass kill-switch) can override an + // administrator-set POLICY ∩ PROFILE decision. This is the un-disableable + // org ceiling: deny in either layer denies regardless of what the agent + // or user grants at lower layers. + if pe.Governance != nil { + if d := pe.Governance.Evaluate(tc.Name, ToolSummary(tc.Name, tc.Args)); !d.Allowed { + return Decision{Outcome: DecisionDeny, Reason: ReasonGovernance, Message: d.Reason} + } + } + toolName := canonicalToolName(tc.Name) // PreToolUse decision hooks — deny gate before autonomy. Hooks that diff --git a/internal/engine/safety/permission_engine_governance_test.go b/internal/engine/safety/permission_engine_governance_test.go new file mode 100644 index 00000000..698a4e49 --- /dev/null +++ b/internal/engine/safety/permission_engine_governance_test.go @@ -0,0 +1,67 @@ +package safety + +import ( + "context" + "testing" + + "github.com/GrayCodeAI/hawk/internal/governance" +) + +// govPolicy builds a policy layer with the given capabilities. +func govPolicy(caps ...governance.Capability) *governance.Layer { + l, err := governance.BuildProfile("policy", governance.Document{ + Version: 1, + FailClosed: true, + Capabilities: caps, + }) + if err != nil { + panic(err) + } + return l +} + +func TestGovernanceCeilingOverridesBypass(t *testing.T) { + pe := NewPermissionEngine() + pe.Governance = governance.New() + pe.Governance.SetPolicy(govPolicy( + governance.Capability{Scope: "bash", Action: governance.ActionDeny}, + )) + pe.BypassKill.Enable() + + d := pe.CheckToolDecision(context.Background(), ToolCallInfo{ + Name: "Bash", + Args: map[string]interface{}{"command": "rm -rf /"}, + }) + if d.Outcome != DecisionDeny || d.Reason != ReasonGovernance { + t.Fatalf("governance deny must override bypass: %+v", d) + } +} + +func TestGovernanceCeilingOverridesAutonomyAndRules(t *testing.T) { + pe := NewPermissionEngine() + pe.Governance = governance.New() + pe.Governance.SetPolicy(govPolicy( + governance.Capability{Scope: "bash", Action: governance.ActionDeny}, + )) + pe.Autonomy = AutonomyYOLO + pe.Memory.AlwaysAllow("Bash") + + d := pe.CheckToolDecision(context.Background(), ToolCallInfo{ + Name: "Bash", + Args: map[string]interface{}{"command": "echo hi"}, + }) + if d.Outcome != DecisionDeny || d.Reason != ReasonGovernance { + t.Fatalf("governance deny must override autonomy+rule allow: %+v", d) + } +} + +func TestGovernanceUnconfiguredIsFailOpen(t *testing.T) { + pe := NewPermissionEngine() + d := pe.CheckToolDecision(context.Background(), ToolCallInfo{ + Name: "Bash", + Args: map[string]interface{}{"command": "echo hi"}, + }) + if d.Reason == ReasonGovernance { + t.Fatalf("unconfigured governance must not interfere: %+v", d) + } +} diff --git a/internal/governance/governance.go b/internal/governance/governance.go new file mode 100644 index 00000000..bac20b11 --- /dev/null +++ b/internal/governance/governance.go @@ -0,0 +1,349 @@ +// Package governance implements a two-level, tightest-wins permission +// ceiling modeled on POLICY ∩ PROFILE. POLICY is an administrator-controlled +// ceiling loaded from a trust-root path; PROFILE is a per-session scope that +// can only narrow the ceiling. A tool is permitted only when both layers +// allow it, so an app or agent can never loosen the enterprise ceiling. +package governance + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "runtime" + "sort" + "strings" + "sync" +) + +// DefaultProfileName is the profile applied when a session does not name one. +const DefaultProfileName = "default" + +// ScopeName is a policy capability scope (e.g. "bash", "filesystem_write"). +type ScopeName string + +// Action is the decision for a single capability row. +type Action string + +const ( + ActionAllow Action = "allow" + ActionDeny Action = "deny" +) + +// ScopeCatalog maps every known scope to the tool families it governs. The +// evaluator is scope-name-agnostic: adding a capability is a data change in +// this catalog, never an evaluator edit. +var ScopeCatalog = map[ScopeName]string{ + "bash": "Bash", + "filesystem_read": "Read,LS,Glob,Grep,SmartReader", + "filesystem_write": "Write,Edit,StructuredEdit,MultiEdit,FileEdit", + "filesystem_delete": "Delete", + "network": "WebFetch,WebSearch,Browser,Screenshot,Download", + "secrets": "CoreMemoryRead,CoreMemorySearch", + "spec": "Proposal,Specify,Design,Plan,Tasks,Clarify,Constitution,Analyze,Checklist,Converge", + "mcp": "McpTool", + "code_intel": "CodeSearch,CodeGraph,Impact,GitHistory,LSP", +} + +// toolToScope reverse-indexes the catalog for evaluation. +var toolToScope = buildToolToScope() + +func buildToolToScope() map[string][]ScopeName { + m := make(map[string][]ScopeName) + for scope, list := range ScopeCatalog { + for _, name := range strings.Split(list, ",") { + name = strings.TrimSpace(name) + if name == "" { + continue + } + m[name] = append(m[name], scope) + } + } + return m +} + +// ScopesForTool returns the capability scopes governing a tool name. +func ScopesForTool(toolName string) []ScopeName { + scopes := toolToScope[strings.TrimSpace(toolName)] + sorted := make([]ScopeName, len(scopes)) + copy(sorted, scopes) + sort.Slice(sorted, func(i, j int) bool { return sorted[i] < sorted[j] }) + return sorted +} + +// Capability is a single allow/deny row in a policy document. +type Capability struct { + Scope ScopeName `json:"scope"` + Action Action `json:"action"` + Pattern string `json:"pattern,omitempty"` // glob over the tool summary; empty = all calls + Reason string `json:"reason,omitempty"` +} + +// Document is the serialized form of a POLICY or PROFILE. +type Document struct { + Version int `json:"version"` + FailClosed bool `json:"fail_closed,omitempty"` // no capabilities => deny everything + Capabilities []Capability `json:"capabilities"` + DeniedTools []string `json:"denied_tools,omitempty"` // flat tool-name deny list + DeniedBash []string `json:"denied_bash,omitempty"` // bash command glob patterns + SensitivePaths []string `json:"sensitive_paths,omitempty"` + Extra map[string]interface{} `json:"-"` +} + +// Layer is a loaded policy or profile with its parsed capabilities indexed by +// scope for fast evaluation. +type Layer struct { + Name string + FailClosed bool + Capabilities []Capability + DeniedTools map[string]struct{} + DeniedBash []string + SensitivePaths []string +} + +// Decision is the result of evaluating a request against the effective policy. +type Decision struct { + Allowed bool + Source string // "policy" or "profile" + Scope ScopeName + Rule string + Reason string +} + +// Engine evaluates tool requests against the composed POLICY ∩ PROFILE. +type Engine struct { + mu sync.RWMutex + policy *Layer + profile *Layer +} + +// New returns an empty engine (fail-open until LoadPolicy is called). +func New() *Engine { + return &Engine{} +} + +// LoadPolicy loads the admin ceiling. It is a fatal configuration error to +// ship a malformed policy, so a parse failure returns an error rather than +// silently falling open. +func (e *Engine) LoadPolicy(path string) error { + layer, err := loadLayer("policy", path) + if err != nil { + return err + } + e.SetPolicy(layer) + return nil +} + +// SetPolicy installs an in-memory policy ceiling. Exported so embedding +// hosts and tests can install a policy without touching the filesystem. +func (e *Engine) SetPolicy(layer *Layer) { + e.mu.Lock() + e.policy = layer + e.mu.Unlock() +} + +// SetProfile replaces the session's narrow scope. +func (e *Engine) SetProfile(layer *Layer) { + e.mu.Lock() + e.profile = layer + e.mu.Unlock() +} + +// Profile returns the active profile layer (nil if none). +func (e *Engine) Profile() *Layer { + e.mu.RLock() + defer e.mu.RUnlock() + return e.profile +} + +// loadLayer reads and parses a policy or profile document. +func loadLayer(name, path string) (*Layer, error) { + data, err := os.ReadFile(path) // #nosec G304 -- path is the configured trust-root policy file, not external input + if err != nil { + return nil, fmt.Errorf("governance: load %s: %w", name, err) + } + var doc Document + if err := json.Unmarshal(data, &doc); err != nil { + return nil, fmt.Errorf("governance: parse %s %s: %w", name, path, err) + } + return buildLayer(name, doc) +} + +// BuildProfile constructs a profile layer from a document in memory. +func BuildProfile(name string, doc Document) (*Layer, error) { + return buildLayer(name, doc) +} + +func buildLayer(name string, doc Document) (*Layer, error) { + if doc.Version != 1 { + return nil, fmt.Errorf("governance: unsupported version %d", doc.Version) + } + l := &Layer{ + Name: name, + FailClosed: doc.FailClosed, + Capabilities: append([]Capability(nil), doc.Capabilities...), + DeniedTools: make(map[string]struct{}), + DeniedBash: append([]string(nil), doc.DeniedBash...), + SensitivePaths: append([]string(nil), doc.SensitivePaths...), + } + for _, t := range doc.DeniedTools { + l.DeniedTools[strings.TrimSpace(t)] = struct{}{} + } + return l, nil +} + +// Evaluate checks a tool call. summary is the one-line tool summary used to +// match pattern rows (e.g. the bash command text). The tightest-wins rule is: +// any deny in either layer denies; otherwise any allow permits; otherwise the +// request is denied when a layer is fail-closed, else not governed. +func (e *Engine) Evaluate(toolName, summary string) Decision { + e.mu.RLock() + defer e.mu.RUnlock() + + if e.policy == nil { + return Decision{Allowed: true, Source: "unconfigured", Reason: "no governance policy loaded"} + } + + // Flat deny list at the policy ceiling is absolute. + if _, denied := e.policy.DeniedTools[strings.TrimSpace(toolName)]; denied { + return Decision{Allowed: false, Source: "policy", Rule: "denied_tools", Reason: "tool denied by policy ceiling"} + } + + scopes := ScopesForTool(toolName) + + // POLICY layer: deny wins; allow passes the layer only when matched. + policyDeny, policyAllow, governedByPolicy := evaluateLayer(e.policy, toolName, summary, scopes) + if policyDeny { + return Decision{Allowed: false, Source: "policy", Scope: policyAllow.Scope, Rule: "capability", Reason: "denied by policy ceiling"} + } + + // PROFILE layer: may only narrow. Deny in the profile denies; allow or + // silence does not expand beyond what the policy already permitted. + var profileDeny bool + var profileAllow Decision + if e.profile != nil { + profileDeny, profileAllow, _ = evaluateLayer(e.profile, toolName, summary, scopes) + if profileDeny { + return Decision{Allowed: false, Source: "profile", Scope: profileAllow.Scope, Rule: "capability", Reason: "denied by session profile"} + } + } + + // If the policy explicitly allowed, the profile did not deny: allow. + if governedByPolicy && policyAllow.Allowed { + return Decision{Allowed: true, Source: "policy", Scope: policyAllow.Scope, Rule: "capability", Reason: "allowed by policy"} + } + + // Otherwise a fail-closed policy denies ungoverned calls; an open policy + // leaves the decision to the normal permission pipeline. + if e.policy.FailClosed { + return Decision{Allowed: false, Source: "policy", Reason: "fail_closed", Rule: "not_governed"} + } + return Decision{Allowed: true, Source: "ungoverned", Reason: "no matching capability"} +} + +// evaluateLayer returns (denyHit, allowHit, governed). denyHit means a deny +// capability matched; allowHit carries the allow Decision when one matched. +func evaluateLayer(l *Layer, toolName, summary string, scopes []ScopeName) (bool, Decision, bool) { + denyHit := false + var allowHit Decision + for _, cap := range l.Capabilities { + if !scopeContains(cap.Scope, scopes) { + continue + } + if cap.Pattern != "" && !globMatch(cap.Pattern, summary) { + continue + } + if cap.Action == ActionDeny { + denyHit = true + } else { + allowHit = Decision{Allowed: true, Scope: cap.Scope, Rule: "capability"} + } + } + // Sensitive-path protection is a deny-only floor at every layer. + if matchesAnyPattern(summary, append(l.SensitivePaths, l.DeniedBash...)) { + denyHit = true + } + // A layer that names the scope with an allow grants; a layer with no + // capability rows for the scope leaves it ungoverned unless fail-closed. + return denyHit, allowHit, len(l.Capabilities) > 0 || len(l.DeniedTools) > 0 +} + +func scopeContains(scope ScopeName, scopes []ScopeName) bool { + for _, s := range scopes { + if s == scope { + return true + } + } + return false +} + +func matchesAnyPattern(summary string, patterns []string) bool { + for _, p := range patterns { + if globMatch(p, summary) { + return true + } + } + return false +} + +// globMatch reports whether pattern matches subject with * wildcard and +// prefix matching for trailing *. For path patterns it also matches against +// every suffix of the subject that starts at a path separator, so a pattern +// like ".ssh/*" matches "~/.ssh/config". +func globMatch(pattern, subject string) bool { + if pattern == "" { + return true + } + norm := strings.ReplaceAll(subject, "~", homePath()) + if matched, err := filepath.Match(pattern, norm); err == nil && matched { + return true + } + if matched, err := filepath.Match(pattern, subject); err == nil && matched { + return true + } + if strings.HasSuffix(pattern, "*") { + prefix := strings.TrimSuffix(pattern, "*") + if strings.HasPrefix(norm, prefix) || strings.HasPrefix(subject, prefix) { + return true + } + } + sep := string(filepath.Separator) + for i := 0; i < len(subject); i++ { + if string(subject[i]) == sep || subject[i] == '/' { + if matched, err := filepath.Match(pattern, subject[i+1:]); err == nil && matched { + return true + } + } + } + return false +} + +func homePath() string { + home, _ := os.UserHomeDir() + if home == "" { + home = "~" + } + return home +} + +// defaultManagedPaths returns the platform-specific trust-root locations for +// the governance policy, mirroring the IT-managed rule locations. These are +// writable only by administrators, which is what makes the ceiling +// un-disableable from inside the agent. +func defaultManagedPaths() []string { + switch runtime.GOOS { + case "darwin": + return []string{"/Library/Application Support/HawkCode/security_policy.json"} + default: + return []string{"/etc/hawk-code/security_policy.json"} + } +} + +// ManagedPolicyPath resolves the trust-root policy file for the platform. +func ManagedPolicyPath() string { + paths := defaultManagedPaths() + if len(paths) == 0 { + return "" + } + return paths[0] +} diff --git a/internal/governance/governance_test.go b/internal/governance/governance_test.go new file mode 100644 index 00000000..e4712c10 --- /dev/null +++ b/internal/governance/governance_test.go @@ -0,0 +1,190 @@ +package governance + +import ( + "os" + "path/filepath" + "testing" +) + +func TestScopesForTool(t *testing.T) { + scopes := ScopesForTool("Bash") + if len(scopes) != 1 || scopes[0] != "bash" { + t.Fatalf("expected bash scope, got %v", scopes) + } + scopes = ScopesForTool("Write") + found := false + for _, s := range scopes { + if s == "filesystem_write" { + found = true + } + } + if !found { + t.Fatalf("expected filesystem_write scope for Write, got %v", scopes) + } +} + +func TestPolicyDenyWinsOverProfileAllow(t *testing.T) { + policy, err := BuildProfile("policy", Document{ + Version: 1, + Capabilities: []Capability{ + {Scope: "bash", Action: ActionDeny}, + }, + }) + if err != nil { + t.Fatal(err) + } + profile, err := BuildProfile("profile", Document{ + Version: 1, + Capabilities: []Capability{ + {Scope: "bash", Action: ActionAllow}, + }, + }) + if err != nil { + t.Fatal(err) + } + + e := New() + e.policy = policy + e.SetProfile(profile) + + d := e.Evaluate("Bash", "rm -rf /") + if d.Allowed { + t.Fatalf("policy deny must beat profile allow: %+v", d) + } + if d.Source != "policy" { + t.Fatalf("expected source=policy, got %q", d.Source) + } +} + +func TestProfileCanNarrowButNotExpand(t *testing.T) { + policy, err := BuildProfile("policy", Document{ + Version: 1, + Capabilities: []Capability{ + {Scope: "bash", Action: ActionAllow}, + }, + }) + if err != nil { + t.Fatal(err) + } + profile, err := BuildProfile("profile", Document{ + Version: 1, + Capabilities: []Capability{ + {Scope: "bash", Action: ActionDeny, Pattern: "git push*"}, + }, + }) + if err != nil { + t.Fatal(err) + } + + e := New() + e.policy = policy + e.SetProfile(profile) + + // Policy allows bash; profile denies git push specifically. + if d := e.Evaluate("Bash", "git push origin main"); d.Allowed { + t.Fatalf("profile deny must win: %+v", d) + } + // Profile silence must not expand what policy denied. + if d := e.Evaluate("Bash", "echo hi"); !d.Allowed { + t.Fatalf("allowed bash should pass: %+v", d) + } +} + +func TestFailClosedDeniesUngovernedTools(t *testing.T) { + policy, err := BuildProfile("policy", Document{ + Version: 1, + FailClosed: true, + Capabilities: []Capability{ + {Scope: "bash", Action: ActionAllow}, + }, + }) + if err != nil { + t.Fatal(err) + } + e := New() + e.policy = policy + + if d := e.Evaluate("Bash", "echo hi"); !d.Allowed { + t.Fatalf("bash is governed by allow: %+v", d) + } + // Network scope is not governed and the policy is fail-closed. + if d := e.Evaluate("WebFetch", "example.com"); d.Allowed { + t.Fatalf("fail-closed must deny ungoverned tool: %+v", d) + } +} + +func TestDeniedToolsFlatList(t *testing.T) { + policy, err := BuildProfile("policy", Document{ + Version: 1, + DeniedTools: []string{"Browser"}, + }) + if err != nil { + t.Fatal(err) + } + e := New() + e.policy = policy + + if d := e.Evaluate("Browser", ""); d.Allowed { + t.Fatalf("denied_tools must block Browser: %+v", d) + } + if d := e.Evaluate("Bash", "echo hi"); !d.Allowed { + t.Fatalf("unlisted tool should be unaffected: %+v", d) + } +} + +func TestLoadPolicyFromFile(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "security_policy.json") + doc := `{"version":1,"fail_closed":true,"denied_tools":["Bash"]}` + if err := os.WriteFile(path, []byte(doc), 0o600); err != nil { + t.Fatal(err) + } + + e := New() + if err := e.LoadPolicy(path); err != nil { + t.Fatal(err) + } + if d := e.Evaluate("Bash", ""); d.Allowed { + t.Fatalf("expected Bash denied: %+v", d) + } +} + +func TestLoadPolicyParseError(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "bad.json") + if err := os.WriteFile(path, []byte("{not json"), 0o600); err != nil { + t.Fatal(err) + } + e := New() + if err := e.LoadPolicy(path); err == nil { + t.Fatal("expected parse error") + } +} + +func TestSensitivePathDenyFloor(t *testing.T) { + policy, err := BuildProfile("policy", Document{ + Version: 1, + Capabilities: []Capability{ + {Scope: "filesystem_write", Action: ActionAllow}, + }, + SensitivePaths: []string{".ssh/*"}, + }) + if err != nil { + t.Fatal(err) + } + e := New() + e.policy = policy + + if d := e.Evaluate("Write", "~/.ssh/config"); d.Allowed { + t.Fatalf("sensitive path must be denied even when write is allowed: %+v", d) + } + if d := e.Evaluate("Write", "src/main.go"); !d.Allowed { + t.Fatalf("normal write should pass: %+v", d) + } +} + +func TestManagedPolicyPath(t *testing.T) { + if ManagedPolicyPath() == "" { + t.Fatal("expected a managed policy path") + } +} From b17dc9e0fc5b174864d77972d1325967f20b1122 Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Wed, 5 Aug 2026 21:52:52 +0530 Subject: [PATCH 3/4] feat(tasks): TaskRunner persistence, retry, replan, checkpoint-resume Adds disk persistence to TaskStore (atomic snapshot, load/resume, id counter preservation) plus a retry budget: MarkFailed requeues while attempts remain and parks tasks in a new failed state for replanning once exhausted. Requeue resets the budget, Checkpoint stores resumable progress, and the task tools expose attempts/lastError/checkpoint and a failed listing action. --- internal/tool/task_create.go | 87 ++++++++-- internal/tool/task_runner.go | 253 ++++++++++++++++++++++++++++++ internal/tool/task_runner_test.go | 173 ++++++++++++++++++++ internal/tool/task_schedule.go | 2 +- 4 files changed, 504 insertions(+), 11 deletions(-) create mode 100644 internal/tool/task_runner.go create mode 100644 internal/tool/task_runner_test.go diff --git a/internal/tool/task_create.go b/internal/tool/task_create.go index 99de0372..d6818568 100644 --- a/internal/tool/task_create.go +++ b/internal/tool/task_create.go @@ -16,8 +16,14 @@ const ( TaskStatusPending TaskStatus = "pending" TaskStatusInProgress TaskStatus = "in_progress" TaskStatusCompleted TaskStatus = "completed" + TaskStatusFailed TaskStatus = "failed" ) +// DefaultMaxAttempts is the retry budget used when a task does not declare +// its own MaxAttempts. After the budget is exhausted the task is left in the +// failed state for replanning rather than being retried forever. +const DefaultMaxAttempts = 3 + // TaskDependency represents a typed dependency between tasks. type TaskDependency struct { TargetID string `json:"targetId"` @@ -37,13 +43,35 @@ type Task struct { Metadata map[string]any `json:"metadata,omitempty"` CreatedAt time.Time `json:"createdAt"` UpdatedAt time.Time `json:"updatedAt"` + // Attempts is the number of times execution of this task has been + // attempted. Incremented by MarkFailed; a task is requeued (back to + // pending) while Attempts < MaxAttempts and parked in failed afterwards. + Attempts int `json:"attempts,omitempty"` + // MaxAttempts is the retry budget for this task. 0 means the store-wide + // DefaultMaxAttempts. + MaxAttempts int `json:"maxAttempts,omitempty"` + // LastError records the most recent failure message for diagnostics and + // replanning input. + LastError string `json:"lastError,omitempty"` + // Checkpoint holds arbitrary resumable progress (e.g. last completed + // phase, partial outputs) so a replan or resume does not start from zero. + Checkpoint map[string]any `json:"checkpoint,omitempty"` +} + +// EffectiveMaxAttempts resolves the retry budget for a task. +func (t *Task) EffectiveMaxAttempts() int { + if t.MaxAttempts > 0 { + return t.MaxAttempts + } + return DefaultMaxAttempts } // TaskStore is a thread-safe in-memory store for tasks. type TaskStore struct { - mu sync.RWMutex - tasks map[string]*Task - next int + mu sync.RWMutex + tasks map[string]*Task + next int + persist *persistState // non-nil when disk persistence is enabled } // Global task store. @@ -58,7 +86,6 @@ func (s *TaskStore) Create(subject, description, activeForm string, metadata map func (s *TaskStore) CreateWithParent(subject, description, activeForm string, metadata map[string]any, parentID string) *Task { s.mu.Lock() - defer s.mu.Unlock() var id string if parentID != "" { @@ -92,6 +119,8 @@ func (s *TaskStore) CreateWithParent(subject, description, activeForm string, me t.Dependencies = append(t.Dependencies, TaskDependency{TargetID: parentID, Type: "parent-child"}) } s.tasks[id] = t + s.mu.Unlock() + s.persistOnMutation() return t } @@ -114,31 +143,37 @@ func (s *TaskStore) List() []*Task { func (s *TaskStore) Update(id string, fn func(*Task)) bool { s.mu.Lock() - defer s.mu.Unlock() t, ok := s.tasks[id] if !ok { + s.mu.Unlock() return false } fn(t) t.UpdatedAt = time.Now() + s.mu.Unlock() + s.persistOnMutation() return true } func (s *TaskStore) Delete(id string) bool { s.mu.Lock() - defer s.mu.Unlock() _, ok := s.tasks[id] if ok { delete(s.tasks, id) } + s.mu.Unlock() + if ok { + s.persistOnMutation() + } return ok } func (s *TaskStore) Reset() { s.mu.Lock() - defer s.mu.Unlock() s.tasks = make(map[string]*Task) s.next = 0 + s.mu.Unlock() + s.persistOnMutation() } // GetReadyWork returns pending tasks with no open blocking dependencies. @@ -156,7 +191,6 @@ func (s *TaskStore) GetSchedule() (TaskSchedule, error) { return s.Schedule() } // CompactCompleted removes completed tasks and returns a summary. func (s *TaskStore) CompactCompleted() string { s.mu.Lock() - defer s.mu.Unlock() var removed []string for id, t := range s.tasks { if t.Status == TaskStatusCompleted { @@ -164,6 +198,8 @@ func (s *TaskStore) CompactCompleted() string { delete(s.tasks, id) } } + s.mu.Unlock() + s.persistOnMutation() if len(removed) == 0 { return "No completed tasks to compact." } @@ -257,6 +293,15 @@ func (TaskGetTool) Execute(_ context.Context, input json.RawMessage) (string, er "description": task.Description, "status": task.Status, "dependencies": task.Dependencies, + "attempts": task.Attempts, + "maxAttempts": task.EffectiveMaxAttempts(), + "lastError": task.LastError, + "checkpoint": task.Checkpoint, + "owner": task.Owner, + "activeForm": task.ActiveForm, + "createdAt": task.CreatedAt, + "updatedAt": task.UpdatedAt, + "retryBackoff": task.Metadata["retryBackoffTick"], }, }) return string(out), nil @@ -273,7 +318,7 @@ func (TaskListTool) Parameters() map[string]interface{} { return map[string]interface{}{ "type": "object", "properties": map[string]interface{}{ - "action": map[string]interface{}{"type": "string", "enum": []string{"list", "ready", "compact"}, "description": "Action: list (default), ready (pending with no blockers), compact (remove completed)"}, + "action": map[string]interface{}{"type": "string", "enum": []string{"list", "ready", "failed", "compact"}, "description": "Action: list (default), ready (pending with no blockers), failed (replan candidates), compact (remove completed)"}, }, } } @@ -305,6 +350,22 @@ func (TaskListTool) Execute(_ context.Context, input json.RawMessage) (string, e } out, _ := json.Marshal(map[string]any{"tasks": summaries, "waves": schedule.Waves}) return string(out), nil + case "failed": + tasks := globalTaskStore.FailedTasks() + summaries := make([]map[string]any, 0, len(tasks)) + for _, t := range tasks { + summaries = append(summaries, map[string]any{ + "id": t.ID, + "subject": t.Subject, + "status": t.Status, + "attempts": t.Attempts, + "lastError": t.LastError, + "checkpoint": t.Checkpoint, + "owner": t.Owner, + }) + } + out, _ := json.Marshal(map[string]any{"tasks": summaries}) + return string(out), nil case "compact": summary := globalTaskStore.CompactCompleted() out, _ := json.Marshal(map[string]any{"result": summary}) @@ -338,7 +399,7 @@ func (TaskUpdateTool) Parameters() map[string]interface{} { "type": "object", "properties": map[string]interface{}{ "taskId": map[string]interface{}{"type": "string", "description": "The ID of the task to update"}, - "status": map[string]interface{}{"type": "string", "enum": []string{"pending", "in_progress", "completed"}, "description": "New task status"}, + "status": map[string]interface{}{"type": "string", "enum": []string{"pending", "in_progress", "completed", "failed"}, "description": "New task status"}, "owner": map[string]interface{}{"type": "string", "description": "Agent name to assign"}, "dependencies": map[string]interface{}{"type": "array", "items": map[string]interface{}{"type": "object", "properties": map[string]interface{}{"targetId": map[string]interface{}{"type": "string"}, "type": map[string]interface{}{"type": "string", "enum": []string{"blocks", "related", "parent-child"}}}}, "description": "Replace dependencies"}, }, @@ -361,6 +422,12 @@ func (TaskUpdateTool) Execute(_ context.Context, input json.RawMessage) (string, } ok := globalTaskStore.Update(p.TaskID, func(t *Task) { if p.Status != "" { + // Moving a failed task back to pending is an explicit replan + // signal: reset the retry budget and error so it starts fresh. + if TaskStatus(p.Status) == TaskStatusPending && t.Status == TaskStatusFailed { + t.Attempts = 0 + t.LastError = "" + } t.Status = TaskStatus(p.Status) } if p.Owner != "" { diff --git a/internal/tool/task_runner.go b/internal/tool/task_runner.go new file mode 100644 index 00000000..8cd5800f --- /dev/null +++ b/internal/tool/task_runner.go @@ -0,0 +1,253 @@ +package tool + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "sort" + "time" +) + +// taskStoreSnapshot is the on-disk representation of a TaskStore. +type taskStoreSnapshot struct { + Version int `json:"version"` + Next int `json:"next"` + Tasks []*Task `json:"tasks"` +} + +// persistDir is the directory a TaskStore writes its snapshot to, or "" when +// persistence is disabled. Stored on the store so mutations can auto-save. +type persistState struct { + dir string +} + +// EnablePersistence turns on disk persistence for the store. The directory is +// created if missing. While enabled, every mutation writes an atomic snapshot +// to /tasks.json so a TaskRunner can resume across process restarts. +// Persistence is opt-in: the global store used by interactive sessions stays +// in-memory unless a caller explicitly enables it. +func (s *TaskStore) EnablePersistence(dir string) error { + if dir == "" { + return fmt.Errorf("task: persistence directory is required") + } + if err := os.MkdirAll(dir, 0o755); err != nil { + return fmt.Errorf("task: create persistence dir: %w", err) + } + s.mu.Lock() + s.persist = &persistState{dir: dir} + s.mu.Unlock() + return s.Save("") +} + +// DisablePersistence turns off disk persistence, leaving the in-memory state +// untouched. +func (s *TaskStore) DisablePersistence() { + s.mu.Lock() + s.persist = nil + s.mu.Unlock() +} + +// PersistDir returns the active persistence directory, or "" if disabled. +func (s *TaskStore) PersistDir() string { + s.mu.RLock() + defer s.mu.RUnlock() + if s.persist == nil { + return "" + } + return s.persist.dir +} + +// Save writes an atomic snapshot of the store to path. Use Save() with a +// configured persistence dir, or Save(path) directly. +func (s *TaskStore) Save(path string) error { + if path == "" { + s.mu.RLock() + p := s.persist + s.mu.RUnlock() + if p == nil { + return nil + } + path = filepath.Join(p.dir, "tasks.json") + } + // Snapshot under lock so the file always reflects one consistent state. + s.mu.RLock() + snap := taskStoreSnapshot{ + Version: 1, + Next: s.next, + Tasks: make([]*Task, 0, len(s.tasks)), + } + for _, t := range s.tasks { + snap.Tasks = append(snap.Tasks, cloneScheduledTask(t)) + } + s.mu.RUnlock() + sort.Slice(snap.Tasks, func(i, j int) bool { return snap.Tasks[i].ID < snap.Tasks[j].ID }) + + data, err := json.MarshalIndent(snap, "", " ") + if err != nil { + return fmt.Errorf("task: encode snapshot: %w", err) + } + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return fmt.Errorf("task: ensure snapshot dir: %w", err) + } + tmp := path + ".tmp" + if err := os.WriteFile(tmp, data, 0o600); err != nil { + return fmt.Errorf("task: write snapshot: %w", err) + } + if err := os.Rename(tmp, path); err != nil { + return fmt.Errorf("task: commit snapshot: %w", err) + } + return nil +} + +// Load replaces the store contents from an on-disk snapshot, preserving the +// id counter. It is the resume entry point for a persisted TaskRunner. +func (s *TaskStore) Load(path string) error { + data, err := os.ReadFile(path) + if err != nil { + return fmt.Errorf("task: read snapshot: %w", err) + } + var snap taskStoreSnapshot + if err := json.Unmarshal(data, &snap); err != nil { + return fmt.Errorf("task: decode snapshot: %w", err) + } + if snap.Version != 1 { + return fmt.Errorf("task: unsupported snapshot version %d", snap.Version) + } + s.mu.Lock() + defer s.mu.Unlock() + s.tasks = make(map[string]*Task, len(snap.Tasks)) + for _, t := range snap.Tasks { + if t == nil || t.ID == "" { + return fmt.Errorf("task: snapshot contains an invalid task") + } + s.tasks[t.ID] = t + } + s.next = snap.Next + if s.next == 0 { + // Backfill the counter from the largest numeric id so new tasks never + // collide with loaded ones. + for id := range s.tasks { + var n int + if _, err := fmt.Sscanf(id, "task_%d", &n); err == nil && n > s.next { + s.next = n + } + } + } + return nil +} + +// persistOnMutation writes the snapshot after a state change when persistence +// is enabled. Callers invoke it while holding no store lock; it is a no-op +// when disabled. +func (s *TaskStore) persistOnMutation() { + if s.PersistDir() == "" { + return + } + _ = s.Save("") +} + +// MarkFailed records a failure and applies the retry budget. When the task +// still has attempts left it is requeued to pending (with the backoff tick in +// metadata) so the runner can pick it up again; once the budget is exhausted +// the task is parked in failed for replanning. Returns whether the task was +// requeued. +func (s *TaskStore) MarkFailed(id, errMsg string) (bool, error) { + s.mu.Lock() + t, ok := s.tasks[id] + if !ok { + s.mu.Unlock() + return false, fmt.Errorf("task %q not found", id) + } + t.Attempts++ + t.LastError = errMsg + t.UpdatedAt = time.Now() + requeued := false + switch t.Status { + case TaskStatusCompleted: + t.Status = TaskStatusCompleted + case TaskStatusInProgress: + if t.Attempts < t.EffectiveMaxAttempts() { + t.Status = TaskStatusPending + if t.Metadata == nil { + t.Metadata = make(map[string]any) + } + t.Metadata["retryBackoffTick"] = t.Attempts + requeued = true + } else { + t.Status = TaskStatusFailed + } + case TaskStatusPending: + // A not-yet-started task that failed during planning: treat as failed. + t.Status = TaskStatusFailed + default: + t.Status = TaskStatusFailed + } + persist := s.persist + s.mu.Unlock() + if persist != nil { + _ = s.Save("") + } + return requeued, nil +} + +// Requeue resets a failed task to pending and clears its last error so the +// runner may attempt it again (replan path). The attempt counter is reset so +// a replan starts with a fresh retry budget. +func (s *TaskStore) Requeue(id string) (bool, error) { + s.mu.Lock() + t, ok := s.tasks[id] + if !ok { + s.mu.Unlock() + return false, fmt.Errorf("task %q not found", id) + } + t.Status = TaskStatusPending + t.Attempts = 0 + t.LastError = "" + t.UpdatedAt = time.Now() + persist := s.persist + s.mu.Unlock() + if persist != nil { + _ = s.Save("") + } + return true, nil +} + +// Checkpoint merges resumable progress onto a task without changing its +// status. A replan or resume reads the checkpoint to avoid starting from zero. +func (s *TaskStore) Checkpoint(id string, data map[string]any) (bool, error) { + s.mu.Lock() + t, ok := s.tasks[id] + if !ok { + s.mu.Unlock() + return false, fmt.Errorf("task %q not found", id) + } + if t.Checkpoint == nil { + t.Checkpoint = make(map[string]any) + } + for k, v := range data { + t.Checkpoint[k] = v + } + t.UpdatedAt = time.Now() + persist := s.persist + s.mu.Unlock() + if persist != nil { + _ = s.Save("") + } + return true, nil +} + +// FailedTasks returns tasks parked in the failed state, most recently updated +// first, for replanning input. +func (s *TaskStore) FailedTasks() []*Task { + s.mu.RLock() + defer s.mu.RUnlock() + var out []*Task + for _, t := range s.tasks { + if t.Status == TaskStatusFailed { + out = append(out, cloneScheduledTask(t)) + } + } + sort.Slice(out, func(i, j int) bool { return out[i].UpdatedAt.After(out[j].UpdatedAt) }) + return out +} diff --git a/internal/tool/task_runner_test.go b/internal/tool/task_runner_test.go new file mode 100644 index 00000000..c6f29c77 --- /dev/null +++ b/internal/tool/task_runner_test.go @@ -0,0 +1,173 @@ +package tool + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" +) + +func TestPersistenceRoundTrip(t *testing.T) { + dir := t.TempDir() + store := &TaskStore{tasks: make(map[string]*Task)} + if err := store.EnablePersistence(dir); err != nil { + t.Fatal(err) + } + a := store.Create("a", "do a", "doing a", nil) + b := store.CreateWithParent("b", "do b", "doing b", nil, a.ID) + store.Update(a.ID, func(ta *Task) { ta.Status = TaskStatusCompleted }) + store.Checkpoint(b.ID, map[string]any{"phase": "parse"}) + + loaded := &TaskStore{tasks: make(map[string]*Task)} + if err := loaded.Load(filepath.Join(dir, "tasks.json")); err != nil { + t.Fatal(err) + } + got, ok := loaded.Get(a.ID) + if !ok { + t.Fatalf("task %q missing after load", a.ID) + } + if got.Status != TaskStatusCompleted { + t.Fatalf("expected completed status after load, got %q", got.Status) + } + gotB, ok := loaded.Get(b.ID) + if !ok { + t.Fatalf("task %q missing after load", b.ID) + } + if gotB.Checkpoint["phase"] != "parse" { + t.Fatalf("checkpoint not preserved: %+v", gotB.Checkpoint) + } + if gotB.ParentID != a.ID { + t.Fatalf("parent link not preserved: %q", gotB.ParentID) + } + // New task ids must not collide with loaded ones. + c := loaded.Create("c", "do c", "doing c", nil) + if c.ID == a.ID || c.ID == b.ID { + t.Fatalf("id counter not preserved: %q", c.ID) + } +} + +func TestEnablePersistencePersistsMutations(t *testing.T) { + dir := t.TempDir() + store := &TaskStore{tasks: make(map[string]*Task)} + if err := store.EnablePersistence(dir); err != nil { + t.Fatal(err) + } + store.Create("a", "do a", "doing a", nil) + + data, err := os.ReadFile(filepath.Join(dir, "tasks.json")) + if err != nil { + t.Fatalf("expected snapshot after create: %v", err) + } + var snap taskStoreSnapshot + if err := json.Unmarshal(data, &snap); err != nil { + t.Fatal(err) + } + if len(snap.Tasks) != 1 { + t.Fatalf("expected 1 persisted task, got %d", len(snap.Tasks)) + } +} + +func TestMarkFailedRequeueAndExhaust(t *testing.T) { + store := &TaskStore{tasks: make(map[string]*Task)} + task := store.Create("a", "do a", "doing a", nil) + store.Update(task.ID, func(ta *Task) { + ta.Status = TaskStatusInProgress + ta.MaxAttempts = 2 + }) + + requeued, err := store.MarkFailed(task.ID, "boom") + if err != nil { + t.Fatal(err) + } + if !requeued { + t.Fatal("attempt 1 of 2 should requeue") + } + got, _ := store.Get(task.ID) + if got.Status != TaskStatusPending || got.Attempts != 1 { + t.Fatalf("expected pending with 1 attempt, got %+v", got) + } + if got.LastError != "boom" { + t.Fatalf("expected lastError recorded, got %q", got.LastError) + } + + store.Update(task.ID, func(ta *Task) { ta.Status = TaskStatusInProgress }) + requeued, err = store.MarkFailed(task.ID, "boom again") + if err != nil { + t.Fatal(err) + } + if requeued { + t.Fatal("attempt 2 of 2 should exhaust the budget") + } + got, _ = store.Get(task.ID) + if got.Status != TaskStatusFailed || got.Attempts != 2 { + t.Fatalf("expected failed with 2 attempts, got %+v", got) + } +} + +func TestRequeueResetsBudget(t *testing.T) { + store := &TaskStore{tasks: make(map[string]*Task)} + task := store.Create("a", "do a", "doing a", nil) + store.Update(task.ID, func(ta *Task) { + ta.Status = TaskStatusInProgress + ta.MaxAttempts = 1 + }) + store.MarkFailed(task.ID, "boom") + + ok, err := store.Requeue(task.ID) + if err != nil || !ok { + t.Fatalf("requeue failed: %v", err) + } + got, _ := store.Get(task.ID) + if got.Status != TaskStatusPending || got.Attempts != 0 || got.LastError != "" { + t.Fatalf("requeue should reset budget: %+v", got) + } +} + +func TestFailedTasksListsForReplan(t *testing.T) { + store := &TaskStore{tasks: make(map[string]*Task)} + a := store.Create("a", "do a", "doing a", nil) + store.Update(a.ID, func(ta *Task) { + ta.Status = TaskStatusInProgress + ta.MaxAttempts = 1 + }) + store.MarkFailed(a.ID, "boom") + + failed := store.FailedTasks() + if len(failed) != 1 || failed[0].ID != a.ID { + t.Fatalf("expected 1 failed task, got %+v", failed) + } + if failed[0].LastError != "boom" { + t.Fatalf("expected lastError on failed task, got %q", failed[0].LastError) + } +} + +func TestLoadSnapshotInvalid(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "tasks.json") + if err := os.WriteFile(path, []byte("{not json"), 0o600); err != nil { + t.Fatal(err) + } + store := &TaskStore{tasks: make(map[string]*Task)} + if err := store.Load(path); err == nil { + t.Fatal("expected error loading malformed snapshot") + } +} + +func TestDisablePersistenceStopsSaves(t *testing.T) { + dir := t.TempDir() + store := &TaskStore{tasks: make(map[string]*Task)} + if err := store.EnablePersistence(dir); err != nil { + t.Fatal(err) + } + store.Create("a", "do a", "doing a", nil) + store.DisablePersistence() + store.Create("b", "do b", "doing b", nil) + + loaded := &TaskStore{tasks: make(map[string]*Task)} + if err := loaded.Load(filepath.Join(dir, "tasks.json")); err != nil { + t.Fatal(err) + } + if _, ok := loaded.Get("task_2"); ok { + t.Fatal("expected second task not persisted after disable") + } +} diff --git a/internal/tool/task_schedule.go b/internal/tool/task_schedule.go index 25e39847..826936cd 100644 --- a/internal/tool/task_schedule.go +++ b/internal/tool/task_schedule.go @@ -41,7 +41,7 @@ func (s *TaskStore) Schedule() (TaskSchedule, error) { return TaskSchedule{}, fmt.Errorf("task schedule contains invalid task identity %q", id) } switch task.Status { - case TaskStatusPending, TaskStatusInProgress, TaskStatusCompleted: + case TaskStatusPending, TaskStatusInProgress, TaskStatusCompleted, TaskStatusFailed: default: return TaskSchedule{}, fmt.Errorf("task %q has invalid status %q", id, task.Status) } From f5c7d3dbddbbbb47e3b6454168a2d75d5b92a436 Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Wed, 5 Aug 2026 21:52:59 +0530 Subject: [PATCH 4/4] feat(lessons): persist failure reflections to cross-session lesson store Closes the self-improvement loop: the engine's Reflector already produced structured WhatFailed/WhyFailed/WhatToDo lessons on tool failures, but they were only appended to the output and never stored. Session now exposes a Learn callback (propagated to sub-sessions) invoked after a successful reflection, and the chat client wires it to SelfImprover.Learn so lessons survive across sessions and feed back via ForPrompt. SelfImprover is now thread-safe, nil-safe, and bounded at 200 entries. --- cmd/chat.go | 6 +++ internal/engine/self_improve.go | 29 +++++++++++++- internal/engine/self_improve_test.go | 57 ++++++++++++++++++++++++++++ internal/engine/session.go | 35 +++++++++++++++++ internal/engine/stream_tool_exec.go | 4 ++ 5 files changed, 129 insertions(+), 2 deletions(-) create mode 100644 internal/engine/self_improve_test.go diff --git a/cmd/chat.go b/cmd/chat.go index 7c8bbe8a..d671587b 100644 --- a/cmd/chat.go +++ b/cmd/chat.go @@ -252,6 +252,12 @@ func newChatModelWithRegistry(ref *progRef, systemPrompt string, settings hawkco m.hintsLoader = engine.NewHintsLoader() m.sourceRoots = engine.NewSourceRoots() m.selfImprover = engine.NewSelfImprover() + // Close the self-improvement loop: failure reflections produced by the + // engine's Reflector are persisted to the cross-session lesson store and + // injected back via ForPrompt on later submits. + if sess != nil { + sess.SetLearnFn(m.selfImprover.Learn) + } m.codingSoul = engine.LoadCodingSoul() startup.EndPhase("newChatModel:bmad-features") diff --git a/internal/engine/self_improve.go b/internal/engine/self_improve.go index 3ae26205..ab2f95b5 100644 --- a/internal/engine/self_improve.go +++ b/internal/engine/self_improve.go @@ -5,11 +5,16 @@ import ( "fmt" "os" "path/filepath" + "sync" "time" "github.com/GrayCodeAI/hawk/internal/storage" ) +// maxSelfImproveEntries caps the persisted lesson store so a long-lived +// machine does not accumulate an unbounded file. Oldest entries are dropped. +const maxSelfImproveEntries = 200 + // SelfImproveEntry records a lesson learned from a mistake. type SelfImproveEntry struct { Timestamp time.Time `json:"timestamp"` @@ -23,6 +28,7 @@ type SelfImproveEntry struct { type SelfImprover struct { Path string Entries []SelfImproveEntry + mu sync.Mutex } // NewSelfImprover loads or creates the improvement log. @@ -33,8 +39,14 @@ func NewSelfImprover() *SelfImprover { return si } -// Learn records a new lesson. +// Learn records a new lesson. It is nil-safe and bounded: oldest entries are +// dropped past maxSelfImproveEntries so the store cannot grow without limit. func (si *SelfImprover) Learn(what, why, lesson, category string) { + if si == nil { + return + } + si.mu.Lock() + defer si.mu.Unlock() si.Entries = append(si.Entries, SelfImproveEntry{ Timestamp: time.Now(), What: what, @@ -42,13 +54,21 @@ func (si *SelfImprover) Learn(what, why, lesson, category string) { Lesson: lesson, Category: category, }) + if len(si.Entries) > maxSelfImproveEntries { + si.Entries = si.Entries[len(si.Entries)-maxSelfImproveEntries:] + } si.save() } // Lessons returns all lessons, optionally filtered by category. func (si *SelfImprover) Lessons(category string) []SelfImproveEntry { + if si == nil { + return nil + } + si.mu.Lock() + defer si.mu.Unlock() if category == "" { - return si.Entries + return append([]SelfImproveEntry{}, si.Entries...) } var filtered []SelfImproveEntry for _, e := range si.Entries { @@ -61,6 +81,11 @@ func (si *SelfImprover) Lessons(category string) []SelfImproveEntry { // ForPrompt formats recent lessons as context for the system prompt. func (si *SelfImprover) ForPrompt(maxEntries int) string { + if si == nil { + return "" + } + si.mu.Lock() + defer si.mu.Unlock() if len(si.Entries) == 0 { return "" } diff --git a/internal/engine/self_improve_test.go b/internal/engine/self_improve_test.go new file mode 100644 index 00000000..4d5643d5 --- /dev/null +++ b/internal/engine/self_improve_test.go @@ -0,0 +1,57 @@ +package engine + +import ( + "path/filepath" + "testing" +) + +func TestSelfImproverLearnAndForPrompt(t *testing.T) { + si := &SelfImprover{Path: filepath.Join(t.TempDir(), "self-improve.json")} + si.Learn("write failed", "wrong encoding", "always verify encoding", "code") + si.Learn("test flaked", "race", "use -race", "test") + + out := si.ForPrompt(5) + if out == "" { + t.Fatal("expected lessons in prompt") + } + if len(si.Lessons("code")) != 1 { + t.Fatalf("expected 1 code lesson, got %d", len(si.Lessons("code"))) + } +} + +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") + } + if len(si.Entries) != maxSelfImproveEntries { + t.Fatalf("expected %d entries, got %d", maxSelfImproveEntries, len(si.Entries)) + } +} + +func TestSelfImproverNilSafe(t *testing.T) { + var si *SelfImprover + si.Learn("x", "y", "z", "code") // must not panic + if out := si.ForPrompt(5); out != "" { + t.Fatalf("expected empty prompt for nil improver, got %q", out) + } + if lessons := si.Lessons("code"); lessons != nil { + t.Fatalf("expected nil lessons for nil improver") + } +} + +func TestSessionLearnCallback(t *testing.T) { + si := &SelfImprover{Path: filepath.Join(t.TempDir(), "self-improve.json")} + s := &Session{} + s.SetLearnFn(si.Learn) + s.Learn("bash failed", "bad path", "quote paths", "tool_failure") + + if len(si.Entries) != 1 || si.Entries[0].Category != "tool_failure" { + t.Fatalf("expected 1 persisted lesson, got %+v", si.Entries) + } +} + +func TestSessionLearnNilSafe(t *testing.T) { + s := &Session{} + s.Learn("x", "y", "z", "code") // no callback, must not panic +} diff --git a/internal/engine/session.go b/internal/engine/session.go index 66d44749..1147ac5a 100644 --- a/internal/engine/session.go +++ b/internal/engine/session.go @@ -72,6 +72,10 @@ type Session struct { memory *MemoryService persist *PersistenceService tools *ToolService + // learnFn persists structured lessons produced by failure reflection to a + // 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) // 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. @@ -218,6 +222,11 @@ func (s *Session) SubSession(model, systemPrompt string, registry *tool.Registry deploymentRouting = llm.DeploymentRouting() } sub := NewSessionWithClient(chat, provider, model, systemPrompt, registry, deploymentRouting) + // Propagate the lesson callback so sub-agent failures also persist + // cross-session lessons. + s.mu.RLock() + sub.learnFn = s.learnFn + s.mu.RUnlock() return sub } @@ -279,6 +288,32 @@ func (s *Session) PermSvc() *PermissionService { return s.perms } // LifecycleSvc returns the extracted LifecycleService (Phase 3). func (s *Session) LifecycleSvc() *LifecycleService { return s.life } +// SetLearnFn installs the callback that persists structured lessons produced +// by failure reflection. Set it to a SelfImprover.Learn-compatible function +// to close the loop: reflections then survive the session. +func (s *Session) SetLearnFn(fn func(what, why, lesson, category string)) { + if s == nil { + return + } + s.mu.Lock() + s.learnFn = fn + s.mu.Unlock() +} + +// Learn persists a lesson through the configured callback. Safe to call with +// nil session or no callback installed. +func (s *Session) Learn(what, why, lesson, category string) { + if s == nil { + return + } + s.mu.RLock() + fn := s.learnFn + s.mu.RUnlock() + if fn != nil { + fn(what, why, lesson, category) + } +} + // MemorySvc returns the extracted MemoryService (Phase 4). func (s *Session) MemorySvc() *MemoryService { return s.memory } diff --git a/internal/engine/stream_tool_exec.go b/internal/engine/stream_tool_exec.go index f70c6f9f..1f5bd2b5 100644 --- a/internal/engine/stream_tool_exec.go +++ b/internal/engine/stream_tool_exec.go @@ -217,6 +217,10 @@ func (s *Session) executeSingleToolWithTool(ctx context.Context, tc types.ToolCa reflection, refErr := s.LifecycleSvc().Reflector().Reflect(ctx, intentText, s.Persistence().RawMessages(), output) if refErr == nil && reflection != nil { output += fmt.Sprintf("\n\n## Self-Reflection\n**What failed:** %s\n**Why:** %s\n**What to do differently:** %s\nTry a different approach based on this analysis.", reflection.WhatFailed, reflection.WhyFailed, reflection.WhatToDo) + // Persist the lesson across sessions so future runs avoid + // the same mistake. Learn is nil-safe when no callback is + // installed (e.g. headless or background sessions). + s.Learn(reflection.WhatFailed, reflection.WhyFailed, reflection.WhatToDo, "tool_failure") } } } else {