From 68e90bac9d9c5b0d449bf1c47d09dcd5111b5744 Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Sat, 1 Aug 2026 19:17:24 +0530 Subject: [PATCH 01/49] fix: enforce permission autonomy and spec gates --- cmd/options.go | 2 + cmd/permissions_center.go | 11 +- cmd/permissions_center_test.go | 6 +- cmd/root.go | 13 ++- docs/user-guide/22-permissions-and-safety.md | 23 ++-- internal/engine/agent_session_tool.go | 1 + internal/engine/permission_service.go | 7 ++ internal/engine/permission_service_test.go | 10 ++ internal/engine/safety/permission.go | 22 +++- internal/engine/safety/permission_engine.go | 102 +++++++++++++++--- .../engine/safety/permission_engine_test.go | 62 ++++++++++- internal/engine/stream_tool_exec.go | 19 ++-- internal/engine/stream_tool_exec_test.go | 33 ++++++ internal/tool/path_guard.go | 8 ++ internal/tool/tool_test.go | 14 +++ 15 files changed, 293 insertions(+), 40 deletions(-) diff --git a/cmd/options.go b/cmd/options.go index 454f782e..05209b3c 100644 --- a/cmd/options.go +++ b/cmd/options.go @@ -20,6 +20,7 @@ import ( "github.com/GrayCodeAI/hawk/internal/prompt" "github.com/GrayCodeAI/hawk/internal/prompts" hawkmodel "github.com/GrayCodeAI/hawk/internal/provider/routing" + "github.com/GrayCodeAI/hawk/internal/sandbox" "github.com/GrayCodeAI/hawk/internal/snapshot" "github.com/GrayCodeAI/hawk/internal/tool" ) @@ -269,6 +270,7 @@ func configureSession(sess *engine.Session, settings hawkconfig.Settings, maxTur func configureSessionStartup(sess *engine.Session, settings hawkconfig.Settings, maxTurnsOverride ...int) error { sess.WireAgentTool() sess.SetAllowedDirs(addDirs) + sess.PermSvc().SetSandboxMode(sandbox.ParseMode(effectivePermissionSandbox(settings))) for _, spec := range settings.AutoAllow { sess.PermSvc().Memory().AllowSpec(spec) diff --git a/cmd/permissions_center.go b/cmd/permissions_center.go index a645f94a..1c27f472 100644 --- a/cmd/permissions_center.go +++ b/cmd/permissions_center.go @@ -7,12 +7,15 @@ import ( tea "charm.land/bubbletea/v2" hawkconfig "github.com/GrayCodeAI/hawk/internal/config" "github.com/GrayCodeAI/hawk/internal/engine" + "github.com/GrayCodeAI/hawk/internal/sandbox" ) const defaultPermissionSandbox = "workspace" func normalizePermissionTier(raw string) (engine.AutonomyLevel, string, bool) { switch strings.ToLower(strings.TrimSpace(raw)) { + case "always_ask", "always-ask", "supervised", "ask": + return engine.AutonomySupervised, "Always Ask", true case "scout", "basic", "read": return engine.AutonomyBasic, "Scout", true case "builder", "semi", "edit": @@ -82,6 +85,8 @@ func effectivePermissionSandbox(settings hawkconfig.Settings) string { func permissionBehaviorSummary(level engine.AutonomyLevel) string { switch level { + case engine.AutonomySupervised: + return "prompts for every tool call" case engine.AutonomyBasic: return "reads auto-approve; edits and commands ask first" case engine.AutonomySemi: @@ -91,7 +96,7 @@ func permissionBehaviorSummary(level engine.AutonomyLevel) string { case engine.AutonomyYOLO: return "minimal prompts; only highest-risk actions stop" default: - return "reads and file changes auto-approve; commands ask first" + return "prompts for every tool call" } } @@ -293,6 +298,7 @@ func resetPermissionCenter(m *chatModel) { return } m.session.PermSvc().SetAutonomy(DefaultContainerAutonomy) + m.session.PermSvc().SetSandboxMode(sandbox.ParseMode(defaultPermissionSandbox)) m.settings.Autonomy = permissionTierSettingValue(DefaultContainerAutonomy) m.settings.Sandbox = defaultPermissionSandbox sandboxFlag = defaultPermissionSandbox @@ -346,7 +352,8 @@ func (m *chatModel) handleAutonomyCommand(parts []string) (chatModel, tea.Cmd) { } m.settings.Sandbox = mode sandboxFlag = mode - m.messages = append(m.messages, displayMsg{role: "system", content: fmt.Sprintf("Permission sandbox → %s\nControls approval policy inside the mandatory Docker container.", label)}) + m.session.PermSvc().SetSandboxMode(sandbox.ParseMode(mode)) + m.messages = append(m.messages, displayMsg{role: "system", content: fmt.Sprintf("Permission sandbox → %s\nControls tool filesystem/process policy independently of the autonomy tier.", label)}) case "dry-run": if len(parts) < 3 { state := "off" diff --git a/cmd/permissions_center_test.go b/cmd/permissions_center_test.go index ce701118..49955b1f 100644 --- a/cmd/permissions_center_test.go +++ b/cmd/permissions_center_test.go @@ -9,7 +9,11 @@ import ( ) func TestNormalizePermissionTier(t *testing.T) { - level, label, ok := normalizePermissionTier("operator") + level, label, ok := normalizePermissionTier("always_ask") + if !ok || level != engine.AutonomySupervised || label != "Always Ask" { + t.Fatalf("always_ask = (%v, %q, %v)", level, label, ok) + } + level, label, ok = normalizePermissionTier("operator") if !ok || level != engine.AutonomyFull || label != "Operator" { t.Fatalf("operator = (%v, %q, %v)", level, label, ok) } diff --git a/cmd/root.go b/cmd/root.go index d00e6418..2d1e5950 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -221,7 +221,7 @@ func init() { rootCmd.Flags().StringArrayVar(&toolsFlag, "tools", nil, `available tools: "" disables all tools, "default" enables all, or names like "Bash,Edit,Read"`) rootCmd.Flags().StringArrayVar(&allowedToolsFlag, "allowed-tools", nil, `comma or space-separated tool permission rules to allow (e.g. "Bash(git:*) Edit")`) rootCmd.Flags().StringArrayVar(&disallowedToolsFlag, "disallowed-tools", nil, `comma or space-separated tool permission rules to deny (e.g. "Bash(git:*) Edit")`) - rootCmd.Flags().BoolVar(&dangerouslySkipPermissions, "dangerously-skip-permissions", false, "bypass all permission checks") + rootCmd.Flags().BoolVar(&dangerouslySkipPermissions, "dangerously-skip-permissions", false, "skip normal permission prompts (hooks, spec gates, sandbox, and dry-run still apply)") rootCmd.Flags().BoolVar(&dryRunFlag, "dry-run", false, "deny every tool call unconditionally (preview only, nothing executes)") rootCmd.Flags().IntVar(&maxTurns, "max-turns", 0, "maximum number of agentic turns in non-interactive mode") rootCmd.Flags().Float64Var(&maxBudgetUSD, "max-budget-usd", 0, "maximum estimated API spend in USD") @@ -286,12 +286,15 @@ func init() { completionCmd.AddCommand(completionInstallCmd) } -// confirmDangerousSkipPermissions enforces a safety guard when --dangerously-skip-permissions is set. -// In a terminal, it prompts for interactive confirmation. In non-interactive mode (CI, scripts), -// it requires the HAWK_DANGEROUSLY_SKIP_PERMISSIONS=1 environment variable. +// confirmDangerousSkipPermissions enforces a safety guard when +// --dangerously-skip-permissions is set. It skips normal permission prompts, +// but does not disable hooks, spec gates, sandbox enforcement, or dry-run. +// In a terminal, it prompts for interactive confirmation. In non-interactive +// mode (CI, scripts), it requires the HAWK_DANGEROUSLY_SKIP_PERMISSIONS=1 +// environment variable. func confirmDangerousSkipPermissions() error { if isStdinTerminal() { - fmt.Fprint(os.Stderr, "Are you sure? This disables all safety checks [y/N]: ") + fmt.Fprint(os.Stderr, "Are you sure? This skips normal permission prompts [y/N]: ") scanner := bufio.NewScanner(os.Stdin) if !scanner.Scan() { return fmt.Errorf("--dangerously-skip-permissions requires confirmation") diff --git a/docs/user-guide/22-permissions-and-safety.md b/docs/user-guide/22-permissions-and-safety.md index 6d6cf9c9..c2b7437c 100644 --- a/docs/user-guide/22-permissions-and-safety.md +++ b/docs/user-guide/22-permissions-and-safety.md @@ -8,11 +8,12 @@ Hawk can read files, edit code, and run shell commands. The permission system co When the model requests a tool: -1. **PreToolUse hooks** — Can deny before other checks -2. **Permission rules** — `deny` > `ask` > `allow` -3. **Remembered grants** — Per-project approvals -4. **Auto-approvals** — Read-only tools -5. **Prompt policy** — Based on autonomy tier +1. **Dry-run** — Denies every tool call +2. **PreToolUse hooks** — Can deny before other checks +3. **Spec-stage gate** — Restricts tools until implementation is approved +4. **Explicit remembered rules** — Deny/allow decisions are checked before autonomy +5. **Autonomy policy** — Determines whether an otherwise-unruled call prompts +6. **High-risk approval gate** — Optional second confirmation for network/destructive actions --- @@ -24,7 +25,7 @@ When the model requests a tool: | `scout` | Classify and approve safe tools | | `builder` | Broader tool access | | `operator` | Full tool access (trusted) | -| `autonomous` | No prompts, hooks and rules still apply | +| `autonomous` | No normal prompts; hooks, explicit rules, spec gates, sandbox, and high-risk approval still apply | Set with: @@ -73,10 +74,16 @@ Permissions control what the model can request. The sandbox controls what actual | Sandbox | OS-level enforcement | Recommended combination: -- `restrictive` rules +- restrictive rules - PreToolUse hooks - `--sandbox strict` +Sandbox modes are enforced independently of autonomy: + +- `strict` is read-only for tool execution. +- `workspace` permits work inside the workspace and configured `--add-dir` paths. +- `off` disables the tool path guard. + --- ## Hook-based Security @@ -117,4 +124,4 @@ See [Hooks](10-hooks.md) for hook authoring. --- -© 2026 GrayCode AI. All rights reserved. \ No newline at end of file +© 2026 GrayCode AI. All rights reserved. diff --git a/internal/engine/agent_session_tool.go b/internal/engine/agent_session_tool.go index 8c46aeb4..183544f0 100644 --- a/internal/engine/agent_session_tool.go +++ b/internal/engine/agent_session_tool.go @@ -142,6 +142,7 @@ func (s *Session) spawnSubAgent(ctx context.Context, norm agentcontracts.Normali // inherits the same gate — an ungated sub-agent would be a permission // escalation hole (it could Write/Bash while the parent still can't). sub.PermSvc().SetSpecStage(s.PermSvc().SpecStage()) + sub.PermSvc().SetSandboxMode(s.PermSvc().SandboxMode()) if s.LifecycleSvc() != nil { s.LifecycleSvc().Limits().SetMaxTurns(maxTurns) } diff --git a/internal/engine/permission_service.go b/internal/engine/permission_service.go index 05815d70..fbac17e2 100644 --- a/internal/engine/permission_service.go +++ b/internal/engine/permission_service.go @@ -6,6 +6,7 @@ import ( "github.com/GrayCodeAI/hawk/internal/observability/logger" "github.com/GrayCodeAI/hawk/internal/permissions" + "github.com/GrayCodeAI/hawk/internal/sandbox" ) // PermissionService is the Session's view of the safety/approval layer. @@ -138,6 +139,12 @@ func (s *PermissionService) SetSpecStage(stage SpecStage) { s.perm.Stage = stage // denied unconditionally, regardless of tier or spec stage. func (s *PermissionService) SetDryRun(dryRun bool) { s.perm.DryRun = dryRun } +// SetSandboxMode sets the OS/tool sandbox policy used for subsequent calls. +func (s *PermissionService) SetSandboxMode(mode sandbox.Mode) { s.perm.SandboxMode = mode } + +// SandboxMode reports the active OS/tool sandbox policy. +func (s *PermissionService) SandboxMode() sandbox.Mode { return s.perm.SandboxMode } + // DryRun reports whether the kill switch is active. func (s *PermissionService) DryRun() bool { return s.perm.DryRun } diff --git a/internal/engine/permission_service_test.go b/internal/engine/permission_service_test.go index c04751c1..db476ea6 100644 --- a/internal/engine/permission_service_test.go +++ b/internal/engine/permission_service_test.go @@ -4,6 +4,8 @@ import ( "context" "strings" "testing" + + "github.com/GrayCodeAI/hawk/internal/sandbox" ) func TestPermissionService_CheckTool(t *testing.T) { @@ -55,6 +57,14 @@ func TestPermissionService_AutonomyAndAllowedDirs(t *testing.T) { } } +func TestPermissionService_SandboxModeRoundTrip(t *testing.T) { + s := NewPermissionService(nil) + s.SetSandboxMode(sandbox.ModeStrict) + if got := s.SandboxMode(); got != sandbox.ModeStrict { + t.Fatalf("SandboxMode = %q, want strict", got) + } +} + func TestPermissionService_CheckApproval_NoGate(t *testing.T) { s := NewPermissionService(nil) approved, _ := s.CheckApproval(context.Background(), "Bash", map[string]interface{}{}) diff --git a/internal/engine/safety/permission.go b/internal/engine/safety/permission.go index 4c233aa2..002d270a 100644 --- a/internal/engine/safety/permission.go +++ b/internal/engine/safety/permission.go @@ -193,7 +193,7 @@ func canonicalToolName(name string) string { return "WebSearch" case "agent", "task": return "Agent" - case "ask_user", "askuserquestion": + case "ask_user", "askuser", "askuserquestion": return "AskUserQuestion" case "todo", "todowrite": return "TodoWrite" @@ -207,6 +207,26 @@ func canonicalToolName(name string) string { return "Tasks" case "approve_implementation", "approveimplementation": return "ApproveImplementation" + case "spec_status", "specstatus": + return "SpecStatus" + case "spec_edit", "specedit": + return "SpecEdit" + case "spec_list", "speclist": + return "SpecList" + case "spec_reset", "specreset": + return "SpecReset" + case "spec_config", "specconfig": + return "SpecConfig" + case "clarify": + return "Clarify" + case "analyze": + return "Analyze" + case "checklist": + return "Checklist" + case "constitution": + return "Constitution" + case "converge": + return "Converge" case "notebook_edit", "notebookedit": return "NotebookEdit" case "config": diff --git a/internal/engine/safety/permission_engine.go b/internal/engine/safety/permission_engine.go index 72b6d902..b518b158 100644 --- a/internal/engine/safety/permission_engine.go +++ b/internal/engine/safety/permission_engine.go @@ -12,6 +12,7 @@ import ( contracts "github.com/GrayCodeAI/hawk-core-contracts/policy" "github.com/GrayCodeAI/hawk/internal/hooks" "github.com/GrayCodeAI/hawk/internal/permissions" + "github.com/GrayCodeAI/hawk/internal/sandbox" "github.com/GrayCodeAI/hawk/internal/tool" ) @@ -37,7 +38,11 @@ type PermissionEngine struct { Classifier *permissions.Classifier BypassKill *permissions.BypassKillswitch Autonomy AutonomyLevel - Stage SpecStage + // SandboxMode controls filesystem/process policy for tool execution. It is + // deliberately separate from Autonomy: autonomy decides whether a user + // prompt is needed, while the sandbox decides what the tool may actually do. + SandboxMode sandbox.Mode + Stage SpecStage // DryRun is a global kill switch: when true, every tool call is denied // unconditionally, regardless of tier or spec stage. Replaces the old // PermissionModeDontAsk's hard-lockout role — that mode was otherwise @@ -92,15 +97,27 @@ func (pe *PermissionEngine) CheckTool(ctx context.Context, tc ToolCallInfo) (boo if denied, reason := pe.checkPreToolHooks(tc); denied { return false, reason } + // Strict sandbox mode is read-only. This check is independent of autonomy + // and the spec workflow so neither can turn a read-only sandbox into a + // write or process-execution path. + if pe.SandboxMode == sandbox.ModeStrict && !pe.strictToolAllowed(tc) { + return false, "Sandbox strict mode: tool execution is read-only." + } // Spec-stage gate — independent of trust tier, so no autonomy level can // bypass it. While a spec workflow is active and not yet approved for // implementation, only the workflow's own tools and reads may proceed. if pe.Stage != SpecStageNone && pe.Stage != SpecStageImplementing { switch toolName { - case "Specify", "Plan", "Tasks": + case "Specify", "Plan", "Tasks", "AskUserQuestion", "SpecStatus", "SpecEdit", "SpecList", "SpecReset", "SpecConfig", "Clarify", "Analyze", "Checklist", "Constitution", "Converge": + if !pe.specToolAllowed(toolName) { + return false, pe.specStageReason(toolName) + } return true, "" case "ApproveImplementation": + if pe.Stage != SpecStageTasks { + return false, "Spec stage active: ApproveImplementation is available only after Tasks completes." + } // Always a real human decision — never auto-allowed by tier, // bypass-kill, or auto-mode, unlike everything below. Show the // actual spec/plan/tasks content in the prompt rather than a @@ -114,6 +131,32 @@ func (pe *PermissionEngine) CheckTool(ctx context.Context, tc ToolCallInfo) (boo } } + summary := ToolSummary(tc.Name, tc.Args) + // Explicit remembered decisions are policy rules. They must be consulted + // before autonomy can short-circuit the request, especially for deny rules. + var memoryDecision *bool + if pe.Memory != nil { + memoryDecision = pe.Memory.Check(tc.Name, summary) + } + var autoDecision *bool + if pe.AutoMode != nil { + if allowed, ok := pe.AutoMode.ShouldAutoAllow(tc.Name, summary); ok { + autoDecision = &allowed + } + } + if memoryDecision != nil && !*memoryDecision { + return false, "Permission denied (rule)." + } + if autoDecision != nil && !*autoDecision { + return false, "Permission denied (auto-mode)." + } + if memoryDecision != nil && *memoryDecision { + return true, "" + } + if autoDecision != nil && *autoDecision { + return true, "" + } + isSafe := !ToolNeedsPermission(tc.Name, tc.Args) autoCfg := PresetConfig(pe.Autonomy) if !autoCfg.NeedsPermission(tc.Name, isSafe) { @@ -122,27 +165,54 @@ func (pe *PermissionEngine) CheckTool(ctx context.Context, tc ToolCallInfo) (boo if pe.BypassKill.IsEnabled() { return true, "" } - summary := ToolSummary(tc.Name, tc.Args) if pe.Classifier != nil && tc.Name == "Bash" { if pe.Classifier.Classify(summary) == "safe" { return true, "" } } - if pe.AutoMode != nil { - if allowed, ok := pe.AutoMode.ShouldAutoAllow(tc.Name, summary); ok { - if allowed { - return true, "" - } - return false, "Permission denied (auto-mode)." - } + return pe.promptUser(ctx, tc) +} + +func (pe *PermissionEngine) specToolAllowed(toolName string) bool { + switch toolName { + case "Specify": + return pe.Stage == SpecStageSpecify + case "Plan": + return pe.Stage == SpecStageSpecify && pe.SpecSlug != "" + case "Tasks": + return pe.Stage == SpecStagePlan + default: + return true } - if decision := pe.Memory.Check(tc.Name, summary); decision != nil { - if !*decision { - return false, "Permission denied (rule)." - } - return true, "" +} + +func (pe *PermissionEngine) specStageReason(toolName string) string { + switch toolName { + case "Plan": + return "Spec stage active: Plan is available only after Specify completes." + case "Tasks": + return "Spec stage active: Tasks is available only after Plan completes." + case "Specify": + return "Spec stage active: Specify is not available at the current stage." + default: + return "Spec stage active: tool is not available at the current stage." + } +} + +func (pe *PermissionEngine) strictToolAllowed(tc ToolCallInfo) bool { + name := canonicalToolName(tc.Name) + if tool.IsReadOnly(tc.Name) || name == "ApproveImplementation" { + return true + } + switch name { + case "AskUserQuestion", "SpecStatus", "SpecList", "Clarify", "Analyze", "Checklist", "Constitution", "Converge": + return true + case "SpecConfig": + action, _ := tc.Args["action"].(string) + return strings.ToLower(strings.TrimSpace(action)) != "set" + default: + return false } - return pe.promptUser(ctx, tc) } // checkPreToolHooks runs decision hooks for PreToolUse / pre_tool. diff --git a/internal/engine/safety/permission_engine_test.go b/internal/engine/safety/permission_engine_test.go index 705c9d6f..532c275c 100644 --- a/internal/engine/safety/permission_engine_test.go +++ b/internal/engine/safety/permission_engine_test.go @@ -3,6 +3,8 @@ package safety import ( "context" "testing" + + "github.com/GrayCodeAI/hawk/internal/sandbox" ) // TestCheckTool_SpecStageBlocksEvenYOLO verifies the core guarantee documented @@ -38,12 +40,22 @@ func TestCheckTool_SpecStageAllowsWorkflowAndReadTools(t *testing.T) { pe.Stage = SpecStageSpecify pe.Autonomy = AutonomySupervised - for _, name := range []string{"Specify", "Plan", "Tasks"} { + for _, name := range []string{"Specify"} { allowed, reason := pe.CheckTool(context.Background(), ToolCallInfo{Name: name}) if !allowed { t.Errorf("tool %q: expected allowed during spec stage, got denied: %q", name, reason) } } + if allowed, reason := pe.CheckTool(context.Background(), ToolCallInfo{Name: "Plan"}); allowed || reason == "" { + t.Fatalf("Plan should wait for Specify, allowed=%v reason=%q", allowed, reason) + } + pe.SpecSlug = "test-spec" + if allowed, reason := pe.CheckTool(context.Background(), ToolCallInfo{Name: "Plan"}); !allowed || reason != "" { + t.Fatalf("Plan should be allowed after Specify, allowed=%v reason=%q", allowed, reason) + } + if allowed, reason := pe.CheckTool(context.Background(), ToolCallInfo{Name: "Tasks"}); allowed || reason == "" { + t.Fatalf("Tasks should wait for Plan, allowed=%v reason=%q", allowed, reason) + } allowed, reason := pe.CheckTool(context.Background(), ToolCallInfo{Name: "Read"}) if !allowed { @@ -147,3 +159,51 @@ func TestCheckTool_DryRunOverridesEverything(t *testing.T) { t.Error("expected a deny reason") } } + +func TestCheckTool_ExplicitDenyOverridesAutonomy(t *testing.T) { + for _, tier := range []AutonomyLevel{AutonomyBasic, AutonomySemi, AutonomyFull, AutonomyYOLO} { + pe := NewPermissionEngine() + pe.Autonomy = tier + pe.Memory.AlwaysDeny("Write") + allowed, reason := pe.CheckTool(context.Background(), ToolCallInfo{Name: "Write", Args: map[string]interface{}{"file_path": "x.txt"}}) + if allowed || reason != "Permission denied (rule)." { + t.Fatalf("tier %v: allowed=%v reason=%q", tier, allowed, reason) + } + } + pe := NewPermissionEngine() + pe.Autonomy = AutonomyYOLO + pe.AutoMode.Record("Write", "x.txt", true) + pe.Memory.AlwaysDenyPattern("Write:x.txt") + if allowed, reason := pe.CheckTool(context.Background(), ToolCallInfo{Name: "Write", Args: map[string]interface{}{"file_path": "x.txt"}}); allowed || reason != "Permission denied (rule)." { + t.Fatalf("explicit deny did not beat auto-allow: allowed=%v reason=%q", allowed, reason) + } +} + +func TestCheckTool_SpecWorkflowRequiresOrderButAllowsSupportTools(t *testing.T) { + pe := NewPermissionEngine() + pe.Stage = SpecStageSpecify + for _, name := range []string{"AskUserQuestion", "SpecStatus", "SpecEdit", "SpecList", "SpecConfig", "Clarify"} { + allowed, reason := pe.CheckTool(context.Background(), ToolCallInfo{Name: name}) + if !allowed { + t.Errorf("support tool %q denied during spec stage: %q", name, reason) + } + } + if allowed, _ := pe.CheckTool(context.Background(), ToolCallInfo{Name: "Tasks"}); allowed { + t.Fatal("Tasks must not skip the Plan stage") + } + if allowed, _ := pe.CheckTool(context.Background(), ToolCallInfo{Name: "ApproveImplementation"}); allowed { + t.Fatal("ApproveImplementation must not skip the Tasks stage") + } +} + +func TestCheckTool_StrictSandboxIsIndependentOfAutonomy(t *testing.T) { + pe := NewPermissionEngine() + pe.Autonomy = AutonomyYOLO + pe.SandboxMode = sandbox.ModeStrict + if allowed, reason := pe.CheckTool(context.Background(), ToolCallInfo{Name: "Write"}); allowed || reason == "" { + t.Fatalf("strict sandbox allowed Write: reason=%q", reason) + } + if allowed, reason := pe.CheckTool(context.Background(), ToolCallInfo{Name: "AskUserQuestion"}); !allowed || reason != "" { + t.Fatalf("strict sandbox blocked user clarification: allowed=%v reason=%q", allowed, reason) + } +} diff --git a/internal/engine/stream_tool_exec.go b/internal/engine/stream_tool_exec.go index 7088be36..47e81c75 100644 --- a/internal/engine/stream_tool_exec.go +++ b/internal/engine/stream_tool_exec.go @@ -16,6 +16,7 @@ import ( hooks "github.com/GrayCodeAI/hawk/internal/hooks" "github.com/GrayCodeAI/hawk/internal/observability/oteltrace" "github.com/GrayCodeAI/hawk/internal/prompts" + "github.com/GrayCodeAI/hawk/internal/sandbox" ) // toolExecResult holds the output of a single tool execution. @@ -323,6 +324,7 @@ func (s *Session) executeSingleToolWithTool(ctx context.Context, tc types.ToolCa }) inputJSON, _ := json.Marshal(tc.Arguments) + sandboxMode := s.PermSvc().SandboxMode() toolCtx := tool.WithToolContext(ctx, &tool.ToolContext{ AgentSpawnFn: s.AgentSpawnFn, AskUserFn: s.AskUserFn, @@ -343,13 +345,18 @@ func (s *Session) executeSingleToolWithTool(ctx context.Context, tc types.ToolCa } return resp.Content, nil }, - YaadBridge: s.MemorySvc().Yaad(), - SpecSlugGet: func() string { return s.Perm.SpecSlug }, - SpecSlugSet: func(slug string) { s.Perm.SpecSlug = slug }, - BackgroundManager: s.ensureBackgroundManager(), - ReadOnlyBash: s.readOnlyBash, - WorkingDir: s.workingDir, + YaadBridge: s.MemorySvc().Yaad(), + SpecSlugGet: func() string { return s.Perm.SpecSlug }, + SpecSlugSet: func(slug string) { s.Perm.SpecSlug = slug }, + BackgroundManager: s.ensureBackgroundManager(), + ReadOnlyBash: s.readOnlyBash, + WorkingDir: s.workingDir, + AllowedDirectories: append([]string(nil), s.AllowedDirs...), + SandboxMode: sandboxMode, }) + if sandboxMode != "" { + toolCtx = sandbox.ContextWithMode(toolCtx, sandboxMode) + } if s.Tools().ContainerExecutor() != nil && s.Tools().ContainerExecutor().Running() { toolCtx = tool.WithContainerExecutor(toolCtx, s.Tools().ContainerExecutor()) } diff --git a/internal/engine/stream_tool_exec_test.go b/internal/engine/stream_tool_exec_test.go index 46d96eaf..cd165041 100644 --- a/internal/engine/stream_tool_exec_test.go +++ b/internal/engine/stream_tool_exec_test.go @@ -8,6 +8,7 @@ import ( "testing" "time" + "github.com/GrayCodeAI/hawk/internal/sandbox" "github.com/GrayCodeAI/hawk/internal/tool" "github.com/GrayCodeAI/hawk/internal/types" ) @@ -110,3 +111,35 @@ var ( _ tool.Tool = orderedReadTool{} _ tool.Tool = (*countedReadTool)(nil) ) + +type contextCaptureTool struct{ ctx *tool.ToolContext } + +func (t *contextCaptureTool) Name() string { return "Read" } +func (t *contextCaptureTool) Description() string { return "capture context" } +func (t *contextCaptureTool) Parameters() map[string]interface{} { + return map[string]interface{}{"type": "object", "properties": map[string]interface{}{}} +} + +func (t *contextCaptureTool) Execute(ctx context.Context, _ json.RawMessage) (string, error) { + t.ctx = tool.GetToolContext(ctx) + return "ok", nil +} + +func TestExecuteSingleTool_PropagatesPermissionContext(t *testing.T) { + capture := &contextCaptureTool{} + sess := NewSession("test", "test", "system", tool.NewRegistry(capture)) + sess.PermSvc().SetAutonomy(AutonomyYOLO) + sess.PermSvc().SetSandboxMode(sandbox.ModeOff) + sess.SetAllowedDirs([]string{"/tmp/extra"}) + ch := make(chan StreamEvent, 4) + res := sess.executeSingleTool(context.Background(), types.ToolCall{Name: "Read", ID: "ctx"}, ch, 0, "") + if res.isErr || capture.ctx == nil { + t.Fatalf("tool failed or context missing: %#v", res) + } + if capture.ctx.SandboxMode != sandbox.ModeOff { + t.Fatalf("SandboxMode = %q, want off", capture.ctx.SandboxMode) + } + if len(capture.ctx.AllowedDirectories) != 1 || capture.ctx.AllowedDirectories[0] != "/tmp/extra" { + t.Fatalf("AllowedDirectories = %#v", capture.ctx.AllowedDirectories) + } +} diff --git a/internal/tool/path_guard.go b/internal/tool/path_guard.go index 89826b36..458ba647 100644 --- a/internal/tool/path_guard.go +++ b/internal/tool/path_guard.go @@ -6,6 +6,8 @@ import ( "os" "path/filepath" "strings" + + "github.com/GrayCodeAI/hawk/internal/sandbox" ) func validatePathAllowed(ctx context.Context, path string) error { @@ -13,6 +15,12 @@ func validatePathAllowed(ctx context.Context, path string) error { if tc == nil { return nil } + // Explicitly selecting the off sandbox disables the workspace path guard. + // An unset mode is intentionally not treated as off so direct tool callers + // retain the safe historical default. + if tc.SandboxMode == sandbox.ModeOff { + return nil + } path = strings.TrimSpace(path) if path == "" { return fmt.Errorf("path is required") diff --git a/internal/tool/tool_test.go b/internal/tool/tool_test.go index 562452ce..d7ec31f7 100644 --- a/internal/tool/tool_test.go +++ b/internal/tool/tool_test.go @@ -8,6 +8,8 @@ import ( "strings" "testing" "time" + + "github.com/GrayCodeAI/hawk/internal/sandbox" ) func TestFileWriteAndRead(t *testing.T) { @@ -209,6 +211,18 @@ func TestPathGuardBlocksOutsideCWDAndAllowsAddDir(t *testing.T) { } } +func TestPathGuard_OffSandboxAllowsOutsidePath(t *testing.T) { + outside := filepath.Join(t.TempDir(), "outside.txt") + if err := os.WriteFile(outside, []byte("outside"), 0o644); err != nil { + t.Fatal(err) + } + ctx := WithToolContext(context.Background(), &ToolContext{SandboxMode: sandbox.ModeOff}) + input, _ := json.Marshal(map[string]string{"path": outside}) + if _, err := (FileReadTool{}).Execute(ctx, input); err != nil { + t.Fatalf("off sandbox should allow outside path: %v", err) + } +} + func TestGrep(t *testing.T) { dir := t.TempDir() os.WriteFile(filepath.Join(dir, "test.go"), []byte("func main() {\n\tfmt.Println(\"hello\")\n}"), 0o644) From 07c05607eabf2a58cddd661ea335c2a88b9135f9 Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Sat, 1 Aug 2026 19:23:12 +0530 Subject: [PATCH 02/49] fix: close permission edge cases --- cmd/chat_subcommand_spec.go | 1 + cmd/chat_update.go | 1 + cmd/options.go | 13 ++++++++++--- cmd/options_test.go | 9 +++++++++ internal/engine/agent_session_tool.go | 1 + internal/engine/spec_mode_test.go | 24 ++++++++++++++++++++++++ internal/engine/stream_tool_exec.go | 3 +++ internal/tool/spec.go | 25 +++++++++++++++++++++++-- 8 files changed, 72 insertions(+), 5 deletions(-) diff --git a/cmd/chat_subcommand_spec.go b/cmd/chat_subcommand_spec.go index 1804b0ce..24e8457f 100644 --- a/cmd/chat_subcommand_spec.go +++ b/cmd/chat_subcommand_spec.go @@ -48,6 +48,7 @@ func (s *specSubcommand) Handle(m *chatModel, args []string, text string) (tea.M case strings.EqualFold(arg, "reset"): m.session.PermSvc().SetSpecStage(engine.SpecStageNone) + m.session.Perm.SpecSlug = "" m.messages = append(m.messages, displayMsg{role: "system", content: "Spec workflow reset — Write/Edit/Bash follow the normal autonomy tier again."}) return m, nil diff --git a/cmd/chat_update.go b/cmd/chat_update.go index aaac1c11..0b25978b 100644 --- a/cmd/chat_update.go +++ b/cmd/chat_update.go @@ -514,6 +514,7 @@ func (m chatModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.messages = append(m.messages, displayMsg{role: "system", content: msg}) case specActionReset: m.session.PermSvc().SetSpecStage(engine.SpecStageNone) + m.session.Perm.SpecSlug = "" m.messages = append(m.messages, displayMsg{role: "system", content: "Spec workflow reset — Write/Edit/Bash follow the trust tier again."}) } } diff --git a/cmd/options.go b/cmd/options.go index 05209b3c..aaecf33f 100644 --- a/cmd/options.go +++ b/cmd/options.go @@ -288,9 +288,6 @@ func configureSessionStartup(sess *engine.Session, settings hawkconfig.Settings, sess.PermSvc().Memory().DenySpec(spec) } - if dangerouslySkipPermissions { - sess.PermSvc().SetAutonomy(engine.AutonomyYOLO) - } if dryRunFlag { sess.PermSvc().SetDryRun(true) } @@ -348,6 +345,11 @@ func configureSessionStartup(sess *engine.Session, settings hawkconfig.Settings, if lvl := autonomyFromSettings(settings.Autonomy); lvl != 0 { sess.PermSvc().SetAutonomy(lvl) } + // CLI safety overrides saved settings: the explicit dangerous-skip flag + // must not be silently downgraded by a persisted autonomy tier. + if dangerouslySkipPermissions { + sess.PermSvc().SetAutonomy(engine.AutonomyYOLO) + } // Per-model thinking preference (Setup → Models Think column), with // provider-specific defaults (e.g. LongCat off). @@ -390,6 +392,11 @@ func bindChatSession(sess *engine.Session, sessionID string, containerRequired b } func validateRootFlags() error { + if strings.TrimSpace(sandboxFlag) != "" { + if _, _, ok := normalizePermissionSandbox(sandboxFlag); !ok { + return fmt.Errorf("--sandbox must be one of: strict, workspace, off") + } + } if outputFormat != "text" && outputFormat != "json" && outputFormat != "stream-json" { return fmt.Errorf("--output-format must be one of: text, json, stream-json") } diff --git a/cmd/options_test.go b/cmd/options_test.go index 8b3ad56b..1ff3f35c 100644 --- a/cmd/options_test.go +++ b/cmd/options_test.go @@ -6,6 +6,15 @@ import ( "github.com/GrayCodeAI/hawk/internal/tool" ) +func TestValidateRootFlagsRejectsInvalidSandbox(t *testing.T) { + old := sandboxFlag + t.Cleanup(func() { sandboxFlag = old }) + sandboxFlag = "invalid-mode" + if err := validateRootFlags(); err == nil { + t.Fatal("expected invalid --sandbox mode to be rejected") + } +} + func TestParseToolListFromCLI(t *testing.T) { got := parseToolListFromCLI([]string{"Bash(git diff:*) Edit,Read", "mcp__server__tool"}) want := []string{"Bash(git diff:*)", "Edit", "Read", "mcp__server__tool"} diff --git a/internal/engine/agent_session_tool.go b/internal/engine/agent_session_tool.go index 183544f0..4633b218 100644 --- a/internal/engine/agent_session_tool.go +++ b/internal/engine/agent_session_tool.go @@ -141,6 +141,7 @@ func (s *Session) spawnSubAgent(ctx context.Context, norm agentcontracts.Normali // A sub-agent spawned while the parent is mid-spec-and-unapproved // inherits the same gate — an ungated sub-agent would be a permission // escalation hole (it could Write/Bash while the parent still can't). + sub.PermSvc().SetAutonomy(s.PermSvc().Autonomy()) sub.PermSvc().SetSpecStage(s.PermSvc().SpecStage()) sub.PermSvc().SetSandboxMode(s.PermSvc().SandboxMode()) if s.LifecycleSvc() != nil { diff --git a/internal/engine/spec_mode_test.go b/internal/engine/spec_mode_test.go index 9b4823c1..11763acd 100644 --- a/internal/engine/spec_mode_test.go +++ b/internal/engine/spec_mode_test.go @@ -23,6 +23,7 @@ func newSpecModeSession(approveImplement bool) (*Session, *int) { tool.PlanTool{}, tool.TasksTool{}, tool.ApproveImplementationTool{}, + tool.SpecResetTool{}, ) s := NewSession("", "", "test", registry) prompts := 0 @@ -221,3 +222,26 @@ func TestSpecMode_ImplementingLiftsGate(t *testing.T) { t.Errorf("expected Write to be permitted once Implementing, got error: %q", res.output) } } + +func TestSpecMode_ResetClearsStageAndSlug(t *testing.T) { + dir := t.TempDir() + old, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + if err := os.Chdir(dir); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = os.Chdir(old) }) + + s, _ := newSpecModeSession(true) + s.PermSvc().SetSpecStage(SpecStageSpecify) + runSpecTool(t, s, "Specify", map[string]interface{}{"title": "reset-test", "spec": "content"}) + if s.Perm.SpecSlug == "" { + t.Fatal("Specify should set an active slug") + } + runSpecTool(t, s, "SpecReset", map[string]interface{}{}) + if s.PermSvc().SpecStage() != SpecStageNone || s.Perm.SpecSlug != "" { + t.Fatalf("reset left stage=%v slug=%q", s.PermSvc().SpecStage(), s.Perm.SpecSlug) + } +} diff --git a/internal/engine/stream_tool_exec.go b/internal/engine/stream_tool_exec.go index 47e81c75..4f9acb60 100644 --- a/internal/engine/stream_tool_exec.go +++ b/internal/engine/stream_tool_exec.go @@ -626,6 +626,9 @@ func (s *Session) executeSingleToolWithTool(ctx context.Context, tc types.ToolCa switch canonicalToolName(tc.Name) { case "Specify", "Plan", "Tasks": s.Perm.AdvanceSpecStage(tc.Name) + case "SpecReset": + s.Perm.SpecSlug = "" + s.Perm.Stage = SpecStageNone case "ApproveImplementation": s.Perm.AdvanceSpecStage(tc.Name) output = "Spec approved — switched to implementation. You may now make changes." diff --git a/internal/tool/spec.go b/internal/tool/spec.go index 1c7b2a8c..cb2595f2 100644 --- a/internal/tool/spec.go +++ b/internal/tool/spec.go @@ -69,6 +69,21 @@ func writeSpecArtifact(ctx context.Context, filename, content string) (string, e if err != nil { return "", err } + return writeSpecArtifactInDir(dir, filename, content) +} + +func writeSpecArtifactForSlug(ctx context.Context, slug, filename, content string) (string, error) { + if strings.TrimSpace(slug) == "" { + return "", fmt.Errorf("spec slug is required") + } + cwd, err := os.Getwd() + if err != nil { + return "", err + } + return writeSpecArtifactInDir(filepath.Join(cwd, ".hawk", "specs", slug), filename, content) +} + +func writeSpecArtifactInDir(dir, filename, content string) (string, error) { if err := os.MkdirAll(dir, 0o700); err != nil { return "", fmt.Errorf("mkdir: %w", err) } @@ -116,13 +131,19 @@ func (SpecifyTool) Execute(ctx context.Context, input json.RawMessage) (string, if slug == "spec" { slug = slugify(firstLine(p.Spec)) } - if err := setSpecSlug(ctx, fmt.Sprintf("%s-%d", slug, time.Now().Unix())); err != nil { + slug = fmt.Sprintf("%s-%d", slug, time.Now().Unix()) + // Clear a previous slug before attempting the new artifact. A failed + // Specify must not leave a stale slug that lets Plan proceed. + if err := setSpecSlug(ctx, ""); err != nil { return "", err } - path, err := writeSpecArtifact(ctx, "spec.md", p.Spec) + path, err := writeSpecArtifactForSlug(ctx, slug, "spec.md", p.Spec) if err != nil { return "", err } + if err := setSpecSlug(ctx, slug); err != nil { + return "", err + } return fmt.Sprintf("Wrote %s. Next, call Plan with your technical approach.", path), nil } From b34119dfe14ad2583e86a717e21a101502d9866f Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Sat, 1 Aug 2026 19:25:49 +0530 Subject: [PATCH 03/49] fix: persist explicit supervised autonomy --- cmd/chat_update.go | 4 +++- cmd/options.go | 4 +++- cmd/permissions_center.go | 6 +++++- cmd/permissions_center_test.go | 4 ++++ cmd/schema.go | 7 ++++--- internal/config/settings.go | 4 +++- internal/config/settings_test.go | 8 ++++++++ internal/engine/permission_service.go | 9 ++++++++- internal/engine/safety/permission_engine.go | 11 ++++++----- 9 files changed, 44 insertions(+), 13 deletions(-) diff --git a/cmd/chat_update.go b/cmd/chat_update.go index 0b25978b..e2b2147e 100644 --- a/cmd/chat_update.go +++ b/cmd/chat_update.go @@ -460,6 +460,7 @@ func (m chatModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if chosen != nil && m.session != nil { m.session.PermSvc().SetAutonomy(chosen.Level) m.settings.Autonomy = permissionTierSettingValue(chosen.Level) + m.settings.AutonomyExplicit = true m.messages = append(m.messages, displayMsg{role: "system", content: fmt.Sprintf("Autonomy tier → %s\nBehavior: %s", chosen.Name, chosen.Description)}) } m.viewDirty = true @@ -878,6 +879,7 @@ func (m chatModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { nextTier = DefaultContainerAutonomy } m.session.PermSvc().SetAutonomy(nextTier) + m.settings.AutonomyExplicit = true m.invalidateConnStatus() m.messages = append(m.messages, displayMsg{ role: "warning", @@ -1422,7 +1424,7 @@ func (m chatModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } } if msg.ready && m.session != nil { - if m.session.PermSvc().Autonomy() == 0 { + if m.session.PermSvc().Autonomy() == 0 && !m.session.PermSvc().AutonomyExplicit() { m.session.PermSvc().SetAutonomy(DefaultContainerAutonomy) } m.invalidateConnStatus() diff --git a/cmd/options.go b/cmd/options.go index aaecf33f..82dbc20c 100644 --- a/cmd/options.go +++ b/cmd/options.go @@ -342,7 +342,9 @@ func configureSessionStartup(sess *engine.Session, settings hawkconfig.Settings, } sess.EnsureAutoCompactor() - if lvl := autonomyFromSettings(settings.Autonomy); lvl != 0 { + if settings.AutonomyExplicit { + sess.PermSvc().SetAutonomy(engine.AutonomySupervised) + } else if lvl := autonomyFromSettings(settings.Autonomy); lvl != 0 { sess.PermSvc().SetAutonomy(lvl) } // CLI safety overrides saved settings: the explicit dangerous-skip flag diff --git a/cmd/permissions_center.go b/cmd/permissions_center.go index 1c27f472..0d674674 100644 --- a/cmd/permissions_center.go +++ b/cmd/permissions_center.go @@ -52,7 +52,7 @@ func effectivePermissionTier(sess *engine.Session) engine.AutonomyLevel { if perms == nil { return DefaultContainerAutonomy } - if perms.Autonomy() == 0 { + if perms.Autonomy() == 0 && !perms.AutonomyExplicit() { return DefaultContainerAutonomy } return perms.Autonomy() @@ -270,6 +270,7 @@ func savePermissionSettings(scope string, settings hawkconfig.Settings, level en scope = "global" } settings.Autonomy = permissionTierSettingValue(level) + settings.AutonomyExplicit = true settings.Sandbox = effectivePermissionSandbox(settings) settings.AllowedTools = dedupeStrings(settings.AllowedTools) settings.DisallowedTools = dedupeStrings(settings.DisallowedTools) @@ -283,6 +284,7 @@ func savePermissionSettings(scope string, settings hawkconfig.Settings, level en target.AllowedTools = append([]string{}, settings.AllowedTools...) target.DisallowedTools = append([]string{}, settings.DisallowedTools...) target.Autonomy = settings.Autonomy + target.AutonomyExplicit = true target.Sandbox = settings.Sandbox if err := hawkconfig.SaveGlobal(target); err != nil { return "", err @@ -300,6 +302,7 @@ func resetPermissionCenter(m *chatModel) { m.session.PermSvc().SetAutonomy(DefaultContainerAutonomy) m.session.PermSvc().SetSandboxMode(sandbox.ParseMode(defaultPermissionSandbox)) m.settings.Autonomy = permissionTierSettingValue(DefaultContainerAutonomy) + m.settings.AutonomyExplicit = true m.settings.Sandbox = defaultPermissionSandbox sandboxFlag = defaultPermissionSandbox m.settings.AutoAllow = nil @@ -338,6 +341,7 @@ func (m *chatModel) handleAutonomyCommand(parts []string) (chatModel, tea.Cmd) { } m.session.PermSvc().SetAutonomy(level) m.settings.Autonomy = permissionTierSettingValue(level) + m.settings.AutonomyExplicit = true m.messages = append(m.messages, displayMsg{role: "system", content: fmt.Sprintf("Autonomy tier → %s\nBehavior: %s", label, permissionBehaviorSummary(level))}) case "sandbox": if len(parts) < 3 { diff --git a/cmd/permissions_center_test.go b/cmd/permissions_center_test.go index 49955b1f..3d07c46a 100644 --- a/cmd/permissions_center_test.go +++ b/cmd/permissions_center_test.go @@ -109,6 +109,10 @@ func TestEffectivePermissionTier_ReadsThroughRealSession(t *testing.T) { if got := effectivePermissionTier(sess); got != engine.AutonomyFull { t.Fatalf("explicit Full: got %v, want %v", got, engine.AutonomyFull) } + sess.PermSvc().SetAutonomy(engine.AutonomySupervised) + if got := effectivePermissionTier(sess); got != engine.AutonomySupervised { + t.Fatalf("explicit Supervised: got %v, want %v", got, engine.AutonomySupervised) + } } func TestPermissionBehaviorSummary(t *testing.T) { diff --git a/cmd/schema.go b/cmd/schema.go index 1f5db744..1080cb54 100644 --- a/cmd/schema.go +++ b/cmd/schema.go @@ -48,9 +48,10 @@ var schemaCmd = &cobra.Command{ }, }, }, - "sandbox": map[string]interface{}{"type": "string", "enum": []string{"strict", "workspace", "off"}}, - "auto_commit": map[string]interface{}{"type": "boolean"}, - "autonomy": map[string]interface{}{"type": "integer", "minimum": 0, "maximum": 4}, + "sandbox": map[string]interface{}{"type": "string", "enum": []string{"strict", "workspace", "off"}}, + "auto_commit": map[string]interface{}{"type": "boolean"}, + "autonomy": map[string]interface{}{"type": "integer", "minimum": 0, "maximum": 4}, + "autonomy_explicit": map[string]interface{}{"type": "boolean"}, "attribution": map[string]interface{}{ "type": "object", "properties": map[string]interface{}{ diff --git a/internal/config/settings.go b/internal/config/settings.go index de694beb..d3050fc6 100644 --- a/internal/config/settings.go +++ b/internal/config/settings.go @@ -47,6 +47,7 @@ type Settings struct { Sandbox string `json:"sandbox,omitempty"` // sandbox mode: strict, workspace, off AutoCommit *bool `json:"auto_commit,omitempty"` // auto-commit file changes Autonomy int `json:"autonomy,omitempty"` // autonomy level 0-4 + AutonomyExplicit bool `json:"autonomy_explicit,omitempty"` // distinguishes persisted Supervised (0) from unset ModelRoles *routing.ModelRoles `json:"model_roles,omitempty"` // per-role model overrides AutoCompactThresholdPct int `json:"auto_compact_threshold_pct,omitempty"` // token % to trigger auto-compact (default 85) Frugal bool `json:"frugal,omitempty"` // aggressive cost optimization: cascade to cheap models, lower max_tokens, earlier compaction @@ -318,8 +319,9 @@ func MergeSettings(base, override Settings) Settings { if override.AutoCommit != nil { base.AutoCommit = override.AutoCommit } - if override.Autonomy != 0 { + if override.AutonomyExplicit || override.Autonomy != 0 { base.Autonomy = override.Autonomy + base.AutonomyExplicit = true } if override.AutoCompactThresholdPct > 0 { base.AutoCompactThresholdPct = override.AutoCompactThresholdPct diff --git a/internal/config/settings_test.go b/internal/config/settings_test.go index eaa26a17..4970178d 100644 --- a/internal/config/settings_test.go +++ b/internal/config/settings_test.go @@ -108,6 +108,14 @@ func TestMergeSettings_SandboxOverride(t *testing.T) { } } +func TestMergeSettings_PersistsExplicitSupervised(t *testing.T) { + base := Settings{Autonomy: 2, AutonomyExplicit: true} + merged := MergeSettings(base, Settings{AutonomyExplicit: true, Autonomy: 0}) + if merged.Autonomy != 0 || !merged.AutonomyExplicit { + t.Fatalf("merged autonomy = %d explicit=%v, want 0/true", merged.Autonomy, merged.AutonomyExplicit) + } +} + func TestMergeSettings_ModelRolesOverride(t *testing.T) { t.Parallel() base := Settings{} diff --git a/internal/engine/permission_service.go b/internal/engine/permission_service.go index fbac17e2..682435a2 100644 --- a/internal/engine/permission_service.go +++ b/internal/engine/permission_service.go @@ -129,7 +129,10 @@ func (s *PermissionService) SetAllowedDirs(dirs []string) { s.allowedDirs = dirs // SetAutonomy sets the agent's autonomy level. Writes directly to the // underlying PermissionEngine — the same field CheckTool reads — rather // than a separate shadow field, so the change actually takes effect. -func (s *PermissionService) SetAutonomy(level AutonomyLevel) { s.perm.Autonomy = level } +func (s *PermissionService) SetAutonomy(level AutonomyLevel) { + s.perm.Autonomy = level + s.perm.AutonomyExplicit = true +} // SetSpecStage sets the independent spec-workflow stage. Also writes // directly to the engine, same reasoning as SetAutonomy. @@ -169,6 +172,10 @@ func (s *PermissionService) AllowedDirs() []string { return s.allowedDirs } // Autonomy returns the autonomy level. func (s *PermissionService) Autonomy() AutonomyLevel { return s.perm.Autonomy } +// AutonomyExplicit reports whether the session selected or loaded a tier, +// including Supervised (which numerically shares the zero value). +func (s *PermissionService) AutonomyExplicit() bool { return s.perm.AutonomyExplicit } + // SpecStage returns the active spec-workflow stage. func (s *PermissionService) SpecStage() SpecStage { return s.perm.Stage } diff --git a/internal/engine/safety/permission_engine.go b/internal/engine/safety/permission_engine.go index b518b158..9dabd77c 100644 --- a/internal/engine/safety/permission_engine.go +++ b/internal/engine/safety/permission_engine.go @@ -33,11 +33,12 @@ const ( // PermissionEngine encapsulates all permission-checking logic. // Extracted from Session to keep the god object lean. type PermissionEngine struct { - Memory *PermissionMemory - AutoMode *permissions.AutoModeState - Classifier *permissions.Classifier - BypassKill *permissions.BypassKillswitch - Autonomy AutonomyLevel + Memory *PermissionMemory + AutoMode *permissions.AutoModeState + Classifier *permissions.Classifier + BypassKill *permissions.BypassKillswitch + Autonomy AutonomyLevel + AutonomyExplicit bool // SandboxMode controls filesystem/process policy for tool execution. It is // deliberately separate from Autonomy: autonomy decides whether a user // prompt is needed, while the sandbox decides what the tool may actually do. From dc605a6ad29cb5f1a283f57f77a44e07d1c79ef1 Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Sat, 1 Aug 2026 19:38:12 +0530 Subject: [PATCH 04/49] feat: add structured permission decisions --- internal/engine/permission_service.go | 25 +++- internal/engine/safety/permission_engine.go | 137 +++++++++++++++--- .../engine/safety/permission_engine_test.go | 23 +++ 3 files changed, 158 insertions(+), 27 deletions(-) diff --git a/internal/engine/permission_service.go b/internal/engine/permission_service.go index 682435a2..a9c9bbb3 100644 --- a/internal/engine/permission_service.go +++ b/internal/engine/permission_service.go @@ -4,6 +4,7 @@ import ( "context" "fmt" + "github.com/GrayCodeAI/hawk/internal/engine/safety" "github.com/GrayCodeAI/hawk/internal/observability/logger" "github.com/GrayCodeAI/hawk/internal/permissions" "github.com/GrayCodeAI/hawk/internal/sandbox" @@ -82,13 +83,29 @@ func (s *PermissionService) Engine() *PermissionEngine { return s.perm } // The caller (engine/stream_tool_exec.go) handles the tool_result // event emission and the post-call side effects. func (s *PermissionService) CheckTool(ctx context.Context, info ToolCallInfo) (bool, string) { - granted, denyMsg := s.perm.CheckTool(ctx, info) - if !granted { + d := s.CheckToolDecision(ctx, info) + if d.Outcome != safety.DecisionAllow { s.log.Warn("permission denied", map[string]interface{}{ - "tool": info.Name, + "tool": info.Name, + "reason": string(d.Reason), }) } - return granted, denyMsg + return d.Outcome == safety.DecisionAllow, d.Message +} + +// CheckToolDecision evaluates a request and exposes stable decision metadata. +func (s *PermissionService) CheckToolDecision(ctx context.Context, info ToolCallInfo) safety.Decision { + return s.perm.CheckToolDecision(ctx, info) +} + +// PolicySnapshot returns the scalar policy used for a single request. +func (s *PermissionService) PolicySnapshot() safety.PolicySnapshot { + return s.perm.Snapshot() +} + +// CheckToolSnapshot evaluates a request against a previously captured policy. +func (s *PermissionService) CheckToolSnapshot(ctx context.Context, info ToolCallInfo, snapshot safety.PolicySnapshot) safety.Decision { + return s.perm.CheckToolSnapshot(ctx, info, snapshot) } // CheckApproval runs the human-in-the-loop gate on high-risk actions. diff --git a/internal/engine/safety/permission_engine.go b/internal/engine/safety/permission_engine.go index 9dabd77c..f4c22b20 100644 --- a/internal/engine/safety/permission_engine.go +++ b/internal/engine/safety/permission_engine.go @@ -66,6 +66,64 @@ type PermissionEngine struct { PromptFn func(PermissionRequest) // callback to ask user } +// DecisionOutcome is the result of evaluating a tool request. +type DecisionOutcome string + +const ( + DecisionAllow DecisionOutcome = "allow" + DecisionAsk DecisionOutcome = "ask" + DecisionDeny DecisionOutcome = "deny" +) + +// DecisionReason is stable metadata for callers, telemetry, and tests. The +// human-readable message remains available through Decision.Message. +type DecisionReason string + +const ( + ReasonNone DecisionReason = "" + ReasonDryRun DecisionReason = "dry_run" + ReasonHookDenied DecisionReason = "hook_denied" + ReasonSandbox DecisionReason = "sandbox" + ReasonSpecGate DecisionReason = "spec_gate" + ReasonRuleDenied DecisionReason = "rule_denied" + ReasonAutoModeDenied DecisionReason = "auto_mode_denied" + ReasonRuleAllowed DecisionReason = "rule_allowed" + ReasonAutoModeAllowed DecisionReason = "auto_mode_allowed" + ReasonAutonomy DecisionReason = "autonomy" + ReasonBypass DecisionReason = "bypass" + ReasonClassifiedSafe DecisionReason = "classified_safe" + ReasonUserPrompt DecisionReason = "user_prompt" + ReasonPromptUnavailable DecisionReason = "prompt_unavailable" +) + +// Decision is the structured result of a permission evaluation. +type Decision struct { + Outcome DecisionOutcome + Reason DecisionReason + Message string +} + +// PolicySnapshot captures the scalar policy state for one tool evaluation. +// Callers can use it to ensure a request is evaluated consistently even when +// the live session settings change while the tool is running. +type PolicySnapshot struct { + Autonomy AutonomyLevel + AutonomyExplicit bool + SandboxMode sandbox.Mode + Stage SpecStage + DryRun bool + SpecSlug string + Phase int + Phases int +} + +// Snapshot returns a copy of the engine's request-relevant scalar policy. +func (pe *PermissionEngine) Snapshot() PolicySnapshot { + return PolicySnapshot{Autonomy: pe.Autonomy, AutonomyExplicit: pe.AutonomyExplicit, + SandboxMode: pe.SandboxMode, Stage: pe.Stage, DryRun: pe.DryRun, + SpecSlug: pe.SpecSlug, Phase: pe.Phase, Phases: pe.Phases} +} + // NewPermissionEngine creates a PermissionEngine with sensible defaults. func NewPermissionEngine() *PermissionEngine { return &PermissionEngine{ @@ -86,8 +144,31 @@ func NewPermissionEngine() *PermissionEngine { // 3. Spec-stage gate // 4. Autonomy / bypass / classifier / auto-mode / memory / user prompt func (pe *PermissionEngine) CheckTool(ctx context.Context, tc ToolCallInfo) (bool, string) { + d := pe.CheckToolDecision(ctx, tc) + return d.Outcome == DecisionAllow, d.Message +} + +// CheckToolSnapshot evaluates a request using the supplied immutable scalar +// policy snapshot. Mutable rule stores and the prompt callback remain owned by +// the engine so remembered decisions and user approval keep their semantics. +func (pe *PermissionEngine) CheckToolSnapshot(ctx context.Context, tc ToolCallInfo, snapshot PolicySnapshot) Decision { + clone := *pe + clone.Autonomy = snapshot.Autonomy + clone.AutonomyExplicit = snapshot.AutonomyExplicit + clone.SandboxMode = snapshot.SandboxMode + clone.Stage = snapshot.Stage + clone.DryRun = snapshot.DryRun + clone.SpecSlug = snapshot.SpecSlug + clone.Phase = snapshot.Phase + clone.Phases = snapshot.Phases + return clone.CheckToolDecision(ctx, tc) +} + +// CheckToolDecision returns structured policy metadata while preserving the +// existing permission behavior and human-readable messages. +func (pe *PermissionEngine) CheckToolDecision(ctx context.Context, tc ToolCallInfo) Decision { if pe.DryRun { - return false, "dry-run: tool execution disabled" + return Decision{Outcome: DecisionDeny, Reason: ReasonDryRun, Message: "dry-run: tool execution disabled"} } toolName := canonicalToolName(tc.Name) @@ -96,13 +177,13 @@ func (pe *PermissionEngine) CheckTool(ctx context.Context, tc ToolCallInfo) (boo // return allow/nil do not grant permission by themselves; they only // short-circuit when ActionDeny (or equivalent). if denied, reason := pe.checkPreToolHooks(tc); denied { - return false, reason + return Decision{Outcome: DecisionDeny, Reason: ReasonHookDenied, Message: reason} } // Strict sandbox mode is read-only. This check is independent of autonomy // and the spec workflow so neither can turn a read-only sandbox into a // write or process-execution path. if pe.SandboxMode == sandbox.ModeStrict && !pe.strictToolAllowed(tc) { - return false, "Sandbox strict mode: tool execution is read-only." + return Decision{Outcome: DecisionDeny, Reason: ReasonSandbox, Message: "Sandbox strict mode: tool execution is read-only."} } // Spec-stage gate — independent of trust tier, so no autonomy level can @@ -112,23 +193,23 @@ func (pe *PermissionEngine) CheckTool(ctx context.Context, tc ToolCallInfo) (boo switch toolName { case "Specify", "Plan", "Tasks", "AskUserQuestion", "SpecStatus", "SpecEdit", "SpecList", "SpecReset", "SpecConfig", "Clarify", "Analyze", "Checklist", "Constitution", "Converge": if !pe.specToolAllowed(toolName) { - return false, pe.specStageReason(toolName) + return Decision{Outcome: DecisionDeny, Reason: ReasonSpecGate, Message: pe.specStageReason(toolName)} } - return true, "" + return Decision{Outcome: DecisionAllow, Reason: ReasonSpecGate} case "ApproveImplementation": if pe.Stage != SpecStageTasks { - return false, "Spec stage active: ApproveImplementation is available only after Tasks completes." + return Decision{Outcome: DecisionDeny, Reason: ReasonSpecGate, Message: "Spec stage active: ApproveImplementation is available only after Tasks completes."} } // Always a real human decision — never auto-allowed by tier, // bypass-kill, or auto-mode, unlike everything below. Show the // actual spec/plan/tasks content in the prompt rather than a // bare tool name, so approval isn't a blind yes/no. - return pe.promptUserWithSummary(ctx, tc, specApprovalSummary(pe.SpecSlug)) + return pe.promptDecisionWithSummary(ctx, tc, specApprovalSummary(pe.SpecSlug)) default: if tool.IsReadOnly(tc.Name) { - return true, "" + return Decision{Outcome: DecisionAllow, Reason: ReasonSpecGate} } - return false, "Spec stage active: only Specify/Plan/Tasks (and reads) are allowed until ApproveImplementation." + return Decision{Outcome: DecisionDeny, Reason: ReasonSpecGate, Message: "Spec stage active: only Specify/Plan/Tasks (and reads) are allowed until ApproveImplementation."} } } @@ -146,32 +227,32 @@ func (pe *PermissionEngine) CheckTool(ctx context.Context, tc ToolCallInfo) (boo } } if memoryDecision != nil && !*memoryDecision { - return false, "Permission denied (rule)." + return Decision{Outcome: DecisionDeny, Reason: ReasonRuleDenied, Message: "Permission denied (rule)."} } if autoDecision != nil && !*autoDecision { - return false, "Permission denied (auto-mode)." + return Decision{Outcome: DecisionDeny, Reason: ReasonAutoModeDenied, Message: "Permission denied (auto-mode)."} } if memoryDecision != nil && *memoryDecision { - return true, "" + return Decision{Outcome: DecisionAllow, Reason: ReasonRuleAllowed} } if autoDecision != nil && *autoDecision { - return true, "" + return Decision{Outcome: DecisionAllow, Reason: ReasonAutoModeAllowed} } isSafe := !ToolNeedsPermission(tc.Name, tc.Args) autoCfg := PresetConfig(pe.Autonomy) if !autoCfg.NeedsPermission(tc.Name, isSafe) { - return true, "" + return Decision{Outcome: DecisionAllow, Reason: ReasonAutonomy} } if pe.BypassKill.IsEnabled() { - return true, "" + return Decision{Outcome: DecisionAllow, Reason: ReasonBypass} } if pe.Classifier != nil && tc.Name == "Bash" { if pe.Classifier.Classify(summary) == "safe" { - return true, "" + return Decision{Outcome: DecisionAllow, Reason: ReasonClassifiedSafe} } } - return pe.promptUser(ctx, tc) + return pe.promptDecision(ctx, tc) } func (pe *PermissionEngine) specToolAllowed(toolName string) bool { @@ -249,15 +330,25 @@ func (pe *PermissionEngine) checkPreToolHooks(tc ToolCallInfo) (bool, string) { // promptUser blocks on PromptFn, asking the user to approve tc, using the // generic tool summary. func (pe *PermissionEngine) promptUser(ctx context.Context, tc ToolCallInfo) (bool, string) { - return pe.promptUserWithSummary(ctx, tc, ToolSummary(tc.Name, tc.Args)) + d := pe.promptDecision(ctx, tc) + return d.Outcome == DecisionAllow, d.Message } // promptUserWithSummary is promptUser with a caller-supplied summary, // letting ApproveImplementation show spec/plan/tasks content instead of // the generic (and, since it takes no args, empty) tool summary. func (pe *PermissionEngine) promptUserWithSummary(ctx context.Context, tc ToolCallInfo, summary string) (bool, string) { + d := pe.promptDecisionWithSummary(ctx, tc, summary) + return d.Outcome == DecisionAllow, d.Message +} + +func (pe *PermissionEngine) promptDecision(ctx context.Context, tc ToolCallInfo) Decision { + return pe.promptDecisionWithSummary(ctx, tc, ToolSummary(tc.Name, tc.Args)) +} + +func (pe *PermissionEngine) promptDecisionWithSummary(ctx context.Context, tc ToolCallInfo, summary string) Decision { if pe.PromptFn == nil { - return false, "Permission prompt unavailable." + return Decision{Outcome: DecisionDeny, Reason: ReasonPromptUnavailable, Message: "Permission prompt unavailable."} } resp := make(chan bool, 1) pe.PromptFn(PermissionRequest{ @@ -271,13 +362,13 @@ func (pe *PermissionEngine) promptUserWithSummary(ctx context.Context, tc ToolCa select { case allowed := <-resp: if !allowed { - return false, "Permission denied by user." + return Decision{Outcome: DecisionDeny, Reason: ReasonUserPrompt, Message: "Permission denied by user."} } - return true, "" + return Decision{Outcome: DecisionAllow, Reason: ReasonUserPrompt} case <-ctx.Done(): - return false, "Permission prompt cancelled." + return Decision{Outcome: DecisionDeny, Reason: ReasonUserPrompt, Message: "Permission prompt cancelled."} case <-time.After(5 * time.Minute): - return false, "Permission prompt timed out." + return Decision{Outcome: DecisionDeny, Reason: ReasonUserPrompt, Message: "Permission prompt timed out."} } } diff --git a/internal/engine/safety/permission_engine_test.go b/internal/engine/safety/permission_engine_test.go index 532c275c..a26f6b43 100644 --- a/internal/engine/safety/permission_engine_test.go +++ b/internal/engine/safety/permission_engine_test.go @@ -120,6 +120,29 @@ func TestCheckTool_SpecStageImplementingUsesAutonomy(t *testing.T) { } } +func TestPermissionEngine_StructuredDecisionIncludesStableReason(t *testing.T) { + pe := NewPermissionEngine() + pe.DryRun = true + d := pe.CheckToolDecision(context.Background(), ToolCallInfo{Name: "Read"}) + if d.Outcome != DecisionDeny || d.Reason != ReasonDryRun { + t.Fatalf("decision = %#v, want deny/dry_run", d) + } + if d.Message == "" { + t.Fatal("structured decision should retain human-readable message") + } +} + +func TestPermissionEngine_SnapshotIsStableAfterLivePolicyChange(t *testing.T) { + pe := NewPermissionEngine() + pe.Autonomy = AutonomyYOLO + snapshot := pe.Snapshot() + pe.DryRun = true + d := pe.CheckToolSnapshot(context.Background(), ToolCallInfo{Name: "Read"}, snapshot) + if d.Outcome != DecisionAllow { + t.Fatalf("snapshot decision = %#v, want allow from captured policy", d) + } +} + // TestCheckTool_SpecStageNoneIgnoresGate verifies that outside of any spec // workflow (Stage == SpecStageNone), the spec gate does not apply at all and // autonomy-tier logic governs directly. From e7c6b45923b97a8a831ae8a0546b49f059b84903 Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Sat, 1 Aug 2026 19:44:23 +0530 Subject: [PATCH 05/49] feat: add capability-aware permission metadata --- internal/engine/permission_service.go | 10 ++- internal/engine/safety/capabilities.go | 80 +++++++++++++++++++++ internal/engine/safety/capabilities_test.go | 27 +++++++ internal/engine/safety/permission_engine.go | 26 +++++-- 4 files changed, 136 insertions(+), 7 deletions(-) create mode 100644 internal/engine/safety/capabilities.go create mode 100644 internal/engine/safety/capabilities_test.go diff --git a/internal/engine/permission_service.go b/internal/engine/permission_service.go index a9c9bbb3..81bd1ebf 100644 --- a/internal/engine/permission_service.go +++ b/internal/engine/permission_service.go @@ -149,18 +149,22 @@ func (s *PermissionService) SetAllowedDirs(dirs []string) { s.allowedDirs = dirs func (s *PermissionService) SetAutonomy(level AutonomyLevel) { s.perm.Autonomy = level s.perm.AutonomyExplicit = true + s.perm.Revision++ } // SetSpecStage sets the independent spec-workflow stage. Also writes // directly to the engine, same reasoning as SetAutonomy. -func (s *PermissionService) SetSpecStage(stage SpecStage) { s.perm.Stage = stage } +func (s *PermissionService) SetSpecStage(stage SpecStage) { s.perm.Stage = stage; s.perm.Revision++ } // SetDryRun toggles the global kill switch: when true, every tool call is // denied unconditionally, regardless of tier or spec stage. -func (s *PermissionService) SetDryRun(dryRun bool) { s.perm.DryRun = dryRun } +func (s *PermissionService) SetDryRun(dryRun bool) { s.perm.DryRun = dryRun; s.perm.Revision++ } // SetSandboxMode sets the OS/tool sandbox policy used for subsequent calls. -func (s *PermissionService) SetSandboxMode(mode sandbox.Mode) { s.perm.SandboxMode = mode } +func (s *PermissionService) SetSandboxMode(mode sandbox.Mode) { + s.perm.SandboxMode = mode + s.perm.Revision++ +} // SandboxMode reports the active OS/tool sandbox policy. func (s *PermissionService) SandboxMode() sandbox.Mode { return s.perm.SandboxMode } diff --git a/internal/engine/safety/capabilities.go b/internal/engine/safety/capabilities.go new file mode 100644 index 00000000..953bf6a3 --- /dev/null +++ b/internal/engine/safety/capabilities.go @@ -0,0 +1,80 @@ +package safety + +// Capability describes a concrete effect a tool may have. Policies should +// reason about capabilities instead of relying on tool-name allowlists. +type Capability string + +// RiskLevel is the default severity associated with a tool capability set. +type RiskLevel string + +const ( + RiskLow RiskLevel = "low" + RiskMedium RiskLevel = "medium" + RiskHigh RiskLevel = "high" +) + +const ( + CapabilityUnknown Capability = "unknown" + CapabilityFilesystemRead Capability = "filesystem.read" + CapabilityFilesystemWrite Capability = "filesystem.write" + CapabilityFilesystemDelete Capability = "filesystem.delete" + CapabilityProcessExecute Capability = "process.execute" + CapabilityNetworkAccess Capability = "network.access" + CapabilityCredentialsAccess Capability = "credentials.access" + CapabilityUserInteraction Capability = "user.interaction" + CapabilitySpecRead Capability = "spec.read" + CapabilitySpecWrite Capability = "spec.write" + CapabilitySpecApprove Capability = "spec.approve" + CapabilityConfigurationRead Capability = "configuration.read" + CapabilityConfigurationWrite Capability = "configuration.write" + CapabilityDestructive Capability = "destructive" +) + +// ToolPolicy is the declarative safety metadata for a canonical tool. +type ToolPolicy struct { + Name string + Capabilities []Capability + DefaultRisk RiskLevel +} + +var toolPolicies = map[string]ToolPolicy{ + "Read": {Name: "Read", Capabilities: []Capability{CapabilityFilesystemRead}, DefaultRisk: RiskLow}, + "Glob": {Name: "Glob", Capabilities: []Capability{CapabilityFilesystemRead}, DefaultRisk: RiskLow}, + "Grep": {Name: "Grep", Capabilities: []Capability{CapabilityFilesystemRead}, DefaultRisk: RiskLow}, + "LS": {Name: "LS", Capabilities: []Capability{CapabilityFilesystemRead}, DefaultRisk: RiskLow}, + "Bash": {Name: "Bash", Capabilities: []Capability{CapabilityProcessExecute}, DefaultRisk: RiskHigh}, + "Write": {Name: "Write", Capabilities: []Capability{CapabilityFilesystemWrite}, DefaultRisk: RiskMedium}, + "Edit": {Name: "Edit", Capabilities: []Capability{CapabilityFilesystemWrite}, DefaultRisk: RiskMedium}, + "Delete": {Name: "Delete", Capabilities: []Capability{CapabilityFilesystemDelete, CapabilityDestructive}, DefaultRisk: RiskHigh}, + "AskUserQuestion": {Name: "AskUserQuestion", Capabilities: []Capability{CapabilityUserInteraction}, DefaultRisk: RiskLow}, + "Specify": {Name: "Specify", Capabilities: []Capability{CapabilitySpecWrite}, DefaultRisk: RiskLow}, + "Plan": {Name: "Plan", Capabilities: []Capability{CapabilitySpecRead, CapabilitySpecWrite}, DefaultRisk: RiskLow}, + "Tasks": {Name: "Tasks", Capabilities: []Capability{CapabilitySpecRead, CapabilitySpecWrite}, DefaultRisk: RiskLow}, + "ApproveImplementation": {Name: "ApproveImplementation", Capabilities: []Capability{CapabilitySpecApprove, CapabilityUserInteraction}, DefaultRisk: RiskHigh}, + "SpecStatus": {Name: "SpecStatus", Capabilities: []Capability{CapabilitySpecRead}, DefaultRisk: RiskLow}, + "SpecList": {Name: "SpecList", Capabilities: []Capability{CapabilitySpecRead}, DefaultRisk: RiskLow}, + "SpecEdit": {Name: "SpecEdit", Capabilities: []Capability{CapabilitySpecWrite}, DefaultRisk: RiskMedium}, + "SpecReset": {Name: "SpecReset", Capabilities: []Capability{CapabilitySpecWrite}, DefaultRisk: RiskMedium}, + "SpecConfig": {Name: "SpecConfig", Capabilities: []Capability{CapabilityConfigurationRead, CapabilityConfigurationWrite}, DefaultRisk: RiskMedium}, + "Clarify": {Name: "Clarify", Capabilities: []Capability{CapabilitySpecRead, CapabilityUserInteraction}, DefaultRisk: RiskLow}, + "Analyze": {Name: "Analyze", Capabilities: []Capability{CapabilitySpecRead}, DefaultRisk: RiskLow}, + "Checklist": {Name: "Checklist", Capabilities: []Capability{CapabilitySpecRead}, DefaultRisk: RiskLow}, + "Constitution": {Name: "Constitution", Capabilities: []Capability{CapabilitySpecRead, CapabilitySpecWrite}, DefaultRisk: RiskMedium}, + "Converge": {Name: "Converge", Capabilities: []Capability{CapabilitySpecRead, CapabilitySpecWrite}, DefaultRisk: RiskMedium}, +} + +// ToolPolicyFor returns a copy of the policy for a canonical tool. Unknown +// tools are explicitly represented and therefore fail closed in strict policy. +func ToolPolicyFor(name string) ToolPolicy { + canonical := canonicalToolName(name) + if policy, ok := toolPolicies[canonical]; ok { + policy.Capabilities = append([]Capability(nil), policy.Capabilities...) + return policy + } + return ToolPolicy{Name: canonical, Capabilities: []Capability{CapabilityUnknown}, DefaultRisk: RiskHigh} +} + +// ToolCapabilities returns a defensive copy of a tool's capabilities. +func ToolCapabilities(name string) []Capability { + return ToolPolicyFor(name).Capabilities +} diff --git a/internal/engine/safety/capabilities_test.go b/internal/engine/safety/capabilities_test.go new file mode 100644 index 00000000..8500af6c --- /dev/null +++ b/internal/engine/safety/capabilities_test.go @@ -0,0 +1,27 @@ +package safety + +import "testing" + +func TestToolPolicyForReturnsDefensiveCapabilities(t *testing.T) { + policy := ToolPolicyFor("Write") + if policy.Name != "Write" || policy.DefaultRisk != RiskMedium { + t.Fatalf("unexpected Write policy: %#v", policy) + } + policy.Capabilities[0] = CapabilityUnknown + if ToolPolicyFor("Write").Capabilities[0] != CapabilityFilesystemWrite { + t.Fatal("ToolPolicyFor returned mutable registry state") + } +} + +func TestToolPolicyForUnknownFailsClosed(t *testing.T) { + policy := ToolPolicyFor("plugin_future_tool") + if policy.DefaultRisk != RiskHigh || len(policy.Capabilities) != 1 || policy.Capabilities[0] != CapabilityUnknown { + t.Fatalf("unexpected unknown policy: %#v", policy) + } +} + +func TestToolPolicyForCanonicalAliases(t *testing.T) { + if got := ToolPolicyFor("bash"); got.Name != "Bash" || got.Capabilities[0] != CapabilityProcessExecute { + t.Fatalf("bash alias did not resolve: %#v", got) + } +} diff --git a/internal/engine/safety/permission_engine.go b/internal/engine/safety/permission_engine.go index f4c22b20..0ce1fa26 100644 --- a/internal/engine/safety/permission_engine.go +++ b/internal/engine/safety/permission_engine.go @@ -39,6 +39,9 @@ type PermissionEngine struct { BypassKill *permissions.BypassKillswitch Autonomy AutonomyLevel AutonomyExplicit bool + // Revision increments when policy configuration is replaced. It is + // attached to decisions so audit consumers can correlate evaluations. + Revision uint64 // SandboxMode controls filesystem/process policy for tool execution. It is // deliberately separate from Autonomy: autonomy decides whether a user // prompt is needed, while the sandbox decides what the tool may actually do. @@ -98,9 +101,13 @@ const ( // Decision is the structured result of a permission evaluation. type Decision struct { - Outcome DecisionOutcome - Reason DecisionReason - Message string + Outcome DecisionOutcome + Reason DecisionReason + Message string + MatchedRule string + Capabilities []Capability + Risk RiskLevel + Revision uint64 } // PolicySnapshot captures the scalar policy state for one tool evaluation. @@ -115,13 +122,14 @@ type PolicySnapshot struct { SpecSlug string Phase int Phases int + Revision uint64 } // Snapshot returns a copy of the engine's request-relevant scalar policy. func (pe *PermissionEngine) Snapshot() PolicySnapshot { return PolicySnapshot{Autonomy: pe.Autonomy, AutonomyExplicit: pe.AutonomyExplicit, SandboxMode: pe.SandboxMode, Stage: pe.Stage, DryRun: pe.DryRun, - SpecSlug: pe.SpecSlug, Phase: pe.Phase, Phases: pe.Phases} + SpecSlug: pe.SpecSlug, Phase: pe.Phase, Phases: pe.Phases, Revision: pe.Revision} } // NewPermissionEngine creates a PermissionEngine with sensible defaults. @@ -161,12 +169,22 @@ func (pe *PermissionEngine) CheckToolSnapshot(ctx context.Context, tc ToolCallIn clone.SpecSlug = snapshot.SpecSlug clone.Phase = snapshot.Phase clone.Phases = snapshot.Phases + clone.Revision = snapshot.Revision return clone.CheckToolDecision(ctx, tc) } // CheckToolDecision returns structured policy metadata while preserving the // existing permission behavior and human-readable messages. func (pe *PermissionEngine) CheckToolDecision(ctx context.Context, tc ToolCallInfo) Decision { + d := pe.evaluateToolDecision(ctx, tc) + policy := ToolPolicyFor(tc.Name) + d.Capabilities = policy.Capabilities + d.Risk = policy.DefaultRisk + d.Revision = pe.Revision + return d +} + +func (pe *PermissionEngine) evaluateToolDecision(ctx context.Context, tc ToolCallInfo) Decision { if pe.DryRun { return Decision{Outcome: DecisionDeny, Reason: ReasonDryRun, Message: "dry-run: tool execution disabled"} } From bdb1bbc87c4e9068ef0ac7b3623fbc7db10aefad Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Sat, 1 Aug 2026 19:45:48 +0530 Subject: [PATCH 06/49] feat: snapshot permission rules per request --- internal/engine/permission_service.go | 4 ++- internal/engine/safety/permission.go | 25 +++++++++++++++++++ internal/engine/safety/permission_engine.go | 10 +++++++- .../engine/safety/permission_engine_test.go | 11 ++++++++ 4 files changed, 48 insertions(+), 2 deletions(-) diff --git a/internal/engine/permission_service.go b/internal/engine/permission_service.go index 81bd1ebf..708291cd 100644 --- a/internal/engine/permission_service.go +++ b/internal/engine/permission_service.go @@ -100,7 +100,9 @@ func (s *PermissionService) CheckToolDecision(ctx context.Context, info ToolCall // PolicySnapshot returns the scalar policy used for a single request. func (s *PermissionService) PolicySnapshot() safety.PolicySnapshot { - return s.perm.Snapshot() + snapshot := s.perm.Snapshot() + snapshot.AllowedDirs = append([]string(nil), s.allowedDirs...) + return snapshot } // CheckToolSnapshot evaluates a request against a previously captured policy. diff --git a/internal/engine/safety/permission.go b/internal/engine/safety/permission.go index 002d270a..ace73110 100644 --- a/internal/engine/safety/permission.go +++ b/internal/engine/safety/permission.go @@ -23,10 +23,35 @@ type PermissionMemory struct { allowAll map[string]bool // tool names that are always allowed } +// RuleSnapshot is an immutable copy of remembered permission rules. +type RuleSnapshot struct { + AllowRules []string + DenyRules []string + AllowAll map[string]bool +} + func NewPermissionMemory() *PermissionMemory { return &PermissionMemory{allowAll: make(map[string]bool)} } +// Snapshot returns a deep copy that can safely be used by one evaluation. +func (pm *PermissionMemory) Snapshot() RuleSnapshot { + if pm == nil { + return RuleSnapshot{AllowAll: map[string]bool{}} + } + pm.mu.RLock() + defer pm.mu.RUnlock() + allowAll := make(map[string]bool, len(pm.allowAll)) + for name, allowed := range pm.allowAll { + allowAll[name] = allowed + } + return RuleSnapshot{AllowRules: append([]string(nil), pm.allowRules...), DenyRules: append([]string(nil), pm.denyRules...), AllowAll: allowAll} +} + +func permissionMemoryFromSnapshot(snapshot RuleSnapshot) *PermissionMemory { + return &PermissionMemory{allowRules: append([]string(nil), snapshot.AllowRules...), denyRules: append([]string(nil), snapshot.DenyRules...), allowAll: snapshot.AllowAll} +} + // Reset clears all allow/deny memory so the active rule set can be rebuilt. func (pm *PermissionMemory) Reset() { pm.mu.Lock() diff --git a/internal/engine/safety/permission_engine.go b/internal/engine/safety/permission_engine.go index 0ce1fa26..0705f0ba 100644 --- a/internal/engine/safety/permission_engine.go +++ b/internal/engine/safety/permission_engine.go @@ -123,13 +123,20 @@ type PolicySnapshot struct { Phase int Phases int Revision uint64 + Rules RuleSnapshot + AllowedDirs []string } // Snapshot returns a copy of the engine's request-relevant scalar policy. func (pe *PermissionEngine) Snapshot() PolicySnapshot { + var rules RuleSnapshot + if pe.Memory != nil { + rules = pe.Memory.Snapshot() + } return PolicySnapshot{Autonomy: pe.Autonomy, AutonomyExplicit: pe.AutonomyExplicit, SandboxMode: pe.SandboxMode, Stage: pe.Stage, DryRun: pe.DryRun, - SpecSlug: pe.SpecSlug, Phase: pe.Phase, Phases: pe.Phases, Revision: pe.Revision} + SpecSlug: pe.SpecSlug, Phase: pe.Phase, Phases: pe.Phases, Revision: pe.Revision, + Rules: rules} } // NewPermissionEngine creates a PermissionEngine with sensible defaults. @@ -170,6 +177,7 @@ func (pe *PermissionEngine) CheckToolSnapshot(ctx context.Context, tc ToolCallIn clone.Phase = snapshot.Phase clone.Phases = snapshot.Phases clone.Revision = snapshot.Revision + clone.Memory = permissionMemoryFromSnapshot(snapshot.Rules) return clone.CheckToolDecision(ctx, tc) } diff --git a/internal/engine/safety/permission_engine_test.go b/internal/engine/safety/permission_engine_test.go index a26f6b43..30ca272f 100644 --- a/internal/engine/safety/permission_engine_test.go +++ b/internal/engine/safety/permission_engine_test.go @@ -143,6 +143,17 @@ func TestPermissionEngine_SnapshotIsStableAfterLivePolicyChange(t *testing.T) { } } +func TestPermissionEngine_SnapshotCapturesRememberedRules(t *testing.T) { + pe := NewPermissionEngine() + pe.Memory.AlwaysDeny("Write") + snapshot := pe.Snapshot() + pe.Memory.Reset() + d := pe.CheckToolSnapshot(context.Background(), ToolCallInfo{Name: "Write"}, snapshot) + if d.Outcome != DecisionDeny || d.Reason != ReasonRuleDenied { + t.Fatalf("snapshot decision = %#v, want remembered deny", d) + } +} + // TestCheckTool_SpecStageNoneIgnoresGate verifies that outside of any spec // workflow (Stage == SpecStageNone), the spec gate does not apply at all and // autonomy-tier logic governs directly. From f4ee21f408f4d7c861fbdca7b1941e75fd44e06d Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Sat, 1 Aug 2026 19:46:56 +0530 Subject: [PATCH 07/49] refactor: validate spec workflow transitions --- internal/engine/safety/permission_engine.go | 16 +++---- internal/engine/safety/spec_workflow.go | 50 ++++++++++++++++++++ internal/engine/safety/spec_workflow_test.go | 42 ++++++++++++++++ 3 files changed, 99 insertions(+), 9 deletions(-) create mode 100644 internal/engine/safety/spec_workflow.go create mode 100644 internal/engine/safety/spec_workflow_test.go diff --git a/internal/engine/safety/permission_engine.go b/internal/engine/safety/permission_engine.go index 0705f0ba..70070b55 100644 --- a/internal/engine/safety/permission_engine.go +++ b/internal/engine/safety/permission_engine.go @@ -475,15 +475,13 @@ func (pe *PermissionEngine) PhaseProgress() string { // model just executed successfully. Called by stream_tool_exec.go — plays // the same role ApplyToolState played for the old Plan Mode. func (pe *PermissionEngine) AdvanceSpecStage(name string) { - switch canonicalToolName(name) { - case "Specify": - pe.Stage = SpecStageSpecify - case "Plan": - pe.Stage = SpecStagePlan - case "Tasks": - pe.Stage = SpecStageTasks - case "ApproveImplementation": - pe.Stage = SpecStageImplementing + w := SpecWorkflow{Stage: pe.Stage, Slug: pe.SpecSlug} + if err := w.Transition(name, pe.SpecSlug); err != nil { + return + } + pe.Stage, pe.SpecSlug = w.Stage, w.Slug + pe.Revision++ + if canonicalToolName(name) == "ApproveImplementation" { pe.Phase = 1 pe.Phases = detectPhases(pe.SpecSlug) } diff --git a/internal/engine/safety/spec_workflow.go b/internal/engine/safety/spec_workflow.go new file mode 100644 index 00000000..54df272a --- /dev/null +++ b/internal/engine/safety/spec_workflow.go @@ -0,0 +1,50 @@ +package safety + +import "fmt" + +// SpecWorkflow is the validated state machine for spec-driven development. +// It intentionally contains no filesystem or UI concerns. +type SpecWorkflow struct { + Stage SpecStage + Slug string +} + +// Transition validates and applies a successful workflow tool transition. +// The state is changed only after all preconditions pass. +func (w *SpecWorkflow) Transition(toolName, slug string) error { + switch canonicalToolName(toolName) { + case "Specify": + if w.Stage != SpecStageNone && w.Stage != SpecStageSpecify { + return fmt.Errorf("Specify is unavailable at spec stage %d", w.Stage) + } + if slug == "" { + return fmt.Errorf("Specify requires a non-empty spec slug") + } + w.Slug = slug + w.Stage = SpecStageSpecify + case "Plan": + if w.Stage != SpecStageSpecify || w.Slug == "" { + return fmt.Errorf("Plan requires a completed Specify stage") + } + w.Stage = SpecStagePlan + case "Tasks": + if w.Stage != SpecStagePlan { + return fmt.Errorf("Tasks requires a completed Plan stage") + } + w.Stage = SpecStageTasks + case "ApproveImplementation": + if w.Stage != SpecStageTasks { + return fmt.Errorf("ApproveImplementation requires a completed Tasks stage") + } + w.Stage = SpecStageImplementing + default: + return nil + } + return nil +} + +// Reset clears all workflow state, including the slug. +func (w *SpecWorkflow) Reset() { + w.Stage = SpecStageNone + w.Slug = "" +} diff --git a/internal/engine/safety/spec_workflow_test.go b/internal/engine/safety/spec_workflow_test.go new file mode 100644 index 00000000..6f31afeb --- /dev/null +++ b/internal/engine/safety/spec_workflow_test.go @@ -0,0 +1,42 @@ +package safety + +import "testing" + +func TestSpecWorkflowTransitionsInOrder(t *testing.T) { + w := SpecWorkflow{} + for _, tc := range []struct { + name string + slug string + stage SpecStage + }{ + {"Specify", "demo", SpecStageSpecify}, + {"Plan", "", SpecStagePlan}, + {"Tasks", "", SpecStageTasks}, + {"ApproveImplementation", "", SpecStageImplementing}, + } { + if err := w.Transition(tc.name, tc.slug); err != nil { + t.Fatalf("Transition(%q): %v", tc.name, err) + } + if w.Stage != tc.stage { + t.Fatalf("after %q stage = %v, want %v", tc.name, w.Stage, tc.stage) + } + } +} + +func TestSpecWorkflowRejectsInvalidTransitionWithoutMutation(t *testing.T) { + w := SpecWorkflow{Stage: SpecStageSpecify, Slug: "demo"} + if err := w.Transition("Tasks", ""); err == nil { + t.Fatal("expected Tasks before Plan to fail") + } + if w.Stage != SpecStageSpecify || w.Slug != "demo" { + t.Fatalf("invalid transition mutated workflow: %#v", w) + } +} + +func TestSpecWorkflowResetClearsSlugAndStage(t *testing.T) { + w := SpecWorkflow{Stage: SpecStageImplementing, Slug: "demo"} + w.Reset() + if w.Stage != SpecStageNone || w.Slug != "" { + t.Fatalf("reset result = %#v", w) + } +} From 54549a58b94e45e45e9b976731e911ada99f803c Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Sat, 1 Aug 2026 19:49:28 +0530 Subject: [PATCH 08/49] feat: bound child policies and version settings --- internal/config/settings.go | 17 ++++++++++++ internal/config/settings_test.go | 14 ++++++++++ internal/engine/agent_session_tool.go | 10 +++----- internal/engine/permission_service.go | 30 ++++++++++++++++++++++ internal/engine/permission_service_test.go | 17 ++++++++++++ internal/engine/safety/permission.go | 9 +++++-- 6 files changed, 89 insertions(+), 8 deletions(-) diff --git a/internal/config/settings.go b/internal/config/settings.go index d3050fc6..a5918785 100644 --- a/internal/config/settings.go +++ b/internal/config/settings.go @@ -26,6 +26,9 @@ func fetchModelsViaRuntime(ctx context.Context, provider string) ([]EngineModel, // Settings holds hawk configuration. // Hawk: no API keys stored here. Secrets come from the OS secret store via eyrie. type Settings struct { + // PolicySchemaVersion versions permission/autonomy/sandbox fields. Zero is + // the legacy format and is migrated to CurrentPolicySchemaVersion on load. + PolicySchemaVersion int `json:"policy_schema_version,omitempty"` // Model and Provider are retained only for one-time migration into eyrie provider.json. // Hawk does not persist model/provider here; use SetActiveModel / SetActiveProvider. Model string `json:"model,omitempty"` @@ -65,6 +68,8 @@ type Settings struct { PaginatorShowLineNums *bool `json:"paginator_show_line_nums,omitempty"` // show line numbers in scrollback } +const CurrentPolicySchemaVersion = 1 + // ToolPreset maps a named preset to a list of allowed tools. type ToolPreset struct { Name string `json:"name"` @@ -200,6 +205,9 @@ func LoadGlobalSettings() Settings { fmt.Fprintf(os.Stderr, "hawk: warning: failed to parse %s: %v\n", path, err) } } + if s.PolicySchemaVersion == 0 { + s.PolicySchemaVersion = CurrentPolicySchemaVersion + } return s } @@ -212,6 +220,9 @@ func LoadSettings() Settings { s = MergeSettings(s, *project) } migrateStoredModelProvider(&s) + if s.PolicySchemaVersion == 0 { + s.PolicySchemaVersion = CurrentPolicySchemaVersion + } return s } @@ -271,6 +282,12 @@ func readSettingsOverride(source string, out *Settings) error { // MergeSettings applies override fields on top of base using project-style precedence. func MergeSettings(base, override Settings) Settings { + if base.PolicySchemaVersion == 0 { + base.PolicySchemaVersion = CurrentPolicySchemaVersion + } + if override.PolicySchemaVersion > base.PolicySchemaVersion { + base.PolicySchemaVersion = override.PolicySchemaVersion + } if override.Model != "" { base.Model = override.Model } diff --git a/internal/config/settings_test.go b/internal/config/settings_test.go index 4970178d..297a38fe 100644 --- a/internal/config/settings_test.go +++ b/internal/config/settings_test.go @@ -413,3 +413,17 @@ func TestValidationResult_Error(t *testing.T) { t.Error("error should contain all field names") } } + +func TestPolicySchemaVersionMigratesLegacySettings(t *testing.T) { + var s Settings + if err := json.Unmarshal([]byte(`{"autonomy":0,"sandbox":"workspace"}`), &s); err != nil { + t.Fatal(err) + } + if s.PolicySchemaVersion != 0 { + t.Fatalf("raw unmarshal should preserve legacy zero, got %d", s.PolicySchemaVersion) + } + merged := MergeSettings(Settings{}, s) + if merged.PolicySchemaVersion != CurrentPolicySchemaVersion { + t.Fatalf("migrated schema version = %d, want %d", merged.PolicySchemaVersion, CurrentPolicySchemaVersion) + } +} diff --git a/internal/engine/agent_session_tool.go b/internal/engine/agent_session_tool.go index 4633b218..b8f9d849 100644 --- a/internal/engine/agent_session_tool.go +++ b/internal/engine/agent_session_tool.go @@ -138,12 +138,10 @@ func (s *Session) spawnSubAgent(ctx context.Context, norm agentcontracts.Normali if IsReadOnlyMode(mode) || norm.CapabilityMode == agentcontracts.CapReadOnly { sub.readOnlyBash = true } - // A sub-agent spawned while the parent is mid-spec-and-unapproved - // inherits the same gate — an ungated sub-agent would be a permission - // escalation hole (it could Write/Bash while the parent still can't). - sub.PermSvc().SetAutonomy(s.PermSvc().Autonomy()) - sub.PermSvc().SetSpecStage(s.PermSvc().SpecStage()) - sub.PermSvc().SetSandboxMode(s.PermSvc().SandboxMode()) + // A child receives an independent snapshot of the parent's policy. This + // prevents parent mutations from changing an in-flight child and prevents + // child defaults from silently widening the parent's permissions. + sub.PermSvc().ApplyPolicySnapshot(s.PermSvc().PolicySnapshot()) if s.LifecycleSvc() != nil { s.LifecycleSvc().Limits().SetMaxTurns(maxTurns) } diff --git a/internal/engine/permission_service.go b/internal/engine/permission_service.go index 708291cd..7836d107 100644 --- a/internal/engine/permission_service.go +++ b/internal/engine/permission_service.go @@ -84,6 +84,18 @@ func (s *PermissionService) Engine() *PermissionEngine { return s.perm } // event emission and the post-call side effects. func (s *PermissionService) CheckTool(ctx context.Context, info ToolCallInfo) (bool, string) { d := s.CheckToolDecision(ctx, info) + capabilities := make([]string, 0, len(d.Capabilities)) + for _, capability := range d.Capabilities { + capabilities = append(capabilities, string(capability)) + } + s.log.Info("permission decision", map[string]interface{}{ + "tool": info.Name, + "outcome": string(d.Outcome), + "reason": string(d.Reason), + "risk": string(d.Risk), + "revision": d.Revision, + "capabilities": capabilities, + }) if d.Outcome != safety.DecisionAllow { s.log.Warn("permission denied", map[string]interface{}{ "tool": info.Name, @@ -110,6 +122,24 @@ func (s *PermissionService) CheckToolSnapshot(ctx context.Context, info ToolCall return s.perm.CheckToolSnapshot(ctx, info, snapshot) } +// ApplyPolicySnapshot installs a bounded parent policy into a child service. +// The rule store is deep-copied so later parent changes cannot widen or alter +// an in-flight child policy. +func (s *PermissionService) ApplyPolicySnapshot(snapshot safety.PolicySnapshot) { + s.perm.Autonomy = snapshot.Autonomy + s.perm.AutonomyExplicit = snapshot.AutonomyExplicit + s.perm.SandboxMode = snapshot.SandboxMode + s.perm.Stage = snapshot.Stage + s.perm.DryRun = snapshot.DryRun + s.perm.SpecSlug = snapshot.SpecSlug + s.perm.Phase = snapshot.Phase + s.perm.Phases = snapshot.Phases + s.perm.Revision = snapshot.Revision + s.perm.Memory = safety.NewPermissionMemoryFromSnapshot(snapshot.Rules) + s.memory = s.perm.Memory + s.allowedDirs = append([]string(nil), snapshot.AllowedDirs...) +} + // CheckApproval runs the human-in-the-loop gate on high-risk actions. // Returns (approved, denyMsg). The caller handles tool_result emission. // This is a thin wrapper around the engine's per-tool session.CheckApproval diff --git a/internal/engine/permission_service_test.go b/internal/engine/permission_service_test.go index db476ea6..d2ca9720 100644 --- a/internal/engine/permission_service_test.go +++ b/internal/engine/permission_service_test.go @@ -65,6 +65,23 @@ func TestPermissionService_SandboxModeRoundTrip(t *testing.T) { } } +func TestPermissionService_ApplyPolicySnapshotCopiesRulesAndScopes(t *testing.T) { + parent := NewPermissionService(nil) + parent.Memory().AlwaysDeny("Write") + parent.SetAllowedDirs([]string{"/workspace"}) + snapshot := parent.PolicySnapshot() + child := NewPermissionService(nil) + child.ApplyPolicySnapshot(snapshot) + snapshot.AllowedDirs[0] = "/changed" + if child.AllowedDirs()[0] != "/workspace" { + t.Fatalf("child allowed dirs changed through snapshot alias: %v", child.AllowedDirs()) + } + allowed, reason := child.CheckTool(context.Background(), ToolCallInfo{Name: "Write"}) + if allowed || reason != "Permission denied (rule)." { + t.Fatalf("child did not inherit deny rule: allowed=%v reason=%q", allowed, reason) + } +} + func TestPermissionService_CheckApproval_NoGate(t *testing.T) { s := NewPermissionService(nil) approved, _ := s.CheckApproval(context.Background(), "Bash", map[string]interface{}{}) diff --git a/internal/engine/safety/permission.go b/internal/engine/safety/permission.go index ace73110..fe158fb7 100644 --- a/internal/engine/safety/permission.go +++ b/internal/engine/safety/permission.go @@ -48,8 +48,13 @@ func (pm *PermissionMemory) Snapshot() RuleSnapshot { return RuleSnapshot{AllowRules: append([]string(nil), pm.allowRules...), DenyRules: append([]string(nil), pm.denyRules...), AllowAll: allowAll} } -func permissionMemoryFromSnapshot(snapshot RuleSnapshot) *PermissionMemory { - return &PermissionMemory{allowRules: append([]string(nil), snapshot.AllowRules...), denyRules: append([]string(nil), snapshot.DenyRules...), allowAll: snapshot.AllowAll} +// NewPermissionMemoryFromSnapshot creates an independent rule store. +func NewPermissionMemoryFromSnapshot(snapshot RuleSnapshot) *PermissionMemory { + allowAll := make(map[string]bool, len(snapshot.AllowAll)) + for name, allowed := range snapshot.AllowAll { + allowAll[name] = allowed + } + return &PermissionMemory{allowRules: append([]string(nil), snapshot.AllowRules...), denyRules: append([]string(nil), snapshot.DenyRules...), allowAll: allowAll} } // Reset clears all allow/deny memory so the active rule set can be rebuilt. From 2928a4544eeae9e820ed8c22d90667d73ee6b546 Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Sat, 1 Aug 2026 19:50:25 +0530 Subject: [PATCH 09/49] feat: expose nonblocking policy evaluation --- internal/engine/permission_service.go | 5 +++++ internal/engine/safety/permission_engine.go | 17 ++++++++++++++++- .../engine/safety/permission_engine_test.go | 9 +++++++++ 3 files changed, 30 insertions(+), 1 deletion(-) diff --git a/internal/engine/permission_service.go b/internal/engine/permission_service.go index 7836d107..823547b2 100644 --- a/internal/engine/permission_service.go +++ b/internal/engine/permission_service.go @@ -110,6 +110,11 @@ func (s *PermissionService) CheckToolDecision(ctx context.Context, info ToolCall return s.perm.CheckToolDecision(ctx, info) } +// EvaluateTool returns allow, ask, or deny without blocking on the UI. +func (s *PermissionService) EvaluateTool(ctx context.Context, info ToolCallInfo) safety.Decision { + return s.perm.EvaluateTool(ctx, info) +} + // PolicySnapshot returns the scalar policy used for a single request. func (s *PermissionService) PolicySnapshot() safety.PolicySnapshot { snapshot := s.perm.Snapshot() diff --git a/internal/engine/safety/permission_engine.go b/internal/engine/safety/permission_engine.go index 70070b55..c98b4ddc 100644 --- a/internal/engine/safety/permission_engine.go +++ b/internal/engine/safety/permission_engine.go @@ -177,7 +177,7 @@ func (pe *PermissionEngine) CheckToolSnapshot(ctx context.Context, tc ToolCallIn clone.Phase = snapshot.Phase clone.Phases = snapshot.Phases clone.Revision = snapshot.Revision - clone.Memory = permissionMemoryFromSnapshot(snapshot.Rules) + clone.Memory = NewPermissionMemoryFromSnapshot(snapshot.Rules) return clone.CheckToolDecision(ctx, tc) } @@ -192,6 +192,21 @@ func (pe *PermissionEngine) CheckToolDecision(ctx context.Context, tc ToolCallIn return d } +// EvaluateTool performs policy evaluation without waiting for UI approval. +// It returns DecisionAsk when the only remaining step is user approval. +// CheckToolDecision remains the compatibility API that performs the prompt. +func (pe *PermissionEngine) EvaluateTool(ctx context.Context, tc ToolCallInfo) Decision { + clone := *pe + clone.PromptFn = nil + d := clone.CheckToolDecision(ctx, tc) + if d.Reason == ReasonPromptUnavailable { + d.Outcome = DecisionAsk + d.Reason = ReasonUserPrompt + d.Message = "Permission approval required." + } + return d +} + func (pe *PermissionEngine) evaluateToolDecision(ctx context.Context, tc ToolCallInfo) Decision { if pe.DryRun { return Decision{Outcome: DecisionDeny, Reason: ReasonDryRun, Message: "dry-run: tool execution disabled"} diff --git a/internal/engine/safety/permission_engine_test.go b/internal/engine/safety/permission_engine_test.go index 30ca272f..1fd3012d 100644 --- a/internal/engine/safety/permission_engine_test.go +++ b/internal/engine/safety/permission_engine_test.go @@ -154,6 +154,15 @@ func TestPermissionEngine_SnapshotCapturesRememberedRules(t *testing.T) { } } +func TestPermissionEngine_EvaluateToolReturnsAskWithoutBlocking(t *testing.T) { + pe := NewPermissionEngine() + pe.Autonomy = AutonomySupervised + d := pe.EvaluateTool(context.Background(), ToolCallInfo{Name: "Write"}) + if d.Outcome != DecisionAsk || d.Reason != ReasonUserPrompt { + t.Fatalf("decision = %#v, want ask/user_prompt", d) + } +} + // TestCheckTool_SpecStageNoneIgnoresGate verifies that outside of any spec // workflow (Stage == SpecStageNone), the spec gate does not apply at all and // autonomy-tier logic governs directly. From 40c37fb809fd05fd1d921c8fb7761902c666c072 Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Sat, 1 Aug 2026 21:17:10 +0530 Subject: [PATCH 10/49] fix: centralize permission scope and spec state --- internal/engine/permission_service.go | 31 +++++++++++++++++++-- internal/engine/permission_service_test.go | 26 ++++++++++++++++- internal/engine/safety/permission_engine.go | 21 +++----------- internal/engine/safety/spec_workflow.go | 8 +++--- internal/engine/session.go | 9 +++++- internal/engine/stream.go | 4 ++- internal/engine/stream_tool_exec.go | 13 ++++----- internal/engine/stream_tool_exec_test.go | 3 ++ 8 files changed, 81 insertions(+), 34 deletions(-) diff --git a/internal/engine/permission_service.go b/internal/engine/permission_service.go index 823547b2..be39ee20 100644 --- a/internal/engine/permission_service.go +++ b/internal/engine/permission_service.go @@ -178,7 +178,10 @@ func (s *PermissionService) SetMaxTurns(turns int) { s.maxTurns = turns } func (s *PermissionService) SetMaxBudgetUSD(usd float64) { s.maxBudgetUSD = usd } // SetAllowedDirs sets the directories the agent may write to. -func (s *PermissionService) SetAllowedDirs(dirs []string) { s.allowedDirs = dirs } +// The service owns its copy so callers cannot mutate policy after publication. +func (s *PermissionService) SetAllowedDirs(dirs []string) { + s.allowedDirs = append([]string(nil), dirs...) +} // SetAutonomy sets the agent's autonomy level. Writes directly to the // underlying PermissionEngine — the same field CheckTool reads — rather @@ -224,8 +227,30 @@ func (s *PermissionService) MaxTurns() int { return s.maxTurns } // MaxBudgetUSD returns the cap. func (s *PermissionService) MaxBudgetUSD() float64 { return s.maxBudgetUSD } -// AllowedDirs returns the write-allowlist. -func (s *PermissionService) AllowedDirs() []string { return s.allowedDirs } +// AllowedDirs returns a copy of the write-allowlist. +func (s *PermissionService) AllowedDirs() []string { + return append([]string(nil), s.allowedDirs...) +} + +// SpecSlug returns the active spec slug. +func (s *PermissionService) SpecSlug() string { return s.perm.SpecSlug } + +// SetSpecSlug updates the active spec slug through the permission service. +// Workflow stage transitions remain explicit via AdvanceSpecStage or ResetSpec. +func (s *PermissionService) SetSpecSlug(slug string) { s.perm.SpecSlug = slug } + +// AdvanceSpecStage applies a validated workflow transition. +func (s *PermissionService) AdvanceSpecStage(name string) { + s.perm.AdvanceSpecStage(name) +} + +// ResetSpec clears the active workflow and records a new policy revision. +func (s *PermissionService) ResetSpec() { + w := safety.SpecWorkflow{Stage: s.perm.Stage, Slug: s.perm.SpecSlug} + w.Reset() + s.perm.Stage, s.perm.SpecSlug = w.Stage, w.Slug + s.perm.Revision++ +} // Autonomy returns the autonomy level. func (s *PermissionService) Autonomy() AutonomyLevel { return s.perm.Autonomy } diff --git a/internal/engine/permission_service_test.go b/internal/engine/permission_service_test.go index d2ca9720..8d3e125f 100644 --- a/internal/engine/permission_service_test.go +++ b/internal/engine/permission_service_test.go @@ -48,13 +48,37 @@ func TestPermissionService_BudgetAndTurnCaps(t *testing.T) { func TestPermissionService_AutonomyAndAllowedDirs(t *testing.T) { s := NewPermissionService(nil) s.SetAutonomy(AutonomySupervised) - s.SetAllowedDirs([]string{"/tmp", "/var/folders"}) + dirs := []string{"/tmp", "/var/folders"} + s.SetAllowedDirs(dirs) + dirs[0] = "/changed" if s.Autonomy() != AutonomySupervised { t.Errorf("Autonomy = %v, want AutonomySupervised", s.Autonomy()) } if len(s.AllowedDirs()) != 2 { t.Errorf("AllowedDirs len = %d, want 2", len(s.AllowedDirs())) } + got := s.AllowedDirs() + got[0] = "/changed-again" + if s.AllowedDirs()[0] != "/tmp" { + t.Fatalf("AllowedDirs returned an aliased slice: %v", s.AllowedDirs()) + } +} + +func TestPermissionService_ResetSpecIncrementsRevision(t *testing.T) { + s := NewPermissionService(nil) + s.SetSpecSlug("demo") + s.SetSpecStage(SpecStageImplementing) + before := s.Engine().Revision + s.ResetSpec() + if got := s.SpecSlug(); got != "" { + t.Fatalf("SpecSlug after reset = %q, want empty", got) + } + if got := s.SpecStage(); got != SpecStageNone { + t.Fatalf("SpecStage after reset = %v, want none", got) + } + if s.Engine().Revision <= before { + t.Fatalf("ResetSpec revision = %d, want > %d", s.Engine().Revision, before) + } } func TestPermissionService_SandboxModeRoundTrip(t *testing.T) { diff --git a/internal/engine/safety/permission_engine.go b/internal/engine/safety/permission_engine.go index c98b4ddc..0e5978a1 100644 --- a/internal/engine/safety/permission_engine.go +++ b/internal/engine/safety/permission_engine.go @@ -133,10 +133,12 @@ func (pe *PermissionEngine) Snapshot() PolicySnapshot { if pe.Memory != nil { rules = pe.Memory.Snapshot() } - return PolicySnapshot{Autonomy: pe.Autonomy, AutonomyExplicit: pe.AutonomyExplicit, + return PolicySnapshot{ + Autonomy: pe.Autonomy, AutonomyExplicit: pe.AutonomyExplicit, SandboxMode: pe.SandboxMode, Stage: pe.Stage, DryRun: pe.DryRun, SpecSlug: pe.SpecSlug, Phase: pe.Phase, Phases: pe.Phases, Revision: pe.Revision, - Rules: rules} + Rules: rules, + } } // NewPermissionEngine creates a PermissionEngine with sensible defaults. @@ -368,21 +370,6 @@ func (pe *PermissionEngine) checkPreToolHooks(tc ToolCallInfo) (bool, string) { } } -// promptUser blocks on PromptFn, asking the user to approve tc, using the -// generic tool summary. -func (pe *PermissionEngine) promptUser(ctx context.Context, tc ToolCallInfo) (bool, string) { - d := pe.promptDecision(ctx, tc) - return d.Outcome == DecisionAllow, d.Message -} - -// promptUserWithSummary is promptUser with a caller-supplied summary, -// letting ApproveImplementation show spec/plan/tasks content instead of -// the generic (and, since it takes no args, empty) tool summary. -func (pe *PermissionEngine) promptUserWithSummary(ctx context.Context, tc ToolCallInfo, summary string) (bool, string) { - d := pe.promptDecisionWithSummary(ctx, tc, summary) - return d.Outcome == DecisionAllow, d.Message -} - func (pe *PermissionEngine) promptDecision(ctx context.Context, tc ToolCallInfo) Decision { return pe.promptDecisionWithSummary(ctx, tc, ToolSummary(tc.Name, tc.Args)) } diff --git a/internal/engine/safety/spec_workflow.go b/internal/engine/safety/spec_workflow.go index 54df272a..49c0401f 100644 --- a/internal/engine/safety/spec_workflow.go +++ b/internal/engine/safety/spec_workflow.go @@ -15,21 +15,21 @@ func (w *SpecWorkflow) Transition(toolName, slug string) error { switch canonicalToolName(toolName) { case "Specify": if w.Stage != SpecStageNone && w.Stage != SpecStageSpecify { - return fmt.Errorf("Specify is unavailable at spec stage %d", w.Stage) + return fmt.Errorf("specify is unavailable at spec stage %d", w.Stage) } if slug == "" { - return fmt.Errorf("Specify requires a non-empty spec slug") + return fmt.Errorf("specify requires a non-empty spec slug") } w.Slug = slug w.Stage = SpecStageSpecify case "Plan": if w.Stage != SpecStageSpecify || w.Slug == "" { - return fmt.Errorf("Plan requires a completed Specify stage") + return fmt.Errorf("plan requires a completed specify stage") } w.Stage = SpecStagePlan case "Tasks": if w.Stage != SpecStagePlan { - return fmt.Errorf("Tasks requires a completed Plan stage") + return fmt.Errorf("tasks requires a completed plan stage") } w.Stage = SpecStageTasks case "ApproveImplementation": diff --git a/internal/engine/session.go b/internal/engine/session.go index 05e20325..7afca92b 100644 --- a/internal/engine/session.go +++ b/internal/engine/session.go @@ -727,7 +727,14 @@ func (s *Session) SetLogger(l *logger.Logger) { // SetAllowedDirs sets directories that file tools are allowed to access. func (s *Session) SetAllowedDirs(dirs []string) { - s.AllowedDirs = append([]string(nil), dirs...) + copyDirs := append([]string(nil), dirs...) + s.mu.Lock() + s.AllowedDirs = append([]string(nil), copyDirs...) + perms := s.perms + s.mu.Unlock() + if perms != nil { + perms.SetAllowedDirs(copyDirs) + } } // SetAutoCompactThresholdPct sets the auto-compact threshold. diff --git a/internal/engine/stream.go b/internal/engine/stream.go index 5d02210b..334132f8 100644 --- a/internal/engine/stream.go +++ b/internal/engine/stream.go @@ -637,7 +637,9 @@ func (s *Session) agentLoop(ctx context.Context, ch chan<- StreamEvent) { // Integration pipeline: post-response (format, score, redact, cache, learn) if s.LifecycleSvc().Pipeline() != nil && textContent.Len() > 0 { postResult := s.LifecycleSvc().Pipeline().PostResponse(textContent.String(), s.Persistence().RawMessages()) - s.recordTokRedactionObservation(textContent.String(), postResult.SecretMatches, postResult.SecretTypes) + if postResult != nil { + s.recordTokRedactionObservation(textContent.String(), postResult.SecretMatches, postResult.SecretTypes) + } if postResult != nil && postResult.FormattedResponse != "" { textContent.Reset() textContent.WriteString(postResult.FormattedResponse) diff --git a/internal/engine/stream_tool_exec.go b/internal/engine/stream_tool_exec.go index 4f9acb60..9268fbcd 100644 --- a/internal/engine/stream_tool_exec.go +++ b/internal/engine/stream_tool_exec.go @@ -346,12 +346,12 @@ func (s *Session) executeSingleToolWithTool(ctx context.Context, tc types.ToolCa return resp.Content, nil }, YaadBridge: s.MemorySvc().Yaad(), - SpecSlugGet: func() string { return s.Perm.SpecSlug }, - SpecSlugSet: func(slug string) { s.Perm.SpecSlug = slug }, + SpecSlugGet: func() string { return s.PermSvc().SpecSlug() }, + SpecSlugSet: func(slug string) { s.PermSvc().SetSpecSlug(slug) }, BackgroundManager: s.ensureBackgroundManager(), ReadOnlyBash: s.readOnlyBash, WorkingDir: s.workingDir, - AllowedDirectories: append([]string(nil), s.AllowedDirs...), + AllowedDirectories: s.PermSvc().AllowedDirs(), SandboxMode: sandboxMode, }) if sandboxMode != "" { @@ -625,12 +625,11 @@ func (s *Session) executeSingleToolWithTool(ctx context.Context, tc types.ToolCa if !isErr { switch canonicalToolName(tc.Name) { case "Specify", "Plan", "Tasks": - s.Perm.AdvanceSpecStage(tc.Name) + s.PermSvc().AdvanceSpecStage(tc.Name) case "SpecReset": - s.Perm.SpecSlug = "" - s.Perm.Stage = SpecStageNone + s.PermSvc().ResetSpec() case "ApproveImplementation": - s.Perm.AdvanceSpecStage(tc.Name) + s.PermSvc().AdvanceSpecStage(tc.Name) output = "Spec approved — switched to implementation. You may now make changes." } } diff --git a/internal/engine/stream_tool_exec_test.go b/internal/engine/stream_tool_exec_test.go index cd165041..4b6f7bce 100644 --- a/internal/engine/stream_tool_exec_test.go +++ b/internal/engine/stream_tool_exec_test.go @@ -142,4 +142,7 @@ func TestExecuteSingleTool_PropagatesPermissionContext(t *testing.T) { if len(capture.ctx.AllowedDirectories) != 1 || capture.ctx.AllowedDirectories[0] != "/tmp/extra" { t.Fatalf("AllowedDirectories = %#v", capture.ctx.AllowedDirectories) } + if got := sess.PermSvc().AllowedDirs(); len(got) != 1 || got[0] != "/tmp/extra" { + t.Fatalf("service AllowedDirs = %#v", got) + } } From f2ad8e5887c1df53753aff345eab62cd044cf383 Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Sat, 1 Aug 2026 21:32:02 +0530 Subject: [PATCH 11/49] refactor: synchronize permission service state --- cmd/chat_subcommand_spec.go | 3 +- cmd/chat_update.go | 35 +++-- cmd/permissions_center.go | 30 ++-- cmd/statusbar.go | 16 +- internal/engine/permission_service.go | 174 ++++++++++++++++++--- internal/engine/permission_service_test.go | 24 +++ internal/engine/stream.go | 15 +- 7 files changed, 238 insertions(+), 59 deletions(-) diff --git a/cmd/chat_subcommand_spec.go b/cmd/chat_subcommand_spec.go index 24e8457f..5fc55487 100644 --- a/cmd/chat_subcommand_spec.go +++ b/cmd/chat_subcommand_spec.go @@ -47,8 +47,7 @@ func (s *specSubcommand) Handle(m *chatModel, args []string, text string) (tea.M return m, nil case strings.EqualFold(arg, "reset"): - m.session.PermSvc().SetSpecStage(engine.SpecStageNone) - m.session.Perm.SpecSlug = "" + m.session.PermSvc().ResetSpec() m.messages = append(m.messages, displayMsg{role: "system", content: "Spec workflow reset — Write/Edit/Bash follow the normal autonomy tier again."}) return m, nil diff --git a/cmd/chat_update.go b/cmd/chat_update.go index e2b2147e..cb6c196a 100644 --- a/cmd/chat_update.go +++ b/cmd/chat_update.go @@ -514,8 +514,7 @@ func (m chatModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { msg += "\nThe agent can also use `SpecConfig` tool to read/update." m.messages = append(m.messages, displayMsg{role: "system", content: msg}) case specActionReset: - m.session.PermSvc().SetSpecStage(engine.SpecStageNone) - m.session.Perm.SpecSlug = "" + m.session.PermSvc().ResetSpec() m.messages = append(m.messages, displayMsg{role: "system", content: "Spec workflow reset — Write/Edit/Bash follow the trust tier again."}) } } @@ -620,8 +619,10 @@ func (m chatModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { req.Response <- true m.permReq = nil m.permTimeoutAt = time.Time{} - if m.session != nil && m.session.Perm != nil && m.session.Perm.AutoMode != nil { - m.session.Perm.AutoMode.Record(req.ToolName, req.Summary, true) + if m.session != nil && m.session.PermSvc() != nil { + if autoMode := m.session.PermSvc().AutoMode(); autoMode != nil { + autoMode.Record(req.ToolName, req.Summary, true) + } } m.messages = append(m.messages, displayMsg{role: "system", content: icons.CheckBold() + " Allowed"}) case "n", "N": @@ -629,8 +630,10 @@ func (m chatModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { req.Response <- false m.permReq = nil m.permTimeoutAt = time.Time{} - if m.session != nil && m.session.Perm != nil && m.session.Perm.AutoMode != nil { - m.session.Perm.AutoMode.Record(req.ToolName, req.Summary, false) + if m.session != nil && m.session.PermSvc() != nil { + if autoMode := m.session.PermSvc().AutoMode(); autoMode != nil { + autoMode.Record(req.ToolName, req.Summary, false) + } } m.messages = append(m.messages, displayMsg{role: "system", content: icons.CloseThick() + " Denied"}) case "a", "A": @@ -640,10 +643,12 @@ func (m chatModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { req.Response <- true m.permReq = nil m.permTimeoutAt = time.Time{} - if m.session != nil && m.session.Perm != nil { - m.session.Perm.Memory.AlwaysAllowPattern(toolName + ":*") - if m.session.Perm.AutoMode != nil { - m.session.Perm.AutoMode.Record(toolName, summary, true) + if m.session != nil && m.session.PermSvc() != nil { + if memory := m.session.PermSvc().Memory(); memory != nil { + memory.AlwaysAllowPattern(toolName + ":*") + } + if autoMode := m.session.PermSvc().AutoMode(); autoMode != nil { + autoMode.Record(toolName, summary, true) } } m.messages = append(m.messages, displayMsg{role: "system", content: icons.CheckBold() + " Always allowed: " + toolName + " (all)"}) @@ -654,10 +659,12 @@ func (m chatModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { req.Response <- false m.permReq = nil m.permTimeoutAt = time.Time{} - if m.session != nil && m.session.Perm != nil { - m.session.Perm.Memory.AlwaysDeny(toolName) - if m.session.Perm.AutoMode != nil { - m.session.Perm.AutoMode.Record(toolName, summary, false) + if m.session != nil && m.session.PermSvc() != nil { + if memory := m.session.PermSvc().Memory(); memory != nil { + memory.AlwaysDeny(toolName) + } + if autoMode := m.session.PermSvc().AutoMode(); autoMode != nil { + autoMode.Record(toolName, summary, false) } } m.messages = append(m.messages, displayMsg{role: "system", content: icons.CloseThick() + " Always denied: " + toolName}) diff --git a/cmd/permissions_center.go b/cmd/permissions_center.go index 0d674674..65e7c3cb 100644 --- a/cmd/permissions_center.go +++ b/cmd/permissions_center.go @@ -108,10 +108,16 @@ func specStageLabel(sess *engine.Session) string { // currentSpecStage returns the session's active spec stage, or // SpecStageNone if the session (or its permission engine) isn't set up yet. func currentSpecStage(sess *engine.Session) engine.SpecStage { - if sess == nil || sess.Perm == nil { + if sess == nil { return engine.SpecStageNone } - return sess.Perm.Stage + if sess.PermSvc() == nil { + if sess.Perm == nil { + return engine.SpecStageNone + } + return sess.Perm.Stage + } + return sess.PermSvc().SpecStage() } // currentDryRun returns whether the session's dry-run kill switch is @@ -120,10 +126,13 @@ func currentSpecStage(sess *engine.Session) engine.SpecStage { // nil for sessions built via a raw struct literal (e.g. in tests) rather // than NewSession. func currentDryRun(sess *engine.Session) bool { - if sess == nil || sess.Perm == nil { + if sess == nil { return false } - return sess.Perm.DryRun + if sess.PermSvc() == nil { + return sess.Perm != nil && sess.Perm.DryRun + } + return sess.PermSvc().DryRun() } func autonomyCommandHelp() string { @@ -236,17 +245,16 @@ func rebuildSessionPermissionRules(sess *engine.Session, settings hawkconfig.Set if sess == nil { return } - mem := sess.PermSvc().Memory() + perm := sess.PermSvc() + if perm == nil { + return + } + mem := perm.Memory() if mem == nil { mem = engine.NewPermissionMemory() - if sess.Perm != nil { - sess.Perm.Memory = mem - } + perm.SetMemory(mem) } mem.Reset() - if sess.Perm != nil && sess.Perm.Memory == nil { - sess.Perm.Memory = mem - } for _, spec := range settings.AutoAllow { mem.AllowSpec(spec) } diff --git a/cmd/statusbar.go b/cmd/statusbar.go index 70e08bbf..5887dc5f 100644 --- a/cmd/statusbar.go +++ b/cmd/statusbar.go @@ -176,16 +176,24 @@ func renderStatusBarLeft(m *chatModel) string { // specStageForStatus returns a short spec stage indicator for the status bar, // or empty string if no spec workflow is active. func specStageForStatus(m *chatModel) string { - if m == nil || m.session == nil || m.session.Perm == nil { + if m == nil || m.session == nil { + return "" + } + var stage engine.SpecStage + var phase, phases int + if m.session.PermSvc() != nil { + stage, phase, phases = m.session.PermSvc().SpecProgress() + } else if m.session.Perm != nil { + stage, phase, phases = m.session.Perm.Stage, m.session.Perm.Phase, m.session.Perm.Phases + } else { return "" } - stage := m.session.Perm.Stage if stage == engine.SpecStageNone { return "" } label := specStageDisplayName(stage) - if stage == engine.SpecStageImplementing && m.session.Perm.Phases > 0 { - return fmt.Sprintf("%s %s %d/%d", icons.FileDocument(), label, m.session.Perm.Phase, m.session.Perm.Phases) + if stage == engine.SpecStageImplementing && phases > 0 { + return fmt.Sprintf("%s %s %d/%d", icons.FileDocument(), label, phase, phases) } return fmt.Sprintf("%s %s", icons.FileDocument(), label) } diff --git a/internal/engine/permission_service.go b/internal/engine/permission_service.go index be39ee20..8e4af08d 100644 --- a/internal/engine/permission_service.go +++ b/internal/engine/permission_service.go @@ -3,6 +3,7 @@ package engine import ( "context" "fmt" + "sync" "github.com/GrayCodeAI/hawk/internal/engine/safety" "github.com/GrayCodeAI/hawk/internal/observability/logger" @@ -24,6 +25,7 @@ import ( // are all thin forwarders to the new service. They will be removed in // Phase 7. type PermissionService struct { + mu sync.RWMutex // perm is the underlying PermissionEngine. Always non-nil after // construction. perm *PermissionEngine @@ -67,6 +69,8 @@ func NewPermissionService(log *logger.Logger) *PermissionService { // WithEngine replaces the underlying PermissionEngine. Used by tests // and by callers that want a pre-configured engine. func (s *PermissionService) WithEngine(pe *PermissionEngine) *PermissionService { + s.mu.Lock() + defer s.mu.Unlock() s.perm = pe s.memory = pe.Memory s.autoMode = pe.AutoMode @@ -107,16 +111,20 @@ func (s *PermissionService) CheckTool(ctx context.Context, info ToolCallInfo) (b // CheckToolDecision evaluates a request and exposes stable decision metadata. func (s *PermissionService) CheckToolDecision(ctx context.Context, info ToolCallInfo) safety.Decision { - return s.perm.CheckToolDecision(ctx, info) + perm := s.engineCopy() + return perm.CheckToolDecision(ctx, info) } // EvaluateTool returns allow, ask, or deny without blocking on the UI. func (s *PermissionService) EvaluateTool(ctx context.Context, info ToolCallInfo) safety.Decision { - return s.perm.EvaluateTool(ctx, info) + perm := s.engineCopy() + return perm.EvaluateTool(ctx, info) } // PolicySnapshot returns the scalar policy used for a single request. func (s *PermissionService) PolicySnapshot() safety.PolicySnapshot { + s.mu.RLock() + defer s.mu.RUnlock() snapshot := s.perm.Snapshot() snapshot.AllowedDirs = append([]string(nil), s.allowedDirs...) return snapshot @@ -124,13 +132,25 @@ func (s *PermissionService) PolicySnapshot() safety.PolicySnapshot { // CheckToolSnapshot evaluates a request against a previously captured policy. func (s *PermissionService) CheckToolSnapshot(ctx context.Context, info ToolCallInfo, snapshot safety.PolicySnapshot) safety.Decision { - return s.perm.CheckToolSnapshot(ctx, info, snapshot) + perm := s.engineCopy() + return perm.CheckToolSnapshot(ctx, info, snapshot) +} + +// engineCopy captures scalar policy state while holding the service lock, then +// lets evaluation run without blocking policy updates or user prompts. +func (s *PermissionService) engineCopy() *PermissionEngine { + s.mu.RLock() + defer s.mu.RUnlock() + copy := *s.perm + return © } // ApplyPolicySnapshot installs a bounded parent policy into a child service. // The rule store is deep-copied so later parent changes cannot widen or alter // an in-flight child policy. func (s *PermissionService) ApplyPolicySnapshot(snapshot safety.PolicySnapshot) { + s.mu.Lock() + defer s.mu.Unlock() s.perm.Autonomy = snapshot.Autonomy s.perm.AutonomyExplicit = snapshot.AutonomyExplicit s.perm.SandboxMode = snapshot.SandboxMode @@ -154,6 +174,8 @@ func (s *PermissionService) ApplyPolicySnapshot(snapshot safety.PolicySnapshot) // service's own CheckApproval is a no-op when s.approval is nil so // callers can use it as the canonical entry point. func (s *PermissionService) CheckApproval(_ context.Context, toolName string, args map[string]interface{}) (bool, string) { + s.mu.RLock() + defer s.mu.RUnlock() if s.approval == nil || !s.approval.Enabled { return true, "" } @@ -172,14 +194,24 @@ func (s *PermissionService) CheckApproval(_ context.Context, toolName string, ar } // SetMaxTurns caps the agent loop's turn count. -func (s *PermissionService) SetMaxTurns(turns int) { s.maxTurns = turns } +func (s *PermissionService) SetMaxTurns(turns int) { + s.mu.Lock() + s.maxTurns = turns + s.mu.Unlock() +} // SetMaxBudgetUSD caps the agent loop's spend in USD. -func (s *PermissionService) SetMaxBudgetUSD(usd float64) { s.maxBudgetUSD = usd } +func (s *PermissionService) SetMaxBudgetUSD(usd float64) { + s.mu.Lock() + s.maxBudgetUSD = usd + s.mu.Unlock() +} // SetAllowedDirs sets the directories the agent may write to. // The service owns its copy so callers cannot mutate policy after publication. func (s *PermissionService) SetAllowedDirs(dirs []string) { + s.mu.Lock() + defer s.mu.Unlock() s.allowedDirs = append([]string(nil), dirs...) } @@ -187,6 +219,8 @@ func (s *PermissionService) SetAllowedDirs(dirs []string) { // underlying PermissionEngine — the same field CheckTool reads — rather // than a separate shadow field, so the change actually takes effect. func (s *PermissionService) SetAutonomy(level AutonomyLevel) { + s.mu.Lock() + defer s.mu.Unlock() s.perm.Autonomy = level s.perm.AutonomyExplicit = true s.perm.Revision++ @@ -194,58 +228,106 @@ func (s *PermissionService) SetAutonomy(level AutonomyLevel) { // SetSpecStage sets the independent spec-workflow stage. Also writes // directly to the engine, same reasoning as SetAutonomy. -func (s *PermissionService) SetSpecStage(stage SpecStage) { s.perm.Stage = stage; s.perm.Revision++ } +func (s *PermissionService) SetSpecStage(stage SpecStage) { + s.mu.Lock() + s.perm.Stage = stage + s.perm.Revision++ + s.mu.Unlock() +} // SetDryRun toggles the global kill switch: when true, every tool call is // denied unconditionally, regardless of tier or spec stage. -func (s *PermissionService) SetDryRun(dryRun bool) { s.perm.DryRun = dryRun; s.perm.Revision++ } +func (s *PermissionService) SetDryRun(dryRun bool) { + s.mu.Lock() + s.perm.DryRun = dryRun + s.perm.Revision++ + s.mu.Unlock() +} // SetSandboxMode sets the OS/tool sandbox policy used for subsequent calls. func (s *PermissionService) SetSandboxMode(mode sandbox.Mode) { + s.mu.Lock() + defer s.mu.Unlock() s.perm.SandboxMode = mode s.perm.Revision++ } // SandboxMode reports the active OS/tool sandbox policy. -func (s *PermissionService) SandboxMode() sandbox.Mode { return s.perm.SandboxMode } +func (s *PermissionService) SandboxMode() sandbox.Mode { + s.mu.RLock() + defer s.mu.RUnlock() + return s.perm.SandboxMode +} // DryRun reports whether the kill switch is active. -func (s *PermissionService) DryRun() bool { return s.perm.DryRun } +func (s *PermissionService) DryRun() bool { + s.mu.RLock() + defer s.mu.RUnlock() + return s.perm.DryRun +} // SetApproval replaces the ApprovalGate. -func (s *PermissionService) SetApproval(a *ApprovalGate) { s.approval = a } +func (s *PermissionService) SetApproval(a *ApprovalGate) { + s.mu.Lock() + s.approval = a + s.mu.Unlock() +} // SetPermissionFn replaces the user-callback. func (s *PermissionService) SetPermissionFn(fn func(PermissionRequest)) { + s.mu.Lock() + defer s.mu.Unlock() s.permissionFn = fn s.perm.PromptFn = fn } // MaxTurns returns the cap (0 = no cap). -func (s *PermissionService) MaxTurns() int { return s.maxTurns } +func (s *PermissionService) MaxTurns() int { + s.mu.RLock() + defer s.mu.RUnlock() + return s.maxTurns +} // MaxBudgetUSD returns the cap. -func (s *PermissionService) MaxBudgetUSD() float64 { return s.maxBudgetUSD } +func (s *PermissionService) MaxBudgetUSD() float64 { + s.mu.RLock() + defer s.mu.RUnlock() + return s.maxBudgetUSD +} // AllowedDirs returns a copy of the write-allowlist. func (s *PermissionService) AllowedDirs() []string { + s.mu.RLock() + defer s.mu.RUnlock() return append([]string(nil), s.allowedDirs...) } // SpecSlug returns the active spec slug. -func (s *PermissionService) SpecSlug() string { return s.perm.SpecSlug } +func (s *PermissionService) SpecSlug() string { + s.mu.RLock() + defer s.mu.RUnlock() + return s.perm.SpecSlug +} // SetSpecSlug updates the active spec slug through the permission service. // Workflow stage transitions remain explicit via AdvanceSpecStage or ResetSpec. -func (s *PermissionService) SetSpecSlug(slug string) { s.perm.SpecSlug = slug } +func (s *PermissionService) SetSpecSlug(slug string) { + s.mu.Lock() + s.perm.SpecSlug = slug + s.mu.Unlock() +} // AdvanceSpecStage applies a validated workflow transition. func (s *PermissionService) AdvanceSpecStage(name string) { + s.mu.Lock() + defer s.mu.Unlock() s.perm.AdvanceSpecStage(name) } // ResetSpec clears the active workflow and records a new policy revision. func (s *PermissionService) ResetSpec() { + s.mu.Lock() + defer s.mu.Unlock() w := safety.SpecWorkflow{Stage: s.perm.Stage, Slug: s.perm.SpecSlug} w.Reset() s.perm.Stage, s.perm.SpecSlug = w.Stage, w.Slug @@ -253,33 +335,81 @@ func (s *PermissionService) ResetSpec() { } // Autonomy returns the autonomy level. -func (s *PermissionService) Autonomy() AutonomyLevel { return s.perm.Autonomy } +func (s *PermissionService) Autonomy() AutonomyLevel { + s.mu.RLock() + defer s.mu.RUnlock() + return s.perm.Autonomy +} // AutonomyExplicit reports whether the session selected or loaded a tier, // including Supervised (which numerically shares the zero value). -func (s *PermissionService) AutonomyExplicit() bool { return s.perm.AutonomyExplicit } +func (s *PermissionService) AutonomyExplicit() bool { + s.mu.RLock() + defer s.mu.RUnlock() + return s.perm.AutonomyExplicit +} // SpecStage returns the active spec-workflow stage. -func (s *PermissionService) SpecStage() SpecStage { return s.perm.Stage } +func (s *PermissionService) SpecStage() SpecStage { + s.mu.RLock() + defer s.mu.RUnlock() + return s.perm.Stage +} + +// SpecProgress returns the workflow stage and phase counters as one snapshot. +func (s *PermissionService) SpecProgress() (SpecStage, int, int) { + s.mu.RLock() + defer s.mu.RUnlock() + return s.perm.Stage, s.perm.Phase, s.perm.Phases +} // Memory returns the legacy PermissionMemory shim. The shim is // kept in sync with the engine's classification state; callers // that historically used `sess.Permissions.AllowSpec(...)` should // migrate to `sess.PermSvc().Memory().AllowSpec(...)`. -func (s *PermissionService) Memory() *PermissionMemory { return s.memory } +func (s *PermissionService) Memory() *PermissionMemory { + s.mu.RLock() + defer s.mu.RUnlock() + return s.memory +} + +// SetMemory replaces the remembered-rule store and keeps the engine alias in sync. +func (s *PermissionService) SetMemory(memory *PermissionMemory) { + s.mu.Lock() + s.memory = memory + s.perm.Memory = memory + s.mu.Unlock() +} // AutoMode returns the legacy AutoModeState shim. -func (s *PermissionService) AutoMode() *permissions.AutoModeState { return s.autoMode } +func (s *PermissionService) AutoMode() *permissions.AutoModeState { + s.mu.RLock() + defer s.mu.RUnlock() + return s.autoMode +} // Classifier returns the legacy Classifier shim. -func (s *PermissionService) Classifier() *permissions.Classifier { return s.classifier } +func (s *PermissionService) Classifier() *permissions.Classifier { + s.mu.RLock() + defer s.mu.RUnlock() + return s.classifier +} // BypassKill returns the legacy BypassKillswitch shim. -func (s *PermissionService) BypassKill() *permissions.BypassKillswitch { return s.bypassKill } +func (s *PermissionService) BypassKill() *permissions.BypassKillswitch { + s.mu.RLock() + defer s.mu.RUnlock() + return s.bypassKill +} // IsZero reports whether this service has been fully configured. // A zero PermissionService has no approval gate and no custom permission // fn — that's the "freshly constructed" state used by NewSessionWithClient. func (s *PermissionService) IsZero() bool { - return s == nil || (s.approval == nil && s.permissionFn == nil) + if s == nil { + return true + } + s.mu.RLock() + defer s.mu.RUnlock() + return s.approval == nil && s.permissionFn == nil } diff --git a/internal/engine/permission_service_test.go b/internal/engine/permission_service_test.go index 8d3e125f..21b2704c 100644 --- a/internal/engine/permission_service_test.go +++ b/internal/engine/permission_service_test.go @@ -3,6 +3,7 @@ package engine import ( "context" "strings" + "sync" "testing" "github.com/GrayCodeAI/hawk/internal/sandbox" @@ -81,6 +82,29 @@ func TestPermissionService_ResetSpecIncrementsRevision(t *testing.T) { } } +func TestPermissionService_ConcurrentPolicyUpdates(t *testing.T) { + s := NewPermissionService(nil) + ctx := context.Background() + modes := []sandbox.Mode{sandbox.ModeStrict, sandbox.ModeWorkspace, sandbox.ModeOff} + var wg sync.WaitGroup + for i := 0; i < 100; i++ { + wg.Add(2) + go func(i int) { + defer wg.Done() + s.SetAutonomy(AutonomyLevel(i % int(AutonomyYOLO+1))) + s.SetSandboxMode(modes[i%len(modes)]) + s.SetAllowedDirs([]string{"/workspace", "/tmp"}) + }(i) + go func() { + defer wg.Done() + _ = s.EvaluateTool(ctx, ToolCallInfo{Name: "Read"}) + _ = s.PolicySnapshot() + _, _, _ = s.SpecProgress() + }() + } + wg.Wait() +} + func TestPermissionService_SandboxModeRoundTrip(t *testing.T) { s := NewPermissionService(nil) s.SetSandboxMode(sandbox.ModeStrict) diff --git a/internal/engine/stream.go b/internal/engine/stream.go index 334132f8..657714fa 100644 --- a/internal/engine/stream.go +++ b/internal/engine/stream.go @@ -309,12 +309,15 @@ func (s *Session) agentLoop(ctx context.Context, ch chan<- StreamEvent) { // explicit approval handoff before any changes. Ephemeral (not // persisted to s.Persistence().System()) so it disappears once the // stage advances to Implementing. - if s.Perm != nil && s.Perm.Stage != SpecStageNone && s.Perm.Stage != SpecStageImplementing { - opts.System += specStageSystemPrompt - // Inject user's spec configuration (language, framework, etc.) - // as context so the model writes specs that match preferences. - if cfgPrompt := specConfigForPrompt(); cfgPrompt != "" { - opts.System += cfgPrompt + if perms := s.PermSvc(); perms != nil { + stage := perms.SpecStage() + if stage != SpecStageNone && stage != SpecStageImplementing { + opts.System += specStageSystemPrompt + // Inject user's spec configuration (language, framework, etc.) + // as context so the model writes specs that match preferences. + if cfgPrompt := specConfigForPrompt(); cfgPrompt != "" { + opts.System += cfgPrompt + } } } if s.Tools() != nil && s.Tools().Registry() != nil { From 610b90b33a96899593292f0e4d41e20b97e73ee4 Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Sat, 1 Aug 2026 21:35:09 +0530 Subject: [PATCH 12/49] fix: make persistence messages snapshot-safe --- internal/engine/persistence_service.go | 64 +++++++++++++------ .../persistence_service_deadlock_test.go | 24 +++++++ 2 files changed, 69 insertions(+), 19 deletions(-) diff --git a/internal/engine/persistence_service.go b/internal/engine/persistence_service.go index 572adc90..85a33466 100644 --- a/internal/engine/persistence_service.go +++ b/internal/engine/persistence_service.go @@ -55,38 +55,31 @@ func NewPersistenceService(log *logger.Logger) *PersistenceService { func (s *PersistenceService) Messages() []types.EyrieMessage { s.mu.RLock() defer s.mu.RUnlock() - // Read s.messages directly; the lock is already held. Calling - // RawMessages() here would recursively RLock and can deadlock if a - // writer arrives between the two read locks (Go's RWMutex forbids - // recursive read-locking). - raw := s.messages - out := make([]types.EyrieMessage, len(raw)) - copy(out, raw) - return out + return cloneMessages(s.messages) } // SetRawMessages replaces the message slice. Used by code paths -// that previously wrote to s.messages directly. Pass-by-reference -// to keep the slice header mutable. Safe on a nil receiver. +// that previously wrote to s.messages directly. The input is copied so +// callers cannot mutate the persisted transcript after publication. func (s *PersistenceService) SetRawMessages(msgs []types.EyrieMessage) { if s == nil { return } s.mu.Lock() - s.messages = msgs + s.messages = cloneMessages(msgs) s.mu.Unlock() } -// RawMessages returns the live slice (no copy). Callers MUST NOT mutate. -// Used by the agent loop's hot path where copy overhead matters. -// Safe to call on a nil receiver (returns nil). +// RawMessages returns a snapshot copy of the transcript. Returning the live +// slice was race-prone because background work could retain it after the lock +// was released. Safe to call on a nil receiver (returns nil). func (s *PersistenceService) RawMessages() []types.EyrieMessage { if s == nil { return nil } s.mu.RLock() defer s.mu.RUnlock() - return s.messages + return cloneMessages(s.messages) } // Graph returns Hawk's product-owned conversation graph. @@ -112,7 +105,7 @@ func (s *PersistenceService) AddAssistant(content string) { // SetMessages replaces the transcript. func (s *PersistenceService) SetMessages(msgs []types.EyrieMessage) { s.mu.Lock() - s.messages = msgs + s.messages = cloneMessages(msgs) s.mu.Unlock() } @@ -216,12 +209,45 @@ func (s *PersistenceService) RemoveLastExchange() { // LoadMessages replaces the transcript with a fresh slice. func (s *PersistenceService) LoadMessages(msgs []types.EyrieMessage) { s.mu.Lock() - // Assign directly; the lock is held, so SetRawMessages() (which locks - // again) would deadlock on a recursive write lock. - s.messages = msgs + s.messages = cloneMessages(msgs) s.mu.Unlock() } +// cloneMessages copies the message slice and its mutable nested slices/maps. +// ContentParts are interface values and are copied at the slice boundary; the +// concrete content blocks remain owned by their callers. +func cloneMessages(msgs []types.EyrieMessage) []types.EyrieMessage { + if msgs == nil { + return nil + } + out := make([]types.EyrieMessage, len(msgs)) + for i, msg := range msgs { + out[i] = msg + out[i].Images = append([]string(nil), msg.Images...) + out[i].ContentParts = append([]types.ContentPart(nil), msg.ContentParts...) + out[i].ToolUse = cloneToolCalls(msg.ToolUse) + out[i].ToolResults = append([]types.ToolResult(nil), msg.ToolResults...) + } + return out +} + +func cloneToolCalls(calls []types.ToolCall) []types.ToolCall { + if calls == nil { + return nil + } + out := make([]types.ToolCall, len(calls)) + for i, call := range calls { + out[i] = call + if call.Arguments != nil { + out[i].Arguments = make(map[string]interface{}, len(call.Arguments)) + for key, value := range call.Arguments { + out[i].Arguments[key] = value + } + } + } + return out +} + // PinnedMessages returns the count of pinned messages. func (s *PersistenceService) PinnedMessages() int { return s.pinnedMessages } diff --git a/internal/engine/persistence_service_deadlock_test.go b/internal/engine/persistence_service_deadlock_test.go index 0fc3d62a..83d8d08d 100644 --- a/internal/engine/persistence_service_deadlock_test.go +++ b/internal/engine/persistence_service_deadlock_test.go @@ -38,3 +38,27 @@ func TestPersistenceServiceNoRecursiveLock(t *testing.T) { t.Fatal("PersistenceService deadlocked (recursive lock acquisition)") } } + +func TestPersistenceService_MessageSnapshotsDoNotAlias(t *testing.T) { + ps := NewPersistenceService(nil) + input := []types.EyrieMessage{{ + Role: "assistant", + Images: []string{"before.png"}, + ToolUse: []types.ToolCall{{Name: "Read", Arguments: map[string]interface{}{"path": "before.txt"}}}, + }} + ps.SetRawMessages(input) + input[0].Images[0] = "input-mutated.png" + input[0].ToolUse[0].Arguments["path"] = "input-mutated.txt" + + snapshot := ps.RawMessages() + snapshot[0].Images[0] = "snapshot-mutated.png" + snapshot[0].ToolUse[0].Arguments["path"] = "snapshot-mutated.txt" + + got := ps.Messages() + if got[0].Images[0] != "before.png" { + t.Fatalf("persisted image aliased caller snapshot: %q", got[0].Images[0]) + } + if got[0].ToolUse[0].Arguments["path"] != "before.txt" { + t.Fatalf("persisted tool arguments aliased caller snapshot: %v", got[0].ToolUse[0].Arguments) + } +} From 3ebe4825421be193f881139cce36dcd61e9f2f0a Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Sat, 1 Aug 2026 21:37:15 +0530 Subject: [PATCH 13/49] fix: track asynchronous hook lifecycle --- internal/hooks/hooks.go | 50 ++++++++++++++++++++++++++---- internal/hooks/hooks_extra_test.go | 28 +++++++++++++++++ 2 files changed, 72 insertions(+), 6 deletions(-) diff --git a/internal/hooks/hooks.go b/internal/hooks/hooks.go index 5b1ba5cc..21841ffa 100644 --- a/internal/hooks/hooks.go +++ b/internal/hooks/hooks.go @@ -63,6 +63,9 @@ type Hook struct { type Registry struct { mu sync.RWMutex hooks map[EventType][]Hook + // asyncWG tracks fire-and-forget hooks so owners can drain them during + // shutdown or deterministic tests. + asyncWG sync.WaitGroup } // NewRegistry creates a new hook registry. @@ -120,21 +123,53 @@ func (r *Registry) ExecuteEnvelope(ctx context.Context, env EventEnvelope) error return firstErr } -// ExecuteAsync runs hooks asynchronously (fire and forget). -// Uses a fresh context to avoid passing a cancelled caller context. -func (r *Registry) ExecuteAsync(_ context.Context, event EventType, data map[string]interface{}) { +// ExecuteAsync runs hooks asynchronously and records them for WaitAsync. +// The caller's values and trace span are preserved, but cancellation is +// detached so a normal request teardown does not kill post-event observers. +func (r *Registry) ExecuteAsync(ctx context.Context, event EventType, data map[string]interface{}) { + if ctx == nil { + ctx = context.Background() + } + ctx = context.WithoutCancel(ctx) + r.asyncWG.Add(1) go func() { - _ = r.Execute(context.Background(), event, data) + defer r.asyncWG.Done() + _ = r.Execute(ctx, event, data) }() } // ExecuteAsyncEnvelope runs hooks asynchronously using a typed EventEnvelope. -func (r *Registry) ExecuteAsyncEnvelope(_ context.Context, env EventEnvelope) { +func (r *Registry) ExecuteAsyncEnvelope(ctx context.Context, env EventEnvelope) { + if ctx == nil { + ctx = context.Background() + } + ctx = context.WithoutCancel(ctx) + r.asyncWG.Add(1) go func() { - _ = r.ExecuteEnvelope(context.Background(), env) + defer r.asyncWG.Done() + _ = r.ExecuteEnvelope(ctx, env) }() } +// WaitAsync waits for currently queued asynchronous hooks to finish or for +// ctx to expire. Callers must stop scheduling new async hooks before waiting. +func (r *Registry) WaitAsync(ctx context.Context) error { + if ctx == nil { + ctx = context.Background() + } + done := make(chan struct{}) + go func() { + r.asyncWG.Wait() + close(done) + }() + select { + case <-done: + return nil + case <-ctx.Done(): + return ctx.Err() + } +} + func sortHooks(hooks []Hook) { for i := 0; i < len(hooks); i++ { for j := i + 1; j < len(hooks); j++ { @@ -171,6 +206,9 @@ func ExecuteAsyncEnvelope(ctx context.Context, env EventEnvelope) { global.ExecuteAsyncEnvelope(ctx, env) } +// WaitAsync waits for currently queued package-level asynchronous hooks. +func WaitAsync(ctx context.Context) error { return global.WaitAsync(ctx) } + // AdaptLegacyFn wraps a legacy hook function into an EnvelopeFn. // The legacy function receives only the payload from the envelope. func AdaptLegacyFn(fn func(ctx context.Context, data map[string]interface{}) error) EnvelopeFn { diff --git a/internal/hooks/hooks_extra_test.go b/internal/hooks/hooks_extra_test.go index 0e00deea..51cccb2c 100644 --- a/internal/hooks/hooks_extra_test.go +++ b/internal/hooks/hooks_extra_test.go @@ -44,6 +44,34 @@ func TestRegistry_ExecuteAsync(t *testing.T) { mu.Unlock() } +func TestRegistry_ExecuteAsync_PreservesValuesAndCanDrain(t *testing.T) { + type contextKey string + const key contextKey = "trace" + r := NewRegistry() + seen := make(chan string, 1) + r.Register(Hook{ + Name: "drainable", + Event: "drainable_event", + Fn: func(ctx context.Context, _ map[string]interface{}) error { + value, _ := ctx.Value(key).(string) + seen <- value + return nil + }, + }) + + ctx, cancel := context.WithCancel(context.WithValue(context.Background(), key, "trace-123")) + cancel() + r.ExecuteAsync(ctx, "drainable_event", nil) + waitCtx, waitCancel := context.WithTimeout(context.Background(), time.Second) + defer waitCancel() + if err := r.WaitAsync(waitCtx); err != nil { + t.Fatalf("WaitAsync: %v", err) + } + if got := <-seen; got != "trace-123" { + t.Fatalf("hook context value = %q, want trace-123", got) + } +} + func TestRegistry_ExecuteAsync_NoHooks(t *testing.T) { r := NewRegistry() // Should not panic From 3bf003291a4d7bff9a52e9b2e6b54b5adc44fde6 Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Sat, 1 Aug 2026 21:39:52 +0530 Subject: [PATCH 14/49] fix: evaluate each tool call from one policy snapshot --- internal/engine/approval_gate.go | 12 +++++++++++- internal/engine/stream_tool_exec.go | 11 +++++++---- 2 files changed, 18 insertions(+), 5 deletions(-) diff --git a/internal/engine/approval_gate.go b/internal/engine/approval_gate.go index 1181eac3..70c766a7 100644 --- a/internal/engine/approval_gate.go +++ b/internal/engine/approval_gate.go @@ -176,7 +176,17 @@ func (s *Session) CheckApproval(_ context.Context, toolName string, args map[str } // Within the auto-approve threshold the operator has opted into automation. - if s.Autonomy <= g.MaxAutoApprove { + autonomy := s.Autonomy + if s.PermSvc() != nil { + autonomy = s.PermSvc().Autonomy() + // Preserve legacy callers that still assign a non-default tier directly + // to Session.Autonomy. Supervised is the zero value, so the service is + // authoritative when the legacy field is still at its default. + if s.Autonomy != AutonomySupervised { + autonomy = s.Autonomy + } + } + if autonomy <= g.MaxAutoApprove { return true, "" } diff --git a/internal/engine/stream_tool_exec.go b/internal/engine/stream_tool_exec.go index 9268fbcd..c34c92ff 100644 --- a/internal/engine/stream_tool_exec.go +++ b/internal/engine/stream_tool_exec.go @@ -10,6 +10,7 @@ import ( "strings" "sync" + "github.com/GrayCodeAI/hawk/internal/engine/safety" "github.com/GrayCodeAI/hawk/internal/tool" "github.com/GrayCodeAI/hawk/internal/types" @@ -288,11 +289,13 @@ func (s *Session) executeSingleToolWithTool(ctx context.Context, tc types.ToolCa if s.Autonomy != 0 { s.PermSvc().SetAutonomy(s.Autonomy) } - granted, denyMsg := s.PermSvc().CheckTool(ctx, ToolCallInfo{ + policySnapshot := s.PermSvc().PolicySnapshot() + decision := s.PermSvc().CheckToolSnapshot(ctx, ToolCallInfo{ Name: tc.Name, ID: tc.ID, Args: tc.Arguments, - }) + }, policySnapshot) + granted, denyMsg := decision.Outcome == safety.DecisionAllow, decision.Message s.recordPolicyObservation(tc, "permission", granted, denyMsg) if !granted { ch <- StreamEvent{Type: "tool_result", ToolName: tc.Name, Content: denyMsg} @@ -324,7 +327,7 @@ func (s *Session) executeSingleToolWithTool(ctx context.Context, tc types.ToolCa }) inputJSON, _ := json.Marshal(tc.Arguments) - sandboxMode := s.PermSvc().SandboxMode() + sandboxMode := policySnapshot.SandboxMode toolCtx := tool.WithToolContext(ctx, &tool.ToolContext{ AgentSpawnFn: s.AgentSpawnFn, AskUserFn: s.AskUserFn, @@ -351,7 +354,7 @@ func (s *Session) executeSingleToolWithTool(ctx context.Context, tc types.ToolCa BackgroundManager: s.ensureBackgroundManager(), ReadOnlyBash: s.readOnlyBash, WorkingDir: s.workingDir, - AllowedDirectories: s.PermSvc().AllowedDirs(), + AllowedDirectories: append([]string(nil), policySnapshot.AllowedDirs...), SandboxMode: sandboxMode, }) if sandboxMode != "" { From 650a133b023295acb2cfe4c60c95b9b71aea246e Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Sat, 1 Aug 2026 21:49:33 +0530 Subject: [PATCH 15/49] fix: harden plugin command execution --- internal/plugin/dynamic.go | 45 +++++++++++++++++++++++++++------ internal/plugin/dynamic_test.go | 3 +++ internal/plugin/manager.go | 29 ++++++++++++++++++--- internal/plugin/manager_test.go | 19 ++++++++++++++ 4 files changed, 85 insertions(+), 11 deletions(-) diff --git a/internal/plugin/dynamic.go b/internal/plugin/dynamic.go index 3570fb85..a1ec728b 100644 --- a/internal/plugin/dynamic.go +++ b/internal/plugin/dynamic.go @@ -9,6 +9,7 @@ import ( "os" "os/exec" "path/filepath" + "strings" "sync" "time" @@ -736,18 +737,46 @@ func isFullURL(s string) bool { // splitCommand splits a command string into parts (simple split on spaces). func splitCommand(cmd string) []string { var parts []string - current := "" + var current strings.Builder + var quote rune + escaped := false + flush := func() { + if current.Len() > 0 { + parts = append(parts, current.String()) + current.Reset() + } + } for _, c := range cmd { - if c == ' ' && current != "" { - parts = append(parts, current) - current = "" - } else if c != ' ' { - current += string(c) + if escaped { + current.WriteRune(c) + escaped = false + continue + } + if c == '\\' && quote != '\'' { + escaped = true + continue + } + if quote != 0 { + if c == quote { + quote = 0 + } else { + current.WriteRune(c) + } + continue + } + switch c { + case '\'', '"': + quote = c + case ' ', '\t', '\n', '\r': + flush() + default: + current.WriteRune(c) } } - if current != "" { - parts = append(parts, current) + if escaped { + current.WriteByte('\\') } + flush() return parts } diff --git a/internal/plugin/dynamic_test.go b/internal/plugin/dynamic_test.go index 7385ef9e..19c2b876 100644 --- a/internal/plugin/dynamic_test.go +++ b/internal/plugin/dynamic_test.go @@ -228,6 +228,9 @@ func TestSplitCommand(t *testing.T) { { "python -c print(1)", []string{"python", "-c", "print(1)"}, }, + {"echo 'hello world'", []string{"echo", "hello world"}}, + {"python -c \"print('x y')\"", []string{"python", "-c", "print('x y')"}}, + {"echo hello\\ world", []string{"echo", "hello world"}}, {"single", []string{"single"}}, {"", nil}, {" spaced out ", []string{"spaced", "out"}}, diff --git a/internal/plugin/manager.go b/internal/plugin/manager.go index 195b70ce..b3c3a509 100644 --- a/internal/plugin/manager.go +++ b/internal/plugin/manager.go @@ -17,6 +17,25 @@ import ( "github.com/GrayCodeAI/hawk/internal/storage" ) +const maxPluginOutputBytes = 8 << 20 + +type cappedBuffer struct { + bytes.Buffer + maxBytes int +} + +func (b *cappedBuffer) Write(p []byte) (int, error) { + remaining := b.maxBytes - b.Len() + if remaining <= 0 { + return 0, fmt.Errorf("plugin output exceeds %d bytes", b.maxBytes) + } + if len(p) > remaining { + _, _ = b.Buffer.Write(p[:remaining]) + return remaining, fmt.Errorf("plugin output exceeds %d bytes", b.maxBytes) + } + return b.Buffer.Write(p) +} + // Plugin represents a loaded plugin with its tools and metadata. type Plugin struct { Name string @@ -247,8 +266,9 @@ func (pm *PluginManager) Execute(ctx context.Context, pluginName, toolName strin return p.WasmRuntime.ExecuteTool(ctx, toolName, input) } - // Parse command and args (subprocess-based) - parts := strings.Fields(tool.Command) + // Parse command and args (subprocess-based). Quoted arguments are preserved; + // shell evaluation is intentionally not supported. + parts := splitCommand(tool.Command) if len(parts) == 0 { return "", fmt.Errorf("tool %q has empty command", toolName) } @@ -261,7 +281,10 @@ func (pm *PluginManager) Execute(ctx context.Context, pluginName, toolName strin cmd.Stdin = bytes.NewReader(input) } - var stdout, stderr bytes.Buffer + var stdout cappedBuffer + stdout.maxBytes = maxPluginOutputBytes + var stderr cappedBuffer + stderr.maxBytes = maxPluginOutputBytes cmd.Stdout = &stdout cmd.Stderr = &stderr diff --git a/internal/plugin/manager_test.go b/internal/plugin/manager_test.go index d7f5ae0f..148c5304 100644 --- a/internal/plugin/manager_test.go +++ b/internal/plugin/manager_test.go @@ -202,6 +202,25 @@ func TestToolExecutionEcho(t *testing.T) { } } +func TestCappedBufferRejectsExcessOutput(t *testing.T) { + t.Parallel() + + var b cappedBuffer + b.maxBytes = 4 + if _, err := b.Write([]byte("abc")); err != nil { + t.Fatalf("first write error: %v", err) + } + if n, err := b.Write([]byte("def")); err == nil || n != 1 { + t.Fatalf("second write = (%d, %v), want one byte and a limit error", n, err) + } + if got := b.String(); got != "abcd" { + t.Fatalf("buffer = %q, want %q", got, "abcd") + } + if n, err := b.Write([]byte("z")); err == nil || n != 0 { + t.Fatalf("write after limit = (%d, %v), want zero bytes and an error", n, err) + } +} + func TestToolExecutionWithStdinInput(t *testing.T) { // FIXME: test skipped in TestToolExecutionWithStdinInput if runtime.GOOS == "windows" { From fbfa373974102033c3b687d6b9696dc8f6cef251 Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Sat, 1 Aug 2026 21:52:00 +0530 Subject: [PATCH 16/49] fix: confine sandbox snapshot restores --- internal/sandbox/snapshot_sandbox.go | 71 ++++++++++++++++++++++- internal/sandbox/snapshot_sandbox_test.go | 43 ++++++++++++++ 2 files changed, 112 insertions(+), 2 deletions(-) diff --git a/internal/sandbox/snapshot_sandbox.go b/internal/sandbox/snapshot_sandbox.go index ff88338f..0207d6d2 100644 --- a/internal/sandbox/snapshot_sandbox.go +++ b/internal/sandbox/snapshot_sandbox.go @@ -325,6 +325,9 @@ func (m *SandboxManager) FormatStatus() string { // saveToDisk writes sandbox state to a JSON file on disk. func (m *SandboxManager) saveToDisk(sb *SandboxState) error { + if err := validateSandboxID(sb.ID); err != nil { + return err + } if err := os.MkdirAll(m.Dir, 0o750); err != nil { return err } @@ -338,6 +341,9 @@ func (m *SandboxManager) saveToDisk(sb *SandboxState) error { // loadFromDisk reads sandbox state from a JSON file on disk. func (m *SandboxManager) loadFromDisk(id string) (*SandboxState, error) { + if err := validateSandboxID(id); err != nil { + return nil, err + } path := filepath.Join(m.Dir, id+".json") data, err := os.ReadFile(path) // #nosec G304 -- path is rooted in m.Dir, our own sandbox state directory if err != nil { @@ -407,9 +413,16 @@ func restoreFiles(dir string, files map[string][]byte) error { if err := os.MkdirAll(dir, 0o750); err != nil { return err } + // Validate every path before writing anything so a malformed snapshot cannot + // partially restore and then fail on a later entry. + for rel := range files { + if err := validateRestorePath(rel); err != nil { + return err + } + } for rel, content := range files { - path := filepath.Join(dir, rel) - if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil { + path, err := prepareRestorePath(dir, rel) + if err != nil { return err } if err := os.WriteFile(path, content, 0o600); err != nil { @@ -419,6 +432,60 @@ func restoreFiles(dir string, files map[string][]byte) error { return nil } +func validateSandboxID(id string) error { + if id == "" || filepath.Base(id) != id || filepath.IsAbs(id) || filepath.VolumeName(id) != "" { + return fmt.Errorf("invalid sandbox id %q", id) + } + return nil +} + +func validateRestorePath(rel string) error { + if rel == "" || filepath.IsAbs(rel) || filepath.VolumeName(rel) != "" { + return fmt.Errorf("invalid snapshot path %q", rel) + } + clean := filepath.Clean(rel) + if clean == "." || clean == ".." || strings.HasPrefix(clean, ".."+string(filepath.Separator)) { + return fmt.Errorf("snapshot path escapes working directory: %q", rel) + } + return nil +} + +// prepareRestorePath creates missing directories while rejecting pre-existing +// symlinks, which prevents a snapshot from redirecting writes outside dir. +func prepareRestorePath(dir, rel string) (string, error) { + if err := validateRestorePath(rel); err != nil { + return "", err + } + root, err := filepath.Abs(dir) + if err != nil { + return "", err + } + current := root + parts := strings.Split(rel, string(filepath.Separator)) + for i, part := range parts { + current = filepath.Join(current, part) + info, statErr := os.Lstat(current) + if os.IsNotExist(statErr) { + if i < len(parts)-1 { + if err := os.Mkdir(current, 0o750); err != nil { + return "", err + } + } + continue + } + if statErr != nil { + return "", statErr + } + if info.Mode()&os.ModeSymlink != 0 { + return "", fmt.Errorf("snapshot path traverses symlink: %q", rel) + } + if i < len(parts)-1 && !info.IsDir() { + return "", fmt.Errorf("snapshot path traverses non-directory: %q", rel) + } + } + return current, nil +} + // copyFileMap creates a deep copy of a file map. func copyFileMap(m map[string][]byte) map[string][]byte { if m == nil { diff --git a/internal/sandbox/snapshot_sandbox_test.go b/internal/sandbox/snapshot_sandbox_test.go index f765b1f1..726f7acd 100644 --- a/internal/sandbox/snapshot_sandbox_test.go +++ b/internal/sandbox/snapshot_sandbox_test.go @@ -26,6 +26,49 @@ func TestNewSandboxManager(t *testing.T) { } } +func TestRestoreRejectsTraversalPath(t *testing.T) { + workDir := t.TempDir() + outside := filepath.Join(filepath.Dir(workDir), "escape.txt") + mgr := NewSandboxManager(t.TempDir()) + data, err := json.Marshal(SandboxState{ + ID: "sb-restore", + WorkDir: workDir, + Files: map[string][]byte{"../escape.txt": []byte("owned")}, + }) + if err != nil { + t.Fatal(err) + } + if _, err := mgr.Restore(data); err == nil { + t.Fatal("expected traversal path to be rejected") + } + if _, err := os.Stat(outside); !os.IsNotExist(err) { + t.Fatalf("outside file exists after rejected restore: %v", err) + } +} + +func TestRestoreRejectsSymlinkPath(t *testing.T) { + workDir := t.TempDir() + target := t.TempDir() + if err := os.Symlink(target, filepath.Join(workDir, "linked")); err != nil { + t.Skipf("symlinks unavailable: %v", err) + } + mgr := NewSandboxManager(t.TempDir()) + data, err := json.Marshal(SandboxState{ + ID: "sb-restore", + WorkDir: workDir, + Files: map[string][]byte{"linked/escape.txt": []byte("owned")}, + }) + if err != nil { + t.Fatal(err) + } + if _, err := mgr.Restore(data); err == nil { + t.Fatal("expected symlink path to be rejected") + } + if _, err := os.Stat(filepath.Join(target, "escape.txt")); !os.IsNotExist(err) { + t.Fatalf("symlink target was modified after rejected restore: %v", err) + } +} + func TestCreateSandbox(t *testing.T) { dir := t.TempDir() workDir := t.TempDir() From f33ff3759371d6d8852ffffdf1f6e689f0966706 Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Sat, 1 Aug 2026 21:53:56 +0530 Subject: [PATCH 17/49] fix: enforce private network proxy boundaries --- internal/sandbox/netproxy.go | 75 +++++++++++++++++++++++++++++-- internal/sandbox/netproxy_test.go | 26 +++++++++++ 2 files changed, 97 insertions(+), 4 deletions(-) diff --git a/internal/sandbox/netproxy.go b/internal/sandbox/netproxy.go index fcf4810f..efb8237a 100644 --- a/internal/sandbox/netproxy.go +++ b/internal/sandbox/netproxy.go @@ -37,6 +37,10 @@ type ProxyConfig struct { BlockedDomains []string Mode string // "allowlist", "blocklist", "open", "closed" LogRequests bool + // BlockPrivateNetworks rejects loopback, link-local, private, multicast, + // and unspecified destinations after DNS resolution. It is intentionally + // opt-in for compatibility; secure built-in configurations enable it. + BlockPrivateNetworks bool } // NetworkProxy provides domain-level network access control for commands @@ -155,6 +159,9 @@ func (np *NetworkProxy) IsAllowed(host string) bool { if np.BlockAll { return false } + if np.config.BlockPrivateNetworks && isPrivateHost(h) { + return false + } // Check blocked domains first (deny wins). for _, pattern := range np.BlockedDomains { @@ -238,7 +245,7 @@ func (np *NetworkProxy) handleConnect(w http.ResponseWriter, r *http.Request) { // Dial the target. dialCtx, cancel := context.WithTimeout(r.Context(), 10*time.Second) defer cancel() - targetConn, err := new(net.Dialer).DialContext(dialCtx, "tcp", host) + targetConn, err := np.dialTarget(dialCtx, "tcp", host) if err != nil { http.Error(w, fmt.Sprintf("Failed to connect to %s: %v", host, err), http.StatusBadGateway) return @@ -304,8 +311,17 @@ func (np *NetworkProxy) handleHTTP(w http.ResponseWriter, r *http.Request) { } } + baseTransport, ok := http.DefaultTransport.(*http.Transport) + if !ok { + http.Error(w, "proxy transport unavailable", http.StatusInternalServerError) + return + } + transport := baseTransport.Clone() + transport.Proxy = nil + transport.DialContext = np.dialTarget client := &http.Client{ - Timeout: 30 * time.Second, + Timeout: 30 * time.Second, + Transport: transport, // Don't follow redirects — let the caller handle them. CheckRedirect: func(req *http.Request, via []*http.Request) error { return http.ErrUseLastResponse @@ -331,6 +347,56 @@ func (np *NetworkProxy) handleHTTP(w http.ResponseWriter, r *http.Request) { atomic.AddInt64(&np.Stats.TotalBytes, n) } +// dialTarget resolves and dials a destination while enforcing the private +// network policy at the point where an address is actually selected. Checking +// only the hostname is insufficient because an allowed hostname can resolve +// to a loopback or private address (including through DNS rebinding). +func (np *NetworkProxy) dialTarget(ctx context.Context, network, address string) (net.Conn, error) { + host, port, err := net.SplitHostPort(address) + if err != nil { + return nil, fmt.Errorf("invalid target %q: %w", address, err) + } + ips, err := net.DefaultResolver.LookupIP(ctx, "ip", host) + if err != nil { + return nil, err + } + dialer := &net.Dialer{} + var lastErr error + for _, ip := range ips { + if np.config.BlockPrivateNetworks && isPrivateIP(ip) { + lastErr = fmt.Errorf("target %s resolves to a private address", host) + continue + } + conn, dialErr := dialer.DialContext(ctx, network, net.JoinHostPort(ip.String(), port)) + if dialErr == nil { + return conn, nil + } + lastErr = dialErr + } + if lastErr == nil { + lastErr = fmt.Errorf("no addresses resolved for %s", host) + } + return nil, lastErr +} + +func isPrivateHost(host string) bool { + if strings.EqualFold(host, "localhost") || strings.HasSuffix(strings.ToLower(host), ".localhost") { + return true + } + ip := net.ParseIP(strings.Trim(host, "[]")) + return ip != nil && isPrivateIP(ip) +} + +func isPrivateIP(ip net.IP) bool { + return ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast() || + ip.IsLinkLocalMulticast() || ip.IsUnspecified() || ip.IsMulticast() || isCarrierGradeNAT(ip) +} + +func isCarrierGradeNAT(ip net.IP) bool { + ip4 := ip.To4() + return ip4 != nil && ip4[0] == 100 && ip4[1] >= 64 && ip4[1] <= 127 +} + // recordRequest updates stats and log for a request. func (np *NetworkProxy) recordRequest(host, method string, allowed bool) { // Strip port for stats. @@ -433,7 +499,8 @@ func DefaultDevelopmentConfig() ProxyConfig { "10.*", "192.168.*", }, - Mode: "allowlist", - LogRequests: true, + Mode: "allowlist", + LogRequests: true, + BlockPrivateNetworks: true, } } diff --git a/internal/sandbox/netproxy_test.go b/internal/sandbox/netproxy_test.go index 80d7affc..06648e4b 100644 --- a/internal/sandbox/netproxy_test.go +++ b/internal/sandbox/netproxy_test.go @@ -364,6 +364,32 @@ func TestDefaultDevelopmentConfig_ModeIsAllowlist(t *testing.T) { } } +func TestDefaultDevelopmentConfig_BlocksPrivateDestinations(t *testing.T) { + proxy := NewNetworkProxy(DefaultDevelopmentConfig()) + for _, host := range []string{"localhost", "127.0.0.1", "[::1]", "10.0.0.1", "100.64.0.1"} { + if proxy.IsAllowed(host) { + t.Errorf("DefaultDevelopmentConfig should block private destination %q", host) + } + } +} + +func TestPrivateIPClassification(t *testing.T) { + for _, tc := range []struct { + ip string + private bool + }{ + {"127.0.0.1", true}, + {"169.254.169.254", true}, + {"10.0.0.1", true}, + {"100.64.0.1", true}, + {"8.8.8.8", false}, + } { + if got := isPrivateIP(net.ParseIP(tc.ip)); got != tc.private { + t.Errorf("isPrivateIP(%q) = %v, want %v", tc.ip, got, tc.private) + } + } +} + func TestStart_AssignsPort(t *testing.T) { proxy := NewNetworkProxy(ProxyConfig{ AllowedDomains: []string{"*"}, From 5567084ac1d1d549e2cb15f40ff76f362034c2c3 Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Sat, 1 Aug 2026 21:58:32 +0530 Subject: [PATCH 18/49] test: isolate package storage and clipboard checks --- cmd/chat_copy_e2e_test.go | 24 ++++++++-------- cmd/clipboard_test.go | 4 ++- cmd/main_test.go | 6 ++++ internal/config/main_test.go | 6 ++++ internal/engine/main_test.go | 6 ++++ internal/plugin/testmain_test.go | 7 +++++ internal/testutil/storage.go | 47 ++++++++++++++++++++++++++++++++ 7 files changed, 87 insertions(+), 13 deletions(-) diff --git a/cmd/chat_copy_e2e_test.go b/cmd/chat_copy_e2e_test.go index 7492596a..94a776ed 100644 --- a/cmd/chat_copy_e2e_test.go +++ b/cmd/chat_copy_e2e_test.go @@ -58,9 +58,7 @@ func runCopySelectionE2EPass(t *testing.T, pass int) { if !ok { t.Fatalf("pass %d: /copy all returned %T", pass, result) } - if !strings.Contains(lastSystemMessage(cm.messages), "Copied chat transcript") { - t.Fatalf("pass %d: /copy all: %s", pass, lastSystemMessage(cm.messages)) - } + assertCopySucceeded(t, pass, "/copy all", lastSystemMessage(cm.messages), "Copied chat transcript") m = cm result, _ = m.handleCommand("/copy") @@ -68,9 +66,7 @@ func runCopySelectionE2EPass(t *testing.T, pass int) { if !ok { t.Fatalf("pass %d: /copy returned %T", pass, result) } - if last := lastSystemMessage(cm.messages); !strings.Contains(last, "Copied") { - t.Fatalf("pass %d: /copy smart: %s", pass, last) - } + assertCopySucceeded(t, pass, "/copy smart", lastSystemMessage(cm.messages), "Copied") m = cm // Keyboard shortcut path @@ -79,9 +75,7 @@ func runCopySelectionE2EPass(t *testing.T, pass int) { if !ok { t.Fatalf("pass %d: handleCopyShortcut returned %T", pass, result) } - if !strings.Contains(lastSystemMessage(cm.messages), "Copied input") { - t.Fatalf("pass %d: Ctrl+Shift+C shortcut: %s", pass, lastSystemMessage(cm.messages)) - } + assertCopySucceeded(t, pass, "Ctrl+Shift+C shortcut", lastSystemMessage(cm.messages), "Copied input") m = cm if !isCopyToClipboardKey(tea.KeyPressMsg{Code: 'c', Mod: tea.ModAlt}) { @@ -130,9 +124,7 @@ func runCopySelectionE2EPass(t *testing.T, pass int) { if !ok { t.Fatalf("pass %d: /copy assistant returned %T", pass, result) } - if !strings.Contains(lastSystemMessage(cm.messages), "Copied assistant reply") { - t.Fatalf("pass %d: /copy assistant: %s", pass, lastSystemMessage(cm.messages)) - } + assertCopySucceeded(t, pass, "/copy assistant", lastSystemMessage(cm.messages), "Copied assistant reply") // Settings-backed mouse default disabled := false @@ -142,6 +134,14 @@ func runCopySelectionE2EPass(t *testing.T, pass int) { } } +func assertCopySucceeded(t *testing.T, pass int, operation, message, copiedText string) { + t.Helper() + if strings.Contains(message, copiedText) || strings.Contains(message, "Clipboard unavailable — saved") { + return + } + t.Fatalf("pass %d: %s: %s", pass, operation, message) +} + func lastSystemMessage(msgs []displayMsg) string { for i := len(msgs) - 1; i >= 0; i-- { if msgs[i].role == "system" || msgs[i].role == "error" { diff --git a/cmd/clipboard_test.go b/cmd/clipboard_test.go index 3ac1d45f..3df35c98 100644 --- a/cmd/clipboard_test.go +++ b/cmd/clipboard_test.go @@ -31,7 +31,9 @@ func TestClipboardRoundTrip(t *testing.T) { } text := "hawk clipboard test" - _ = copyToClipboard(text) // best-effort; fallback file may be used + if err := copyToClipboardNative(text); err != nil { + t.Skipf("native clipboard unavailable: %v", err) + } got, err := pasteFromClipboard() if err != nil { diff --git a/cmd/main_test.go b/cmd/main_test.go index 2d0e29d6..a2d64484 100644 --- a/cmd/main_test.go +++ b/cmd/main_test.go @@ -5,9 +5,15 @@ import ( "testing" "github.com/GrayCodeAI/hawk/internal/catalogtest" + "github.com/GrayCodeAI/hawk/internal/testutil" ) func TestMain(m *testing.M) { + cleanupStorage, err := testutil.InstallHermeticStorage() + if err != nil { + os.Exit(1) + } + defer cleanupStorage() cleanup := catalogtest.InstallGlobal() defer cleanup() os.Exit(m.Run()) diff --git a/internal/config/main_test.go b/internal/config/main_test.go index f70ea616..15a4fb04 100644 --- a/internal/config/main_test.go +++ b/internal/config/main_test.go @@ -5,9 +5,15 @@ import ( "testing" "github.com/GrayCodeAI/hawk/internal/catalogtest" + "github.com/GrayCodeAI/hawk/internal/testutil" ) func TestMain(m *testing.M) { + cleanupStorage, err := testutil.InstallHermeticStorage() + if err != nil { + os.Exit(1) + } + defer cleanupStorage() cleanup := catalogtest.InstallGlobal() defer cleanup() os.Exit(m.Run()) diff --git a/internal/engine/main_test.go b/internal/engine/main_test.go index 29926717..21db81da 100644 --- a/internal/engine/main_test.go +++ b/internal/engine/main_test.go @@ -5,9 +5,15 @@ import ( "testing" "github.com/GrayCodeAI/hawk/internal/catalogtest" + "github.com/GrayCodeAI/hawk/internal/testutil" ) func TestMain(m *testing.M) { + cleanupStorage, err := testutil.InstallHermeticStorage() + if err != nil { + os.Exit(1) + } + defer cleanupStorage() cleanup := catalogtest.InstallGlobal() defer cleanup() os.Exit(m.Run()) diff --git a/internal/plugin/testmain_test.go b/internal/plugin/testmain_test.go index 31206378..68ddf65b 100644 --- a/internal/plugin/testmain_test.go +++ b/internal/plugin/testmain_test.go @@ -3,9 +3,16 @@ package plugin import ( "os" "testing" + + "github.com/GrayCodeAI/hawk/internal/testutil" ) func TestMain(m *testing.M) { + cleanupStorage, err := testutil.InstallHermeticStorage() + if err != nil { + os.Exit(1) + } + defer cleanupStorage() if _, err := os.UserHomeDir(); err != nil { dir, mkErr := os.MkdirTemp("", "hawk-plugin-home-*") if mkErr != nil { diff --git a/internal/testutil/storage.go b/internal/testutil/storage.go index fb0f5c01..55108f01 100644 --- a/internal/testutil/storage.go +++ b/internal/testutil/storage.go @@ -1,6 +1,8 @@ package testutil import ( + "os" + "path/filepath" "testing" "github.com/GrayCodeAI/hawk/internal/storage" @@ -14,6 +16,51 @@ func IsolateStorage(t *testing.T) string { return root } +// InstallHermeticStorage gives package-level TestMain functions writable, +// isolated storage without overriding explicit caller configuration. The +// returned cleanup restores the environment and removes the temporary root. +func InstallHermeticStorage() (func(), error) { + root, err := os.MkdirTemp("", "hawk-test-storage-") + if err != nil { + return nil, err + } + keys := []string{"HOME", "HAWK_CONFIG_DIR", "HAWK_STATE_DIR", "HAWK_CACHE_DIR", "EYRIE_CONFIG_DIR"} + previous := make(map[string]string, len(keys)) + wasSet := make(map[string]bool, len(keys)) + for _, key := range keys { + value, ok := os.LookupEnv(key) + previous[key] = value + wasSet[key] = ok + } + if err := os.Setenv("HOME", root); err != nil { + _ = os.RemoveAll(root) + return nil, err + } + for key, suffix := range map[string]string{ + "HAWK_CONFIG_DIR": "config", + "HAWK_STATE_DIR": "state", + "HAWK_CACHE_DIR": "cache", + "EYRIE_CONFIG_DIR": "eyrie-config", + } { + if _, ok := os.LookupEnv(key); !ok { + if err := os.Setenv(key, filepath.Join(root, suffix)); err != nil { + _ = os.RemoveAll(root) + return nil, err + } + } + } + return func() { + for _, key := range keys { + if wasSet[key] { + _ = os.Setenv(key, previous[key]) + } else { + _ = os.Unsetenv(key) + } + } + _ = os.RemoveAll(root) + }, nil +} + // IsolateStorageIn configures isolated HOME and Hawk config/state/cache dirs rooted at root. func IsolateStorageIn(t *testing.T, root string) { t.Helper() From 4a982e2a67650144c3aa8d0562962c279b2a0a65 Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Sat, 1 Aug 2026 22:01:41 +0530 Subject: [PATCH 19/49] test: isolate sandbox and prompt storage --- internal/prompts/testmain_test.go | 17 +++++++++++++++++ internal/sandbox/testmain_test.go | 17 +++++++++++++++++ 2 files changed, 34 insertions(+) create mode 100644 internal/prompts/testmain_test.go create mode 100644 internal/sandbox/testmain_test.go diff --git a/internal/prompts/testmain_test.go b/internal/prompts/testmain_test.go new file mode 100644 index 00000000..01d52406 --- /dev/null +++ b/internal/prompts/testmain_test.go @@ -0,0 +1,17 @@ +package prompts + +import ( + "os" + "testing" + + "github.com/GrayCodeAI/hawk/internal/testutil" +) + +func TestMain(m *testing.M) { + cleanup, err := testutil.InstallHermeticStorage() + if err != nil { + os.Exit(1) + } + defer cleanup() + os.Exit(m.Run()) +} diff --git a/internal/sandbox/testmain_test.go b/internal/sandbox/testmain_test.go new file mode 100644 index 00000000..27543591 --- /dev/null +++ b/internal/sandbox/testmain_test.go @@ -0,0 +1,17 @@ +package sandbox + +import ( + "os" + "testing" + + "github.com/GrayCodeAI/hawk/internal/testutil" +) + +func TestMain(m *testing.M) { + cleanup, err := testutil.InstallHermeticStorage() + if err != nil { + os.Exit(1) + } + defer cleanup() + os.Exit(m.Run()) +} From 589c99c1daf1e9e39decfe20bb4ed02a951da431 Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Sat, 1 Aug 2026 22:03:38 +0530 Subject: [PATCH 20/49] docs: reconcile architecture extraction status --- docs/architecture/plan.md | 14 +++++++++++--- docs/session-decomposition.md | 26 +++++++++++++++++++++++++- 2 files changed, 36 insertions(+), 4 deletions(-) diff --git a/docs/architecture/plan.md b/docs/architecture/plan.md index 5c4a3027..6c283f41 100644 --- a/docs/architecture/plan.md +++ b/docs/architecture/plan.md @@ -8,15 +8,16 @@ This plan defines the technical approach for implementing the hawk architecture ### AD-1: God-Object Decomposition -**Decision:** Decompose the Session god-object into 7 cohesive sub-services. +**Decision:** Decompose the Session god-object into 7 cohesive sub-services, +with the safety boundary extracted first. **Rationale:** The Session struct accumulated 35+ collaborators over time. The decomposition phases (1-7) extract these into focused sub-services while maintaining backward compatibility via legacy field accessors. **Trade-offs:** - Pro: Clear responsibility boundaries, easier testing - Pro: Sub-services can be nil-checked independently -- Con: Legacy fields remain for backward compat -- Con: Two access paths (sub-service vs legacy field) during migration +- Con: Legacy fields remain for backward compatibility during migration +- Con: The remaining services still need extraction and interface seams **Implementation:** ``` @@ -29,6 +30,13 @@ Session └─ tools *ToolService (Phase 6: tool execution) ``` +**Current implementation boundary:** `PermissionService` now owns the +authoritative policy state and exposes an immutable per-tool `PolicySnapshot`. +Tool approval and execution consume that same snapshot. `PersistenceService` +also protects transcript ownership with deep-copy snapshots. The diagram above +remains the target for the unextracted Chat, Memory, Lifecycle, and Tool +services; it is not a claim that those fields have already moved. + ### AD-2: Spec-Driven Development Gate **Decision:** Gate all write/edit/bash tools behind spec workflow stages. diff --git a/docs/session-decomposition.md b/docs/session-decomposition.md index 91680bad..e06b8675 100644 --- a/docs/session-decomposition.md +++ b/docs/session-decomposition.md @@ -1,6 +1,7 @@ # Session God-Object Decomposition — Design Sketch -> Status: **DRAFT / NOT YET IMPLEMENTED** +> Status: **IN PROGRESS** — the safety and persistence portions are implemented; +> the remaining service extraction is still a design target. > Author: opencode session > Date: 2026-06-12 > Scope: `hawk/internal/engine/session.go` (the 35-collaborator `Session` struct) @@ -25,6 +26,29 @@ Break `Session` into ~6 cohesive sub-services, each with: The `agentLoop` should consume these sub-services as named dependencies — no implicit `s.Beliefs.Size()` reach-throughs. +## Implemented Safety Invariants (2026-08) + +The first extraction slice is now live and should be treated as a contract for +future work: + +- `PermissionService` is the authoritative source for autonomy, sandbox mode, + allowed directories, spec stage/slug, and policy rules. Legacy `Session` + fields remain only as compatibility shims for older callers. +- Each tool call captures one immutable `PolicySnapshot` before approval and + execution. The same snapshot supplies the approval decision and the + `ToolContext`, so a mid-call policy update cannot create a mixed-policy turn. +- Permission service state is guarded by an internal RW mutex; user approval + callbacks execute outside the lock. +- `PersistenceService` returns and stores deep-copied message snapshots. A + caller cannot mutate the live transcript by retaining a returned slice or + nested tool payload. +- Asynchronous hooks preserve context values after parent cancellation and can + be drained through `hooks.WaitAsync` during shutdown. + +The remaining decomposition work must preserve these invariants. In +particular, new tool or session services must consume `PolicySnapshot` rather +than reintroducing direct reads of legacy permission fields. + ## Proposed Decomposition ### 1. `ChatService` — owns the LLM transport From 6ddfa1d91eb3e5349218fa76c714e942e58e88fe Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Sat, 1 Aug 2026 22:08:53 +0530 Subject: [PATCH 21/49] test: clean up hermetic test roots --- cmd/main_test.go | 7 ++++--- internal/config/main_test.go | 7 ++++--- internal/engine/main_test.go | 7 ++++--- internal/plugin/marketplace_test.go | 5 ++--- internal/plugin/testmain_test.go | 6 ++++-- internal/prompts/testmain_test.go | 5 +++-- internal/sandbox/testmain_test.go | 5 +++-- 7 files changed, 24 insertions(+), 18 deletions(-) diff --git a/cmd/main_test.go b/cmd/main_test.go index a2d64484..1f6d9ee7 100644 --- a/cmd/main_test.go +++ b/cmd/main_test.go @@ -13,8 +13,9 @@ func TestMain(m *testing.M) { if err != nil { os.Exit(1) } - defer cleanupStorage() cleanup := catalogtest.InstallGlobal() - defer cleanup() - os.Exit(m.Run()) + code := m.Run() + cleanup() + cleanupStorage() + os.Exit(code) } diff --git a/internal/config/main_test.go b/internal/config/main_test.go index 15a4fb04..e3567512 100644 --- a/internal/config/main_test.go +++ b/internal/config/main_test.go @@ -13,8 +13,9 @@ func TestMain(m *testing.M) { if err != nil { os.Exit(1) } - defer cleanupStorage() cleanup := catalogtest.InstallGlobal() - defer cleanup() - os.Exit(m.Run()) + code := m.Run() + cleanup() + cleanupStorage() + os.Exit(code) } diff --git a/internal/engine/main_test.go b/internal/engine/main_test.go index 21db81da..98e9448b 100644 --- a/internal/engine/main_test.go +++ b/internal/engine/main_test.go @@ -13,8 +13,9 @@ func TestMain(m *testing.M) { if err != nil { os.Exit(1) } - defer cleanupStorage() cleanup := catalogtest.InstallGlobal() - defer cleanup() - os.Exit(m.Run()) + code := m.Run() + cleanup() + cleanupStorage() + os.Exit(code) } diff --git a/internal/plugin/marketplace_test.go b/internal/plugin/marketplace_test.go index 4dc1f865..2bb3f313 100644 --- a/internal/plugin/marketplace_test.go +++ b/internal/plugin/marketplace_test.go @@ -3,10 +3,10 @@ package plugin import ( "encoding/json" "net/http" - "net/http/httptest" "testing" "github.com/GrayCodeAI/hawk/internal/flags" + "github.com/GrayCodeAI/hawk/internal/testutil" ) func TestMarketplaceFind(t *testing.T) { @@ -20,10 +20,9 @@ func TestMarketplaceFind(t *testing.T) { {Name: "cool-plugin", Repo: "org/cool-plugin", Description: "cool", Version: "1.0.0"}, }, } - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + srv := testutil.NewLoopbackHTTPServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { _ = json.NewEncoder(w).Encode(idx) })) - t.Cleanup(srv.Close) mc := &MarketplaceClient{ Sources: []MarketplaceSource{{Name: "test", URL: srv.URL}}, diff --git a/internal/plugin/testmain_test.go b/internal/plugin/testmain_test.go index 68ddf65b..50ae7b6e 100644 --- a/internal/plugin/testmain_test.go +++ b/internal/plugin/testmain_test.go @@ -12,7 +12,6 @@ func TestMain(m *testing.M) { if err != nil { os.Exit(1) } - defer cleanupStorage() if _, err := os.UserHomeDir(); err != nil { dir, mkErr := os.MkdirTemp("", "hawk-plugin-home-*") if mkErr != nil { @@ -23,8 +22,11 @@ func TestMain(m *testing.M) { os.Exit(1) } code := m.Run() + cleanupStorage() _ = os.RemoveAll(dir) os.Exit(code) } - os.Exit(m.Run()) + code := m.Run() + cleanupStorage() + os.Exit(code) } diff --git a/internal/prompts/testmain_test.go b/internal/prompts/testmain_test.go index 01d52406..1346d683 100644 --- a/internal/prompts/testmain_test.go +++ b/internal/prompts/testmain_test.go @@ -12,6 +12,7 @@ func TestMain(m *testing.M) { if err != nil { os.Exit(1) } - defer cleanup() - os.Exit(m.Run()) + code := m.Run() + cleanup() + os.Exit(code) } diff --git a/internal/sandbox/testmain_test.go b/internal/sandbox/testmain_test.go index 27543591..767e5df6 100644 --- a/internal/sandbox/testmain_test.go +++ b/internal/sandbox/testmain_test.go @@ -12,6 +12,7 @@ func TestMain(m *testing.M) { if err != nil { os.Exit(1) } - defer cleanup() - os.Exit(m.Run()) + code := m.Run() + cleanup() + os.Exit(code) } From 4f476e6e7dc19332063bff4483ddf215221bae0e Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Sat, 1 Aug 2026 22:48:44 +0530 Subject: [PATCH 22/49] fix: harden permission and spec filesystem boundaries --- cmd/ai_comments.go | 6 ++- cmd/chat_commands.go | 1 + cmd/cloud_graph_test.go | 4 -- cmd/completions.go | 2 +- cmd/context_export.go | 3 +- cmd/daemon.go | 3 +- cmd/markdown.go | 2 +- internal/codegraph/multiview.go | 3 +- internal/config/catalog_startup.go | 4 +- internal/engine/errs/error_grouper.go | 2 +- internal/engine/history/head_tail.go | 4 +- internal/engine/io/ai_watch.go | 3 +- internal/engine/io/clipboard.go | 2 +- internal/engine/project/dep_updater.go | 4 +- internal/engine/project/migration_planner.go | 8 +-- internal/engine/project/project_metrics.go | 6 ++- internal/engine/project/release.go | 8 +-- internal/engine/self_heal.go | 12 ++--- internal/engine/stream.go | 8 +-- internal/fsutil/fsutil.go | 46 +++++++++++++++- internal/fsutil/fsutil_test.go | 15 ++++++ internal/mcp/ws.go | 7 ++- internal/prompts/workspace.go | 4 +- internal/sandbox/container.go | 2 +- internal/sandbox/devenv.go | 2 +- internal/sandbox/netproxy.go | 4 +- internal/sandbox/sandbox.go | 2 +- internal/spec/archive.go | 18 +++++-- internal/spec/state.go | 44 +++++++++++---- internal/spec/state_security_test.go | 20 +++++++ internal/spec/validator.go | 9 +++- internal/tool/backup.go | 8 +-- internal/tool/diagnostics.go | 8 +-- internal/tool/download_test.go | 11 ++-- internal/tool/file_edit.go | 4 +- internal/tool/git_commit.go | 4 +- internal/tool/git_fs.go | 12 ++--- internal/tool/git_history.go | 2 +- internal/tool/git_hooks.go | 28 ++++++++-- internal/tool/grep.go | 17 ++++-- internal/tool/impact.go | 19 +++++-- internal/tool/import_organizer.go | 12 +++-- internal/tool/lsp.go | 2 +- internal/tool/mcp_auth.go | 13 +++-- internal/tool/mcp_auth_test.go | 9 ++-- internal/tool/multiedit.go | 5 +- internal/tool/patch.go | 2 +- internal/tool/path_guard.go | 57 ++++++++++++++++++++ internal/tool/path_guard_root_test.go | 45 ++++++++++++++++ internal/tool/pr_generator.go | 2 +- internal/tool/refactor.go | 42 ++++++++------- internal/tool/spec.go | 28 ++++++---- internal/tool/structured_edit.go | 5 +- internal/tool/task_tools.go | 4 +- internal/tool/testmain_test.go | 18 +++++++ 55 files changed, 467 insertions(+), 148 deletions(-) create mode 100644 internal/spec/state_security_test.go create mode 100644 internal/tool/path_guard_root_test.go create mode 100644 internal/tool/testmain_test.go diff --git a/cmd/ai_comments.go b/cmd/ai_comments.go index 2d33e964..82b203fb 100644 --- a/cmd/ai_comments.go +++ b/cmd/ai_comments.go @@ -8,6 +8,8 @@ import ( "regexp" "sort" "strings" + + "github.com/GrayCodeAI/hawk/internal/fsutil" ) // AIDirective represents a found AI comment directive in a source file. @@ -54,7 +56,7 @@ func scanForAIComments(dir string, ignore []string) []AIDirective { if !aiSupportedExts[ext] { return nil } - data, err := os.ReadFile(path) // #nosec G304 -- path comes from filepath.Walk over the target directory + data, err := fsutil.ReadPinnedFile(path) if err != nil { return nil } @@ -207,5 +209,5 @@ func removeAIComment(path string, line int) error { } // #nosec G306 -- rewrites an existing project source file in place, matching typical source file permissions - return os.WriteFile(path, []byte(strings.Join(lines, "\n")), 0o644) + return fsutil.WritePinnedFile(path, []byte(strings.Join(lines, "\n")), 0o644) } diff --git a/cmd/chat_commands.go b/cmd/chat_commands.go index e4b725db..148cb674 100644 --- a/cmd/chat_commands.go +++ b/cmd/chat_commands.go @@ -165,6 +165,7 @@ func slashAliases() map[string]string { } } +// #nosec G101 -- command descriptions are static UI strings, not credentials. var slashDescriptions = map[string]string{ "/add": "Add files to conversation context", "/add-dir": "Add a directory to context", diff --git a/cmd/cloud_graph_test.go b/cmd/cloud_graph_test.go index c631bfdc..4604e87b 100644 --- a/cmd/cloud_graph_test.go +++ b/cmd/cloud_graph_test.go @@ -3,8 +3,6 @@ package cmd import "testing" func TestCloudGraphSyncCommandIsVisible(t *testing.T) { - t.Parallel() - command, _, err := rootCmd.Find([]string{"cloud", "graph", "sync"}) if err != nil { t.Fatalf("find cloud graph sync: %v", err) @@ -15,8 +13,6 @@ func TestCloudGraphSyncCommandIsVisible(t *testing.T) { } func TestCloudGraphSyncSupportsMissionGraphs(t *testing.T) { - t.Parallel() - command, _, err := rootCmd.Find([]string{"cloud", "graph", "sync"}) if err != nil { t.Fatalf("find cloud graph sync: %v", err) diff --git a/cmd/completions.go b/cmd/completions.go index 596b3fea..4e22ea37 100644 --- a/cmd/completions.go +++ b/cmd/completions.go @@ -572,7 +572,7 @@ func zshInstallPath() string { parts := strings.Split(fpath, ":") for _, p := range parts { if p != "" { - if info, err := os.Stat(p); err == nil && info.IsDir() { + if info, err := os.Stat(p); err == nil && info.IsDir() { // #nosec G703 -- shell completion only probes the user-selected directory return filepath.Join(p, "_hawk") } } diff --git a/cmd/context_export.go b/cmd/context_export.go index c6cfd833..a21a943e 100644 --- a/cmd/context_export.go +++ b/cmd/context_export.go @@ -9,6 +9,7 @@ import ( "path/filepath" "strings" + "github.com/GrayCodeAI/hawk/internal/fsutil" "github.com/GrayCodeAI/hawk/internal/intelligence/repomap" "github.com/GrayCodeAI/hawk/internal/tool" ) @@ -313,7 +314,7 @@ func renderCXML(dir string) (string, string, error) { skipped++ return nil } - data, err := os.ReadFile(path) // #nosec G304 -- path comes from filepath.WalkDir over the target directory + data, err := fsutil.ReadPinnedFile(path) if err != nil { skipped++ return nil diff --git a/cmd/daemon.go b/cmd/daemon.go index cd7d97b6..b1175e67 100644 --- a/cmd/daemon.go +++ b/cmd/daemon.go @@ -18,6 +18,7 @@ import ( "github.com/GrayCodeAI/hawk/internal/daemon" "github.com/GrayCodeAI/hawk/internal/engine" "github.com/GrayCodeAI/hawk/internal/executiongraph" + "github.com/GrayCodeAI/hawk/internal/fsutil" "github.com/GrayCodeAI/hawk/internal/multiagent/agents" "github.com/GrayCodeAI/hawk/internal/netutil" "github.com/GrayCodeAI/hawk/internal/observability/logger" @@ -150,7 +151,7 @@ func runDaemonStart(_ *cobra.Command, _ []string) error { } keyFile := filepath.Join(storage.DaemonRunDir(), "daemon.key") _ = os.MkdirAll(filepath.Dir(keyFile), 0o700) - if err := os.WriteFile(keyFile, []byte(apiKey), 0o600); err == nil { + if err := fsutil.WritePinnedFile(keyFile, []byte(apiKey), 0o600); err == nil { fmt.Printf("Full API key written to %s\n", keyFile) } diff --git a/cmd/markdown.go b/cmd/markdown.go index 0787bec4..d2644ca3 100644 --- a/cmd/markdown.go +++ b/cmd/markdown.go @@ -359,7 +359,7 @@ func isHorizontalRule(trimmed string) bool { return false } for _, c := range cleaned { - if byte(c) != ch { + if c != rune(ch) { return false } } diff --git a/internal/codegraph/multiview.go b/internal/codegraph/multiview.go index cea129a2..e2b93a59 100644 --- a/internal/codegraph/multiview.go +++ b/internal/codegraph/multiview.go @@ -4,6 +4,7 @@ import ( "go/ast" "go/parser" "go/token" + "strconv" "strings" ) @@ -360,5 +361,5 @@ func findEnclosingFunc(node *ast.File, pos token.Pos, fset *token.FileSet) strin } func itoa(i int) string { - return strings.TrimLeft(strings.Replace(string(rune(i/10+'0'))+string(rune(i%10+'0')), "", "", -1), "") + return strconv.Itoa(i) } diff --git a/internal/config/catalog_startup.go b/internal/config/catalog_startup.go index de6dab0b..506521a2 100644 --- a/internal/config/catalog_startup.go +++ b/internal/config/catalog_startup.go @@ -98,7 +98,7 @@ func PrepareCatalogForSession(ctx context.Context, out io.Writer, opts CatalogSt // the background instead of blocking startup on the network (print // mode would otherwise stall up to 90s before the first token). go func() { - bgCtx, cancel := context.WithTimeout(context.Background(), 90*time.Second) + bgCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 90*time.Second) defer cancel() _ = AutoRefreshCatalog(bgCtx, nil, false) }() @@ -203,7 +203,7 @@ func StartupCatalogPrefetch(ctx context.Context) { return } go func() { - bgCtx, cancel := context.WithTimeout(context.Background(), 90*time.Second) + bgCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 90*time.Second) defer cancel() _ = AutoRefreshCatalog(bgCtx, nil, false) }() diff --git a/internal/engine/errs/error_grouper.go b/internal/engine/errs/error_grouper.go index a4f8245d..71cfd3fd 100644 --- a/internal/engine/errs/error_grouper.go +++ b/internal/engine/errs/error_grouper.go @@ -71,7 +71,7 @@ func NormalizeError(msg string) string { func groupID(normalized string) string { var h uint32 - for _, c := range normalized { + for _, c := range []byte(normalized) { h = h*31 + uint32(c) } return fmt.Sprintf("eg_%08x", h) diff --git a/internal/engine/history/head_tail.go b/internal/engine/history/head_tail.go index 46f8c531..b28fe268 100644 --- a/internal/engine/history/head_tail.go +++ b/internal/engine/history/head_tail.go @@ -348,11 +348,11 @@ func formatWindowTokens(n int) string { s := fmt.Sprintf("%d", n) result := []byte{} - for i, c := range s { + for i := range s { if i > 0 && (len(s)-i)%3 == 0 { result = append(result, ',') } - result = append(result, byte(c)) + result = append(result, s[i]) } return string(result) } diff --git a/internal/engine/io/ai_watch.go b/internal/engine/io/ai_watch.go index f89b5a41..6c589e89 100644 --- a/internal/engine/io/ai_watch.go +++ b/internal/engine/io/ai_watch.go @@ -13,6 +13,7 @@ import ( "sync" "time" + "github.com/GrayCodeAI/hawk/internal/fsutil" "github.com/fsnotify/fsnotify" ) @@ -443,7 +444,7 @@ func RemoveComment(file string, line int, marker string) error { lines[line-1] = strings.TrimRight(lines[line-1], " \t") } - return os.WriteFile(file, []byte(strings.Join(lines, "\n")), 0o600) + return fsutil.WritePinnedFile(file, []byte(strings.Join(lines, "\n")), 0o600) } // commentHash produces a unique hash for a comment based on file, line, and text. diff --git a/internal/engine/io/clipboard.go b/internal/engine/io/clipboard.go index 9b354bbd..a3d73562 100644 --- a/internal/engine/io/clipboard.go +++ b/internal/engine/io/clipboard.go @@ -156,7 +156,7 @@ func WriteClipboard(content string) error { return fmt.Errorf("clipboard: no clipboard tool found (install xclip or xsel)") } case "windows": - cmd = exec.CommandContext(context.Background(), "powershell", "-command", "Set-Clipboard", "-Value", content) + cmd = exec.CommandContext(context.Background(), "powershell", "-command", "Set-Clipboard", "-Value", content) // #nosec G204 -- fixed PowerShell command; clipboard content is one isolated argument return cmd.Run() default: return fmt.Errorf("clipboard: unsupported platform %s", runtime.GOOS) diff --git a/internal/engine/project/dep_updater.go b/internal/engine/project/dep_updater.go index bac96cb7..01ff56f3 100644 --- a/internal/engine/project/dep_updater.go +++ b/internal/engine/project/dep_updater.go @@ -12,6 +12,8 @@ import ( "strings" "sync" + "github.com/GrayCodeAI/hawk/internal/fsutil" + "github.com/GrayCodeAI/hawk/internal/ui/icons" ) @@ -384,7 +386,7 @@ func (du *DependencyUpdater) applyRustUpdate(dep Dependency) error { content = strings.Replace(content, oldPattern, newPattern, 1) } - if err := os.WriteFile(cargoPath, []byte(content), 0o600); err != nil { + if err := fsutil.WritePinnedFile(cargoPath, []byte(content), 0o600); err != nil { return fmt.Errorf("failed to write Cargo.toml: %w", err) } return nil diff --git a/internal/engine/project/migration_planner.go b/internal/engine/project/migration_planner.go index 14ac4e19..60f7f52a 100644 --- a/internal/engine/project/migration_planner.go +++ b/internal/engine/project/migration_planner.go @@ -11,6 +11,8 @@ import ( "strings" "sync" "time" + + "github.com/GrayCodeAI/hawk/internal/fsutil" ) // MigrationPlan represents a complete plan for a large-scale code migration. @@ -506,7 +508,7 @@ func (mp *MigrationPlanner) Rollback(plan *MigrationPlan) error { if !ok { return fmt.Errorf("no backup found for %s", f) } - if err := os.WriteFile(f, backup, 0o600); err != nil { + if err := fsutil.WritePinnedFile(f, backup, 0o600); err != nil { return fmt.Errorf("restoring %s: %w", f, err) } } @@ -544,7 +546,7 @@ func (mp *MigrationPlanner) executeStep(step *MigrationStep) error { } newContent := re.ReplaceAll(content, []byte(step.Replacement)) - if err := os.WriteFile(f, newContent, 0o600); err != nil { + if err := fsutil.WritePinnedFile(f, newContent, 0o600); err != nil { return fmt.Errorf("writing %s: %w", f, err) } } @@ -572,7 +574,7 @@ func (mp *MigrationPlanner) findFilesContaining(text string) ([]string, error) { if !isTextFile(path) { return nil } - content, readErr := os.ReadFile(path) // #nosec G304 -- path provided by caller via tool/task parameters, inherent to this dev CLI's file operations + content, readErr := fsutil.ReadPinnedFile(path) if readErr != nil { return nil } diff --git a/internal/engine/project/project_metrics.go b/internal/engine/project/project_metrics.go index 13356f89..afc687f2 100644 --- a/internal/engine/project/project_metrics.go +++ b/internal/engine/project/project_metrics.go @@ -10,6 +10,8 @@ import ( "os" "path/filepath" "strings" + + "github.com/GrayCodeAI/hawk/internal/fsutil" ) // This file holds the quantitative project metrics gathered by ProjectAnalyzer @@ -169,7 +171,7 @@ func (pa *ProjectAnalyzer) hasPatternInFiles(pattern string) bool { if !strings.HasSuffix(path, ".go") || strings.HasSuffix(path, "_test.go") { return nil } - data, readErr := os.ReadFile(path) // #nosec G304 -- path provided by caller via tool/task parameters, inherent to this dev CLI's file operations + data, readErr := fsutil.ReadPinnedFile(path) if readErr != nil { return nil } @@ -198,7 +200,7 @@ func (pa *ProjectAnalyzer) hasPatternInTestFiles(pattern string) bool { if !strings.HasSuffix(path, "_test.go") { return nil } - data, readErr := os.ReadFile(path) // #nosec G304 -- path provided by caller via tool/task parameters, inherent to this dev CLI's file operations + data, readErr := fsutil.ReadPinnedFile(path) if readErr != nil { return nil } diff --git a/internal/engine/project/release.go b/internal/engine/project/release.go index 26e6bbb3..f24162ae 100644 --- a/internal/engine/project/release.go +++ b/internal/engine/project/release.go @@ -14,6 +14,8 @@ import ( "strings" "sync" "time" + + "github.com/GrayCodeAI/hawk/internal/fsutil" ) // ReleaseManager handles release automation including changelog generation, @@ -644,7 +646,7 @@ func UpdateVersionFile(version, filePath string) error { return fmt.Errorf("no version pattern found in %s", filePath) } - if err := os.WriteFile(filePath, []byte(updated), 0o600); err != nil { + if err := fsutil.WritePinnedFile(filePath, []byte(updated), 0o600); err != nil { return fmt.Errorf("failed to write file %s: %w", filePath, err) } @@ -714,11 +716,11 @@ func formatNumber(n int) string { s := strconv.Itoa(n) var result []byte - for i, digit := range s { + for i := range s { if i > 0 && (len(s)-i)%3 == 0 { result = append(result, ',') } - result = append(result, byte(digit)) + result = append(result, s[i]) } return string(result) } diff --git a/internal/engine/self_heal.go b/internal/engine/self_heal.go index 4d13e64a..8c2c3672 100644 --- a/internal/engine/self_heal.go +++ b/internal/engine/self_heal.go @@ -351,7 +351,7 @@ func (sh *SelfHealer) ApplyFixes(fixes []FileFix) error { } func (sh *SelfHealer) applyFix(fix FileFix) error { - data, err := os.ReadFile(fix.File) + data, err := tool.ReadPinnedFile(fix.File) if err != nil { return err } @@ -365,7 +365,7 @@ func (sh *SelfHealer) applyFix(fix FileFix) error { content := string(data) if strings.Contains(content, fix.OldContent) { content = strings.Replace(content, fix.OldContent, fix.NewContent, 1) - return os.WriteFile(fix.File, []byte(content), 0o600) + return tool.WritePinnedFile(fix.File, []byte(content), 0o600) } } // Fallback: replace by line number @@ -384,7 +384,7 @@ func (sh *SelfHealer) applyFix(fix FileFix) error { result = append(result, lines[:startIdx]...) result = append(result, newLines...) result = append(result, lines[endIdx:]...) - return os.WriteFile(fix.File, []byte(strings.Join(result, "\n")), 0o600) + return tool.WritePinnedFile(fix.File, []byte(strings.Join(result, "\n")), 0o600) } case "insert": @@ -400,14 +400,14 @@ func (sh *SelfHealer) applyFix(fix FileFix) error { result = append(result, lines[:insertIdx]...) result = append(result, newLines...) result = append(result, lines[insertIdx:]...) - return os.WriteFile(fix.File, []byte(strings.Join(result, "\n")), 0o600) + return tool.WritePinnedFile(fix.File, []byte(strings.Join(result, "\n")), 0o600) case "delete": if fix.OldContent != "" { content := string(data) if strings.Contains(content, fix.OldContent) { content = strings.Replace(content, fix.OldContent, "", 1) - return os.WriteFile(fix.File, []byte(content), 0o600) + return tool.WritePinnedFile(fix.File, []byte(content), 0o600) } } // Fallback: delete by line number @@ -415,7 +415,7 @@ func (sh *SelfHealer) applyFix(fix FileFix) error { result := make([]string, 0, len(lines)-1) result = append(result, lines[:fix.Line-1]...) result = append(result, lines[fix.Line:]...) - return os.WriteFile(fix.File, []byte(strings.Join(result, "\n")), 0o600) + return tool.WritePinnedFile(fix.File, []byte(strings.Join(result, "\n")), 0o600) } } diff --git a/internal/engine/stream.go b/internal/engine/stream.go index 657714fa..7f236cc9 100644 --- a/internal/engine/stream.go +++ b/internal/engine/stream.go @@ -654,7 +654,7 @@ func (s *Session) agentLoop(ctx context.Context, ch chan<- StreamEvent) { if s.MemorySvc().Memory() != nil && shouldRemember(textContent.String()) { go func(content string) { // Use timeout context so goroutine doesn't hang if backend is slow. - rCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + rCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 10*time.Second) defer cancel() _ = s.MemorySvc().Memory().Remember(content, "assistant_learning") _ = rCtx // timeout context available if Remember is extended to accept it @@ -677,7 +677,7 @@ func (s *Session) agentLoop(ctx context.Context, ch chan<- StreamEvent) { } prompt := s.MemorySvc().Sleeptime().BuildConsolidationPrompt(transcript, memState) // Use timeout context to prevent goroutine leak if LLM hangs - sCtx, sCancel := context.WithTimeout(context.Background(), 2*time.Minute) + sCtx, sCancel := context.WithTimeout(context.WithoutCancel(ctx), 2*time.Minute) defer sCancel() resp, err := s.ChatLLM().Chat(sCtx, []types.EyrieMessage{ {Role: "user", Content: prompt}, @@ -709,7 +709,7 @@ func (s *Session) agentLoop(ctx context.Context, ch chan<- StreamEvent) { sd := s.MemorySvc().SkillDistiller() prompt := sd.BuildSkillPrompt(taskDesc, tools, files, textContent.String()) // Use timeout context to prevent goroutine leak if LLM hangs - dCtx, dCancel := context.WithTimeout(context.Background(), 2*time.Minute) + dCtx, dCancel := context.WithTimeout(context.WithoutCancel(ctx), 2*time.Minute) defer dCancel() resp, err := s.ChatLLM().Chat(dCtx, []types.EyrieMessage{ {Role: "user", Content: prompt}, @@ -786,7 +786,7 @@ func (s *Session) agentLoop(ctx context.Context, ch chan<- StreamEvent) { go func() { // Bound the snapshot so a slow filesystem doesn't // leak a goroutine after the session ends. - snapCtx, snapCancel := context.WithTimeout(context.Background(), 30*time.Second) + snapCtx, snapCancel := context.WithTimeout(context.WithoutCancel(ctx), 30*time.Second) defer snapCancel() _, _ = s.Snapshots.TrackCtx(snapCtx, strings.Join(writeNames, ", ")) }() diff --git a/internal/fsutil/fsutil.go b/internal/fsutil/fsutil.go index bf89c468..0eca9e46 100644 --- a/internal/fsutil/fsutil.go +++ b/internal/fsutil/fsutil.go @@ -1,7 +1,11 @@ // Package fsutil provides small shared filesystem helpers. package fsutil -import "os" +import ( + "fmt" + "os" + "path/filepath" +) // Exists reports whether path exists on disk (following symlinks). // It returns false for any stat error, including permission errors. @@ -9,3 +13,43 @@ func Exists(path string) bool { _, err := os.Stat(path) return err == nil } + +// ReadPinnedFile reads a file through a directory handle pinned to its +// canonical parent. This closes the symlink/rename race between path +// validation and the read operation. +func ReadPinnedFile(path string) ([]byte, error) { + root, name, err := openPinnedParent(path) + if err != nil { + return nil, err + } + defer func() { _ = root.Close() }() + return root.ReadFile(name) +} + +// WritePinnedFile writes a file through a directory handle pinned to its +// canonical parent. The parent must already exist. +func WritePinnedFile(path string, data []byte, perm os.FileMode) error { + root, name, err := openPinnedParent(path) + if err != nil { + return err + } + defer func() { _ = root.Close() }() + return root.WriteFile(name, data, perm) +} + +func openPinnedParent(path string) (*os.Root, string, error) { + abs, err := filepath.Abs(path) + if err != nil { + return nil, "", err + } + if resolved, resolveErr := filepath.EvalSymlinks(abs); resolveErr == nil { + abs = resolved + } else if resolvedDir, dirErr := filepath.EvalSymlinks(filepath.Dir(abs)); dirErr == nil { + abs = filepath.Join(resolvedDir, filepath.Base(abs)) + } + root, err := os.OpenRoot(filepath.Dir(abs)) + if err != nil { + return nil, "", fmt.Errorf("open pinned parent: %w", err) + } + return root, filepath.Base(abs), nil +} diff --git a/internal/fsutil/fsutil_test.go b/internal/fsutil/fsutil_test.go index d3621e64..abbc54c2 100644 --- a/internal/fsutil/fsutil_test.go +++ b/internal/fsutil/fsutil_test.go @@ -19,3 +19,18 @@ func TestExists(t *testing.T) { t.Error("Exists(missing) = true, want false") } } + +func TestPinnedFileReadWrite(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "value.txt") + if err := WritePinnedFile(path, []byte("hello"), 0o600); err != nil { + t.Fatal(err) + } + data, err := ReadPinnedFile(path) + if err != nil { + t.Fatal(err) + } + if string(data) != "hello" { + t.Fatalf("ReadPinnedFile() = %q, want hello", data) + } +} diff --git a/internal/mcp/ws.go b/internal/mcp/ws.go index 6d6b5aac..73cd47e6 100644 --- a/internal/mcp/ws.go +++ b/internal/mcp/ws.go @@ -352,15 +352,18 @@ func (s *WSServer) readFrame() (fin bool, opcode int, payload []byte, err error) func (s *WSServer) writeFrame(opcode int, payload []byte) error { s.writeM.Lock() defer s.writeM.Unlock() + if opcode < 0 || opcode > 0x0f { + return fmt.Errorf("invalid websocket opcode %d", opcode) + } var hdr []byte - b0 := byte(0x80 | opcode) // FIN + opcode + b0 := 0x80 | byte(opcode) // FIN + opcode hdr = append(hdr, b0) n := len(payload) switch { case n <= 125: - hdr = append(hdr, byte(0x80|n)) // mask bit + length + hdr = append(hdr, 0x80|byte(n)) // mask bit + length case n <= 0xFFFF: hdr = append(hdr, 0x80|126) var ext [2]byte diff --git a/internal/prompts/workspace.go b/internal/prompts/workspace.go index 129ce088..587dba33 100644 --- a/internal/prompts/workspace.go +++ b/internal/prompts/workspace.go @@ -8,6 +8,8 @@ import ( "path/filepath" "sort" "strings" + + "github.com/GrayCodeAI/hawk/internal/fsutil" ) // WorkspaceContext gathers git and project info for prompt injection. @@ -158,7 +160,7 @@ func readGitBranch(dir string) string { gitDir = filepath.Join(dir, gitDir) } headPath = filepath.Join(gitDir, "HEAD") - data, err = os.ReadFile(headPath) // #nosec G304 -- path is derived from the workspace directory being inspected, not external input + data, err = fsutil.ReadPinnedFile(headPath) if err != nil { return "" } diff --git a/internal/sandbox/container.go b/internal/sandbox/container.go index 326d1660..d966993a 100644 --- a/internal/sandbox/container.go +++ b/internal/sandbox/container.go @@ -203,7 +203,7 @@ func (c *ContainerSandbox) BuildFromDockerfile(ctx context.Context, dockerfile s if err := os.MkdirAll(filepath.Dir(dfPath), 0o750); err != nil { return "", err } - if err := os.WriteFile(dfPath, []byte(dockerfile), 0o600); err != nil { + if err := os.WriteFile(dfPath, []byte(dockerfile), 0o600); err != nil { // #nosec G703 -- path is the managed project-state Dockerfile return "", err } diff --git a/internal/sandbox/devenv.go b/internal/sandbox/devenv.go index fcfbad91..4d05bacb 100644 --- a/internal/sandbox/devenv.go +++ b/internal/sandbox/devenv.go @@ -202,7 +202,7 @@ func (d *DevEnvManager) augmentDockerfile(path string) (string, error) { } augmented := d.runtime.AppendExtraDeps(string(data)) outPath := filepath.Join(filepath.Dir(path), "Dockerfile.hawk-runtime") - if err := os.WriteFile(outPath, []byte(augmented), 0o600); err != nil { + if err := os.WriteFile(outPath, []byte(augmented), 0o600); err != nil { // #nosec G703 -- sibling output is derived from the caller's project Dockerfile return "", err } return outPath, nil diff --git a/internal/sandbox/netproxy.go b/internal/sandbox/netproxy.go index efb8237a..c8516244 100644 --- a/internal/sandbox/netproxy.go +++ b/internal/sandbox/netproxy.go @@ -298,7 +298,7 @@ func (np *NetworkProxy) handleHTTP(w http.ResponseWriter, r *http.Request) { } // Forward the request. - outReq, err := http.NewRequestWithContext(r.Context(), r.Method, r.URL.String(), r.Body) + outReq, err := http.NewRequestWithContext(r.Context(), r.Method, r.URL.String(), r.Body) // #nosec G704 -- IsAllowed validates the host and dialTarget revalidates resolved addresses if err != nil { http.Error(w, fmt.Sprintf("Failed to create request: %v", err), http.StatusInternalServerError) return @@ -328,7 +328,7 @@ func (np *NetworkProxy) handleHTTP(w http.ResponseWriter, r *http.Request) { }, } - resp, err := client.Do(outReq) + resp, err := client.Do(outReq) // #nosec G704 -- transport is policy-bound with Proxy disabled and dialTarget enforcement if err != nil { http.Error(w, fmt.Sprintf("Failed to forward request: %v", err), http.StatusBadGateway) return diff --git a/internal/sandbox/sandbox.go b/internal/sandbox/sandbox.go index a5fdda84..feeb2399 100644 --- a/internal/sandbox/sandbox.go +++ b/internal/sandbox/sandbox.go @@ -297,5 +297,5 @@ func copyFile(src, dst string) error { if err != nil { return err } - return os.WriteFile(dst, data, 0o755) // #nosec G306 -- copied binary must remain executable inside the chroot + return os.WriteFile(dst, data, 0o755) // #nosec G703,G306 -- dst is constructed beneath the private sandbox root; binary must remain executable } diff --git a/internal/spec/archive.go b/internal/spec/archive.go index 57bc93cd..1ef3d11e 100644 --- a/internal/spec/archive.go +++ b/internal/spec/archive.go @@ -21,7 +21,10 @@ func Archive(slug string) (string, error) { if err != nil { return "", err } - specDir := filepath.Join(dir, slug) + specDir, err := specDir(dir, slug) + if err != nil { + return "", err + } // Verify the spec directory exists if _, err := os.Stat(specDir); os.IsNotExist(err) { @@ -48,7 +51,7 @@ func Archive(slug string) (string, error) { if readErr == nil { merged, mergeErr := ApplyDelta(string(baseData), delta) if mergeErr == nil { - _ = os.WriteFile(baseSpecPath, []byte(merged), 0o600) + _ = os.WriteFile(baseSpecPath, []byte(merged), 0o600) // #nosec G703 -- baseSpecPath is confined beneath a validated spec slug } } } @@ -101,7 +104,10 @@ func AssessConvergence(slug string) ConvergenceReport { Summary: fmt.Sprintf("cannot access specs: %v", err), } } - specDir := filepath.Join(dir, slug) + specDir, err := specDir(dir, slug) + if err != nil { + return ConvergenceReport{Summary: err.Error()} + } report := ConvergenceReport{Converged: true} @@ -190,7 +196,11 @@ func AppendConvergenceTasks(slug string, report ConvergenceReport) (string, erro if err != nil { return "", err } - tasksPath := filepath.Join(dir, slug, "tasks.md") + specPath, err := specDir(dir, slug) + if err != nil { + return "", err + } + tasksPath := filepath.Join(specPath, "tasks.md") existing := readFileOrEmpty(tasksPath) diff --git a/internal/spec/state.go b/internal/spec/state.go index d9408b73..d3b8645e 100644 --- a/internal/spec/state.go +++ b/internal/spec/state.go @@ -5,9 +5,19 @@ import ( "fmt" "os" "path/filepath" + "regexp" "time" ) +var validSlug = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$`) + +func specDir(root, slug string) (string, error) { + if !validSlug.MatchString(slug) || slug == "." || slug == ".." { + return "", fmt.Errorf("invalid spec slug %q", slug) + } + return filepath.Join(root, slug), nil +} + // StageMeta is persisted to .hawk/specs//spec.json to enable // cross-session spec workflow recovery. type StageMeta struct { @@ -60,7 +70,11 @@ func WriteStageMeta(slug, stage, schema, title string) error { if err != nil { return fmt.Errorf("marshal meta: %w", err) } - path := filepath.Join(dir, slug, "spec.json") + specPath, err := specDir(dir, slug) + if err != nil { + return err + } + path := filepath.Join(specPath, "spec.json") if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { return fmt.Errorf("mkdir meta: %w", err) } @@ -76,8 +90,12 @@ func LoadStageMeta(slug string) *StageMeta { if err != nil { return nil } - path := filepath.Join(dir, slug, "spec.json") - data, err := os.ReadFile(path) // #nosec G304 -- path built from SpecsRoot()+slug, internal spec stage metadata + specPath, err := specDir(dir, slug) + if err != nil { + return nil + } + path := filepath.Join(specPath, "spec.json") + data, err := os.ReadFile(path) // #nosec G304 -- path is confined by specDir's validated slug if err != nil { return nil } @@ -122,11 +140,14 @@ func StageFromFiles(slug string) string { if err != nil { return "" } - specDir := filepath.Join(dir, slug) + specPath, err := specDir(dir, slug) + if err != nil { + return "" + } - hasSpec := fileExists(filepath.Join(specDir, "spec.md")) - hasPlan := fileExists(filepath.Join(specDir, "plan.md")) - hasTasks := fileExists(filepath.Join(specDir, "tasks.md")) + hasSpec := fileExists(filepath.Join(specPath, "spec.md")) + hasPlan := fileExists(filepath.Join(specPath, "plan.md")) + hasTasks := fileExists(filepath.Join(specPath, "tasks.md")) switch { case hasTasks: @@ -196,9 +217,12 @@ func DeleteSpec(slug string) error { if err != nil { return err } - specDir := filepath.Join(dir, slug) - if _, err := os.Stat(specDir); os.IsNotExist(err) { + specPath, err := specDir(dir, slug) + if err != nil { + return err + } + if _, err := os.Stat(specPath); os.IsNotExist(err) { return nil } - return os.RemoveAll(specDir) + return os.RemoveAll(specPath) } diff --git a/internal/spec/state_security_test.go b/internal/spec/state_security_test.go new file mode 100644 index 00000000..781fb3d1 --- /dev/null +++ b/internal/spec/state_security_test.go @@ -0,0 +1,20 @@ +package spec + +import ( + "testing" +) + +func TestSpecSlugRejectsTraversal(t *testing.T) { + for _, slug := range []string{"../escape", "nested/name", "/absolute", `..\\escape`, "", ".", ".."} { + if _, err := specDir(t.TempDir(), slug); err == nil { + t.Errorf("slug %q was accepted", slug) + } + } +} + +func TestWriteStageMetaRejectsTraversal(t *testing.T) { + t.Setenv("HAWK_STATE_DIR", t.TempDir()) + if err := WriteStageMeta("../escape", "specify", "", ""); err == nil { + t.Fatal("expected traversal slug to be rejected") + } +} diff --git a/internal/spec/validator.go b/internal/spec/validator.go index add7a693..8a2e4c12 100644 --- a/internal/spec/validator.go +++ b/internal/spec/validator.go @@ -332,7 +332,14 @@ func ValidateDirectory(slug string) ValidationResult { }}, } } - specDir := filepath.Join(dir, slug) + specDir, err := specDir(dir, slug) + if err != nil { + return ValidationResult{Issues: []ValidationIssue{{ + Level: ValidationError, + Code: "INVALID_SLUG", + Message: err.Error(), + }}} + } var allIssues []ValidationResult for _, f := range []string{"spec.md", "plan.md", "tasks.md"} { diff --git a/internal/tool/backup.go b/internal/tool/backup.go index 56b12d1e..32de1041 100644 --- a/internal/tool/backup.go +++ b/internal/tool/backup.go @@ -24,7 +24,7 @@ func BackupFile(path string) (string, error) { return "", nil // don't backup files >10MB } - data, err := os.ReadFile(path) // #nosec G304 -- path provided by caller via tool/task parameters, inherent to this dev CLI's file operations + data, err := readPinnedFile(path) if err != nil { return "", nil } @@ -82,11 +82,11 @@ func RestoreFromBackup(path string) error { return fmt.Errorf("no backups found for %s", path) } - data, err := os.ReadFile(latest) // #nosec G304 -- path provided by caller via tool/task parameters, inherent to this dev CLI's file operations + data, err := readPinnedFile(latest) if err != nil { return err } - return os.WriteFile(path, data, 0o600) + return writePinnedFile(path, data, 0o600) } // ListBackups returns all backups for a file. @@ -179,7 +179,7 @@ func backupDirFor(path string) string { func simpleHash(s string) string { h := uint32(0) - for _, c := range s { + for _, c := range []byte(s) { h = h*31 + uint32(c) } return fmt.Sprintf("%08x", h) diff --git a/internal/tool/diagnostics.go b/internal/tool/diagnostics.go index c7f54c42..b2dc9cdd 100644 --- a/internal/tool/diagnostics.go +++ b/internal/tool/diagnostics.go @@ -92,7 +92,7 @@ func runGoDiagnostics(ctx context.Context, path, scope string) (string, error) { buildCmd = exec.CommandContext(ctx, "go", "build", "./...") buildCmd.Dir = path } else { - buildCmd = exec.CommandContext(ctx, "go", "build", path) + buildCmd = exec.CommandContext(ctx, "go", "build", path) // #nosec G204 -- fixed compiler and separate package path argument buildCmd.Dir = filepath.Dir(path) } buildOutput, buildErr := buildCmd.CombinedOutput() @@ -116,7 +116,7 @@ func runGoDiagnostics(ctx context.Context, path, scope string) (string, error) { } func runPythonDiagnostics(ctx context.Context, path string) (string, error) { - cmd := exec.CommandContext(ctx, "python3", "-m", "py_compile", path) + cmd := exec.CommandContext(ctx, "python3", "-m", "py_compile", path) // #nosec G204 -- fixed interpreter and separate path argument output, err := cmd.CombinedOutput() result := strings.TrimSpace(string(output)) if err != nil && result == "" { @@ -130,13 +130,13 @@ func runPythonDiagnostics(ctx context.Context, path string) (string, error) { func runJSTSDiagnostics(ctx context.Context, path, ext string) (string, error) { // Try eslint first - cmd := exec.CommandContext(ctx, "npx", "eslint", path, "--format", "compact") + cmd := exec.CommandContext(ctx, "npx", "eslint", path, "--format", "compact") // #nosec G204 -- fixed executable and separate path argument output, _ := cmd.CombinedOutput() result := strings.TrimSpace(string(output)) // For TypeScript, also try tsc if ext == ".ts" || ext == ".tsx" { - tscCmd := exec.CommandContext(ctx, "npx", "tsc", "--noEmit", path) + tscCmd := exec.CommandContext(ctx, "npx", "tsc", "--noEmit", path) // #nosec G204 -- fixed executable and separate path argument tscOutput, _ := tscCmd.CombinedOutput() tscResult := strings.TrimSpace(string(tscOutput)) if tscResult != "" { diff --git a/internal/tool/download_test.go b/internal/tool/download_test.go index 0598cd0d..6348a626 100644 --- a/internal/tool/download_test.go +++ b/internal/tool/download_test.go @@ -4,9 +4,10 @@ import ( "context" "encoding/json" "net/http" - "net/http/httptest" "strings" "testing" + + "github.com/GrayCodeAI/hawk/internal/testutil" ) func TestDownloadTool_Name(t *testing.T) { @@ -119,7 +120,7 @@ func TestDownloadTool_Execute_BothEmpty(t *testing.T) { func TestDownloadTool_Execute_Success(t *testing.T) { // Set up a test HTTP server - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + server := testutil.NewLoopbackHTTPServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "text/plain") w.WriteHeader(http.StatusOK) w.Write([]byte("hello world")) @@ -145,7 +146,7 @@ func TestDownloadTool_Execute_Success(t *testing.T) { } func TestDownloadTool_Execute_HTTPError(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + server := testutil.NewLoopbackHTTPServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNotFound) w.Write([]byte("not found")) })) @@ -168,7 +169,7 @@ func TestDownloadTool_Execute_HTTPError(t *testing.T) { func TestDownloadTool_Execute_CredentialContent(t *testing.T) { // Server returns content that looks like credentials - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + server := testutil.NewLoopbackHTTPServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "text/plain") w.WriteHeader(http.StatusOK) w.Write([]byte("password=sk-abc0123456789012345wxyz secret key")) @@ -191,7 +192,7 @@ func TestDownloadTool_Execute_CredentialContent(t *testing.T) { } func TestDownloadTool_Execute_EmptyBody(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + server := testutil.NewLoopbackHTTPServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/octet-stream") w.WriteHeader(http.StatusOK) // Empty body diff --git a/internal/tool/file_edit.go b/internal/tool/file_edit.go index bcbb23b5..64b28d0d 100644 --- a/internal/tool/file_edit.go +++ b/internal/tool/file_edit.go @@ -81,7 +81,7 @@ func (FileEditTool) Execute(ctx context.Context, input json.RawMessage) (string, if info.Size() > maxFileSize { return "", fmt.Errorf("file too large: %d bytes", info.Size()) } - data, err := os.ReadFile(path) // #nosec G304 -- path provided by caller via tool/task parameters, inherent to this dev CLI's file operations + data, err := readGuardedFile(ctx, path) if err != nil { return "", fmt.Errorf("read %s: %w", path, err) } @@ -123,7 +123,7 @@ func (FileEditTool) Execute(ctx context.Context, input json.RawMessage) (string, result = strings.ReplaceAll(result, "\n", "\r\n") } - if err := os.WriteFile(path, []byte(result), info.Mode()); err != nil { + if err := writeGuardedFile(ctx, path, []byte(result), info.Mode()); err != nil { return "", fmt.Errorf("write: %w", err) } if autoCommitEnabled(ctx) { diff --git a/internal/tool/git_commit.go b/internal/tool/git_commit.go index a27c5898..f87953c0 100644 --- a/internal/tool/git_commit.go +++ b/internal/tool/git_commit.go @@ -63,7 +63,7 @@ func AutoCommit(ctx context.Context, path, toolName, description string) error { return fmt.Errorf("not a git repository") } - add := exec.CommandContext(context.Background(), "git", "add", "--", path) + add := exec.CommandContext(context.Background(), "git", "add", "--", path) // #nosec G204 -- fixed git subcommand and path after argument separator if out, err := add.CombinedOutput(); err != nil { return fmt.Errorf("git add: %s (%w)", strings.TrimSpace(string(out)), err) } @@ -207,7 +207,7 @@ func CommitStaged(ctx context.Context, message string, modes *AttributionModes) } message = applyAttributionModes(message, modes) - commit := exec.CommandContext(ctx, "git", "commit", "-m", message) + commit := exec.CommandContext(ctx, "git", "commit", "-m", message) // #nosec G204 -- fixed git subcommand and message passed as one argument commit.Env = commitEnv(os.Environ(), modes) if out, err := commit.CombinedOutput(); err != nil { return fmt.Errorf("git commit: %s (%w)", strings.TrimSpace(string(out)), err) diff --git a/internal/tool/git_fs.go b/internal/tool/git_fs.go index 511b65da..12551326 100644 --- a/internal/tool/git_fs.go +++ b/internal/tool/git_fs.go @@ -2,6 +2,7 @@ package tool import ( "bufio" + "bytes" "fmt" "os" "path/filepath" @@ -30,7 +31,7 @@ func ReadGitState(dir string) (*GitState, error) { // If .git is a file, it's a worktree reference worktree := false if !info.IsDir() { - data, readErr := os.ReadFile(gitDir) // #nosec G304 -- path provided by caller via tool/task parameters, inherent to this dev CLI's file operations + data, readErr := readPinnedFile(gitDir) if readErr != nil { return nil, readErr } @@ -46,7 +47,7 @@ func ReadGitState(dir string) (*GitState, error) { } } - headContent, err := os.ReadFile(filepath.Join(gitDir, "HEAD")) // #nosec G304 -- path provided by caller via tool/task parameters, inherent to this dev CLI's file operations + headContent, err := readPinnedFile(filepath.Join(gitDir, "HEAD")) if err != nil { return nil, fmt.Errorf("cannot read HEAD: %w", err) } @@ -83,7 +84,7 @@ func ReadGitState(dir string) (*GitState, error) { func resolveRef(gitDir, ref string) (string, error) { // Try loose ref first refPath := filepath.Join(gitDir, ref) - data, err := os.ReadFile(refPath) // #nosec G304 -- path provided by caller via tool/task parameters, inherent to this dev CLI's file operations + data, err := readPinnedFile(refPath) if err == nil { resolved := strings.TrimSpace(string(data)) if strings.HasPrefix(resolved, "ref: ") { @@ -108,14 +109,13 @@ func resolveRef(gitDir, ref string) (string, error) { // parsePackedRefs reads .git/packed-refs and returns a map of ref to commit hash. func parsePackedRefs(gitDir string) (map[string]string, error) { packedPath := filepath.Join(gitDir, "packed-refs") - f, err := os.Open(packedPath) // #nosec G304 -- path provided by caller via tool/task parameters, inherent to this dev CLI's file operations + data, err := readPinnedFile(packedPath) if err != nil { return nil, err } - defer func() { _ = f.Close() }() refs := make(map[string]string) - scanner := bufio.NewScanner(f) + scanner := bufio.NewScanner(bytes.NewReader(data)) for scanner.Scan() { line := scanner.Text() // Skip comments and peeled refs diff --git a/internal/tool/git_history.go b/internal/tool/git_history.go index d6750bfc..d1ae29e5 100644 --- a/internal/tool/git_history.go +++ b/internal/tool/git_history.go @@ -241,7 +241,7 @@ func gitBlame(ctx context.Context, root, file string) (string, error) { return "", fmt.Errorf("file is required for blame action") } - cmd := exec.CommandContext(ctx, "git", "blame", "--line-porcelain", file) + cmd := exec.CommandContext(ctx, "git", "blame", "--line-porcelain", file) // #nosec G204 -- fixed git subcommand and separate file argument cmd.Dir = root out, err := cmd.Output() if err != nil { diff --git a/internal/tool/git_hooks.go b/internal/tool/git_hooks.go index 6e722e0a..0146f50b 100644 --- a/internal/tool/git_hooks.go +++ b/internal/tool/git_hooks.go @@ -27,6 +27,15 @@ type HookConfig struct { Priority int } +func validHookName(name string) bool { + switch name { + case "pre-commit", "prepare-commit-msg", "post-commit", "pre-push": + return true + default: + return false + } +} + // NewGitHookInstaller creates a new installer rooted at the given project directory. // It resolves .git/hooks relative to projectDir and probes which hooks are already // installed. @@ -40,7 +49,7 @@ func NewGitHookInstaller(projectDir string) *GitHookInstaller { // Probe existing hawk-managed hooks. for _, name := range []string{"pre-commit", "post-commit", "prepare-commit-msg", "pre-push"} { hookPath := filepath.Join(hooksDir, name) - data, err := os.ReadFile(hookPath) // #nosec G304 -- path provided by caller via tool/task parameters, inherent to this dev CLI's file operations + data, err := readPinnedFile(hookPath) if err == nil && strings.Contains(string(data), "# hawk-managed") { installer.Installed[name] = true } @@ -58,6 +67,9 @@ func (g *GitHookInstaller) Install(hook HookConfig) error { if !hook.Enabled { return nil } + if !validHookName(hook.Name) { + return fmt.Errorf("unsupported git hook name %q", hook.Name) + } if err := os.MkdirAll(g.HooksDir, 0o750); err != nil { return fmt.Errorf("create hooks dir: %w", err) @@ -66,7 +78,7 @@ func (g *GitHookInstaller) Install(hook HookConfig) error { hookPath := filepath.Join(g.HooksDir, hook.Name) // Preserve existing hook if present and not hawk-managed. - existing, err := os.ReadFile(hookPath) // #nosec G304 -- path provided by caller via tool/task parameters, inherent to this dev CLI's file operations + existing, err := readPinnedFile(hookPath) if err == nil && !strings.Contains(string(existing), "# hawk-managed") { // Back up and chain existing hook. if backupErr := g.backupExisting(hook.Name); backupErr != nil { @@ -78,7 +90,7 @@ func (g *GitHookInstaller) Install(hook HookConfig) error { } // #nosec G306 -- git hook must be executable by git - if err := os.WriteFile(hookPath, []byte(hook.Script), 0o755); err != nil { + if err := writePinnedFile(hookPath, []byte(hook.Script), 0o755); err != nil { return fmt.Errorf("write hook %s: %w", hook.Name, err) } @@ -91,6 +103,9 @@ func (g *GitHookInstaller) Uninstall(hookName string) error { g.mu.Lock() defer g.mu.Unlock() + if !validHookName(hookName) { + return fmt.Errorf("unsupported git hook name %q", hookName) + } hookPath := filepath.Join(g.HooksDir, hookName) backupPath := hookPath + ".bak" @@ -255,10 +270,13 @@ func (g *GitHookInstaller) BackupExisting(hookName string) error { // backupExisting is the internal (unlocked) implementation. func (g *GitHookInstaller) backupExisting(hookName string) error { + if !validHookName(hookName) { + return fmt.Errorf("unsupported git hook name %q", hookName) + } hookPath := filepath.Join(g.HooksDir, hookName) backupPath := hookPath + ".bak" - data, err := os.ReadFile(hookPath) // #nosec G304 -- path provided by caller via tool/task parameters, inherent to this dev CLI's file operations + data, err := readPinnedFile(hookPath) if err != nil { if os.IsNotExist(err) { return nil // nothing to back up @@ -267,7 +285,7 @@ func (g *GitHookInstaller) backupExisting(hookName string) error { } // #nosec G306 -- backup preserves executable hook script - if err := os.WriteFile(backupPath, data, 0o755); err != nil { + if err := writePinnedFile(backupPath, data, 0o755); err != nil { return fmt.Errorf("write backup %s: %w", hookName, err) } return nil diff --git a/internal/tool/grep.go b/internal/tool/grep.go index 75c19835..5cbc3d20 100644 --- a/internal/tool/grep.go +++ b/internal/tool/grep.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "fmt" + "io/fs" "os" "path/filepath" "regexp" @@ -48,8 +49,18 @@ func (GrepTool) Execute(ctx context.Context, input json.RawMessage) (string, err if err := validatePathAllowed(ctx, root); err != nil { return "", err } + rootAbs, err := guardedAbs(root) + if err != nil { + return "", err + } + rootHandle, err := os.OpenRoot(rootAbs) + if err != nil { + return "", fmt.Errorf("open search root: %w", err) + } + defer func() { _ = rootHandle.Close() }() + var results []string - _ = filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error { + _ = fs.WalkDir(rootHandle.FS(), ".", func(path string, d fs.DirEntry, err error) error { if err != nil || d.IsDir() { if d != nil && d.IsDir() && (d.Name() == ".git" || d.Name() == "node_modules") { return filepath.SkipDir @@ -61,14 +72,14 @@ func (GrepTool) Execute(ctx context.Context, input json.RawMessage) (string, err return nil } } - data, err := os.ReadFile(path) // #nosec G304 -- path provided by caller via tool/task parameters, inherent to this dev CLI's file operations + data, err := fs.ReadFile(rootHandle.FS(), path) if err != nil { return nil } lines := strings.Split(string(data), "\n") for i, line := range lines { if re.MatchString(line) { - results = append(results, fmt.Sprintf("%s:%d: %s", path, i+1, line)) + results = append(results, fmt.Sprintf("%s:%d: %s", filepath.Join(rootAbs, filepath.FromSlash(path)), i+1, line)) if len(results) >= 200 { return fmt.Errorf("limit") } diff --git a/internal/tool/impact.go b/internal/tool/impact.go index 07490d5f..ff3fc0b7 100644 --- a/internal/tool/impact.go +++ b/internal/tool/impact.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "fmt" + "io/fs" "os" "os/exec" "path/filepath" @@ -416,7 +417,17 @@ func buildSimpleImportGraph(root string) (*simpleImportGraph, error) { reverse: make(map[string][]string), } - err := filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error { + rootAbs, err := guardedAbs(root) + if err != nil { + return nil, err + } + rootHandle, err := os.OpenRoot(rootAbs) + if err != nil { + return nil, fmt.Errorf("open impact root: %w", err) + } + defer func() { _ = rootHandle.Close() }() + + err = fs.WalkDir(rootHandle.FS(), ".", func(path string, d fs.DirEntry, err error) error { if err != nil || d.IsDir() { if d != nil && d.IsDir() && (d.Name() == ".git" || d.Name() == "node_modules" || d.Name() == "vendor") { return filepath.SkipDir @@ -428,16 +439,16 @@ func buildSimpleImportGraph(root string) (*simpleImportGraph, error) { return nil } - data, err := os.ReadFile(path) // #nosec G304 -- path provided by caller via tool/task parameters, inherent to this dev CLI's file operations + data, err := fs.ReadFile(rootHandle.FS(), path) if err != nil { return nil } - relPath, _ := filepath.Rel(root, path) + relPath := filepath.FromSlash(path) imports := parseImports(string(data), ext) for _, imp := range imports { // Try to resolve to local file - resolved := resolveImport(imp, ext, filepath.Dir(path), root) + resolved := resolveImport(imp, ext, filepath.Dir(filepath.Join(rootAbs, filepath.FromSlash(path))), rootAbs) if resolved != "" { g.edges[relPath] = append(g.edges[relPath], resolved) g.reverse[resolved] = append(g.reverse[resolved], relPath) diff --git a/internal/tool/import_organizer.go b/internal/tool/import_organizer.go index b48759e2..91f28b06 100644 --- a/internal/tool/import_organizer.go +++ b/internal/tool/import_organizer.go @@ -7,7 +7,6 @@ import ( "go/ast" "go/parser" "go/token" - "os" "regexp" "sort" "strings" @@ -808,8 +807,13 @@ func (ImportOrganizerTool) Execute(ctx context.Context, input json.RawMessage) ( return "", fmt.Errorf("path is required") } - // Read the file. - data, err := os.ReadFile(p.Path) + if err := validatePathAllowed(ctx, p.Path); err != nil { + return "", err + } + + // Read the file through a directory-pinned root so a symlink swap cannot + // redirect the operation after the policy check. + data, err := readGuardedFile(ctx, p.Path) if err != nil { return "", fmt.Errorf("read file: %w", err) } @@ -839,7 +843,7 @@ func (ImportOrganizerTool) Execute(ctx context.Context, input json.RawMessage) ( } // Write back. - if err := os.WriteFile(p.Path, []byte(result), 0o600); err != nil { + if err := writeGuardedFile(ctx, p.Path, []byte(result), 0o600); err != nil { return "", fmt.Errorf("write file: %w", err) } diff --git a/internal/tool/lsp.go b/internal/tool/lsp.go index 2c9c60e1..a07df477 100644 --- a/internal/tool/lsp.go +++ b/internal/tool/lsp.go @@ -346,7 +346,7 @@ func lspDiagnostics(ctx context.Context, path string) (string, error) { case ".ts", ".tsx", ".js", ".jsx": cmd = exec.CommandContext(ctx, "npx", "tsc", "--noEmit", "--pretty") case ".py": - cmd = exec.CommandContext(ctx, "python3", "-m", "py_compile", path) + cmd = exec.CommandContext(ctx, "python3", "-m", "py_compile", path) // #nosec G204 -- fixed interpreter and separate path argument case ".rs": cmd = exec.CommandContext(ctx, "cargo", "check", "--message-format=short") default: diff --git a/internal/tool/mcp_auth.go b/internal/tool/mcp_auth.go index 079b4c54..8d0e9de5 100644 --- a/internal/tool/mcp_auth.go +++ b/internal/tool/mcp_auth.go @@ -147,7 +147,7 @@ func (McpAuthTool) Execute(ctx context.Context, input json.RawMessage) (string, state := &MCPAuthState{ServerName: p.ServerName, AuthURL: authURL, Status: "pending"} globalMCPAuthManager.setState(p.ServerName, state) - go completeMCPAuth(p.ServerName, meta, clientID, verifier, reqState, redirectURI, resultCh, shutdown) + go completeMCPAuth(context.WithoutCancel(ctx), p.ServerName, meta, clientID, verifier, reqState, redirectURI, resultCh, shutdown) return authStatusJSON(state), nil } @@ -163,6 +163,7 @@ func failState(serverName string, err error) *MCPAuthState { // that started it, since the user completing authorization in their // browser is an open-ended, asynchronous step from hawk's perspective. func completeMCPAuth( + parentCtx context.Context, serverName string, meta *mcp.AuthServerMetadata, clientID, verifier, wantState, redirectURI string, @@ -170,7 +171,13 @@ func completeMCPAuth( shutdown func(), ) { defer shutdown() - result := <-resultCh + var result mcp.CallbackResult + select { + case result = <-resultCh: + case <-parentCtx.Done(): + failState(serverName, parentCtx.Err()) + return + } if result.Err != nil { failState(serverName, result.Err) return @@ -180,7 +187,7 @@ func completeMCPAuth( return } - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + ctx, cancel := context.WithTimeout(parentCtx, 30*time.Second) defer cancel() tokens, err := mcp.ExchangeCode(ctx, meta, clientID, result.Code, verifier, redirectURI) if err != nil { diff --git a/internal/tool/mcp_auth_test.go b/internal/tool/mcp_auth_test.go index 78b62a67..138e3061 100644 --- a/internal/tool/mcp_auth_test.go +++ b/internal/tool/mcp_auth_test.go @@ -11,6 +11,7 @@ import ( "time" "github.com/GrayCodeAI/hawk/internal/mcp" + "github.com/GrayCodeAI/hawk/internal/testutil" ) // fakeTokenBackend is an in-memory stand-in for the OS keychain — tests in @@ -73,7 +74,7 @@ func newTestOAuthServer(t *testing.T) *httptest.Server { "expires_in": 3600, }) }) - srv = httptest.NewServer(mux) + srv = testutil.NewLoopbackHTTPServer(t, mux) t.Cleanup(srv.Close) return srv } @@ -182,7 +183,7 @@ func TestMcpAuthTool_ExplicitClientID_SkipsRegistration(t *testing.T) { w.WriteHeader(http.StatusCreated) _ = json.NewEncoder(w).Encode(map[string]string{"client_id": "should-not-be-used"}) }) - srv = httptest.NewServer(mux) + srv = testutil.NewLoopbackHTTPServer(t, mux) defer srv.Close() input, _ := json.Marshal(map[string]string{ @@ -225,7 +226,7 @@ func TestMcpAuthTool_NoRegistrationEndpointAndNoClientID_Errors(t *testing.T) { // No RegistrationEndpoint. }) }) - srv = httptest.NewServer(mux) + srv = testutil.NewLoopbackHTTPServer(t, mux) defer srv.Close() input, _ := json.Marshal(map[string]string{ @@ -313,7 +314,7 @@ func TestAuthHeaderForMCPServer_RefreshesExpiringToken(t *testing.T) { defer restore() refreshServerCalled := false - tokenServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + tokenServer := testutil.NewLoopbackHTTPServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { refreshServerCalled = true _ = r.ParseForm() if r.FormValue("grant_type") != "refresh_token" || r.FormValue("refresh_token") != "old-refresh" { diff --git a/internal/tool/multiedit.go b/internal/tool/multiedit.go index 06b8005f..cd24e590 100644 --- a/internal/tool/multiedit.go +++ b/internal/tool/multiedit.go @@ -4,7 +4,6 @@ import ( "context" "encoding/json" "fmt" - "os" "strings" ) @@ -69,7 +68,7 @@ func (MultiEditTool) Execute(ctx context.Context, input json.RawMessage) (string return "", fmt.Errorf("path %s is protected (read-only)", p.FilePath) } - data, err := os.ReadFile(p.FilePath) + data, err := readGuardedFile(ctx, p.FilePath) if err != nil { return "", fmt.Errorf("read file: %w", err) } @@ -99,7 +98,7 @@ func (MultiEditTool) Execute(ctx context.Context, input json.RawMessage) (string return fmt.Sprintf("No edits applied (%d failed — old_string not found in file).", failed), nil } - if err := os.WriteFile(p.FilePath, []byte(content), 0o600); err != nil { + if err := writeGuardedFile(ctx, p.FilePath, []byte(content), 0o600); err != nil { return "", fmt.Errorf("write file: %w", err) } return fmt.Sprintf("Applied %d/%d edit(s) to %s.", applied, applied+failed, p.FilePath), nil diff --git a/internal/tool/patch.go b/internal/tool/patch.go index 68068628..bdfef062 100644 --- a/internal/tool/patch.go +++ b/internal/tool/patch.go @@ -239,7 +239,7 @@ func Apply(patch *FilePatch) error { } result := strings.Join(lines, "\n") - return os.WriteFile(patch.Path, []byte(result), 0o600) + return writePinnedFile(patch.Path, []byte(result), 0o600) } // ApplyAll applies all patches and returns the list of modified file paths. diff --git a/internal/tool/path_guard.go b/internal/tool/path_guard.go index 458ba647..b5614825 100644 --- a/internal/tool/path_guard.go +++ b/internal/tool/path_guard.go @@ -61,6 +61,63 @@ func guardedAbs(path string) (string, error) { return abs, nil } +// guardedRootPath opens the canonical parent directory of a path and returns +// the basename to use with os.Root. The permission check happens before the +// root is opened; os.Root then pins the directory handle and closes the +// symlink/rename race between validation and the actual file operation. +func guardedRootPath(ctx context.Context, path string) (*os.Root, string, error) { + if err := validatePathAllowed(ctx, path); err != nil { + return nil, "", err + } + absPath, err := guardedAbs(path) + if err != nil { + return nil, "", err + } + root, err := os.OpenRoot(filepath.Dir(absPath)) + if err != nil { + return nil, "", fmt.Errorf("open guarded parent: %w", err) + } + return root, filepath.Base(absPath), nil +} + +func readGuardedFile(ctx context.Context, path string) ([]byte, error) { + root, name, err := guardedRootPath(ctx, path) + if err != nil { + return nil, err + } + defer func() { _ = root.Close() }() + return root.ReadFile(name) +} + +func writeGuardedFile(ctx context.Context, path string, data []byte, perm os.FileMode) error { + root, name, err := guardedRootPath(ctx, path) + if err != nil { + return err + } + defer func() { _ = root.Close() }() + return root.WriteFile(name, data, perm) +} + +// readPinnedFile and writePinnedFile are for lower-level tool APIs that do not +// carry a context. They still pin the canonical parent directory with os.Root, +// preventing a symlink swap between path resolution and the file operation. +func readPinnedFile(path string) ([]byte, error) { + return readGuardedFile(context.Background(), path) +} + +func writePinnedFile(path string, data []byte, perm os.FileMode) error { + return writeGuardedFile(context.Background(), path, data, perm) +} + +// ReadPinnedFile exposes the same root-pinned read for engine components that +// already depend on the tool package but do not carry a ToolContext. +func ReadPinnedFile(path string) ([]byte, error) { return readPinnedFile(path) } + +// WritePinnedFile exposes the same root-pinned write for engine components. +func WritePinnedFile(path string, data []byte, perm os.FileMode) error { + return writePinnedFile(path, data, perm) +} + func sameOrWithin(path, root string) bool { path = filepath.Clean(path) root = filepath.Clean(root) diff --git a/internal/tool/path_guard_root_test.go b/internal/tool/path_guard_root_test.go new file mode 100644 index 00000000..4d2d3683 --- /dev/null +++ b/internal/tool/path_guard_root_test.go @@ -0,0 +1,45 @@ +package tool + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestGuardedRootPathRejectsSymlinkEscape(t *testing.T) { + allowed := t.TempDir() + outside := t.TempDir() + link := filepath.Join(allowed, "link") + if err := os.Symlink(outside, link); err != nil { + t.Skipf("symlinks unavailable: %v", err) + } + path := filepath.Join(link, "secret.txt") + if err := os.WriteFile(filepath.Join(outside, "secret.txt"), []byte("secret"), 0o600); err != nil { + t.Fatal(err) + } + ctx := sandboxedContext(t, allowed) + if _, err := readGuardedFile(ctx, path); err == nil || !strings.Contains(err.Error(), "outside") { + t.Fatalf("read through symlink = %v, want an out-of-sandbox error", err) + } +} + +func TestGuardedRootPathAllowsRegularFile(t *testing.T) { + allowed := t.TempDir() + path := filepath.Join(allowed, "file.txt") + if err := os.WriteFile(path, []byte("before"), 0o600); err != nil { + t.Fatal(err) + } + ctx := sandboxedContext(t, allowed) + if err := writeGuardedFile(ctx, path, []byte("after"), 0o600); err != nil { + t.Fatal(err) + } + data, err := readGuardedFile(context.Background(), path) + if err != nil { + t.Fatal(err) + } + if string(data) != "after" { + t.Fatalf("content = %q, want after", data) + } +} diff --git a/internal/tool/pr_generator.go b/internal/tool/pr_generator.go index 57e5413f..934755ea 100644 --- a/internal/tool/pr_generator.go +++ b/internal/tool/pr_generator.go @@ -579,7 +579,7 @@ func (t *PRGeneratorTool) Execute(ctx context.Context, input json.RawMessage) (s // --- Internal helpers --- func (g *PRGenerator) runGit(args ...string) (string, error) { - cmd := exec.CommandContext(context.Background(), "git", args...) + cmd := exec.CommandContext(context.Background(), "git", args...) // #nosec G204 -- executable is fixed to git; args are generated by this tool cmd.Dir = g.ProjectDir out, err := cmd.Output() if err != nil { diff --git a/internal/tool/refactor.go b/internal/tool/refactor.go index d8a31923..c1c7c143 100644 --- a/internal/tool/refactor.go +++ b/internal/tool/refactor.go @@ -4,7 +4,6 @@ import ( "context" "encoding/json" "fmt" - "os" "regexp" "sort" "strings" @@ -39,7 +38,7 @@ func (r *Refactorer) ExtractFunction(file string, startLine, endLine int, newNam r.mu.Lock() defer r.mu.Unlock() - data, err := os.ReadFile(file) // #nosec G304 -- path provided by caller via tool/task parameters, inherent to this dev CLI's file operations + data, err := readPinnedFile(file) if err != nil { return nil, fmt.Errorf("read file: %w", err) } @@ -89,7 +88,7 @@ func (r *Refactorer) ExtractFunction(file string, startLine, endLine int, newNam // Append the new function at end. newContent := strings.Join(newLines, "\n") + newFunc - if err := os.WriteFile(file, []byte(newContent), 0o600); err != nil { + if err := writePinnedFile(file, []byte(newContent), 0o600); err != nil { return nil, fmt.Errorf("write file: %w", err) } @@ -148,7 +147,7 @@ func (r *Refactorer) RenameSymbol(file, oldName, newName string) (*RefactoringRe r.mu.Lock() defer r.mu.Unlock() - data, err := os.ReadFile(file) // #nosec G304 -- path provided by caller via tool/task parameters, inherent to this dev CLI's file operations + data, err := readPinnedFile(file) if err != nil { return nil, fmt.Errorf("read file: %w", err) } @@ -163,7 +162,7 @@ func (r *Refactorer) RenameSymbol(file, oldName, newName string) (*RefactoringRe result := pattern.ReplaceAllString(content, newName) - if err := os.WriteFile(file, []byte(result), 0o600); err != nil { + if err := writePinnedFile(file, []byte(result), 0o600); err != nil { return nil, fmt.Errorf("write file: %w", err) } @@ -183,7 +182,7 @@ func (r *Refactorer) InlineVariable(file string, line int) (*RefactoringResult, r.mu.Lock() defer r.mu.Unlock() - data, err := os.ReadFile(file) // #nosec G304 -- path provided by caller via tool/task parameters, inherent to this dev CLI's file operations + data, err := readPinnedFile(file) if err != nil { return nil, fmt.Errorf("read file: %w", err) } @@ -227,7 +226,7 @@ func (r *Refactorer) InlineVariable(file string, line int) (*RefactoringResult, } result := strings.Join(newLines, "\n") - if err := os.WriteFile(file, []byte(result), 0o600); err != nil { + if err := writePinnedFile(file, []byte(result), 0o600); err != nil { return nil, fmt.Errorf("write file: %w", err) } @@ -247,7 +246,7 @@ func (r *Refactorer) ExtractVariable(file string, line int, expr, varName string r.mu.Lock() defer r.mu.Unlock() - data, err := os.ReadFile(file) // #nosec G304 -- path provided by caller via tool/task parameters, inherent to this dev CLI's file operations + data, err := readPinnedFile(file) if err != nil { return nil, fmt.Errorf("read file: %w", err) } @@ -287,7 +286,7 @@ func (r *Refactorer) ExtractVariable(file string, line int, expr, varName string newLines = append(newLines, lines[line:]...) result := strings.Join(newLines, "\n") - if err := os.WriteFile(file, []byte(result), 0o600); err != nil { + if err := writePinnedFile(file, []byte(result), 0o600); err != nil { return nil, fmt.Errorf("write file: %w", err) } @@ -307,7 +306,7 @@ func (r *Refactorer) AddErrorCheck(file string, line int) (*RefactoringResult, e r.mu.Lock() defer r.mu.Unlock() - data, err := os.ReadFile(file) // #nosec G304 -- path provided by caller via tool/task parameters, inherent to this dev CLI's file operations + data, err := readPinnedFile(file) if err != nil { return nil, fmt.Errorf("read file: %w", err) } @@ -357,7 +356,7 @@ func (r *Refactorer) AddErrorCheck(file string, line int) (*RefactoringResult, e newLines = append(newLines, lines[line:]...) result := strings.Join(newLines, "\n") - if err := os.WriteFile(file, []byte(result), 0o600); err != nil { + if err := writePinnedFile(file, []byte(result), 0o600); err != nil { return nil, fmt.Errorf("write file: %w", err) } @@ -377,7 +376,7 @@ func (r *Refactorer) WrapWithContext(file string, line int, context string) (*Re r.mu.Lock() defer r.mu.Unlock() - data, err := os.ReadFile(file) // #nosec G304 -- path provided by caller via tool/task parameters, inherent to this dev CLI's file operations + data, err := readPinnedFile(file) if err != nil { return nil, fmt.Errorf("read file: %w", err) } @@ -408,7 +407,7 @@ func (r *Refactorer) WrapWithContext(file string, line int, context string) (*Re lines[line-1] = newLine result := strings.Join(lines, "\n") - if err := os.WriteFile(file, []byte(result), 0o600); err != nil { + if err := writePinnedFile(file, []byte(result), 0o600); err != nil { return nil, fmt.Errorf("write file: %w", err) } @@ -429,7 +428,7 @@ func (r *Refactorer) WrapWithContext(file string, line int, context string) (*Re lines[line-1] = newLine result := strings.Join(lines, "\n") - if err := os.WriteFile(file, []byte(result), 0o600); err != nil { + if err := writePinnedFile(file, []byte(result), 0o600); err != nil { return nil, fmt.Errorf("write file: %w", err) } @@ -448,7 +447,7 @@ func (r *Refactorer) ConvertToTableTest(file, testFunc string) (*RefactoringResu r.mu.Lock() defer r.mu.Unlock() - data, err := os.ReadFile(file) // #nosec G304 -- path provided by caller via tool/task parameters, inherent to this dev CLI's file operations + data, err := readPinnedFile(file) if err != nil { return nil, fmt.Errorf("read file: %w", err) } @@ -495,7 +494,7 @@ func (r *Refactorer) ConvertToTableTest(file, testFunc string) (*RefactoringResu after := b.String() newContent := content[:loc[0]] + after + content[loc[1]:] - if err := os.WriteFile(file, []byte(newContent), 0o600); err != nil { + if err := writePinnedFile(file, []byte(newContent), 0o600); err != nil { return nil, fmt.Errorf("write file: %w", err) } @@ -514,7 +513,7 @@ func (r *Refactorer) SortImports(file string) (*RefactoringResult, error) { r.mu.Lock() defer r.mu.Unlock() - data, err := os.ReadFile(file) // #nosec G304 -- path provided by caller via tool/task parameters, inherent to this dev CLI's file operations + data, err := readPinnedFile(file) if err != nil { return nil, fmt.Errorf("read file: %w", err) } @@ -537,7 +536,7 @@ func (r *Refactorer) SortImports(file string) (*RefactoringResult, error) { }, nil } - if err := os.WriteFile(file, []byte(result), 0o600); err != nil { + if err := writePinnedFile(file, []byte(result), 0o600); err != nil { return nil, fmt.Errorf("write file: %w", err) } @@ -556,7 +555,7 @@ func (r *Refactorer) RemoveUnusedParams(file, funcName string) (*RefactoringResu r.mu.Lock() defer r.mu.Unlock() - data, err := os.ReadFile(file) // #nosec G304 -- path provided by caller via tool/task parameters, inherent to this dev CLI's file operations + data, err := readPinnedFile(file) if err != nil { return nil, fmt.Errorf("read file: %w", err) } @@ -630,7 +629,7 @@ func (r *Refactorer) RemoveUnusedParams(file, funcName string) (*RefactoringResu // Replace the parameter list in the content. newContent := content[:loc[2]] + newParamStr + content[loc[3]:] - if err := os.WriteFile(file, []byte(newContent), 0o600); err != nil { + if err := writePinnedFile(file, []byte(newContent), 0o600); err != nil { return nil, fmt.Errorf("write file: %w", err) } @@ -862,6 +861,9 @@ func (rt RefactorTool) Execute(ctx context.Context, input json.RawMessage) (stri if p.File == "" { return "", fmt.Errorf("file is required") } + if err := validatePathAllowed(ctx, p.File); err != nil { + return "", err + } ref := rt.refactorer var result *RefactoringResult diff --git a/internal/tool/spec.go b/internal/tool/spec.go index cb2595f2..f812a6c4 100644 --- a/internal/tool/spec.go +++ b/internal/tool/spec.go @@ -13,7 +13,10 @@ import ( "github.com/GrayCodeAI/hawk/internal/spec" ) -var reSlugInvalid = regexp.MustCompile(`[^a-z0-9]+`) +var ( + reSlugInvalid = regexp.MustCompile(`[^a-z0-9]+`) + reSpecSlug = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{0,63}$`) +) func slugify(s string) string { s = strings.ToLower(strings.TrimSpace(s)) @@ -57,6 +60,9 @@ func specDir(ctx context.Context) (string, error) { if slug == "" { return "", fmt.Errorf("no active spec — call Specify first") } + if !reSpecSlug.MatchString(slug) { + return "", fmt.Errorf("invalid active spec slug") + } cwd, err := os.Getwd() if err != nil { return "", err @@ -73,7 +79,7 @@ func writeSpecArtifact(ctx context.Context, filename, content string) (string, e } func writeSpecArtifactForSlug(ctx context.Context, slug, filename, content string) (string, error) { - if strings.TrimSpace(slug) == "" { + if !reSpecSlug.MatchString(slug) { return "", fmt.Errorf("spec slug is required") } cwd, err := os.Getwd() @@ -491,21 +497,23 @@ func (SpecEditTool) Execute(ctx context.Context, input json.RawMessage) (string, return "", fmt.Errorf("no active spec — call Specify first") } - dir, err := specsDir() + dir, err := specDir(ctx) if err != nil { return "", err } - specDir := filepath.Join(dir, slug) - path := filepath.Join(specDir, p.Artifact) + if p.Artifact != "spec.md" && p.Artifact != "plan.md" && p.Artifact != "tasks.md" && p.Artifact != "specs.md" { + return "", fmt.Errorf("invalid spec artifact %q", p.Artifact) + } + path := filepath.Join(dir, p.Artifact) // Ensure directory exists - if err := os.MkdirAll(specDir, 0o700); err != nil { + if err := os.MkdirAll(dir, 0o700); err != nil { return "", fmt.Errorf("mkdir: %w", err) } if p.Content != "" { // Full replacement - if err := os.WriteFile(path, []byte(p.Content), 0o600); err != nil { + if err := writeGuardedFile(ctx, path, []byte(p.Content), 0o600); err != nil { return "", fmt.Errorf("write %s: %w", p.Artifact, err) } // Update stage meta @@ -527,10 +535,10 @@ func (SpecEditTool) Execute(ctx context.Context, input json.RawMessage) (string, } // Read existing content - existing, err := os.ReadFile(path) // #nosec G304 -- path provided by caller via tool/task parameters, inherent to this dev CLI's file operations + existing, err := readGuardedFile(ctx, path) if err != nil { // File doesn't exist yet — just write the delta as-is - if writeErr := os.WriteFile(path, []byte(p.Delta), 0o600); writeErr != nil { + if writeErr := writeGuardedFile(ctx, path, []byte(p.Delta), 0o600); writeErr != nil { return "", fmt.Errorf("write %s: %w", p.Artifact, writeErr) } return fmt.Sprintf("Created %s with delta content (%d requirements)", path, len(delta.Requirements)), nil @@ -542,7 +550,7 @@ func (SpecEditTool) Execute(ctx context.Context, input json.RawMessage) (string, return "", fmt.Errorf("apply delta: %w", err) } - if err := os.WriteFile(path, []byte(merged), 0o600); err != nil { + if err := writeGuardedFile(ctx, path, []byte(merged), 0o600); err != nil { return "", fmt.Errorf("write merged %s: %w", p.Artifact, err) } diff --git a/internal/tool/structured_edit.go b/internal/tool/structured_edit.go index 01981cdf..3c049f95 100644 --- a/internal/tool/structured_edit.go +++ b/internal/tool/structured_edit.go @@ -4,7 +4,6 @@ import ( "context" "encoding/json" "fmt" - "os" "strings" ) @@ -87,7 +86,7 @@ func (s StructuredEditTool) Execute(ctx context.Context, input json.RawMessage) } // Read the file. - data, err := os.ReadFile(p.Path) + data, err := readGuardedFile(ctx, p.Path) if err != nil { return "", fmt.Errorf("read %s: %w", p.Path, err) } @@ -122,7 +121,7 @@ func (s StructuredEditTool) Execute(ctx context.Context, input json.RawMessage) } // Write the result. - if err := os.WriteFile(p.Path, []byte(content), 0o600); err != nil { + if err := writeGuardedFile(ctx, p.Path, []byte(content), 0o600); err != nil { return "", fmt.Errorf("write %s: %w", p.Path, err) } diff --git a/internal/tool/task_tools.go b/internal/tool/task_tools.go index cecb7c5d..787aa3a9 100644 --- a/internal/tool/task_tools.go +++ b/internal/tool/task_tools.go @@ -73,7 +73,9 @@ func startBackgroundBash(ctx context.Context, command string) (string, error) { // request times out. bgCtx := context.Background() - cmd := exec.CommandContext(bgCtx, "bash", "-c", command) + // The Bash tool performs command policy/approval checks before starting a + // background task; this is the intentional shell execution boundary. + cmd := exec.CommandContext(bgCtx, "bash", "-c", command) // #nosec G204 -- intentional Bash tool execution after policy checks // Put the child in its own process group so we can kill the whole tree // (including grandchildren spawned by the shell) via kill(-pgid). Without // this, e.g. `bash -c 'sleep 60 &'` leaves an orphan when the parent is diff --git a/internal/tool/testmain_test.go b/internal/tool/testmain_test.go new file mode 100644 index 00000000..f4c07ff9 --- /dev/null +++ b/internal/tool/testmain_test.go @@ -0,0 +1,18 @@ +package tool + +import ( + "os" + "testing" + + "github.com/GrayCodeAI/hawk/internal/testutil" +) + +func TestMain(m *testing.M) { + cleanup, err := testutil.InstallHermeticStorage() + if err != nil { + os.Exit(1) + } + code := m.Run() + cleanup() + os.Exit(code) +} From 1407616395a8ac544228066aa086cc5b2858906a Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Sat, 1 Aug 2026 23:08:08 +0530 Subject: [PATCH 23/49] chore: close remaining security scan findings --- cmd/chat_commands_util.go | 2 +- cmd/chat_export.go | 6 +++--- cmd/context_export.go | 2 +- cmd/exec.go | 2 +- cmd/execution_graph.go | 3 ++- cmd/feedback.go | 6 +++--- cmd/harness.go | 8 ++++---- cmd/notify.go | 2 +- cmd/pager.go | 2 +- cmd/pr.go | 2 +- cmd/review_run.go | 2 +- cmd/root.go | 4 ++-- cmd/vibe.go | 2 +- internal/auth/auth.go | 2 +- internal/codegraph/codegraph_cgo_query.go | 2 +- internal/config/config.go | 2 +- internal/context/repomap/scan.go | 2 +- internal/crash/crash_runtime.go | 2 +- internal/engine/assumptions.go | 2 +- internal/engine/branching/shadow.go | 4 ++-- internal/engine/code/code_lens.go | 2 +- internal/engine/directive_scanner.go | 2 +- internal/engine/docs/doc_updater.go | 2 +- internal/engine/git/git_context.go | 2 +- internal/engine/planning/suggested_tasks.go | 6 +++--- internal/engine/self_heal.go | 4 ++-- internal/engine/validate.go | 4 ++-- internal/engine/workflow/workspace_diff_report.go | 2 +- internal/feature/eval/parallel_runner.go | 2 +- internal/feature/fingerprint/detect.go | 2 +- internal/feature/fingerprint/project_conventions.go | 10 +++++----- internal/fsutil/fsutil.go | 6 ++++++ internal/gitworktree/worktree.go | 10 +++++----- internal/harness/evaluator.go | 6 ++++-- internal/harness/fix.go | 10 +++++----- internal/intelligence/repomap/api_scanner.go | 4 ++-- internal/intelligence/repomap/complexity.go | 2 +- internal/intelligence/repomap/dead_code.go | 2 +- internal/intelligence/repomap/depgraph_build.go | 2 +- internal/intelligence/repomap/doclint.go | 2 +- internal/intelligence/repomap/health_score.go | 2 +- .../intelligence/repomap/health_score_dimensions.go | 4 ++-- internal/intelligence/repomap/migration_detector.go | 2 +- internal/intelligence/repomap/navigation.go | 2 +- internal/intelligence/repomap/smells.go | 2 +- internal/lint/linters.go | 10 +++++----- internal/lsp/lsp.go | 2 +- internal/mcp/mcp.go | 2 +- internal/mcp/oauth_store.go | 4 ++-- internal/multiagent/parallel/worktree.go | 8 ++++---- internal/multiagent/worker.go | 2 +- internal/plugin/components.go | 4 +++- internal/plugin/env.go | 2 +- internal/plugin/malware_check.go | 2 +- internal/plugin/manager.go | 2 +- internal/plugin/marketplace.go | 5 +++-- internal/plugin/scopes.go | 3 ++- internal/prompts/workspace.go | 2 +- internal/sandbox/config_toml.go | 3 ++- internal/sandbox/image.go | 2 +- internal/sandbox/sandbox.go | 4 ++-- internal/sandbox/snapshot_sandbox.go | 2 +- internal/snapshot/snapshot.go | 4 ++-- internal/snapshot/workspace.go | 4 ++-- internal/trust/store.go | 5 +++-- 65 files changed, 120 insertions(+), 105 deletions(-) diff --git a/cmd/chat_commands_util.go b/cmd/chat_commands_util.go index 88160ad4..d0352176 100644 --- a/cmd/chat_commands_util.go +++ b/cmd/chat_commands_util.go @@ -18,7 +18,7 @@ import ( func gitOutput(args ...string) (string, error) { // Output (not CombinedOutput): git writes warnings to stderr, which must // not be folded into values like the branch name shown in the status bar. - out, err := exec.CommandContext(context.Background(), "git", args...).Output() + out, err := exec.CommandContext(context.Background(), "git", args...).Output() // #nosec G204 -- fixed git executable return strings.TrimSpace(string(out)), err } diff --git a/cmd/chat_export.go b/cmd/chat_export.go index 2f41f467..54b51a3f 100644 --- a/cmd/chat_export.go +++ b/cmd/chat_export.go @@ -95,7 +95,7 @@ func exportSessionMarkdown(m *chatModel) (string, error) { if err := os.MkdirAll(exportDir, 0o700); err != nil { return "", err } - _ = os.Chmod(exportDir, 0o700) + _ = os.Chmod(exportDir, 0o700) // #nosec G302 -- exports are private session data exportPath := filepath.Join(exportDir, m.sessionID+".md") if err := os.WriteFile(exportPath, data, 0o600); err != nil { return "", err @@ -146,7 +146,7 @@ func exportSessionJSON(m *chatModel) (string, error) { if err := os.MkdirAll(exportDir, 0o700); err != nil { return "", err } - _ = os.Chmod(exportDir, 0o700) + _ = os.Chmod(exportDir, 0o700) // #nosec G302 -- exports are private session data exportPath := filepath.Join(exportDir, m.sessionID+".json") if err := os.WriteFile(exportPath, data, 0o600); err != nil { return "", err @@ -190,7 +190,7 @@ func exportSessionTxt(m *chatModel) (string, error) { if err := os.MkdirAll(exportDir, 0o700); err != nil { return "", err } - _ = os.Chmod(exportDir, 0o700) + _ = os.Chmod(exportDir, 0o700) // #nosec G302 -- exports are private session data exportPath := filepath.Join(exportDir, m.sessionID+".txt") if err := os.WriteFile(exportPath, []byte(b.String()), 0o600); err != nil { return "", err diff --git a/cmd/context_export.go b/cmd/context_export.go index a21a943e..81ab48fe 100644 --- a/cmd/context_export.go +++ b/cmd/context_export.go @@ -216,7 +216,7 @@ func gitContextInfo(dir string) string { // runGit executes a git command in the given directory. func runGit(dir string, args ...string) (string, error) { - cmd := exec.CommandContext(context.Background(), "git", args...) + cmd := exec.CommandContext(context.Background(), "git", args...) // #nosec G204 -- fixed git executable cmd.Dir = dir out, err := cmd.Output() if err != nil { diff --git a/cmd/exec.go b/cmd/exec.go index 35ae386b..09f82471 100644 --- a/cmd/exec.go +++ b/cmd/exec.go @@ -688,7 +688,7 @@ func cleanupExecWorktree(repoDir, wtPath string) { if wtPath == "" { return } - cmd := exec.CommandContext(context.Background(), "git", "worktree", "remove", "--force", wtPath) + cmd := exec.CommandContext(context.Background(), "git", "worktree", "remove", "--force", wtPath) // #nosec G204 -- fixed git executable cmd.Dir = repoDir _ = cmd.Run() } diff --git a/cmd/execution_graph.go b/cmd/execution_graph.go index caf8a136..722dc2a6 100644 --- a/cmd/execution_graph.go +++ b/cmd/execution_graph.go @@ -13,6 +13,7 @@ import ( graphcontracts "github.com/GrayCodeAI/hawk-core-contracts/graph" policycontracts "github.com/GrayCodeAI/hawk-core-contracts/policy" "github.com/GrayCodeAI/hawk/internal/executiongraph" + "github.com/GrayCodeAI/hawk/internal/fsutil" "github.com/GrayCodeAI/hawk/internal/graphjournal" "github.com/GrayCodeAI/hawk/internal/session" "github.com/GrayCodeAI/hawk/internal/taskruntime" @@ -238,7 +239,7 @@ func loadMissionGraphExport(dir string) (executiongraph.Export, error) { if info.Size() > 1<<20 { return executiongraph.Export{}, fmt.Errorf("mission graph is %d bytes; maximum is 1 MiB", info.Size()) } - data, err := os.ReadFile(path) + data, err := fsutil.ReadPinnedFile(path) if err != nil { return executiongraph.Export{}, fmt.Errorf("read mission graph %q: %w", path, err) } diff --git a/cmd/feedback.go b/cmd/feedback.go index 8d9e7019..06cc56fe 100644 --- a/cmd/feedback.go +++ b/cmd/feedback.go @@ -160,11 +160,11 @@ func openFeedbackIssue(report FeedbackReport) error { func openBrowser(url string) error { switch runtime.GOOS { case "darwin": - return exec.CommandContext(context.Background(), "open", url).Start() + return exec.CommandContext(context.Background(), "open", url).Start() // #nosec G204 -- fixed platform URL opener case "linux": - return exec.CommandContext(context.Background(), "xdg-open", url).Start() + return exec.CommandContext(context.Background(), "xdg-open", url).Start() // #nosec G204 -- fixed platform URL opener case "windows": - return exec.CommandContext(context.Background(), "rundll32", "url.dll,FileProtocolHandler", url).Start() + return exec.CommandContext(context.Background(), "rundll32", "url.dll,FileProtocolHandler", url).Start() // #nosec G204 -- fixed platform URL opener default: return fmt.Errorf("unsupported platform") } diff --git a/cmd/harness.go b/cmd/harness.go index 91596c7b..cdae05fc 100644 --- a/cmd/harness.go +++ b/cmd/harness.go @@ -63,21 +63,21 @@ Use --fix to automatically repair missing AGENTS.md, skills, or spec directories outDir = filepath.Join(targetDir, ".hawk", "harness") } - if mkdirErr := os.MkdirAll(outDir, 0o755); mkdirErr != nil { + if mkdirErr := os.MkdirAll(outDir, 0o750); mkdirErr != nil { return fmt.Errorf("failed to create harness output directory: %w", mkdirErr) } // Write Markdown report mdPath := filepath.Join(outDir, "report.md") mdContent := harness.RenderMarkdown(report) - if writeErr := os.WriteFile(mdPath, []byte(mdContent), 0o644); writeErr != nil { + if writeErr := os.WriteFile(mdPath, []byte(mdContent), 0o640); writeErr != nil { // #nosec G306 -- report is intentionally group-readable return fmt.Errorf("failed to write report.md: %w", writeErr) } // Write HTML report htmlPath := filepath.Join(outDir, "report.html") htmlContent := harness.RenderHTML(report) - if writeErr := os.WriteFile(htmlPath, []byte(htmlContent), 0o644); writeErr != nil { + if writeErr := os.WriteFile(htmlPath, []byte(htmlContent), 0o640); writeErr != nil { // #nosec G306 -- report is intentionally group-readable return fmt.Errorf("failed to write report.html: %w", writeErr) } @@ -87,7 +87,7 @@ Use --fix to automatically repair missing AGENTS.md, skills, or spec directories if renderErr != nil { return fmt.Errorf("failed to serialize findings.json: %w", renderErr) } - if writeErr := os.WriteFile(jsonPath, jsonContent, 0o644); writeErr != nil { + if writeErr := os.WriteFile(jsonPath, jsonContent, 0o640); writeErr != nil { // #nosec G306 -- report is intentionally group-readable return fmt.Errorf("failed to write findings.json: %w", writeErr) } diff --git a/cmd/notify.go b/cmd/notify.go index 5294f846..781140b8 100644 --- a/cmd/notify.go +++ b/cmd/notify.go @@ -141,7 +141,7 @@ func (n *Notifier) DesktopNotify(title, message string) error { cmd := exec.CommandContext(context.Background(), "osascript", "-e", script) // #nosec G204 -- fixed command 'osascript'; script built from escaped internal strings return cmd.Run() case "linux": - cmd := exec.CommandContext(context.Background(), "notify-send", title, message) + cmd := exec.CommandContext(context.Background(), "notify-send", title, message) // #nosec G204 -- fixed notification executable return cmd.Run() case "windows": script := fmt.Sprintf(` diff --git a/cmd/pager.go b/cmd/pager.go index 2f4a95b6..a923979a 100644 --- a/cmd/pager.go +++ b/cmd/pager.go @@ -55,7 +55,7 @@ func StartPager() io.Writer { // G204: name is derived from environment or LookPath, which is the standard // pattern for pager invocation. Users control their own PAGER env var. //nolint:gosec // G204 - cmd := exec.Command(name, args...) + cmd := exec.Command(name, args...) // #nosec G204 -- pager executable is selected from a validated allowlist cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr diff --git a/cmd/pr.go b/cmd/pr.go index 741cf9ab..54e40d82 100644 --- a/cmd/pr.go +++ b/cmd/pr.go @@ -243,7 +243,7 @@ func gitDiffBase(base string) (string, error) { out, err := cmd.Output() if err != nil { // Fallback to two-dot diff - cmd = exec.CommandContext(ctx, "git", "diff", base, "HEAD") + cmd = exec.CommandContext(ctx, "git", "diff", base, "HEAD") // #nosec G204 -- fixed git executable out, err = cmd.Output() if err != nil { return "", err diff --git a/cmd/review_run.go b/cmd/review_run.go index 528e8ddc..a9d3b894 100644 --- a/cmd/review_run.go +++ b/cmd/review_run.go @@ -147,7 +147,7 @@ func runReviewRun(_ *cobra.Command, args []string) error { func getCommitDiff(sha string) (string, error) { // For the first commit, diff against empty tree. - out, err := exec.CommandContext(context.Background(), "git", "diff-tree", "-p", sha).Output() + out, err := exec.CommandContext(context.Background(), "git", "diff-tree", "-p", sha).Output() // #nosec G204 -- fixed git executable if err != nil { // Fallback: diff against parent. out, err = exec.CommandContext(context.Background(), "git", "diff", sha+"^", sha).Output() // #nosec G204 -- fixed command 'git' with args, not user-controlled binary diff --git a/cmd/root.go b/cmd/root.go index 2d1e5950..b09cccf0 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -416,11 +416,11 @@ Fish: // Ensure parent directory exists. dir := path[:strings.LastIndex(path, "/")] - if err := os.MkdirAll(dir, 0o755); err != nil { + if err := os.MkdirAll(dir, 0o755); err != nil { // #nosec G301 -- shell completion directory must be traversable return fmt.Errorf("cannot create directory %s: %w", dir, err) } - if err := os.WriteFile(path, []byte(script.String()), 0o644); err != nil { + if err := os.WriteFile(path, []byte(script.String()), 0o644); err != nil { // #nosec G306 -- completion scripts are intentionally user-readable return fmt.Errorf("cannot write completion script: %w", err) } diff --git a/cmd/vibe.go b/cmd/vibe.go index a603131e..6ac26035 100644 --- a/cmd/vibe.go +++ b/cmd/vibe.go @@ -134,7 +134,7 @@ func runVibeCommand(ctx context.Context, command string) (string, error) { ctx, cancel := context.WithTimeout(ctx, 5*time.Minute) defer cancel() - cmd := exec.CommandContext(ctx, "sh", "-c", command) + cmd := exec.CommandContext(ctx, "sh", "-c", command) // #nosec G204 -- intentional vibe command execution boundary out, err := cmd.CombinedOutput() return strings.TrimSpace(string(out)), err } diff --git a/internal/auth/auth.go b/internal/auth/auth.go index 15bacb4e..915f5695 100644 --- a/internal/auth/auth.go +++ b/internal/auth/auth.go @@ -271,7 +271,7 @@ func GenerateNonce() string { } func execCommand(name string, args ...string) (string, error) { - cmd := exec.CommandContext(context.Background(), name, args...) + cmd := exec.CommandContext(context.Background(), name, args...) // #nosec G204 -- executable is selected by the platform credential backend out, err := cmd.Output() if err != nil { if exitErr, ok := err.(*exec.ExitError); ok { diff --git a/internal/codegraph/codegraph_cgo_query.go b/internal/codegraph/codegraph_cgo_query.go index 309d329f..9fd60f80 100644 --- a/internal/codegraph/codegraph_cgo_query.go +++ b/internal/codegraph/codegraph_cgo_query.go @@ -231,7 +231,7 @@ func (cg *CodeGraph) Sync() (*SyncResult, error) { result.FilesChecked++ // Check if file changed - source, err := os.ReadFile(path) // #nosec G304 -- path comes from filepath.WalkDir over cg.root, the project being indexed + source, err := os.ReadFile(path) // #nosec G304,G122 -- read-only codegraph indexing scan if err != nil { return nil } diff --git a/internal/config/config.go b/internal/config/config.go index 07d3099e..665868fc 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -94,7 +94,7 @@ func GitContext() string { func gitCmd(args ...string) (string, error) { ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() - out, err := exec.CommandContext(ctx, "git", args...).Output() + out, err := exec.CommandContext(ctx, "git", args...).Output() // #nosec G204 -- fixed git executable return strings.TrimSpace(string(out)), err } diff --git a/internal/context/repomap/scan.go b/internal/context/repomap/scan.go index 59d7d3e4..1f9411f1 100644 --- a/internal/context/repomap/scan.go +++ b/internal/context/repomap/scan.go @@ -51,7 +51,7 @@ func (g *Graph) scan(root string) error { if ierr != nil || info.Size() > maxFileBytes { return nil } - data, rerr := os.ReadFile(path) // #nosec G304 -- path comes from filepath.WalkDir over the project root being scanned, not external input + data, rerr := os.ReadFile(path) // #nosec G304,G122 -- read-only repository scan if rerr != nil { return nil } diff --git a/internal/crash/crash_runtime.go b/internal/crash/crash_runtime.go index c8ef9ccf..6c058220 100644 --- a/internal/crash/crash_runtime.go +++ b/internal/crash/crash_runtime.go @@ -23,7 +23,7 @@ func installRuntimeCrashOutput() { return } path := filepath.Join(dir, "runtime-crash.log") - f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600) + f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600) // #nosec G304 -- path is the private runtime crash report path if err != nil { return } diff --git a/internal/engine/assumptions.go b/internal/engine/assumptions.go index 01bfe351..e5b2c6e9 100644 --- a/internal/engine/assumptions.go +++ b/internal/engine/assumptions.go @@ -65,7 +65,7 @@ func (at *AssumptionTracker) VerifyCommandSucceeds(text, cmd string) { at.mu.Lock() defer at.mu.Unlock() a := Assumption{Text: text} - out, err := exec.CommandContext(context.Background(), "sh", "-c", cmd).CombinedOutput() + out, err := exec.CommandContext(context.Background(), "sh", "-c", cmd).CombinedOutput() // #nosec G204 -- intentional assumption-check command boundary if err == nil { a.Status = AssumptionConfirmed a.Proof = "command succeeded" diff --git a/internal/engine/branching/shadow.go b/internal/engine/branching/shadow.go index e91c872c..fad7c38f 100644 --- a/internal/engine/branching/shadow.go +++ b/internal/engine/branching/shadow.go @@ -106,7 +106,7 @@ func shadowValidateGo(tmpPath, origPath string) []ValidationError { defer func() { _ = os.Remove(modPath) }() } - cmd := exec.CommandContext(context.Background(), "go", "vet", "./...") + cmd := exec.CommandContext(context.Background(), "go", "vet", "./...") // #nosec G204 -- fixed Go executable cmd.Dir = dir output, err := cmd.CombinedOutput() if err == nil { @@ -142,7 +142,7 @@ func shadowValidatePython(tmpPath, origPath string) []ValidationError { // shadowValidateTS runs `npx tsc --noEmit` on the temp file. func shadowValidateTS(tmpPath, origPath string) []ValidationError { - cmd := exec.CommandContext(context.Background(), "npx", "tsc", "--noEmit", "--allowJs", tmpPath) + cmd := exec.CommandContext(context.Background(), "npx", "tsc", "--noEmit", "--allowJs", tmpPath) // #nosec G204 -- fixed TypeScript compiler executable output, err := cmd.CombinedOutput() if err == nil { return nil diff --git a/internal/engine/code/code_lens.go b/internal/engine/code/code_lens.go index da1fb686..e78e401d 100644 --- a/internal/engine/code/code_lens.go +++ b/internal/engine/code/code_lens.go @@ -376,7 +376,7 @@ type blameEntry struct { } func getGitBlame(file string) []blameEntry { - cmd := exec.CommandContext(context.Background(), "git", "blame", "--porcelain", file) + cmd := exec.CommandContext(context.Background(), "git", "blame", "--porcelain", file) // #nosec G204 -- fixed git executable out, err := cmd.Output() if err != nil { return nil diff --git a/internal/engine/directive_scanner.go b/internal/engine/directive_scanner.go index 694a39b6..703e5104 100644 --- a/internal/engine/directive_scanner.go +++ b/internal/engine/directive_scanner.go @@ -39,7 +39,7 @@ func ScanDirectives(dir string) []Directive { default: return nil } - data, err := os.ReadFile(path) // #nosec G304 -- path provided by caller via tool/task parameters, inherent to this dev CLI's file operations + data, err := os.ReadFile(path) // #nosec G304,G122 -- read-only directive scan if err != nil { return nil } diff --git a/internal/engine/docs/doc_updater.go b/internal/engine/docs/doc_updater.go index 0949af07..373fa685 100644 --- a/internal/engine/docs/doc_updater.go +++ b/internal/engine/docs/doc_updater.go @@ -198,7 +198,7 @@ func (du *DocUpdater) ScanProjectForStaleDocs(projectDir string) []DocUpdate { } if strings.HasSuffix(path, ".go") && !strings.HasSuffix(path, "_test.go") { goFiles = append(goFiles, path) - data, err := os.ReadFile(path) // #nosec G304 -- path provided by caller via tool/task parameters, inherent to this dev CLI's file operations + data, err := os.ReadFile(path) // #nosec G304,G122 -- read-only documentation scan if err != nil { return nil } diff --git a/internal/engine/git/git_context.go b/internal/engine/git/git_context.go index 6f158cf8..e2ba16a5 100644 --- a/internal/engine/git/git_context.go +++ b/internal/engine/git/git_context.go @@ -58,7 +58,7 @@ func NewGitContext(repoDir string) *GitContext { // runGit executes a git command in the repo directory and returns its output. func (gc *GitContext) runGit(args ...string) (string, error) { - cmd := exec.CommandContext(context.Background(), "git", args...) + cmd := exec.CommandContext(context.Background(), "git", args...) // #nosec G204 -- fixed git executable cmd.Dir = gc.RepoDir out, err := cmd.Output() if err != nil { diff --git a/internal/engine/planning/suggested_tasks.go b/internal/engine/planning/suggested_tasks.go index 9e5ab9a3..41ec87ad 100644 --- a/internal/engine/planning/suggested_tasks.go +++ b/internal/engine/planning/suggested_tasks.go @@ -242,7 +242,7 @@ func ScanTODOs(projectDir string) []*SuggestedTask { return nil } - f, err := os.Open(path) // #nosec G304 -- path provided by caller via tool/task parameters, inherent to this dev CLI's file operations + f, err := os.Open(path) // #nosec G304,G122 -- read-only task discovery traversal if err != nil { return nil } @@ -423,7 +423,7 @@ func scanDocsTasks(projectDir string) []*SuggestedTask { return nil } - f, err := os.Open(path) // #nosec G304 -- path provided by caller via tool/task parameters, inherent to this dev CLI's file operations + f, err := os.Open(path) // #nosec G304,G122 -- read-only task discovery traversal if err != nil { return nil } @@ -547,7 +547,7 @@ func scanSecurityTasks(projectDir string) []*SuggestedTask { ext := filepath.Ext(path) - f, err := os.Open(path) // #nosec G304 -- path provided by caller via tool/task parameters, inherent to this dev CLI's file operations + f, err := os.Open(path) // #nosec G304,G122 -- read-only task discovery traversal if err != nil { return nil } diff --git a/internal/engine/self_heal.go b/internal/engine/self_heal.go index 8c2c3672..1a74459e 100644 --- a/internal/engine/self_heal.go +++ b/internal/engine/self_heal.go @@ -427,7 +427,7 @@ func (sh *SelfHealer) RunScript(ctx context.Context, path string) (stdout, stder ctx, cancel := context.WithTimeout(ctx, sh.Timeout) defer cancel() - cmd := exec.CommandContext(ctx, "sh", "-c", path) + cmd := exec.CommandContext(ctx, "sh", "-c", path) // #nosec G204 -- intentional self-heal script execution boundary var outBuf, errBuf bytes.Buffer cmd.Stdout = &outBuf cmd.Stderr = &errBuf @@ -462,7 +462,7 @@ func (sh *SelfHealer) runCommand(ctx context.Context, command string) (stdout, s ctx, cancel := context.WithTimeout(ctx, sh.Timeout) defer cancel() - cmd := exec.CommandContext(ctx, "sh", "-c", command) + cmd := exec.CommandContext(ctx, "sh", "-c", command) // #nosec G204 -- intentional self-heal command boundary var outBuf, errBuf bytes.Buffer cmd.Stdout = &outBuf cmd.Stderr = &errBuf diff --git a/internal/engine/validate.go b/internal/engine/validate.go index 82b31619..aa3b02a5 100644 --- a/internal/engine/validate.go +++ b/internal/engine/validate.go @@ -133,7 +133,7 @@ func validatePython(path string) *ValidationResult { // validateJS runs node --check on a JavaScript file. func validateJS(path string) *ValidationResult { - cmd := exec.CommandContext(context.Background(), "node", "--check", path) + cmd := exec.CommandContext(context.Background(), "node", "--check", path) // #nosec G204 -- fixed Node executable output, err := cmd.CombinedOutput() if err == nil { @@ -159,7 +159,7 @@ func validateJS(path string) *ValidationResult { // Uses npx tsc --noEmit if available, otherwise falls back to node --check. func validateTS(path string) *ValidationResult { // Try tsc first - cmd := exec.CommandContext(context.Background(), "npx", "tsc", "--noEmit", "--allowJs", path) + cmd := exec.CommandContext(context.Background(), "npx", "tsc", "--noEmit", "--allowJs", path) // #nosec G204 -- fixed TypeScript compiler executable output, err := cmd.CombinedOutput() if err == nil { return &ValidationResult{Valid: true} diff --git a/internal/engine/workflow/workspace_diff_report.go b/internal/engine/workflow/workspace_diff_report.go index d553c341..0b8b9122 100644 --- a/internal/engine/workflow/workspace_diff_report.go +++ b/internal/engine/workflow/workspace_diff_report.go @@ -590,7 +590,7 @@ func (dr *DiffReporter) addUntrackedFiles(report *WorkspaceDiffReport, untracked // runGit executes a git command in the project directory and returns output. func (dr *DiffReporter) runGit(args ...string) (string, error) { - cmd := exec.CommandContext(context.Background(), "git", args...) + cmd := exec.CommandContext(context.Background(), "git", args...) // #nosec G204 -- fixed git executable cmd.Dir = dr.ProjectDir out, err := cmd.Output() if err != nil { diff --git a/internal/feature/eval/parallel_runner.go b/internal/feature/eval/parallel_runner.go index a0d990bb..bb744417 100644 --- a/internal/feature/eval/parallel_runner.go +++ b/internal/feature/eval/parallel_runner.go @@ -191,7 +191,7 @@ func (r *ParallelRunner) RunSinglePackage(ctx context.Context, pkg string) *Pack Package: pkg, } - cmd := exec.CommandContext(ctx, "go", "test", "-v", "-json", pkg) + cmd := exec.CommandContext(ctx, "go", "test", "-v", "-json", pkg) // #nosec G204 -- fixed Go executable out, err := cmd.CombinedOutput() result.Duration = time.Since(start) result.Output = string(out) diff --git a/internal/feature/fingerprint/detect.go b/internal/feature/fingerprint/detect.go index e8fcf263..ca2c5f5b 100644 --- a/internal/feature/fingerprint/detect.go +++ b/internal/feature/fingerprint/detect.go @@ -523,7 +523,7 @@ func collectGitInfo(dir string) *GitInfo { // gitCmd runs a git command in the given directory and returns its stdout. func gitCmd(dir string, args ...string) (string, error) { - cmd := exec.CommandContext(context.Background(), "git", args...) + cmd := exec.CommandContext(context.Background(), "git", args...) // #nosec G204 -- fixed git executable cmd.Dir = dir var stdout, stderr bytes.Buffer cmd.Stdout = &stdout diff --git a/internal/feature/fingerprint/project_conventions.go b/internal/feature/fingerprint/project_conventions.go index 677de93c..f6fc3e73 100644 --- a/internal/feature/fingerprint/project_conventions.go +++ b/internal/feature/fingerprint/project_conventions.go @@ -116,7 +116,7 @@ func detectIndentationConvention(dir string) *Convention { return nil } - f, err := os.Open(path) // #nosec G304 -- path comes from filepath.WalkDir over the project directory being scanned by this dev tool + f, err := os.Open(path) // #nosec G304,G122 -- read-only convention scan if err != nil { return nil } @@ -191,7 +191,7 @@ func detectNamingConvention(dir string, lang string) *Convention { return nil } - data, err := os.ReadFile(path) // #nosec G304 -- path comes from filepath.WalkDir over a project directory being scanned by this dev tool + data, err := os.ReadFile(path) // #nosec G304,G122 -- read-only convention scan if err != nil { return nil } @@ -243,7 +243,7 @@ func detectGoErrorHandling(dir string) *Convention { return nil } - data, err := os.ReadFile(path) // #nosec G304 -- path comes from filepath.WalkDir over a project directory being scanned by this dev tool + data, err := os.ReadFile(path) // #nosec G304,G122 -- read-only convention scan if err != nil { return nil } @@ -294,7 +294,7 @@ func detectImportOrganization(dir string, lang string) *Convention { return nil } - data, err := os.ReadFile(path) // #nosec G304 -- path comes from filepath.WalkDir over a project directory being scanned by this dev tool + data, err := os.ReadFile(path) // #nosec G304,G122 -- read-only convention scan if err != nil { return nil } @@ -376,7 +376,7 @@ func detectTestNaming(dir string, lang string) *Convention { return nil } - data, err := os.ReadFile(path) // #nosec G304 -- path comes from filepath.WalkDir over a project directory being scanned by this dev tool + data, err := os.ReadFile(path) // #nosec G304,G122 -- read-only convention scan if err != nil { return nil } diff --git a/internal/fsutil/fsutil.go b/internal/fsutil/fsutil.go index 0eca9e46..7079ebc5 100644 --- a/internal/fsutil/fsutil.go +++ b/internal/fsutil/fsutil.go @@ -20,6 +20,12 @@ func Exists(path string) bool { func ReadPinnedFile(path string) ([]byte, error) { root, name, err := openPinnedParent(path) if err != nil { + if os.IsNotExist(err) || func() bool { + _, statErr := os.Stat(filepath.Dir(path)) + return os.IsNotExist(statErr) + }() { + return nil, &os.PathError{Op: "open", Path: path, Err: os.ErrNotExist} + } return nil, err } defer func() { _ = root.Close() }() diff --git a/internal/gitworktree/worktree.go b/internal/gitworktree/worktree.go index 30a788ee..f3f60785 100644 --- a/internal/gitworktree/worktree.go +++ b/internal/gitworktree/worktree.go @@ -20,11 +20,11 @@ func Create(ctx context.Context, repoDir, branch string) (path string, cleanup f return "", nil, err } // Ensure we are in a git repo. - if out, e := exec.CommandContext(ctx, "git", "-C", repoDir, "rev-parse", "--is-inside-work-tree").CombinedOutput(); e != nil { + if out, e := exec.CommandContext(ctx, "git", "-C", repoDir, "rev-parse", "--is-inside-work-tree").CombinedOutput(); e != nil { // #nosec G204 -- fixed git executable return "", nil, fmt.Errorf("not a git repository: %s", strings.TrimSpace(string(out))) } base := filepath.Join(repoDir, ".hawk", "worktrees") - if err := os.MkdirAll(base, 0o755); err != nil { + if err := os.MkdirAll(base, 0o700); err != nil { return "", nil, err } if branch == "" { @@ -35,17 +35,17 @@ func Create(ctx context.Context, repoDir, branch string) (path string, cleanup f _ = os.RemoveAll(path) // #nosec G204 -- fixed git binary; path/branch derived internally - cmd := exec.CommandContext(ctx, "git", "-C", repoDir, "worktree", "add", "-b", branch, path, "HEAD") + cmd := exec.CommandContext(ctx, "git", "-C", repoDir, "worktree", "add", "-b", branch, path, "HEAD") // #nosec G204 -- fixed git executable if out, e := cmd.CombinedOutput(); e != nil { return "", nil, fmt.Errorf("git worktree add: %s: %w", strings.TrimSpace(string(out)), e) } cleanup = func() { cctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() - _ = exec.CommandContext(cctx, "git", "-C", repoDir, "worktree", "remove", "--force", path).Run() + _ = exec.CommandContext(cctx, "git", "-C", repoDir, "worktree", "remove", "--force", path).Run() // #nosec G204 -- fixed git executable _ = os.RemoveAll(path) // Best-effort branch delete (ignore if checked out elsewhere). - _ = exec.CommandContext(cctx, "git", "-C", repoDir, "branch", "-D", branch).Run() + _ = exec.CommandContext(cctx, "git", "-C", repoDir, "branch", "-D", branch).Run() // #nosec G204 -- fixed git executable } return path, cleanup, nil } diff --git a/internal/harness/evaluator.go b/internal/harness/evaluator.go index 1171a146..336519f8 100644 --- a/internal/harness/evaluator.go +++ b/internal/harness/evaluator.go @@ -7,6 +7,8 @@ import ( "path/filepath" "strings" "time" + + "github.com/GrayCodeAI/hawk/internal/fsutil" ) // EvaluateWorkspace performs a comprehensive harness evaluation of the specified workspace directory. @@ -133,7 +135,7 @@ func detectAssets(root string) AssetsDetected { // Check test runners if fileExists(filepath.Join(root, "Makefile")) { - content, _ := os.ReadFile(filepath.Join(root, "Makefile")) + content, _ := fsutil.ReadPinnedFile(filepath.Join(root, "Makefile")) str := string(content) if strings.Contains(str, "test:") || strings.Contains(str, "go test") { assets.TestRunners = append(assets.TestRunners, "make test") @@ -148,7 +150,7 @@ func detectAssets(root string) AssetsDetected { assets.TestRunners = append(assets.TestRunners, "go test") } if fileExists(filepath.Join(root, "package.json")) { - content, _ := os.ReadFile(filepath.Join(root, "package.json")) + content, _ := fsutil.ReadPinnedFile(filepath.Join(root, "package.json")) if strings.Contains(string(content), `"test"`) { assets.TestRunners = append(assets.TestRunners, "npm test") } diff --git a/internal/harness/fix.go b/internal/harness/fix.go index 62558c08..2be5149e 100644 --- a/internal/harness/fix.go +++ b/internal/harness/fix.go @@ -35,7 +35,7 @@ func FixWorkspaceHarness(ctx context.Context, targetPath string, report *Harness if !report.Assets.AgentsMD { agentsPath := filepath.Join(root, "AGENTS.md") template := buildAgentsMDTemplate(report.Assets) - if err := os.WriteFile(agentsPath, []byte(template), 0o644); err == nil { + if err := os.WriteFile(agentsPath, []byte(template), 0o644); err == nil { // #nosec G306 -- AGENTS.md is intentionally project-readable result.RepairsPerformed = append(result.RepairsPerformed, "Created baseline AGENTS.md with build, lint, and test conventions") result.FilesCreated = append(result.FilesCreated, agentsPath) } @@ -44,9 +44,9 @@ func FixWorkspaceHarness(ctx context.Context, targetPath string, report *Harness // 2. Repair missing .zero/skills/ directory & starter skill skillsDir := filepath.Join(root, ".zero", "skills") if !dirExists(skillsDir) { - if err := os.MkdirAll(skillsDir, 0o755); err == nil { + if err := os.MkdirAll(skillsDir, 0o750); err == nil { starterSkillDir := filepath.Join(skillsDir, "code-review") - _ = os.MkdirAll(starterSkillDir, 0o755) + _ = os.MkdirAll(starterSkillDir, 0o750) starterSkillFile := filepath.Join(starterSkillDir, "SKILL.md") skillContent := `--- description: Standard Go and Project Code Review Conventions @@ -60,7 +60,7 @@ alwaysApply: true 2. Check for unhandled error returns. 3. Confirm tests exist alongside modified source files (*_test.go). ` - if err := os.WriteFile(starterSkillFile, []byte(skillContent), 0o644); err == nil { + if err := os.WriteFile(starterSkillFile, []byte(skillContent), 0o644); err == nil { // #nosec G306 -- skill manifest is intentionally project-readable result.RepairsPerformed = append(result.RepairsPerformed, "Created .zero/skills/ directory and starter code-review skill") result.FilesCreated = append(result.FilesCreated, starterSkillFile) } @@ -70,7 +70,7 @@ alwaysApply: true // 3. Repair missing .hawk/specs/ directory specsDir := filepath.Join(root, ".hawk", "specs") if !dirExists(specsDir) { - if err := os.MkdirAll(specsDir, 0o755); err == nil { + if err := os.MkdirAll(specsDir, 0o750); err == nil { result.RepairsPerformed = append(result.RepairsPerformed, "Created .hawk/specs/ directory for task specification management") } } diff --git a/internal/intelligence/repomap/api_scanner.go b/internal/intelligence/repomap/api_scanner.go index 6ca55f9d..e22e52cf 100644 --- a/internal/intelligence/repomap/api_scanner.go +++ b/internal/intelligence/repomap/api_scanner.go @@ -89,7 +89,7 @@ func (s *APIScanner) ScanProject(dir string) (*APIMap, error) { return nil } - data, err := os.ReadFile(path) // #nosec G304 -- path is a repo file discovered while walking the repo being analyzed by this dev CLI + data, err := os.ReadFile(path) // #nosec G304,G122 -- read-only repository analysis if err != nil { return nil } @@ -471,7 +471,7 @@ func DetectFramework(dir string) string { if !strings.HasSuffix(path, ".go") || strings.HasSuffix(path, "_test.go") { return nil } - data, err := os.ReadFile(path) // #nosec G304 -- path is a repo file discovered while walking the project directory being analyzed by this dev CLI + data, err := os.ReadFile(path) // #nosec G304,G122 -- read-only repository analysis if err != nil { return nil } diff --git a/internal/intelligence/repomap/complexity.go b/internal/intelligence/repomap/complexity.go index 18f41b29..1a555023 100644 --- a/internal/intelligence/repomap/complexity.go +++ b/internal/intelligence/repomap/complexity.go @@ -698,7 +698,7 @@ func (ca *ComplexityAnalyzer) FindHotspots(dir string, limit int) []FunctionComp return nil } - data, err := os.ReadFile(path) // #nosec G304 -- path is a repo file discovered while walking the repo being analyzed by this dev CLI + data, err := os.ReadFile(path) // #nosec G304,G122 -- read-only repository analysis if err != nil { return nil } diff --git a/internal/intelligence/repomap/dead_code.go b/internal/intelligence/repomap/dead_code.go index a549d1c7..fc97af3b 100644 --- a/internal/intelligence/repomap/dead_code.go +++ b/internal/intelligence/repomap/dead_code.go @@ -85,7 +85,7 @@ func (d *DeadCodeDetector) Scan(projectDir string) ([]DeadCode, error) { if !strings.HasSuffix(path, ".go") { return nil } - content, err := os.ReadFile(path) // #nosec G304 -- path is a repo file discovered while walking the project directory being analyzed by this dev CLI + content, err := os.ReadFile(path) // #nosec G304,G122 -- read-only repository analysis if err != nil { return nil } diff --git a/internal/intelligence/repomap/depgraph_build.go b/internal/intelligence/repomap/depgraph_build.go index 81438b8d..42520693 100644 --- a/internal/intelligence/repomap/depgraph_build.go +++ b/internal/intelligence/repomap/depgraph_build.go @@ -277,7 +277,7 @@ func (dg *DepGraph) BuildFromPackageJSON(projectDir string) error { internalModules[modPath].LOC += countFileLOC(path) // Read file and find imports. - content, readErr := os.ReadFile(path) // #nosec G304 -- path is a repo file discovered while walking the project directory being analyzed by this dev CLI + content, readErr := os.ReadFile(path) // #nosec G304,G122 -- read-only repository analysis if readErr != nil { return nil } diff --git a/internal/intelligence/repomap/doclint.go b/internal/intelligence/repomap/doclint.go index c62aa84f..82474adb 100644 --- a/internal/intelligence/repomap/doclint.go +++ b/internal/intelligence/repomap/doclint.go @@ -236,7 +236,7 @@ func (dl *DocLinter) LintDirectory(dir string) ([]*DocLintResult, error) { return nil } - content, readErr := os.ReadFile(path) // #nosec G304 -- path is a repo file discovered while walking the repo being analyzed by this dev CLI + content, readErr := os.ReadFile(path) // #nosec G304,G122 -- read-only repository analysis if readErr != nil { return nil } diff --git a/internal/intelligence/repomap/health_score.go b/internal/intelligence/repomap/health_score.go index 62262b8c..ccb49e3d 100644 --- a/internal/intelligence/repomap/health_score.go +++ b/internal/intelligence/repomap/health_score.go @@ -384,7 +384,7 @@ func checkErrorPatterns(dir string) float64 { return nil } - data, readErr := os.ReadFile(path) // #nosec G304 -- path is a repo file discovered while walking the repo being analyzed by this dev CLI + data, readErr := os.ReadFile(path) // #nosec G304,G122 -- read-only repository analysis if readErr != nil { return nil } diff --git a/internal/intelligence/repomap/health_score_dimensions.go b/internal/intelligence/repomap/health_score_dimensions.go index b34d8c1e..fba39b4b 100644 --- a/internal/intelligence/repomap/health_score_dimensions.go +++ b/internal/intelligence/repomap/health_score_dimensions.go @@ -372,7 +372,7 @@ func (hs *HealthScorer) ScoreCodeQuality(dir string) (float64, []HealthIssue) { totalFiles++ hasIssue := false - data, readErr := os.ReadFile(path) // #nosec G304 -- path is a repo file discovered while walking the repo being analyzed by this dev CLI + data, readErr := os.ReadFile(path) // #nosec G304,G122 -- read-only repository analysis if readErr != nil { return nil } @@ -565,7 +565,7 @@ func (hs *HealthScorer) ScoreSecurity(dir string) (float64, []HealthIssue) { return nil } - data, readErr := os.ReadFile(path) // #nosec G304 -- path is a repo file discovered while walking the repo being analyzed by this dev CLI + data, readErr := os.ReadFile(path) // #nosec G304,G122 -- read-only repository analysis if readErr != nil { return nil } diff --git a/internal/intelligence/repomap/migration_detector.go b/internal/intelligence/repomap/migration_detector.go index 4d0c1b79..5a4c65ba 100644 --- a/internal/intelligence/repomap/migration_detector.go +++ b/internal/intelligence/repomap/migration_detector.go @@ -473,7 +473,7 @@ func (md *MigrationDetector) Scan(projectDir string) ([]MigrationOpportunity, er return nil } - content, err := os.ReadFile(path) // #nosec G304 -- path is a repo file discovered while walking the repo being analyzed by this dev CLI + content, err := os.ReadFile(path) // #nosec G304,G122 -- read-only repository analysis if err != nil { return nil } diff --git a/internal/intelligence/repomap/navigation.go b/internal/intelligence/repomap/navigation.go index 4fb09ec0..b4b698c6 100644 --- a/internal/intelligence/repomap/navigation.go +++ b/internal/intelligence/repomap/navigation.go @@ -115,7 +115,7 @@ func (idx *NavIndex) BuildIndex(projectDir string) error { } // Read file lines for context extraction - content, readErr := os.ReadFile(path) // #nosec G304 -- path is a repo file discovered while walking the repo being analyzed by this dev CLI + content, readErr := os.ReadFile(path) // #nosec G304,G122 -- read-only repository analysis if readErr != nil { return nil } diff --git a/internal/intelligence/repomap/smells.go b/internal/intelligence/repomap/smells.go index 52387c8c..c85214db 100644 --- a/internal/intelligence/repomap/smells.go +++ b/internal/intelligence/repomap/smells.go @@ -638,7 +638,7 @@ func (sd *SmellDetector) ScanDirectory(dir string) []CodeSmell { return nil } - data, err := os.ReadFile(path) // #nosec G304 -- path is a repo file discovered while walking the repo being analyzed by this dev CLI + data, err := os.ReadFile(path) // #nosec G304,G122 -- read-only repository analysis if err != nil { return nil } diff --git a/internal/lint/linters.go b/internal/lint/linters.go index 721e0880..4ffd9b76 100644 --- a/internal/lint/linters.go +++ b/internal/lint/linters.go @@ -21,7 +21,7 @@ func (goLinter) Lint(ctx context.Context, file string) Result { // gofmt -l prints the filename when it is NOT properly formatted. if toolAvailable("gofmt") { - fmtOut, _ := runCmd(exec.CommandContext(ctx, "gofmt", "-l", file)) + fmtOut, _ := runCmd(exec.CommandContext(ctx, "gofmt", "-l", file)) // #nosec G204 -- fixed formatter executable if fmtOut != "" { findings = append(findings, "gofmt: needs formatting:\n"+fmtOut) } @@ -29,7 +29,7 @@ func (goLinter) Lint(ctx context.Context, file string) Result { // go vet runs against the package directory containing the file. if toolAvailable("go") { - cmd := exec.CommandContext(ctx, "go", "vet", file) + cmd := exec.CommandContext(ctx, "go", "vet", file) // #nosec G204 -- fixed Go executable cmd.Dir = filepath.Dir(file) vetOut, err := runCmd(cmd) if err != nil || vetOut != "" { @@ -58,9 +58,9 @@ func (eslintLinter) Lint(ctx context.Context, file string) Result { } var cmd *exec.Cmd if toolAvailable("eslint") { - cmd = exec.CommandContext(ctx, "eslint", "--format", "compact", file) + cmd = exec.CommandContext(ctx, "eslint", "--format", "compact", file) // #nosec G204 -- fixed linter executable } else { - cmd = exec.CommandContext(ctx, "npx", "--no-install", "eslint", "--format", "compact", file) + cmd = exec.CommandContext(ctx, "npx", "--no-install", "eslint", "--format", "compact", file) // #nosec G204 -- fixed linter executable } cmd.Dir = filepath.Dir(file) out, err := runCmd(cmd) @@ -83,7 +83,7 @@ func (ruffLinter) Lint(ctx context.Context, file string) Result { if !toolAvailable("ruff") { return Result{Linter: "ruff", OK: true} } - cmd := exec.CommandContext(ctx, "ruff", "check", file) + cmd := exec.CommandContext(ctx, "ruff", "check", file) // #nosec G204 -- fixed linter executable cmd.Dir = filepath.Dir(file) out, err := runCmd(cmd) if err == nil && out == "" { diff --git a/internal/lsp/lsp.go b/internal/lsp/lsp.go index 6bab6487..545774e3 100644 --- a/internal/lsp/lsp.go +++ b/internal/lsp/lsp.go @@ -66,7 +66,7 @@ func (m *ServerManager) Start(name, command string, args ...string) error { } ctx := context.Background() - cmd := exec.CommandContext(ctx, command, args...) + cmd := exec.CommandContext(ctx, command, args...) // #nosec G204 -- command comes from the configured LSP definition stdin, err := cmd.StdinPipe() if err != nil { return err diff --git a/internal/mcp/mcp.go b/internal/mcp/mcp.go index c2379dd1..0322b15b 100644 --- a/internal/mcp/mcp.go +++ b/internal/mcp/mcp.go @@ -78,7 +78,7 @@ const defaultCallTimeout = 30 * time.Second // Connect starts an MCP server process via stdio transport. func Connect(ctx context.Context, name, command string, args ...string) (*Server, error) { - cmd := exec.CommandContext(ctx, command, args...) + cmd := exec.CommandContext(ctx, command, args...) // #nosec G204 -- command comes from the configured MCP server definition stdin, err := cmd.StdinPipe() if err != nil { return nil, fmt.Errorf("mcp: stdin pipe: %w", err) diff --git a/internal/mcp/oauth_store.go b/internal/mcp/oauth_store.go index a9297a62..6ed3647e 100644 --- a/internal/mcp/oauth_store.go +++ b/internal/mcp/oauth_store.go @@ -18,7 +18,7 @@ const oauthTokenService = "hawk-mcp-oauth" // #nosec G101 -- fixed storage label // into that string — not auth.TokenStore, which (as of this writing) has // no-op Load/Save and doesn't actually persist anything. type StoredToken struct { - AccessToken string `json:"access_token"` + AccessToken string `json:"access_token"` // #nosec G117 -- token is intentionally serialized into OS keychain storage RefreshToken string `json:"refresh_token,omitempty"` ExpiresAt time.Time `json:"expires_at,omitempty"` ClientID string `json:"client_id"` @@ -64,7 +64,7 @@ func SetTokenBackendForTesting(backend interface { // SaveToken persists tok for serverName. func SaveToken(serverName string, tok *StoredToken) error { - data, err := json.Marshal(tok) + data, err := json.Marshal(tok) // #nosec G117 -- token serialization is required before OS keychain storage if err != nil { return err } diff --git a/internal/multiagent/parallel/worktree.go b/internal/multiagent/parallel/worktree.go index 2ab566f6..d68b543b 100644 --- a/internal/multiagent/parallel/worktree.go +++ b/internal/multiagent/parallel/worktree.go @@ -23,7 +23,7 @@ func createWorktree(repoDir, baseBranch, branchName string) (string, error) { wtPath := filepath.Join(dir, "work") // #nosec G204 -- binary is the fixed string "git"; branchName/wtPath/baseBranch come from internal caller state, not raw external input - cmd := exec.CommandContext(context.Background(), "git", "worktree", "add", "-b", branchName, wtPath, baseBranch) + cmd := exec.CommandContext(context.Background(), "git", "worktree", "add", "-b", branchName, wtPath, baseBranch) // #nosec G204 -- fixed git executable cmd.Dir = repoDir out, err := cmd.CombinedOutput() if err != nil { @@ -38,12 +38,12 @@ func createWorktree(repoDir, baseBranch, branchName string) (string, error) { // It is safe to call on a path that has already been removed. func removeWorktree(repoDir, worktreePath string) error { // Remove the worktree reference from git. - cmd := exec.CommandContext(context.Background(), "git", "worktree", "remove", "--force", worktreePath) + cmd := exec.CommandContext(context.Background(), "git", "worktree", "remove", "--force", worktreePath) // #nosec G204 -- fixed git executable cmd.Dir = repoDir out, err := cmd.CombinedOutput() if err != nil { // If the directory is already gone, git may complain. Try pruning instead. - prune := exec.CommandContext(context.Background(), "git", "worktree", "prune") + prune := exec.CommandContext(context.Background(), "git", "worktree", "prune") // #nosec G204 -- fixed git executable prune.Dir = repoDir _ = prune.Run() // best-effort; ignore error @@ -73,7 +73,7 @@ func removeWorktree(repoDir, worktreePath string) error { // The caller must ensure no uncommitted changes exist in the main repo. func mergeWorktree(repoDir, baseBranch, taskBranch string) error { // Checkout the base branch. - checkout := exec.CommandContext(context.Background(), "git", "checkout", baseBranch) + checkout := exec.CommandContext(context.Background(), "git", "checkout", baseBranch) // #nosec G204 -- fixed git executable checkout.Dir = repoDir if out, err := checkout.CombinedOutput(); err != nil { return fmt.Errorf("git checkout %s: %s: %w", baseBranch, strings.TrimSpace(string(out)), err) diff --git a/internal/multiagent/worker.go b/internal/multiagent/worker.go index 66d4e5ec..9d24910f 100644 --- a/internal/multiagent/worker.go +++ b/internal/multiagent/worker.go @@ -207,7 +207,7 @@ func createWorktree(ctx context.Context, repoDir, baseBranch, branch string) (st } func removeWorktree(ctx context.Context, repoDir, wtPath string) { - cmd := exec.CommandContext(ctx, "git", "worktree", "remove", "--force", wtPath) + cmd := exec.CommandContext(ctx, "git", "worktree", "remove", "--force", wtPath) // #nosec G204 -- fixed git executable cmd.Dir = repoDir if err := cmd.Run(); err != nil { fmt.Fprintf(os.Stderr, "warning: failed to remove worktree %s: %v\n", wtPath, err) diff --git a/internal/plugin/components.go b/internal/plugin/components.go index 617f4630..a324f57e 100644 --- a/internal/plugin/components.go +++ b/internal/plugin/components.go @@ -5,6 +5,8 @@ import ( "os" "path/filepath" "strings" + + "github.com/GrayCodeAI/hawk/internal/fsutil" ) // DiscoveredComponents is the result of scanning a multi-component plugin package. @@ -96,7 +98,7 @@ func DiscoverComponents(pluginDir string) (DiscoveredComponents, error) { // mcp.json mcpPath := filepath.Join(pluginDir, mcpFile) - if data, err := os.ReadFile(mcpPath); err == nil { + if data, err := fsutil.ReadPinnedFile(mcpPath); err == nil { var file struct { Servers []MCPServerSpec `json:"servers"` // also accept map form { "servers": { "name": {...} } } diff --git a/internal/plugin/env.go b/internal/plugin/env.go index 67c1291c..f9535349 100644 --- a/internal/plugin/env.go +++ b/internal/plugin/env.go @@ -22,7 +22,7 @@ func PluginDataDir(pluginName string) string { func ensurePluginDataDir(pluginRoot, pluginName string) string { data := PluginDataDir(pluginName) - _ = os.MkdirAll(data, 0o755) + _ = os.MkdirAll(data, 0o700) return data } diff --git a/internal/plugin/malware_check.go b/internal/plugin/malware_check.go index 32efbf54..ed791fe4 100644 --- a/internal/plugin/malware_check.go +++ b/internal/plugin/malware_check.go @@ -58,7 +58,7 @@ func CheckExtensionMalware(dir string) (*MalwareCheckResult, error) { if !isScannableExt(ext) { return nil } - data, err := os.ReadFile(path) // #nosec G304 -- path comes from filepath.WalkDir over a caller-specified extension directory being scanned, not raw external input + data, err := os.ReadFile(path) // #nosec G304,G122 -- read-only malware scan if err != nil { return nil } diff --git a/internal/plugin/manager.go b/internal/plugin/manager.go index b3c3a509..a7b9ca5d 100644 --- a/internal/plugin/manager.go +++ b/internal/plugin/manager.go @@ -434,7 +434,7 @@ func ScanPlugin(pluginDir string) []SecurityIssue { return nil } - data, err := os.ReadFile(path) // #nosec G304 -- path comes from filepath.WalkDir over pluginDir, a locally installed plugin directory, not raw external input + data, err := os.ReadFile(path) // #nosec G304,G122 -- read-only scan of a locally installed plugin if err != nil { return nil } diff --git a/internal/plugin/marketplace.go b/internal/plugin/marketplace.go index 6ffdca14..e5cc8b69 100644 --- a/internal/plugin/marketplace.go +++ b/internal/plugin/marketplace.go @@ -13,6 +13,7 @@ import ( "time" "github.com/GrayCodeAI/hawk/internal/flags" + "github.com/GrayCodeAI/hawk/internal/fsutil" "github.com/GrayCodeAI/hawk/internal/storage" ) @@ -84,7 +85,7 @@ func loadUserMarketplaceSources() []MarketplaceSource { // SaveUserSources writes extra marketplace sources to config. func SaveUserSources(srcs []MarketplaceSource) error { path := filepath.Join(storage.ConfigDir(), "marketplace-sources.json") - if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { return err } data, err := json.MarshalIndent(srcs, "", " ") @@ -137,7 +138,7 @@ func (mc *MarketplaceClient) fetchOne(src MarketplaceSource) (*MarketplaceIndex, cachePath := filepath.Join(mc.CacheDir, sanitizeName(src.Name)+".json") if info, err := os.Stat(cachePath); err == nil && time.Since(info.ModTime()) < time.Hour { - if data, err := os.ReadFile(cachePath); err == nil { + if data, err := fsutil.ReadPinnedFile(cachePath); err == nil { var idx MarketplaceIndex if json.Unmarshal(data, &idx) == nil { return &idx, nil diff --git a/internal/plugin/scopes.go b/internal/plugin/scopes.go index 92628860..f1c7d128 100644 --- a/internal/plugin/scopes.go +++ b/internal/plugin/scopes.go @@ -5,6 +5,7 @@ import ( "path/filepath" "sort" + "github.com/GrayCodeAI/hawk/internal/fsutil" "github.com/GrayCodeAI/hawk/internal/storage" "github.com/GrayCodeAI/hawk/internal/trust" ) @@ -52,7 +53,7 @@ func DiscoverScopeDirs(projectRoot string) []ScopeDir { if p == "" { continue } - if st, err := os.Stat(p); err == nil && st.IsDir() { + if fsutil.Exists(p) { out = append(out, ScopeDir{Scope: ScopeManaged, Path: p}) } } diff --git a/internal/prompts/workspace.go b/internal/prompts/workspace.go index 587dba33..5bc7f704 100644 --- a/internal/prompts/workspace.go +++ b/internal/prompts/workspace.go @@ -280,7 +280,7 @@ func detectLanguage(dir string) string { // gitCmd runs a git command in the given directory and returns its output. func gitCmd(dir string, args ...string) (string, error) { - cmd := exec.CommandContext(context.Background(), "git", args...) + cmd := exec.CommandContext(context.Background(), "git", args...) // #nosec G204 -- fixed git executable cmd.Dir = dir out, err := cmd.Output() return strings.TrimSpace(string(out)), err diff --git a/internal/sandbox/config_toml.go b/internal/sandbox/config_toml.go index f9976566..a050053a 100644 --- a/internal/sandbox/config_toml.go +++ b/internal/sandbox/config_toml.go @@ -8,6 +8,7 @@ import ( "github.com/BurntSushi/toml" + "github.com/GrayCodeAI/hawk/internal/fsutil" "github.com/GrayCodeAI/hawk/internal/storage" ) @@ -62,7 +63,7 @@ func ProjectSandboxTOMLPath(projectRoot string) string { // LoadTOML reads a sandbox.toml file. Missing file returns empty config. func LoadTOML(path string) (TOMLConfig, error) { var cfg TOMLConfig - data, err := os.ReadFile(path) + data, err := fsutil.ReadPinnedFile(path) if err != nil { if os.IsNotExist(err) { return cfg, nil diff --git a/internal/sandbox/image.go b/internal/sandbox/image.go index 0c43bb6f..42a5eaa1 100644 --- a/internal/sandbox/image.go +++ b/internal/sandbox/image.go @@ -25,7 +25,7 @@ var sandboxImageTag = strings.TrimSpace(rawSandboxImageTag) // dockerImageCommand is replaceable in tests. var dockerImageCommand = func(ctx context.Context, args ...string) ([]byte, error) { - return exec.CommandContext(ctx, "docker", args...).CombinedOutput() + return exec.CommandContext(ctx, "docker", args...).CombinedOutput() // #nosec G204 -- fixed Docker executable } // ImageProvisionResult describes how EnsureImage satisfied the image contract. diff --git a/internal/sandbox/sandbox.go b/internal/sandbox/sandbox.go index feeb2399..b6941b9c 100644 --- a/internal/sandbox/sandbox.go +++ b/internal/sandbox/sandbox.go @@ -132,7 +132,7 @@ func (s *Sandbox) setupNamespace() error { // Run executes a command in the sandbox. func (s *Sandbox) Run(ctx context.Context, command string) (*exec.Cmd, error) { if !s.config.Enabled { - return exec.CommandContext(ctx, "bash", "-c", command), nil + return exec.CommandContext(ctx, "bash", "-c", command), nil // #nosec G204 -- intentional sandbox command boundary } // Auto-select the best available sandbox backend. @@ -188,7 +188,7 @@ func (s *Sandbox) runNamespace(ctx context.Context, command string) (*exec.Cmd, args = append(args, "--net") } args = append(args, "sh", "-c", command) - return exec.CommandContext(ctx, "unshare", args...), nil + return exec.CommandContext(ctx, "unshare", args...), nil // #nosec G204 -- fixed sandbox executable } // runChroot runs a command in a chroot. diff --git a/internal/sandbox/snapshot_sandbox.go b/internal/sandbox/snapshot_sandbox.go index 0207d6d2..2b516093 100644 --- a/internal/sandbox/snapshot_sandbox.go +++ b/internal/sandbox/snapshot_sandbox.go @@ -398,7 +398,7 @@ func captureFiles(dir string) (map[string][]byte, error) { if err != nil { return nil } - data, err := os.ReadFile(path) // #nosec G304 -- path comes from filepath.WalkDir over the sandbox's own working directory + data, err := os.ReadFile(path) // #nosec G304,G122 -- read-only sandbox capture traversal if err != nil { return nil } diff --git a/internal/snapshot/snapshot.go b/internal/snapshot/snapshot.go index 4a047778..8bd04026 100644 --- a/internal/snapshot/snapshot.go +++ b/internal/snapshot/snapshot.go @@ -225,7 +225,7 @@ func (t *Tracker) gitWork(args ...string) error { } func (t *Tracker) gitWorkCtx(ctx context.Context, args ...string) error { - cmd := exec.CommandContext(ctx, "git", args...) + cmd := exec.CommandContext(ctx, "git", args...) // #nosec G204 -- fixed git executable cmd.Dir = t.shadowDir cmd.Env = append( os.Environ(), @@ -244,7 +244,7 @@ func (t *Tracker) gitWorkOutput(args ...string) (string, error) { } func (t *Tracker) gitWorkOutputCtx(ctx context.Context, args ...string) (string, error) { - cmd := exec.CommandContext(ctx, "git", args...) + cmd := exec.CommandContext(ctx, "git", args...) // #nosec G204 -- fixed git executable cmd.Dir = t.shadowDir cmd.Env = append( os.Environ(), diff --git a/internal/snapshot/workspace.go b/internal/snapshot/workspace.go index d4d601c8..4f12bcbb 100644 --- a/internal/snapshot/workspace.go +++ b/internal/snapshot/workspace.go @@ -139,7 +139,7 @@ func (s *SnapshotStore) Capture(projectDir, name, description string) (*Workspac } // Read file content - content, readErr := os.ReadFile(path) // #nosec G304 -- path from filepath.WalkDir over the project directory being snapshotted + content, readErr := os.ReadFile(path) // #nosec G304,G122 -- read-only snapshot traversal if readErr != nil { return nil // skip unreadable files } @@ -360,7 +360,7 @@ func (s *SnapshotStore) Diff(snapshotID string, projectDir string) (*SnapshotDif if !d.Type().IsRegular() { return nil } - content, readErr := os.ReadFile(path) // #nosec G304 -- path from filepath.WalkDir over the project directory being diffed + content, readErr := os.ReadFile(path) // #nosec G304,G122 -- read-only diff traversal if readErr != nil { return nil } diff --git a/internal/trust/store.go b/internal/trust/store.go index 3c4d4dd0..471e5418 100644 --- a/internal/trust/store.go +++ b/internal/trust/store.go @@ -15,6 +15,7 @@ import ( "time" "github.com/GrayCodeAI/hawk/internal/flags" + "github.com/GrayCodeAI/hawk/internal/fsutil" "github.com/GrayCodeAI/hawk/internal/storage" ) @@ -43,7 +44,7 @@ func Open(path string) (*Store, error) { path = DefaultPath() } s := &Store{path: path, Entries: make(map[string]Entry)} - data, err := os.ReadFile(path) + data, err := fsutil.ReadPinnedFile(path) if err != nil { if os.IsNotExist(err) { return s, nil @@ -67,7 +68,7 @@ func Open(path string) (*Store, error) { func (s *Store) Save() error { s.mu.Lock() defer s.mu.Unlock() - if err := os.MkdirAll(filepath.Dir(s.path), 0o755); err != nil { + if err := os.MkdirAll(filepath.Dir(s.path), 0o700); err != nil { return err } data, err := json.MarshalIndent(s, "", " ") From b9cc9508ed7849c23b0f82aa3a2d2da9f6589c15 Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Sat, 1 Aug 2026 23:40:52 +0530 Subject: [PATCH 24/49] refactor: establish canonical session services --- internal/engine/compact.go | 27 ++-- internal/engine/compact_provider_native.go | 2 +- internal/engine/compact_split.go | 36 +++-- internal/engine/context_compaction.go | 54 ++++--- internal/engine/context_governor.go | 44 ++++-- internal/engine/engine.go | 4 +- internal/engine/lifecycle_service.go | 66 +++++++- internal/engine/memory_service.go | 46 +++++- internal/engine/memory_service_test.go | 22 +++ internal/engine/permission_service.go | 18 +++ internal/engine/persistence_service.go | 145 ++++++++++++++++-- .../persistence_service_deadlock_test.go | 32 ++++ internal/engine/session.go | 115 ++++++++------ internal/engine/session_services.go | 121 +++++++++------ internal/engine/stream.go | 145 ++++++------------ internal/engine/stream_tool_exec.go | 41 +++-- internal/engine/tool_service.go | 40 +++++ 17 files changed, 648 insertions(+), 310 deletions(-) create mode 100644 internal/engine/memory_service_test.go diff --git a/internal/engine/compact.go b/internal/engine/compact.go index 88a7d179..ecc956f0 100644 --- a/internal/engine/compact.go +++ b/internal/engine/compact.go @@ -44,8 +44,8 @@ func (s *Session) smartCompact() { // Keep last N messages + summary, respecting pinned count keepEnd := 10 - if s.PinnedMessages > keepEnd { - keepEnd = s.PinnedMessages + if pinned := s.Persistence().PinnedMessages(); pinned > keepEnd { + keepEnd = pinned } // Check for split-turn condition first @@ -55,14 +55,15 @@ func (s *Session) smartCompact() { } // Extract file tracking from messages being compacted - if s.Files == nil { - s.Files = NewFileTracker() + if s.Persistence().Files() == nil { + s.Persistence().SetFiles(NewFileTracker()) } + files := s.Persistence().Files() compactedMsgs := s.Persistence().RawMessages()[:len(s.Persistence().RawMessages())-keepEnd] - s.Files.ExtractFromMessages(compactedMsgs) + files.ExtractFromMessages(compactedMsgs) // Also parse any previous tracked-files from existing summary if len(compactedMsgs) > 0 && strings.Contains(compactedMsgs[0].Content, "") { - s.Files.ParseFromSummary(compactedMsgs[0].Content) + files.ParseFromSummary(compactedMsgs[0].Content) } // Try LLM-based summary first, fall back to truncation @@ -73,7 +74,7 @@ func (s *Session) smartCompact() { } // Append file tracking to summary - fileBlock := s.Files.FormatForSummary() + fileBlock := files.FormatForSummary() if fileBlock != "" { summary += "\n\n" + fileBlock } @@ -128,8 +129,8 @@ func (s *Session) generateSummary() string { ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() - resp, err := s.client.Chat(ctx, summaryMsgs, types.ChatOptions{ - Provider: s.provider, + resp, err := s.ChatLLM().Chat(ctx, summaryMsgs, types.ChatOptions{ + Provider: s.ChatLLM().Provider(), Model: s.compactModel(), MaxTokens: 1000, }) @@ -179,10 +180,10 @@ func CompressMessageContent(content string, maxTokens int) string { // Queries eyrie's catalog at runtime — no hardcoded model names. // Summarization doesn't need frontier reasoning, so the cheapest model suffices. func (s *Session) compactModel() string { - provider := strings.ToLower(s.provider) + provider := strings.ToLower(s.ChatLLM().Provider()) models := modelPkg.ByProvider(provider) if len(models) == 0 { - return s.model + return s.ChatLLM().Model() } // Find the cheapest model by input price @@ -194,9 +195,9 @@ func (s *Session) compactModel() string { } // Only use a cheaper model if it actually costs less than the session model - if info, ok := modelPkg.Find(s.model); ok { + if info, ok := modelPkg.Find(s.ChatLLM().Model()); ok { if cheapest.InputPrice >= info.InputPrice { - return s.model + return s.ChatLLM().Model() } } diff --git a/internal/engine/compact_provider_native.go b/internal/engine/compact_provider_native.go index 443bddb4..1c7978b9 100644 --- a/internal/engine/compact_provider_native.go +++ b/internal/engine/compact_provider_native.go @@ -64,5 +64,5 @@ func (s *Session) supportsNativeCompaction() bool { if s == nil || s.ChatLLM() == nil { return false } - return clientNativeCompaction(s.ChatLLM().Client(), context.Background(), s.provider, s.model) + return clientNativeCompaction(s.ChatLLM().Client(), context.Background(), s.ChatLLM().Provider(), s.ChatLLM().Model()) } diff --git a/internal/engine/compact_split.go b/internal/engine/compact_split.go index 6e828cdf..f4084af1 100644 --- a/internal/engine/compact_split.go +++ b/internal/engine/compact_split.go @@ -56,8 +56,8 @@ func (s *Session) SplitTurnNeeded(keepCount int) bool { // Result: merged summary + tail of oversized turn + any messages after func (s *Session) splitTurnCompact() { keepEnd := 10 - if s.PinnedMessages > keepEnd { - keepEnd = s.PinnedMessages + if pinned := s.Persistence().PinnedMessages(); pinned > keepEnd { + keepEnd = pinned } if len(s.Persistence().RawMessages()) <= keepEnd { return @@ -94,10 +94,11 @@ func (s *Session) splitTurnCompact() { splitPoint := tailStart + oversizedIdx // Extract file tracking before compaction - if s.Files == nil { - s.Files = NewFileTracker() + if s.Persistence().Files() == nil { + s.Persistence().SetFiles(NewFileTracker()) } - s.Files.ExtractFromMessages(s.Persistence().RawMessages()[:splitPoint]) + files := s.Persistence().Files() + files.ExtractFromMessages(s.Persistence().RawMessages()[:splitPoint]) // Phase 1: Summarize everything before the oversized turn phase1Summary := s.generatePartialSummary(s.Persistence().RawMessages()[:splitPoint]) @@ -118,7 +119,7 @@ func (s *Session) splitTurnCompact() { } // Append file tracking - fileBlock := s.Files.FormatForSummary() + fileBlock := files.FormatForSummary() if fileBlock != "" { combined.WriteString("\n\n") combined.WriteString(fileBlock) @@ -174,8 +175,8 @@ func (s *Session) generatePartialSummary(messages []types.EyrieMessage) string { ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() - resp, err := s.client.Chat(ctx, summaryMsgs, types.ChatOptions{ - Provider: s.provider, + resp, err := s.ChatLLM().Chat(ctx, summaryMsgs, types.ChatOptions{ + Provider: s.ChatLLM().Provider(), Model: s.compactModel(), MaxTokens: 1000, }) @@ -215,8 +216,8 @@ func (s *Session) summarizeOversizedTurn(msg types.EyrieMessage) string { ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() - resp, err := s.client.Chat(ctx, summaryMsgs, types.ChatOptions{ - Provider: s.provider, + resp, err := s.ChatLLM().Chat(ctx, summaryMsgs, types.ChatOptions{ + Provider: s.ChatLLM().Provider(), Model: s.compactModel(), MaxTokens: 500, }) @@ -234,17 +235,18 @@ func (s *Session) smartCompactFallback() { } keepEnd := 10 - if s.PinnedMessages > keepEnd { - keepEnd = s.PinnedMessages + if pinned := s.Persistence().PinnedMessages(); pinned > keepEnd { + keepEnd = pinned } - if s.Files == nil { - s.Files = NewFileTracker() + if s.Persistence().Files() == nil { + s.Persistence().SetFiles(NewFileTracker()) } + files := s.Persistence().Files() compactedMsgs := s.Persistence().RawMessages()[:len(s.Persistence().RawMessages())-keepEnd] - s.Files.ExtractFromMessages(compactedMsgs) + files.ExtractFromMessages(compactedMsgs) if len(compactedMsgs) > 0 && strings.Contains(compactedMsgs[0].Content, "") { - s.Files.ParseFromSummary(compactedMsgs[0].Content) + files.ParseFromSummary(compactedMsgs[0].Content) } summary := s.generateSummary() @@ -253,7 +255,7 @@ func (s *Session) smartCompactFallback() { return } - fileBlock := s.Files.FormatForSummary() + fileBlock := files.FormatForSummary() if fileBlock != "" { summary += "\n\n" + fileBlock } diff --git a/internal/engine/context_compaction.go b/internal/engine/context_compaction.go index 190f4765..318cf6e5 100644 --- a/internal/engine/context_compaction.go +++ b/internal/engine/context_compaction.go @@ -29,6 +29,10 @@ func (s *Session) SetPersistID(id string) { s.persistID = id s.checkpointMgr = nil s.mu.Unlock() + if p := s.Persistence(); p != nil { + p.SetPersistID(id) + p.SetCheckpointManager(nil) + } s.ConfigureContextGraphObservation("") } @@ -45,6 +49,9 @@ func (s *Session) RecordAPIUsage(prompt, completion int) { if completion > 0 { s.lastCompletionTokens = completion } + if p := s.Persistence(); p != nil { + p.SetTokenUsage(prompt, completion) + } } // LastPromptTokens returns the most recent API prompt token count (0 if unknown). @@ -52,6 +59,9 @@ func (s *Session) LastPromptTokens() int { if s == nil { return 0 } + if p := s.Persistence(); p != nil { + return p.LastPromptTokens() + } s.mu.RLock() defer s.mu.RUnlock() return s.lastPromptTokens @@ -69,21 +79,23 @@ func (s *Session) ContextUsedTokens() int { lastLen = len(msgs[count-1].Content) } - s.mu.RLock() - if s.estTokensMsgCount == count && s.estTokensLastLen == lastLen && s.estTokensCache > 0 { - cache := s.estTokensCache - s.mu.RUnlock() - return cache + if p := s.Persistence(); p != nil { + if cache, cachedCount, cachedLen := p.TokenEstimateCache(); cachedCount == count && cachedLen == lastLen && cache > 0 { + return cache + } } - s.mu.RUnlock() est := EstimateTokens(msgs) - s.mu.Lock() - s.estTokensMsgCount = count - s.estTokensLastLen = lastLen - s.estTokensCache = est - s.mu.Unlock() + if p := s.Persistence(); p != nil { + p.SetTokenEstimateCache(est, count, lastLen) + } else { + s.mu.Lock() + s.estTokensMsgCount = count + s.estTokensLastLen = lastLen + s.estTokensCache = est + s.mu.Unlock() + } return est } @@ -92,7 +104,9 @@ func (s *Session) notifyCompaction(ev CompactionEvent) { if s == nil { return } - if s.OnCompaction != nil { + if fn := s.Persistence().OnCompaction(); fn != nil { + fn(ev) + } else if s.OnCompaction != nil { s.OnCompaction(ev) } s.saveCompactionCheckpoint() @@ -115,9 +129,7 @@ func (s *Session) checkpointDir() string { if s == nil { return "" } - s.mu.RLock() - id := s.persistID - s.mu.RUnlock() + id := s.Persistence().PersistID() if id == "" { return "" } @@ -128,17 +140,17 @@ func (s *Session) checkpointManager() *session.CheckpointManager { if s == nil { return nil } - s.mu.Lock() - defer s.mu.Unlock() dir := s.checkpointDir() if dir == "" { return nil } - if s.checkpointMgr == nil { - s.checkpointMgr = session.NewCheckpointManager(dir) - _ = s.checkpointMgr.Load() + p := s.Persistence() + if p.CheckpointManager() == nil { + cm := session.NewCheckpointManager(dir) + _ = cm.Load() + p.SetCheckpointManager(cm) } - return s.checkpointMgr + return p.CheckpointManager() } func rawToSessionMessages(raw []types.EyrieMessage) []session.Message { diff --git a/internal/engine/context_governor.go b/internal/engine/context_governor.go index b8a522b3..a3861c56 100644 --- a/internal/engine/context_governor.go +++ b/internal/engine/context_governor.go @@ -34,7 +34,11 @@ func (s *Session) ContextWindowSize() int { if w := s.ContextWindowCachedValue(); w > 0 { return w } - return ResolveModelContextWindow(s.model, 0) + model := "" + if s.ChatLLM() != nil { + model = s.ChatLLM().Model() + } + return ResolveModelContextWindow(model, 0) } // EnsureAutoCompactor initializes the compaction orchestrator from session settings. @@ -42,15 +46,21 @@ func (s *Session) EnsureAutoCompactor() { if s == nil { return } - if s.AutoCompactor != nil { - s.AutoCompactor.Configure(s.compactConfig()) + p := s.Persistence() + if p == nil { + return + } + if p.AutoCompactor() != nil { + p.AutoCompactor().Configure(s.compactConfig()) + s.AutoCompactor = p.AutoCompactor() return } - s.AutoCompactor = NewAutoCompactor(s.compactConfig()) + p.SetAutoCompactor(NewAutoCompactor(s.compactConfig())) + s.AutoCompactor = p.AutoCompactor() } func (s *Session) compactThresholdPct() int { - pct := s.AutoCompactThresholdPct + pct := s.Persistence().AutoCompactThresholdPct() if pct <= 0 { pct = DefaultAutoCompactThresholdPct } @@ -83,9 +93,21 @@ func (s *Session) refreshContextWindowCache() { if s == nil { return } - s.ContextWindowCached = 0 - if info, ok := modelPkg.Find(s.model); ok && info.ContextSize > 0 { - s.ContextWindowCached = info.ContextSize + if s.Persistence() == nil { + s.ContextWindowCached = 0 + } else { + s.SetContextWindowCached(0) + } + model := "" + if s.ChatLLM() != nil { + model = s.ChatLLM().Model() + } + if info, ok := modelPkg.Find(model); ok && info.ContextSize > 0 { + if s.Persistence() == nil { + s.ContextWindowCached = info.ContextSize + } else { + s.SetContextWindowCached(info.ContextSize) + } } s.EnsureAutoCompactor() } @@ -96,7 +118,7 @@ func (s *Session) WillCompactBeforeTurn() bool { return false } s.EnsureAutoCompactor() - if s.AutoCompactor.ShouldAutoCompact(s) { + if s.Persistence().AutoCompactor().ShouldAutoCompact(s) { return true } if len(s.Persistence().RawMessages()) > maxContextMessages { @@ -116,7 +138,7 @@ func (s *Session) ManageContextBeforeTurn(ctx context.Context) (strategy string, s.Persistence().SetRawMessages(ctxmgr.CollapseRepeatedMessages(s.Persistence().RawMessages())) s.EnsureAutoCompactor() - if compactStrategy, ok := s.AutoCompactor.AutoCompactIfNeeded(ctx, s); ok { + if compactStrategy, ok := s.Persistence().AutoCompactor().AutoCompactIfNeeded(ctx, s); ok { return compactStrategy, true // recordCompaction emitted inside AutoCompactIfNeeded } @@ -148,7 +170,7 @@ func (s *Session) CompactConversation(ctx context.Context) (strategy string, tok s.Persistence().SetRawMessages(ctxmgr.CollapseRepeatedMessages(s.Persistence().RawMessages())) s.EnsureAutoCompactor() tokensBefore = EstimateTokens(s.Persistence().RawMessages()) - strategy, err = s.AutoCompactor.RunCompaction(ctx, s) + strategy, err = s.Persistence().AutoCompactor().RunCompaction(ctx, s) if err != nil { s.smartCompact() strategy = "smart_fallback" diff --git a/internal/engine/engine.go b/internal/engine/engine.go index 66ca40f0..8254a0a7 100644 --- a/internal/engine/engine.go +++ b/internal/engine/engine.go @@ -20,8 +20,8 @@ func (s *Session) SmartCompact() { s.smartCompact() } // compact removes older messages while preserving tool_use/tool_result pairing. func (s *Session) compact() { keepEnd := 16 - if s.PinnedMessages > keepEnd { - keepEnd = s.PinnedMessages + if pinned := s.Persistence().PinnedMessages(); pinned > keepEnd { + keepEnd = pinned } if len(s.Persistence().RawMessages()) <= keepEnd+4 { return diff --git a/internal/engine/lifecycle_service.go b/internal/engine/lifecycle_service.go index 446210e4..85b8450d 100644 --- a/internal/engine/lifecycle_service.go +++ b/internal/engine/lifecycle_service.go @@ -8,6 +8,7 @@ import ( "github.com/GrayCodeAI/hawk/internal/intelligence/memory" "github.com/GrayCodeAI/hawk/internal/observability/logger" "github.com/GrayCodeAI/hawk/internal/prompts" + "github.com/GrayCodeAI/hawk/internal/types" ) // LifecycleService is the Session's view of the self-improvement and @@ -54,6 +55,10 @@ type LifecycleService struct { pipeline *IntegrationPipeline // steering queue. steering *SteeringQueue + // local quality loops run after write tools and belong to lifecycle + // feedback rather than transport or persistence. + lintLoop *LintLoop + testLoop *TestLoop // session-level lifecycle hook. lifecycle *SessionLifecycle // log is the session logger. @@ -119,6 +124,51 @@ func (s *LifecycleService) OnSessionEnd(ctx context.Context, s2 *Session, succes } } +// StartContext prepares session-start context without requiring a Session +// object. This is the service boundary used by the agent loop. +func (s *LifecycleService) StartContext(ctx context.Context, lastUserMsg string) string { + if s == nil || s.lifecycle == nil { + return "" + } + return s.lifecycle.OnSessionStart(ctx, lastUserMsg) +} + +// Finalize performs lifecycle bookkeeping from immutable session snapshots. +// It intentionally accepts data rather than *Session so the lifecycle layer +// cannot reach through the god object for unrelated state. +func (s *LifecycleService) Finalize(ctx context.Context, messages []types.EyrieMessage, success bool, duration time.Duration, totalCost float64) { + if s == nil { + return + } + outcome := SessionOutcome{Success: success, Duration: duration, TotalCost: totalCost} + for _, message := range messages { + if message.Role == "user" && len(message.ToolResults) == 0 && outcome.TaskGoal == "" { + outcome.TaskGoal = message.Content + } + } + if s.lifecycle != nil { + _ = s.lifecycle.OnSessionEnd(ctx, struct{}{}, outcome) + } + if s.fewShotStore != nil && success && len(messages) >= 2 { + response := "" + for _, message := range messages { + if message.Role == "assistant" && message.Content != "" { + response = message.Content + } + } + if outcome.TaskGoal != "" && response != "" { + s.fewShotStore.Record(outcome.TaskGoal, response, "general") + } + } + if s.adaptivePrompt != nil { + for _, message := range messages { + if message.Role == "user" && len(message.ToolResults) == 0 { + s.adaptivePrompt.LearnFromFeedback(message.Content) + } + } + } +} + // SelectModel picks the optimal model for a turn. Returns the current // model unchanged if cascade is nil. func (s *LifecycleService) SelectModel(currentModel, lastUserMsg, hint string) string { @@ -177,6 +227,8 @@ func (s *LifecycleService) SetAdaptivePrompt(a *AdaptivePrompt) { s.adap func (s *LifecycleService) SetActivity(act *memory.ActivityTracker) { s.activity = act } func (s *LifecycleService) SetAgentsAccum(a *prompts.AgentsAccumulator) { s.agentsAccum = a } func (s *LifecycleService) SetSteering(st *SteeringQueue) { s.steering = st } +func (s *LifecycleService) SetLintLoop(loop *LintLoop) { s.lintLoop = loop } +func (s *LifecycleService) SetTestLoop(loop *TestLoop) { s.testLoop = loop } // Accessors used by stream.go and the agent loop. nil-safe. func (s *LifecycleService) Beliefs() *BeliefState { return s.beliefs } @@ -190,7 +242,13 @@ func (s *LifecycleService) FewShotStore() *FewShotStore { return s.f func (s *LifecycleService) AdaptivePrompt() *AdaptivePrompt { return s.adaptivePrompt } func (s *LifecycleService) Activity() *memory.ActivityTracker { return s.activity } func (s *LifecycleService) AgentsAccum() *prompts.AgentsAccumulator { return s.agentsAccum } -func (s *LifecycleService) ResponseCache() *ResponseCache { return s.responseCache } -func (s *LifecycleService) Pipeline() *IntegrationPipeline { return s.pipeline } -func (s *LifecycleService) Steering() *SteeringQueue { return s.steering } -func (s *LifecycleService) Lifecycle() *SessionLifecycle { return s.lifecycle } + +// SetAgentsAccumulator attaches the project-learning accumulator. +func (s *LifecycleService) SetAgentsAccumulator(a *prompts.AgentsAccumulator) { s.agentsAccum = a } + +func (s *LifecycleService) ResponseCache() *ResponseCache { return s.responseCache } +func (s *LifecycleService) Pipeline() *IntegrationPipeline { return s.pipeline } +func (s *LifecycleService) Steering() *SteeringQueue { return s.steering } +func (s *LifecycleService) Lifecycle() *SessionLifecycle { return s.lifecycle } +func (s *LifecycleService) LintLoop() *LintLoop { return s.lintLoop } +func (s *LifecycleService) TestLoop() *TestLoop { return s.testLoop } diff --git a/internal/engine/memory_service.go b/internal/engine/memory_service.go index d3711aa4..6aa52eed 100644 --- a/internal/engine/memory_service.go +++ b/internal/engine/memory_service.go @@ -2,9 +2,11 @@ package engine import ( "context" + "fmt" "github.com/GrayCodeAI/hawk/internal/intelligence/memory" "github.com/GrayCodeAI/hawk/internal/observability/logger" + "github.com/GrayCodeAI/hawk/internal/types" ) // MemoryService is the Session's view of the memory layer: yaad bridge, @@ -66,11 +68,21 @@ func (s *MemoryService) WithEnhanced(e *memory.EnhancedMemoryManager) *MemorySer // no memory is wired. Combines yaad recall + few-shot examples + // user-preference learning into one shot. func (s *MemoryService) RecallContext(_ context.Context, lastUserMsg string, budget int) string { - if s.yaad == nil { + if s == nil { return "" } - out, err := s.yaad.Recall(lastUserMsg, budget) - if err != nil || out == "" { + var out string + if s.yaad != nil { + out, _ = s.yaad.Recall(lastUserMsg, budget) + } + // The simple recaller is the compatibility path used by tests and + // lightweight integrations that do not install Yaad. Memory ownership + // stays in this service instead of leaking a backend decision into the + // agent loop. + if out == "" && s.memory != nil { + out, _ = s.memory.Recall(lastUserMsg, budget) + } + if out == "" { return "" } return "## Relevant Memories\n" + out @@ -97,6 +109,34 @@ func (s *MemoryService) OnSessionEnd(success bool) { } } +// Finalize performs memory-side session bookkeeping from a transcript +// snapshot. The agent loop does not need to know which backend is installed. +func (s *MemoryService) Finalize(messages []types.EyrieMessage, success bool) { + if s == nil { + return + } + if s.enhanced != nil { + s.enhanced.EndSession(success) + } + if s.memory == nil { + return + } + goal := "" + for _, message := range messages { + if message.Role == "user" && len(message.ToolResults) == 0 { + goal = message.Content + break + } + } + if goal != "" { + summary := fmt.Sprintf("Session goal: %s", goal) + if !success { + summary += " (interrupted)" + } + _ = s.memory.Remember(summary, "session") + } +} + // Accessors. func (s *MemoryService) Yaad() *memory.YaadBridge { return s.yaad } func (s *MemoryService) Memory() MemoryRecaller { return s.memory } diff --git a/internal/engine/memory_service_test.go b/internal/engine/memory_service_test.go new file mode 100644 index 00000000..5e0f1f66 --- /dev/null +++ b/internal/engine/memory_service_test.go @@ -0,0 +1,22 @@ +package engine + +import ( + "context" + "testing" +) + +func TestMemoryServiceRecallContextFallsBackToRecaller(t *testing.T) { + mem := &mockMemoryRecaller{} + service := NewMemoryService(nil).WithMemory(mem) + + got := service.RecallContext(context.Background(), "question", 128) + if got != "## Relevant Memories\nrecalled: question" { + t.Fatalf("RecallContext() = %q", got) + } +} + +func TestMemoryServiceRecallContextIsEmptyWithoutBackends(t *testing.T) { + if got := NewMemoryService(nil).RecallContext(context.Background(), "question", 128); got != "" { + t.Fatalf("RecallContext() = %q, want empty", got) + } +} diff --git a/internal/engine/permission_service.go b/internal/engine/permission_service.go index 05815d70..aaec5206 100644 --- a/internal/engine/permission_service.go +++ b/internal/engine/permission_service.go @@ -144,6 +144,24 @@ func (s *PermissionService) DryRun() bool { return s.perm.DryRun } // SetApproval replaces the ApprovalGate. func (s *PermissionService) SetApproval(a *ApprovalGate) { s.approval = a } +// Approval returns the configured human-in-the-loop gate. +func (s *PermissionService) Approval() *ApprovalGate { return s.approval } + +// SpecSlug returns the active specification identifier. +func (s *PermissionService) SpecSlug() string { + if s == nil || s.perm == nil { + return "" + } + return s.perm.SpecSlug +} + +// SetSpecSlug updates the active specification identifier. +func (s *PermissionService) SetSpecSlug(slug string) { + if s != nil && s.perm != nil { + s.perm.SpecSlug = slug + } +} + // SetPermissionFn replaces the user-callback. func (s *PermissionService) SetPermissionFn(fn func(PermissionRequest)) { s.permissionFn = fn diff --git a/internal/engine/persistence_service.go b/internal/engine/persistence_service.go index 572adc90..ffc33c65 100644 --- a/internal/engine/persistence_service.go +++ b/internal/engine/persistence_service.go @@ -38,6 +38,18 @@ type PersistenceService struct { steering *SteeringQueue // logger. log *logger.Logger + // Compaction and checkpoint state belongs to persistence. Session keeps + // deprecated aliases only for source compatibility with older callers. + autoCompactor *AutoCompactor + files *FileTracker + persistID string + lastPromptTokens int + lastCompletionTokens int + estTokensCache int + estTokensMsgCount int + estTokensLastLen int + checkpointMgr *session.CheckpointManager + onCompaction OnCompaction } // NewPersistenceService constructs an empty PersistenceService. @@ -59,10 +71,7 @@ func (s *PersistenceService) Messages() []types.EyrieMessage { // RawMessages() here would recursively RLock and can deadlock if a // writer arrives between the two read locks (Go's RWMutex forbids // recursive read-locking). - raw := s.messages - out := make([]types.EyrieMessage, len(raw)) - copy(out, raw) - return out + return cloneMessages(s.messages) } // SetRawMessages replaces the message slice. Used by code paths @@ -77,8 +86,9 @@ func (s *PersistenceService) SetRawMessages(msgs []types.EyrieMessage) { s.mu.Unlock() } -// RawMessages returns the live slice (no copy). Callers MUST NOT mutate. -// Used by the agent loop's hot path where copy overhead matters. +// RawMessages returns an immutable snapshot of the transcript. Nested tool +// arguments and multimodal slices are cloned as well, so callers cannot +// mutate live session state by retaining or editing the returned value. // Safe to call on a nil receiver (returns nil). func (s *PersistenceService) RawMessages() []types.EyrieMessage { if s == nil { @@ -86,7 +96,7 @@ func (s *PersistenceService) RawMessages() []types.EyrieMessage { } s.mu.RLock() defer s.mu.RUnlock() - return s.messages + return cloneMessages(s.messages) } // Graph returns Hawk's product-owned conversation graph. @@ -112,7 +122,7 @@ func (s *PersistenceService) AddAssistant(content string) { // SetMessages replaces the transcript. func (s *PersistenceService) SetMessages(msgs []types.EyrieMessage) { s.mu.Lock() - s.messages = msgs + s.messages = cloneMessages(msgs) s.mu.Unlock() } @@ -150,8 +160,16 @@ func (s *PersistenceService) AppendSystemContext(content string) { if s == nil { return } + content = strings.TrimSpace(content) + if content == "" { + return + } s.mu.Lock() - s.system += content + if strings.TrimSpace(s.system) == "" { + s.system = content + } else { + s.system += "\n\n" + content + } s.mu.Unlock() } @@ -159,17 +177,31 @@ func (s *PersistenceService) AppendSystemContext(content string) { // identified by a header string. Used by yaad recall (which refreshes // the "## Relevant Memories" block on every turn). func (s *PersistenceService) ReplaceSystemContextSection(header, content string) { + if s == nil { + return + } + content = strings.TrimSpace(content) + if content == "" { + return + } s.mu.Lock() defer s.mu.Unlock() idx := strings.Index(s.system, header) if idx < 0 { - s.system += content + if strings.TrimSpace(s.system) == "" { + s.system = content + } else { + s.system += "\n\n" + content + } return } - end := idx + len(header) - if nl := strings.Index(s.system[end:], "\n\n"); nl >= 0 { - end += nl + 2 + rest := s.system[idx+len(header):] + var end int + if next := strings.Index(rest, "\n\n## "); next >= 0 { + end = idx + len(header) + next } else { + // Replace the entire existing section, retaining the next section + // separator when one exists. end = len(s.system) } s.system = s.system[:idx] + content + s.system[end:] @@ -216,12 +248,60 @@ func (s *PersistenceService) RemoveLastExchange() { // LoadMessages replaces the transcript with a fresh slice. func (s *PersistenceService) LoadMessages(msgs []types.EyrieMessage) { s.mu.Lock() - // Assign directly; the lock is held, so SetRawMessages() (which locks - // again) would deadlock on a recursive write lock. - s.messages = msgs + // Clone while holding the lock; callers retain no mutable alias to the + // live transcript. + s.messages = cloneMessages(msgs) s.mu.Unlock() } +// cloneMessages performs a deep copy of the provider-neutral transcript. +// Tool arguments are arbitrary JSON-shaped values, so cloneJSONValue walks +// maps and slices recursively instead of relying on a shallow slice copy. +func cloneMessages(in []types.EyrieMessage) []types.EyrieMessage { + if in == nil { + return nil + } + out := make([]types.EyrieMessage, len(in)) + for i, msg := range in { + out[i] = msg + out[i].Images = append([]string(nil), msg.Images...) + out[i].ContentParts = append([]types.ContentPart(nil), msg.ContentParts...) + if msg.ToolUse != nil { + out[i].ToolUse = make([]types.ToolCall, len(msg.ToolUse)) + for j, call := range msg.ToolUse { + out[i].ToolUse[j] = call + if call.Arguments != nil { + out[i].ToolUse[j].Arguments = make(map[string]interface{}, len(call.Arguments)) + for key, value := range call.Arguments { + out[i].ToolUse[j].Arguments[key] = cloneJSONValue(value) + } + } + } + } + out[i].ToolResults = append([]types.ToolResult(nil), msg.ToolResults...) + } + return out +} + +func cloneJSONValue(value interface{}) interface{} { + switch v := value.(type) { + case map[string]interface{}: + out := make(map[string]interface{}, len(v)) + for key, nested := range v { + out[key] = cloneJSONValue(nested) + } + return out + case []interface{}: + out := make([]interface{}, len(v)) + for i, nested := range v { + out[i] = cloneJSONValue(nested) + } + return out + default: + return value + } +} + // PinnedMessages returns the count of pinned messages. func (s *PersistenceService) PinnedMessages() int { return s.pinnedMessages } @@ -241,3 +321,36 @@ func (s *PersistenceService) ContextWindowCached() int { return s.contextWindowC // SetContextWindowCached replaces the catalog context window. func (s *PersistenceService) SetContextWindowCached(n int) { s.contextWindowCached = n } + +func (s *PersistenceService) AutoCompactor() *AutoCompactor { return s.autoCompactor } +func (s *PersistenceService) SetAutoCompactor(ac *AutoCompactor) { s.autoCompactor = ac } +func (s *PersistenceService) Files() *FileTracker { return s.files } +func (s *PersistenceService) SetFiles(files *FileTracker) { s.files = files } +func (s *PersistenceService) PersistID() string { return s.persistID } +func (s *PersistenceService) SetPersistID(id string) { s.persistID = id } +func (s *PersistenceService) LastPromptTokens() int { return s.lastPromptTokens } +func (s *PersistenceService) LastCompletionTokens() int { return s.lastCompletionTokens } +func (s *PersistenceService) SetTokenUsage(prompt, completion int) { + if prompt > 0 { + s.lastPromptTokens = prompt + } + if completion > 0 { + s.lastCompletionTokens = completion + } +} + +func (s *PersistenceService) TokenEstimateCache() (tokens, count, lastLen int) { + return s.estTokensCache, s.estTokensMsgCount, s.estTokensLastLen +} + +func (s *PersistenceService) SetTokenEstimateCache(tokens, count, lastLen int) { + s.estTokensCache, s.estTokensMsgCount, s.estTokensLastLen = tokens, count, lastLen +} + +func (s *PersistenceService) CheckpointManager() *session.CheckpointManager { return s.checkpointMgr } + +func (s *PersistenceService) SetCheckpointManager(cm *session.CheckpointManager) { + s.checkpointMgr = cm +} +func (s *PersistenceService) OnCompaction() OnCompaction { return s.onCompaction } +func (s *PersistenceService) SetOnCompaction(fn OnCompaction) { s.onCompaction = fn } diff --git a/internal/engine/persistence_service_deadlock_test.go b/internal/engine/persistence_service_deadlock_test.go index 0fc3d62a..bb83b236 100644 --- a/internal/engine/persistence_service_deadlock_test.go +++ b/internal/engine/persistence_service_deadlock_test.go @@ -38,3 +38,35 @@ func TestPersistenceServiceNoRecursiveLock(t *testing.T) { t.Fatal("PersistenceService deadlocked (recursive lock acquisition)") } } + +func TestPersistenceServiceSnapshotsAreDeepCopies(t *testing.T) { + ps := NewPersistenceService(nil) + ps.LoadMessages([]types.EyrieMessage{{ + Role: "assistant", + Images: []string{"data:image/png;base64,abc"}, + ToolUse: []types.ToolCall{{ + ID: "call-1", + Name: "Write", + Arguments: map[string]interface{}{ + "path": "file.txt", + "nested": map[string]interface{}{"ok": true}, + }, + }}, + }}) + + snapshot := ps.RawMessages() + snapshot[0].Images[0] = "mutated" + snapshot[0].ToolUse[0].Arguments["path"] = "evil.txt" + snapshot[0].ToolUse[0].Arguments["nested"].(map[string]interface{})["ok"] = false + + got := ps.RawMessages()[0] + if got.Images[0] != "data:image/png;base64,abc" { + t.Fatalf("image mutation leaked into persistence: %q", got.Images[0]) + } + if got.ToolUse[0].Arguments["path"] != "file.txt" { + t.Fatalf("tool argument mutation leaked into persistence: %v", got.ToolUse[0].Arguments["path"]) + } + if got.ToolUse[0].Arguments["nested"].(map[string]interface{})["ok"] != true { + t.Fatal("nested tool argument mutation leaked into persistence") + } +} diff --git a/internal/engine/session.go b/internal/engine/session.go index 05e20325..593aed0d 100644 --- a/internal/engine/session.go +++ b/internal/engine/session.go @@ -94,6 +94,10 @@ type Session struct { memory *MemoryService persist *PersistenceService tools *ToolService + // services is the canonical composition root for the extracted runtime + // collaborators. Legacy fields remain on Session only as compatibility + // shims while callers migrate to Services()/SubServices(). + services *SessionServices Perm *PermissionEngine // extracted permission subsystem // Backward-compatible accessors below (will be removed after full migration) @@ -305,7 +309,11 @@ func NewSessionWithClient(chat ChatClient, provider, model, systemPrompt string, s.memory = NewMemoryService(log) s.persist = NewPersistenceService(log) s.persist.SetSystem(systemPrompt) - s.tools = NewToolService(registry) + s.tools = NewToolService(registry).WithExecutionHost(s) + s.refreshContextWindowCache() + s.life.SetAgentsAccumulator(s.AgentsAccum) + s.life.SetLintLoop(s.LintLoop) + s.life.SetTestLoop(s.TestLoop) // Alias legacy fields at the service instances so legacy readers see // the same state as new code that goes through the sub-service getters. @@ -377,6 +385,12 @@ func (s *Session) Provider() string { } func (s *Session) Metrics() *metrics.Registry { return s.metrics } +// Logger returns the session logger through the observability boundary. +func (s *Session) Logger() *logger.Logger { return s.log } + +// TracerValue returns the session tracer through the observability boundary. +func (s *Session) TracerValue() *oteltrace.Tracer { return s.Tracer } + // ChatLLM returns the extracted ChatService (Phase 1 of the god-object // decomposition). New code should prefer this over the legacy Client / // Provider / Model / Router fields. Returns nil only if the @@ -462,6 +476,20 @@ type SubServices struct { // only production constructor); the nil cases are reachable only // via direct struct literal construction in tests. func (s *Session) SubServices() SubServices { + if s == nil { + return SubServices{} + } + services := s.Services() + if services != nil { + return SubServices{ + LLM: services.Chat, + Perms: services.Permissions, + Life: services.LifecycleService, + Memory: services.MemoryService, + Persistence: services.Persist, + Tools: services.ToolService, + } + } return SubServices{ LLM: s.llm, Perms: s.perms, @@ -479,6 +507,9 @@ func (s *Session) SetModel(model string) { s.model = m s.Cost.Model = m s.mu.Unlock() + if s.llm != nil { + s.llm.SetModel(m) + } s.syncCascadeDefaultModel() s.refreshContextWindowCache() } @@ -662,61 +693,16 @@ func (s *Session) ConvoHead() string { // AppendSystemContext adds runtime context, such as /add-dir, to future model calls. func (s *Session) AppendSystemContext(content string) { - content = strings.TrimSpace(content) - if content == "" { - return - } - s.mu.Lock() - if strings.TrimSpace(s.system) == "" { - s.system = content - } else { - s.system += "\n\n" + content - } - updated := s.system - persist := s.persist - s.mu.Unlock() - if persist != nil { - persist.SetSystem(updated) + if p := s.Persistence(); p != nil { + p.AppendSystemContext(content) } } // ReplaceSystemContextSection replaces the content of a system prompt section identified by its header. // If the header is not found, appends the content as a new section. func (s *Session) ReplaceSystemContextSection(header, content string) { - content = strings.TrimSpace(content) - if content == "" { - return - } - s.mu.Lock() - idx := strings.Index(s.system, header) - if idx < 0 { - // AppendSystemContext is not called here to avoid double-locking; - // replicate its logic inline. - if strings.TrimSpace(s.system) == "" { - s.system = content - } else { - s.system += "\n\n" + content - } - updated := s.system - persist := s.persist - s.mu.Unlock() - if persist != nil { - persist.SetSystem(updated) - } - return - } - rest := s.system[idx+len(header):] - endIdx := strings.Index(rest, "\n\n## ") - if endIdx < 0 { - s.system = s.system[:idx] + content - } else { - s.system = s.system[:idx] + content + rest[endIdx:] - } - updated := s.system - persist := s.persist - s.mu.Unlock() - if persist != nil { - persist.SetSystem(updated) + if p := s.Persistence(); p != nil { + p.ReplaceSystemContextSection(header, content) } } @@ -728,6 +714,9 @@ func (s *Session) SetLogger(l *logger.Logger) { // SetAllowedDirs sets directories that file tools are allowed to access. func (s *Session) SetAllowedDirs(dirs []string) { s.AllowedDirs = append([]string(nil), dirs...) + if s.perms != nil { + s.perms.SetAllowedDirs(append([]string(nil), dirs...)) + } } // SetAutoCompactThresholdPct sets the auto-compact threshold. @@ -735,6 +724,9 @@ func (s *Session) SetAllowedDirs(dirs []string) { // s.AutoCompactThresholdPct field directly. func (s *Session) SetAutoCompactThresholdPct(pct int) { s.AutoCompactThresholdPct = pct + if s.persist != nil { + s.persist.SetAutoCompactThresholdPct(pct) + } } // SetPinnedMessages sets the number of recent messages that are @@ -765,6 +757,9 @@ func (s *Session) SetGLMThinkingEnabled(v *bool) { // this instead of writing to the legacy s.Snapshots field directly. func (s *Session) SetSnapshots(snap *snapshot.Tracker) { s.Snapshots = snap + if s.tools != nil { + s.tools.WithSnapshots(snap) + } } // SetContainerRequired sets the container-first mode flag on the @@ -793,6 +788,26 @@ func (s *Session) SetAskUserFn(fn func(question string) (string, error)) { // call this instead of writing to the legacy s.Approval field. func (s *Session) SetApproval(a *ApprovalGate) { s.Approval = a + if s.perms != nil { + s.perms.SetApproval(a) + } +} + +// syncPermissionCompatibility copies legacy callback fields into the +// authoritative permission service for callers that have not migrated yet. +func (s *Session) syncPermissionCompatibility() { + if s == nil || s.perms == nil { + return + } + if s.PermissionFn != nil { + s.perms.SetPermissionFn(s.PermissionFn) + } + if s.Autonomy != 0 { + s.perms.SetAutonomy(s.Autonomy) + } + if s.Approval != nil { + s.perms.SetApproval(s.Approval) + } } // SetConversationGraph attaches Hawk's product-owned conversation graph and diff --git a/internal/engine/session_services.go b/internal/engine/session_services.go index c97d4969..e8b83824 100644 --- a/internal/engine/session_services.go +++ b/internal/engine/session_services.go @@ -128,6 +128,17 @@ type Observability struct { // fields into coherent sub-services. Use Session.Services() to obtain this // view from existing code. type SessionServices struct { + // Canonical extracted services. These are the authoritative runtime + // collaborators for sessions created by NewSessionWithClient. The + // grouped views below remain during the compatibility migration so older + // callers can move incrementally without creating a second object graph. + Chat *ChatService + Permissions *PermissionService + LifecycleService *LifecycleService + MemoryService *MemoryService + Persist *PersistenceService + ToolService *ToolService + Core *CoreLoop Safety *SafetyLayer Intel *Intelligence @@ -304,52 +315,68 @@ func NewSessionServices(opts ...ServiceOption) *SessionServices { // The returned *SessionServices references the same underlying objects as // Session, so mutations are visible in both directions. func (s *Session) Services() *SessionServices { - return &SessionServices{ - Core: &CoreLoop{ - Client: s.client, - Registry: s.registry, - Messages: s.Persistence().RawMessages(), - Provider: s.provider, - Model: s.model, - System: s.Persistence().System(), - Log: s.log, - MaxTurns: s.LifecycleSvc().Limits().MaxTurns(), - }, - Safety: &SafetyLayer{ - Perm: s.Perm, - Sandbox: s.Tools().Sandbox(), - Limits: s.LifecycleSvc().Limits(), - Autonomy: s.Autonomy, - }, - Intel: &Intelligence{ - Beliefs: s.LifecycleSvc().Beliefs(), - Memory: s.MemorySvc().Memory(), - YaadBridge: s.MemorySvc().Yaad(), - Enhanced: s.MemorySvc().Enhanced(), - Sleeptime: s.MemorySvc().Sleeptime(), - Activity: s.MemorySvc().Activity(), - SkillDistill: s.MemorySvc().SkillDistiller(), - }, - Optim: &Optimizer{ - Cost: Cost{Model: s.Cost.Model, PromptTokens: s.Cost.PromptTokens, CompletionTokens: s.Cost.CompletionTokens, TotalCostUSD: s.Cost.TotalCostUSD}, - CostTracker: s.CostTracker, - Cascade: s.LifecycleSvc().Cascade(), - MaxBudget: s.LifecycleSvc().Limits().MaxBudgetUSD(), - }, - Observe: &Observability{ - Tracer: s.Tracer, - Metrics: s.metrics, - Log: s.log, - }, - Lifecycle: s.LifecycleSvc().Lifecycle(), - Reflector: s.LifecycleSvc().Reflector(), - Critic: s.LifecycleSvc().Critic(), - Backtrack: s.LifecycleSvc().Backtrack(), - Shadow: s.LifecycleSvc().Shadow(), - ConversationGraph: s.Persistence().Graph(), - Plan: s.Plan, - Teach: s.Teach, - Trajectory: s.Trajectory, - Snapshots: s.Snapshots, + if s == nil { + return nil + } + ss := s.services + if ss == nil { + ss = &SessionServices{} + s.services = ss + } + // Refresh the compatibility views on every call. The canonical service + // pointers are stable, but legacy callers may configure their fields + // after construction (for example /config wiring memory or lifecycle). + ss.Chat = s.llm + ss.Permissions = s.perms + ss.LifecycleService = s.life + ss.MemoryService = s.memory + ss.Persist = s.persist + ss.ToolService = s.tools + ss.Core = &CoreLoop{ + Client: s.client, + Registry: s.registry, + Messages: s.Persistence().RawMessages(), + Provider: s.provider, + Model: s.model, + System: s.Persistence().System(), + Log: s.log, + MaxTurns: s.LifecycleSvc().Limits().MaxTurns(), + } + ss.Safety = &SafetyLayer{ + Perm: s.Perm, + Sandbox: s.Tools().Sandbox(), + Limits: s.LifecycleSvc().Limits(), + Autonomy: s.Autonomy, } + ss.Intel = &Intelligence{ + Beliefs: s.LifecycleSvc().Beliefs(), + Memory: s.MemorySvc().Memory(), + YaadBridge: s.MemorySvc().Yaad(), + Enhanced: s.MemorySvc().Enhanced(), + Sleeptime: s.MemorySvc().Sleeptime(), + Activity: s.MemorySvc().Activity(), + SkillDistill: s.MemorySvc().SkillDistiller(), + } + ss.Optim = &Optimizer{ + Cost: Cost{Model: s.Cost.Model, PromptTokens: s.Cost.PromptTokens, CompletionTokens: s.Cost.CompletionTokens, TotalCostUSD: s.Cost.TotalCostUSD}, + CostTracker: s.CostTracker, + Cascade: s.LifecycleSvc().Cascade(), + MaxBudget: s.LifecycleSvc().Limits().MaxBudgetUSD(), + } + ss.Observe = &Observability{ + Tracer: s.Tracer, + Metrics: s.metrics, + Log: s.log, + } + ss.Lifecycle = s.LifecycleSvc().Lifecycle() + ss.Reflector = s.LifecycleSvc().Reflector() + ss.Critic = s.LifecycleSvc().Critic() + ss.Backtrack = s.LifecycleSvc().Backtrack() + ss.Shadow = s.LifecycleSvc().Shadow() + ss.ConversationGraph = s.Persistence().Graph() + ss.Plan = s.Plan + ss.Teach = s.Teach + ss.Trajectory = s.Trajectory + ss.Snapshots = s.Snapshots + return ss } diff --git a/internal/engine/stream.go b/internal/engine/stream.go index 5d02210b..7a0bb5d1 100644 --- a/internal/engine/stream.go +++ b/internal/engine/stream.go @@ -33,98 +33,39 @@ func (s *Session) agentLoop(ctx context.Context, ch chan<- StreamEvent) { // Start session-level trace span var sessionSpan *oteltrace.Span - if s.Tracer != nil { - ctx, sessionSpan = oteltrace.StartSessionSpan(ctx, s.Tracer, fmt.Sprintf("%d", sessionStart.UnixNano())) + if s.TracerValue() != nil { + ctx, sessionSpan = oteltrace.StartSessionSpan(ctx, s.TracerValue(), fmt.Sprintf("%d", sessionStart.UnixNano())) defer oteltrace.EndSpanWithError(sessionSpan, nil) } - // Self-improvement: run OnSessionEnd when the loop exits (regardless of how) + // Lifecycle and memory bookkeeping consume immutable service snapshots, + // keeping the agent loop independent of backend-specific state. defer func() { success := ctx.Err() == nil - if s.LifecycleSvc().Lifecycle() != nil { - outcome := SessionOutcome{ - Success: success, - Duration: time.Since(sessionStart), - } - if len(s.Persistence().RawMessages()) > 0 { - for _, m := range s.Persistence().RawMessages() { - if m.Role == "user" && len(m.ToolResults) == 0 && outcome.TaskGoal == "" { - outcome.TaskGoal = m.Content - } - } - } - _ = s.LifecycleSvc().Lifecycle().OnSessionEnd(ctx, s, outcome) - } - // Enhanced memory: session-end processing (confidence, diff, continuity) - if s.MemorySvc().Enhanced() != nil { - s.MemorySvc().Enhanced().EndSession(success) - } - // Yaad: save session summary - if s.MemorySvc().Memory() != nil { - taskGoal := "" - if len(s.Persistence().RawMessages()) > 0 { - for _, m := range s.Persistence().RawMessages() { - if m.Role == "user" && len(m.ToolResults) == 0 && taskGoal == "" { - taskGoal = m.Content - } - } - } - if taskGoal != "" { - summary := fmt.Sprintf("Session goal: %s", taskGoal) - if !success { - summary += " (interrupted)" - } - _ = s.MemorySvc().Memory().Remember(summary, "session") - } - } - - // Few-shot learning: record successful session patterns - if success && s.LifecycleSvc().FewShotStore() != nil && len(s.Persistence().RawMessages()) >= 2 { - taskGoal := "" - response := "" - for _, m := range s.Persistence().RawMessages() { - if m.Role == "user" && len(m.ToolResults) == 0 && taskGoal == "" { - taskGoal = m.Content - } - if m.Role == "assistant" && m.Content != "" { - response = m.Content - } - } - if taskGoal != "" && response != "" { - s.LifecycleSvc().FewShotStore().Record(taskGoal, response, "general") - } - } - - // Adaptive prompt: learn from user corrections in this session - if s.LifecycleSvc().AdaptivePrompt() != nil { - for _, m := range s.Persistence().RawMessages() { - if m.Role == "user" && len(m.ToolResults) == 0 { - s.LifecycleSvc().AdaptivePrompt().LearnFromFeedback(m.Content) - } - } - } + messages := s.Persistence().Messages() + s.LifecycleSvc().Finalize(ctx, messages, success, time.Since(sessionStart), s.CostValue().TotalUSD()) + s.MemorySvc().Finalize(messages, success) }() // Session start hook hooks.ExecuteAsync(ctx, hooks.EventSessionStart, map[string]interface{}{ - "provider": s.provider, - "model": s.model, + "provider": s.ChatLLM().Provider(), + "model": s.ChatLLM().Model(), }) // Self-improvement: inject learned guidelines and skills from prior sessions if s.LifecycleSvc().Lifecycle() != nil && len(s.Persistence().RawMessages()) > 0 { lastMsg := s.Persistence().RawMessages()[len(s.Persistence().RawMessages())-1].Content - if learnedCtx := s.LifecycleSvc().Lifecycle().OnSessionStart(ctx, lastMsg); learnedCtx != "" { + if learnedCtx := s.LifecycleSvc().StartContext(ctx, lastMsg); learnedCtx != "" { s.AppendSystemContext(learnedCtx) } } - // Inject remembered context from yaad into system prompt - if s.MemorySvc().Memory() != nil && len(s.Persistence().RawMessages()) > 0 { + // Inject remembered context through the memory service boundary. + if len(s.Persistence().RawMessages()) > 0 { lastMsg := s.Persistence().RawMessages()[len(s.Persistence().RawMessages())-1].Content - remembered, err := s.MemorySvc().Memory().Recall(lastMsg, 2000) - if err == nil && remembered != "" { - s.AppendSystemContext("## Relevant Memories\n" + remembered) + if remembered := s.MemorySvc().RecallContext(ctx, lastMsg, 2000); remembered != "" { + s.AppendSystemContext(remembered) } } @@ -144,8 +85,8 @@ func (s *Session) agentLoop(ctx context.Context, ch chan<- StreamEvent) { } // Agents accumulator: inject learnings from previous sessions - if s.AgentsAccum != nil { - if learnings := s.AgentsAccum.ForPrompt(5); learnings != "" { + if s.LifecycleSvc().AgentsAccum() != nil { + if learnings := s.LifecycleSvc().AgentsAccum().ForPrompt(5); learnings != "" { s.AppendSystemContext(learnings) } } @@ -230,14 +171,14 @@ func (s *Session) agentLoop(ctx context.Context, ch chan<- StreamEvent) { // Pre-query hook _ = hooks.Execute(ctx, hooks.EventPreQuery, map[string]interface{}{ - "provider": s.provider, - "model": s.model, + "provider": s.ChatLLM().Provider(), + "model": s.ChatLLM().Model(), "messages": len(s.Persistence().RawMessages()), }) - s.log.Info("stream query", map[string]interface{}{ - "provider": s.provider, - "model": s.model, + s.Logger().Info("stream query", map[string]interface{}{ + "provider": s.ChatLLM().Provider(), + "model": s.ChatLLM().Model(), "messages": len(s.Persistence().RawMessages()), }) @@ -247,9 +188,9 @@ func (s *Session) agentLoop(ctx context.Context, ch chan<- StreamEvent) { maxTok := DynamicMaxTokens(s.Persistence().RawMessages(), contextSize, taskType) // Model cascade: select optimal model for this request - activeModel := strings.TrimSpace(s.model) + activeModel := strings.TrimSpace(s.ChatLLM().Model()) if activeModel == "" { - activeModel = strings.TrimSpace(s.Cost.Model) + activeModel = strings.TrimSpace(s.ChatLLM().Model()) } if s.LifecycleSvc().Cascade() != nil && s.LifecycleSvc().Cascade().Enabled { lastUserMsg := "" @@ -266,12 +207,12 @@ func (s *Session) agentLoop(ctx context.Context, ch chan<- StreamEvent) { return } - // Yaad: recall and refresh memories before every LLM call - if s.MemorySvc().Memory() != nil && len(s.Persistence().RawMessages()) > 0 { + // Recall and refresh memories before every LLM call through the + // memory service boundary. + if len(s.Persistence().RawMessages()) > 0 { lastMsg := s.Persistence().RawMessages()[len(s.Persistence().RawMessages())-1].Content - remembered, err := s.MemorySvc().Memory().Recall(lastMsg, 3000) - if err == nil && remembered != "" { - s.ReplaceSystemContextSection("## Relevant Memories\n", "## Relevant Memories\n"+remembered) + if remembered := s.MemorySvc().RecallContext(ctx, lastMsg, 3000); remembered != "" { + s.ReplaceSystemContextSection("## Relevant Memories\n", remembered) } } @@ -309,7 +250,7 @@ func (s *Session) agentLoop(ctx context.Context, ch chan<- StreamEvent) { // explicit approval handoff before any changes. Ephemeral (not // persisted to s.Persistence().System()) so it disappears once the // stage advances to Implementing. - if s.Perm != nil && s.Perm.Stage != SpecStageNone && s.Perm.Stage != SpecStageImplementing { + if stage := s.PermSvc().SpecStage(); stage != SpecStageNone && stage != SpecStageImplementing { opts.System += specStageSystemPrompt // Inject user's spec configuration (language, framework, etc.) // as context so the model writes specs that match preferences. @@ -344,10 +285,10 @@ func (s *Session) agentLoop(ctx context.Context, ch chan<- StreamEvent) { } } inputTokens += CountTokensFast(s.Persistence().System()) - s.log.Info("token count", map[string]interface{}{"input_tokens": inputTokens, "model": s.model}) + s.Logger().Info("token count", map[string]interface{}{"input_tokens": inputTokens, "model": s.ChatLLM().Model()}) // Cost warning for expensive calls - inPrice, outPrice := ModelPricing(s.model) + inPrice, outPrice := ModelPricing(s.ChatLLM().Model()) estCost := float64(inputTokens)*inPrice/1_000_000 + float64(maxTok)*outPrice/1_000_000 if estCost > 0.50 { ch <- StreamEvent{Type: "blast_radius", Content: fmt.Sprintf("%s This request will use ~%d tokens (~$%.2f). Continue? The agent will proceed automatically.", icons.Alert(), inputTokens+maxTok, estCost)} @@ -355,8 +296,8 @@ func (s *Session) agentLoop(ctx context.Context, ch chan<- StreamEvent) { // Trace: start agent loop span for this turn var loopSpan *oteltrace.Span - if s.Tracer != nil { - ctx, loopSpan = oteltrace.StartAgentLoopSpan(ctx, s.Tracer, s.provider, activeModel, len(s.Persistence().RawMessages())) + if s.TracerValue() != nil { + ctx, loopSpan = oteltrace.StartAgentLoopSpan(ctx, s.TracerValue(), s.ChatLLM().Provider(), activeModel, len(s.Persistence().RawMessages())) } // Issue the LLM call via the ChatService. The service handles @@ -388,7 +329,7 @@ func (s *Session) agentLoop(ctx context.Context, ch chan<- StreamEvent) { var stopReason string var lastUsage *types.EyrieUsage var usageLedger streamUsageLedger - resolvedProvider := strings.TrimSpace(s.provider) + resolvedProvider := strings.TrimSpace(s.ChatLLM().Provider()) resolvedModel := strings.TrimSpace(activeModel) // Compatibility clients retain Hawk's historical stream retry and @@ -571,8 +512,8 @@ func (s *Session) agentLoop(ctx context.Context, ch chan<- StreamEvent) { // Budget enforcement limits := s.LifecycleSvc().Limits() - if limits.MaxBudgetUSD() > 0 && s.Cost.TotalUSD() >= limits.MaxBudgetUSD() { - ch <- StreamEvent{Type: "content", Content: fmt.Sprintf("\n\nBudget limit reached ($%.2f spent of $%.2f).", s.Cost.TotalUSD(), limits.MaxBudgetUSD())} + if limits.MaxBudgetUSD() > 0 && s.CostValue().TotalUSD() >= limits.MaxBudgetUSD() { + ch <- StreamEvent{Type: "content", Content: fmt.Sprintf("\n\nBudget limit reached ($%.2f spent of $%.2f).", s.CostValue().TotalUSD(), limits.MaxBudgetUSD())} ch <- StreamEvent{Type: "done"} return } @@ -676,7 +617,7 @@ func (s *Session) agentLoop(ctx context.Context, ch chan<- StreamEvent) { defer sCancel() resp, err := s.ChatLLM().Chat(sCtx, []types.EyrieMessage{ {Role: "user", Content: prompt}, - }, types.ChatOptions{Provider: s.provider, Model: s.model, MaxTokens: 2048}) + }, types.ChatOptions{Provider: s.ChatLLM().Provider(), Model: s.ChatLLM().Model(), MaxTokens: 2048}) if err != nil || resp == nil { return } @@ -708,7 +649,7 @@ func (s *Session) agentLoop(ctx context.Context, ch chan<- StreamEvent) { defer dCancel() resp, err := s.ChatLLM().Chat(dCtx, []types.EyrieMessage{ {Role: "user", Content: prompt}, - }, types.ChatOptions{Provider: s.provider, Model: s.model, MaxTokens: 2048}) + }, types.ChatOptions{Provider: s.ChatLLM().Provider(), Model: s.ChatLLM().Model(), MaxTokens: 2048}) if err != nil || resp == nil { return } @@ -734,8 +675,8 @@ func (s *Session) agentLoop(ctx context.Context, ch chan<- StreamEvent) { } // Session end hook hooks.ExecuteAsync(ctx, hooks.EventSessionEnd, map[string]interface{}{ - "provider": s.provider, - "model": s.model, + "provider": s.ChatLLM().Provider(), + "model": s.ChatLLM().Model(), "messages": len(s.Persistence().RawMessages()), }) return @@ -767,10 +708,10 @@ func (s *Session) agentLoop(ctx context.Context, ch chan<- StreamEvent) { s.LifecycleSvc().Backtrack().RecordDecision(turnCount, strings.Join(toolNames, ", "), nil, s.Persistence().RawMessages()) } - results := s.executeToolCalls(ctx, toolCalls, ch, turnCount, textContent.String()) + results := s.Tools().ExecuteAll(ctx, toolCalls, ch, turnCount, textContent.String()) // Auto-snapshot after write operations for granular undo - if s.Snapshots != nil && len(toolCalls) > 0 { + if s.Tools().Snapshots() != nil && len(toolCalls) > 0 { var writeNames []string for _, tc := range toolCalls { if !tool.IsReadOnly(tc.Name) { @@ -783,7 +724,7 @@ func (s *Session) agentLoop(ctx context.Context, ch chan<- StreamEvent) { // leak a goroutine after the session ends. snapCtx, snapCancel := context.WithTimeout(context.Background(), 30*time.Second) defer snapCancel() - _, _ = s.Snapshots.TrackCtx(snapCtx, strings.Join(writeNames, ", ")) + _, _ = s.Tools().Snapshots().TrackCtx(snapCtx, strings.Join(writeNames, ", ")) }() } } diff --git a/internal/engine/stream_tool_exec.go b/internal/engine/stream_tool_exec.go index 7088be36..77897095 100644 --- a/internal/engine/stream_tool_exec.go +++ b/internal/engine/stream_tool_exec.go @@ -265,8 +265,8 @@ func (s *Session) executeSingleToolWithTool(ctx context.Context, tc types.ToolCa } var toolSpan *oteltrace.Span - if s.Tracer != nil { - _, toolSpan = oteltrace.StartToolSpan(ctx, s.Tracer, tc.Name, tc.ID) + if s.TracerValue() != nil { + _, toolSpan = oteltrace.StartToolSpan(ctx, s.TracerValue(), tc.Name, tc.ID) } // Delegate to the extracted PermissionService (Phase 7 migration). @@ -281,12 +281,7 @@ func (s *Session) executeSingleToolWithTool(ctx context.Context, tc types.ToolCa // only consults the values it holds. The sync is cheap (two // pointer assignments) and removes a class of "settings lost" // bugs when callers mutate the session after construction. - if s.PermissionFn != nil { - s.PermSvc().SetPermissionFn(s.PermissionFn) - } - if s.Autonomy != 0 { - s.PermSvc().SetAutonomy(s.Autonomy) - } + s.syncPermissionCompatibility() granted, denyMsg := s.PermSvc().CheckTool(ctx, ToolCallInfo{ Name: tc.Name, ID: tc.ID, @@ -305,7 +300,7 @@ func (s *Session) executeSingleToolWithTool(ctx context.Context, tc types.ToolCa // Human-in-the-loop approval gate for high-risk actions (additive; no-op // unless s.Approval is configured and enabled). See approval_gate.go. approved, approvalDeny := s.CheckApproval(ctx, tc.Name, tc.Arguments) - if s.Approval != nil && s.Approval.Enabled { + if approval := s.PermSvc().Approval(); approval != nil && approval.Enabled { s.recordPolicyObservation(tc, "approval", approved, approvalDeny) } if !approved { @@ -331,8 +326,8 @@ func (s *Session) executeSingleToolWithTool(ctx context.Context, tc types.ToolCa return "", fmt.Errorf("commit message model is unavailable") } resp, err := s.ChatLLM().Chat(chatCtx, []types.EyrieMessage{{Role: "user", Content: prompt}}, types.ChatOptions{ - Provider: s.provider, - Model: s.model, + Provider: s.ChatLLM().Provider(), + Model: s.ChatLLM().Model(), MaxTokens: 256, }) if err != nil { @@ -344,8 +339,8 @@ func (s *Session) executeSingleToolWithTool(ctx context.Context, tc types.ToolCa return resp.Content, nil }, YaadBridge: s.MemorySvc().Yaad(), - SpecSlugGet: func() string { return s.Perm.SpecSlug }, - SpecSlugSet: func(slug string) { s.Perm.SpecSlug = slug }, + SpecSlugGet: func() string { return s.PermSvc().SpecSlug() }, + SpecSlugSet: func(slug string) { s.PermSvc().SetSpecSlug(slug) }, BackgroundManager: s.ensureBackgroundManager(), ReadOnlyBash: s.readOnlyBash, WorkingDir: s.workingDir, @@ -426,7 +421,7 @@ func (s *Session) executeSingleToolWithTool(ctx context.Context, tc types.ToolCa // Self-Review Before Apply: for Write/Edit, ask LLM to review changes if preEditPath != "" && s.client != nil && shouldSelfReview(tc.Name) { if newContent, readErr := readFileContent(preEditPath); readErr == nil && newContent != preEditContent { - reviewResult, reviewErr := ReviewBeforeWrite(ctx, s.client, s.model, intentText, preEditPath, preEditContent, newContent) + reviewResult, reviewErr := ReviewBeforeWrite(ctx, s.ChatLLM().Client(), s.ChatLLM().Model(), intentText, preEditPath, preEditContent, newContent) if reviewErr == nil && reviewResult != nil && !reviewResult.Approved { // Revert the file to its original state. If revert fails we // MUST surface that as a hard tool error: silently leaving @@ -498,12 +493,12 @@ func (s *Session) executeSingleToolWithTool(ctx context.Context, tc types.ToolCa } // Auto-accumulate learnings into Hawk user state. - if s.AgentsAccum != nil && !isErr && (canonical == "Write" || canonical == "Edit") { + if s.LifecycleSvc().AgentsAccum() != nil && !isErr && (canonical == "Write" || canonical == "Edit") { if p, ok := pathArgument(tc.Arguments); ok && p != "" { pattern := prompts.ExtractPattern(tc.Name, p, output) - s.AgentsAccum.Record(intentText, pattern, []string{p}) + s.LifecycleSvc().AgentsAccum().Record(intentText, pattern, []string{p}) // Flush periodically (every 5 learnings) - if err := s.AgentsAccum.Flush(); err != nil { + if err := s.LifecycleSvc().AgentsAccum().Flush(); err != nil { slog.Warn("failed to flush agents accumulator", "error", err) } } @@ -554,14 +549,14 @@ func (s *Session) executeSingleToolWithTool(ctx context.Context, tc types.ToolCa } } - if s.LintLoop != nil && s.LintLoop.Enabled && !isErr && !sandboxIntercepted && (canonical == "Write" || canonical == "Edit") { + if s.LifecycleSvc().LintLoop() != nil && s.LifecycleSvc().LintLoop().Enabled && !isErr && !sandboxIntercepted && (canonical == "Write" || canonical == "Edit") { if p, ok := pathArgument(tc.Arguments); ok { - count := s.LintLoop.ReflectionCount(p) - if s.LintLoop.ShouldRetry(count) { - if lintResult, lintErr := s.LintLoop.RunLint(p); lintErr == nil && lintResult != nil { - reflected := s.LintLoop.BuildReflectedMessage(lintResult) + count := s.LifecycleSvc().LintLoop().ReflectionCount(p) + if s.LifecycleSvc().LintLoop().ShouldRetry(count) { + if lintResult, lintErr := s.LifecycleSvc().LintLoop().RunLint(p); lintErr == nil && lintResult != nil { + reflected := s.LifecycleSvc().LintLoop().BuildReflectedMessage(lintResult) if reflected != "" { - s.LintLoop.RecordReflection(p) + s.LifecycleSvc().LintLoop().RecordReflection(p) output += "\n\n" + reflected } } diff --git a/internal/engine/tool_service.go b/internal/engine/tool_service.go index d13e5f83..c42bba8b 100644 --- a/internal/engine/tool_service.go +++ b/internal/engine/tool_service.go @@ -17,6 +17,7 @@ import ( // god-object decomposition (see docs/session-decomposition.md). type ToolService struct { registry *tool.Registry + host toolExecutionHost containerExecutor tool.ContainerExecutor containerRequired bool tracer *oteltrace.Tracer @@ -25,11 +26,28 @@ type ToolService struct { sandbox *diff.DiffSandbox } +// toolExecutionHost is the narrow compatibility seam used while the +// historical post-tool pipeline is moved out of Session. It deliberately +// exposes only batch execution; policy, persistence, and lifecycle state are +// still obtained from their dedicated services by the host implementation. +// Keeping this seam small lets callers migrate to ToolService without +// reintroducing direct Session field access. +type toolExecutionHost interface { + executeToolCalls(context.Context, []types.ToolCall, chan<- StreamEvent, int, string) []toolExecResult +} + // NewToolService constructs a ToolService with the given registry. func NewToolService(registry *tool.Registry) *ToolService { return &ToolService{registry: registry} } +// WithExecutionHost attaches the session-independent execution seam. It is +// set once during session construction and is safe to replace in tests. +func (s *ToolService) WithExecutionHost(host toolExecutionHost) *ToolService { + s.host = host + return s +} + // WithContainerExecutor configures container isolation. func (s *ToolService) WithContainerExecutor(ce tool.ContainerExecutor, required bool) *ToolService { s.containerExecutor = ce @@ -71,6 +89,25 @@ func (s *ToolService) Classify(calls []types.ToolCall) (concurrent, sequential [ return } +// ExecuteAll runs the complete tool batch pipeline. The service owns the +// public operation and callers no longer need to reach into Session's +// unexported execution method. A missing host produces deterministic error +// results instead of panicking, which keeps isolated service tests useful. +func (s *ToolService) ExecuteAll(ctx context.Context, calls []types.ToolCall, ch chan<- StreamEvent, turn int, intent string) []toolExecResult { + if s == nil || s.host == nil { + results := make([]toolExecResult, len(calls)) + for i, call := range calls { + msg := "tool execution host is unavailable" + results[i] = toolExecResult{tc: call, output: msg, isErr: true} + if ch != nil { + ch <- StreamEvent{Type: "tool_result", ToolName: call.Name, Content: msg} + } + } + return results + } + return s.host.executeToolCalls(ctx, calls, ch, turn, intent) +} + // ExtractTargets returns the file targets for a tool call. func (s *ToolService) ExtractTargets(tc types.ToolCall) []string { if t, ok := s.registry.Get(tc.Name); ok { @@ -125,6 +162,9 @@ func (s *ToolService) ContainerRequired() bool { return s.containerRequired } // ContainerExecutor returns the configured container executor, or nil. func (s *ToolService) ContainerExecutor() tool.ContainerExecutor { return s.containerExecutor } +// Snapshots returns the configured automatic snapshot tracker. +func (s *ToolService) Snapshots() SnapshotTracker { return s.snapshots } + // Sandbox returns the diff sandbox (staged file changes for // review before apply). New code should access this through // s.Tools().Sandbox(). From eb4062b768fa2278287e8e0af6725442196242c2 Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Sat, 1 Aug 2026 23:42:03 +0530 Subject: [PATCH 25/49] refactor: initialize standalone session service graph --- internal/engine/session_services.go | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/internal/engine/session_services.go b/internal/engine/session_services.go index e8b83824..2de16c2e 100644 --- a/internal/engine/session_services.go +++ b/internal/engine/session_services.go @@ -277,9 +277,15 @@ func WithMaxBudget(budget float64) ServiceOption { // NewSessionServices creates a SessionServices with defaults and applies // the given functional options. func NewSessionServices(opts ...ServiceOption) *SessionServices { + log := logger.Default() ss := &SessionServices{ + Permissions: NewPermissionService(log), + LifecycleService: NewLifecycleService(log), + MemoryService: NewMemoryService(log), + Persist: NewPersistenceService(log), + ToolService: NewToolService(nil), Core: &CoreLoop{ - Log: logger.Default(), + Log: log, }, Safety: &SafetyLayer{ Perm: NewPermissionEngine(), @@ -292,7 +298,7 @@ func NewSessionServices(opts ...ServiceOption) *SessionServices { Observe: &Observability{ Tracer: oteltrace.NewTracer(), Metrics: metrics.NewRegistry(), - Log: logger.Default(), + Log: log, }, Backtrack: NewBacktrackEngine(), } From e9b8a40ee98aa943032418987cc971a0cd098997 Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Sat, 1 Aug 2026 23:52:01 +0530 Subject: [PATCH 26/49] refactor: move tool and approval orchestration into services --- docs/session-decomposition.md | 35 +++++++- internal/engine/agent_session_tool.go | 21 ++--- internal/engine/approval_gate.go | 66 ++------------ internal/engine/permission_service.go | 66 ++++++++++++-- internal/engine/persistence_service.go | 120 +++++++++++++++++++++---- internal/engine/session.go | 31 ++++++- internal/engine/stream_tool_exec.go | 6 ++ internal/engine/stream_usage.go | 6 +- internal/engine/tool_service.go | 46 +++++++++- 9 files changed, 291 insertions(+), 106 deletions(-) diff --git a/docs/session-decomposition.md b/docs/session-decomposition.md index 91680bad..87d6a322 100644 --- a/docs/session-decomposition.md +++ b/docs/session-decomposition.md @@ -1,6 +1,8 @@ -# Session God-Object Decomposition — Design Sketch +# Session God-Object Decomposition — Design and Migration Status -> Status: **DRAFT / NOT YET IMPLEMENTED** +> Status: **IN PROGRESS** — the canonical service graph and first runtime +> migrations are implemented; compatibility shims remain while the remaining +> Session call sites are moved. > Author: opencode session > Date: 2026-06-12 > Scope: `hawk/internal/engine/session.go` (the 35-collaborator `Session` struct) @@ -25,6 +27,30 @@ Break `Session` into ~6 cohesive sub-services, each with: The `agentLoop` should consume these sub-services as named dependencies — no implicit `s.Beliefs.Size()` reach-throughs. +## Implemented Migration Slice (2026-08) + +The refactor branch now enforces these boundaries: + +- `SessionServices` is the canonical composition root for the six extracted + services; `SubServices()` and `Services()` reference the same instances. +- `PersistenceService` owns immutable transcript snapshots, system-context + mutations, compaction metadata, token accounting, and checkpoint identity. + Returned messages are deep copies, including nested tool arguments. +- `LifecycleService` owns session-start/end bookkeeping, few-shot learning, + adaptive feedback, model cascade access, and quality-loop handles. +- `MemoryService` owns recall fallback, Yaad/enhanced-memory finalization, and + session summaries. +- `PermissionService` owns the approval gate and fallback ask-user callback; + `Session.CheckApproval` is now only a compatibility facade. +- `ToolService.ExecuteAll` owns batching, ordering, blast-radius reporting, + and read-only concurrency limits. The old Session method is a compatibility + wrapper for in-package callers. +- The agent loop uses these service APIs for transport, persistence, memory, + lifecycle, permission-stage, and tool-batch operations. + +Legacy fields remain until all external and in-package callers migrate. They +are compatibility aliases, not a second authoritative state store. + ## Proposed Decomposition ### 1. `ChatService` — owns the LLM transport @@ -253,4 +279,7 @@ These tests don't need to construct a `Session` anymore; they can construct just ## Status -**NOT YET IMPLEMENTED.** The above is a design proposal pending review. The 4 concrete fixes (time.Sleep, ReadOnlyTools, AGENTS.md, self-review revert) are merged independently of this refactor. +**IN PROGRESS.** The implemented migration slice above is live and tested. +The remaining work is to move the internals of the tool execution pipeline, +finish compaction ownership, migrate all production call sites, and then +remove the compatibility fields in a separately reviewed cleanup commit. diff --git a/internal/engine/agent_session_tool.go b/internal/engine/agent_session_tool.go index 8c46aeb4..104d1d38 100644 --- a/internal/engine/agent_session_tool.go +++ b/internal/engine/agent_session_tool.go @@ -132,8 +132,7 @@ func (s *Session) spawnSubAgent(ctx context.Context, norm agentcontracts.Normali } sub := s.SubSession(model, subSystemPrompt, registry) - sub.PermissionFn = s.PermissionFn - sub.Permissions = s.Permissions + sub.PermSvc().SetPermissionFn(s.PermSvc().PermissionFn()) // Explore/plan: hard read-only bash allowlist (in addition to tool filter). if IsReadOnlyMode(mode) || norm.CapabilityMode == agentcontracts.CapReadOnly { sub.readOnlyBash = true @@ -211,29 +210,31 @@ const planSystemPrefix = "You are a planning sub-agent. Produce an ordered, acti "Do not modify files. Prefer research tools (Read, Grep, Glob, LS) and only use Bash for read-only inspection." func (s *Session) resolveSubAgentModel(mode SubAgentMode) string { + current := s.ChatLLM().Model() if s.LifecycleSvc().Cascade() == nil { - return s.model + return current } switch mode { case SubAgentExplore: - return s.LifecycleSvc().Cascade().SelectModel("summarize", s.model, "") + return s.LifecycleSvc().Cascade().SelectModel("summarize", current, "") case SubAgentPlan: - return s.LifecycleSvc().Cascade().SelectModel("summarize", s.model, "") + return s.LifecycleSvc().Cascade().SelectModel("summarize", current, "") case SubAgentGeneral: - return s.LifecycleSvc().Cascade().SelectModel("implement", s.model, "") + return s.LifecycleSvc().Cascade().SelectModel("implement", current, "") default: - return s.model + return current } } func (s *Session) resolveSubAgentTools(mode SubAgentMode) *tool.Registry { + registry := s.Tools().Registry() switch mode { case SubAgentExplore: - return s.registry.Filter(ExploreTools) + return registry.Filter(ExploreTools) case SubAgentPlan: - return s.registry.Filter(PlanTools) + return registry.Filter(PlanTools) default: - return s.registry + return registry } } diff --git a/internal/engine/approval_gate.go b/internal/engine/approval_gate.go index 1181eac3..97fafcd3 100644 --- a/internal/engine/approval_gate.go +++ b/internal/engine/approval_gate.go @@ -164,68 +164,12 @@ func (g *ApprovalGate) isSessionApproved(cat ApprovalCategory) bool { // // This is invoked from the tool execution pipeline after the normal permission // check succeeds; it never loosens an existing denial. -func (s *Session) CheckApproval(_ context.Context, toolName string, args map[string]interface{}) (bool, string) { - g := s.Approval - if g == nil || !g.Enabled { - return true, "" +func (s *Session) CheckApproval(ctx context.Context, toolName string, args map[string]interface{}) (bool, string) { + if s == nil || s.PermSvc() == nil { + return false, "permission service is unavailable" } - - cat, risky := g.classifyAction(toolName, args) - if !risky || !g.categoryEnabled(cat) { - return true, "" - } - - // Within the auto-approve threshold the operator has opted into automation. - if s.Autonomy <= g.MaxAutoApprove { - return true, "" - } - - // Session-wide approval: human already chose "approve for session" earlier. - if g.isSessionApproved(cat) { - return true, "" - } - - req := ApprovalRequest{ - ToolName: canonicalToolName(toolName), - Category: cat, - Summary: approvalSummary(toolName, args), - Args: args, - } - - if g.ConfirmFn != nil { - resp := g.ConfirmFn(req) - switch resp { - case ApprovalApproveForSession: - g.sessionApprove(cat) - return true, "" - case ApprovalApprove: - return true, "" - default: - return false, "Action denied by human approval gate (" + string(cat) + ")." - } - } - - // Fall back to the session's generic ask-user callback. - if s.AskUserFn != nil { - q := "Approve high-risk action [" + string(cat) + "]: " + req.Summary + "? (yes/no/session)" - ans, err := s.AskUserFn(q) - if err != nil { - return false, "Action denied by human approval gate (" + string(cat) + ")." - } - switch strings.ToLower(strings.TrimSpace(ans)) { - case "session", "s", "approve-session", "yes-session": - g.sessionApprove(cat) - return true, "" - default: - if isAffirmative(ans) { - return true, "" - } - return false, "Action denied by human approval gate (" + string(cat) + ")." - } - } - - // No way to ask: fail closed. - return false, "High-risk action requires approval but no confirmation handler is configured (" + string(cat) + ")." + s.syncPermissionCompatibility() + return s.PermSvc().CheckApproval(ctx, toolName, args) } func approvalSummary(toolName string, args map[string]interface{}) string { diff --git a/internal/engine/permission_service.go b/internal/engine/permission_service.go index aaec5206..62c133f4 100644 --- a/internal/engine/permission_service.go +++ b/internal/engine/permission_service.go @@ -3,6 +3,7 @@ package engine import ( "context" "fmt" + "strings" "github.com/GrayCodeAI/hawk/internal/observability/logger" "github.com/GrayCodeAI/hawk/internal/permissions" @@ -41,6 +42,8 @@ type PermissionService struct { permissionFn func(PermissionRequest) // approval is the human-in-the-loop gate for high-risk tool actions. approval *ApprovalGate + // askUserFn is the fallback interactive approval callback. + askUserFn func(question string) (string, error) // log is the session logger. log *logger.Logger } @@ -99,21 +102,54 @@ func (s *PermissionService) CheckTool(ctx context.Context, info ToolCallInfo) (b // service's own CheckApproval is a no-op when s.approval is nil so // callers can use it as the canonical entry point. func (s *PermissionService) CheckApproval(_ context.Context, toolName string, args map[string]interface{}) (bool, string) { - if s.approval == nil || !s.approval.Enabled { + g := s.approval + if g == nil || !g.Enabled { return true, "" } - // Delegate to the ApprovalGate classifier. The full session-aware - // CheckApproval (which honors sessionApprovals cache) lives on Session - // because it needs Session-scoped state. The classifier-only check - // here is sufficient for the safety/dry-run code paths. - cat, triggered := s.approval.classifyAction(toolName, args) - if !triggered { + cat, risky := g.classifyAction(toolName, args) + if !risky || !g.categoryEnabled(cat) { return true, "" } - if s.approval.MaxAutoApprove > 0 && s.perm.Autonomy <= s.approval.MaxAutoApprove { + if s.perm.Autonomy <= g.MaxAutoApprove { return true, "" } - return false, fmt.Sprintf("approval required for category %q", cat) + if g.isSessionApproved(cat) { + return true, "" + } + req := ApprovalRequest{ + ToolName: canonicalToolName(toolName), + Category: cat, + Summary: approvalSummary(toolName, args), + Args: args, + } + if g.ConfirmFn != nil { + switch g.ConfirmFn(req) { + case ApprovalApproveForSession: + g.sessionApprove(cat) + return true, "" + case ApprovalApprove: + return true, "" + default: + return false, "Action denied by human approval gate (" + string(cat) + ")." + } + } + if s.askUserFn != nil { + ans, err := s.askUserFn("Approve high-risk action [" + string(cat) + "]: " + req.Summary + "? (yes/no/session)") + if err != nil { + return false, "Action denied by human approval gate (" + string(cat) + ")." + } + switch strings.ToLower(strings.TrimSpace(ans)) { + case "session", "s", "approve-session", "yes-session": + g.sessionApprove(cat) + return true, "" + default: + if isAffirmative(ans) { + return true, "" + } + return false, "Action denied by human approval gate (" + string(cat) + ")." + } + } + return false, fmt.Sprintf("High-risk action requires approval but no confirmation handler is configured (%q).", cat) } // SetMaxTurns caps the agent loop's turn count. @@ -144,6 +180,9 @@ func (s *PermissionService) DryRun() bool { return s.perm.DryRun } // SetApproval replaces the ApprovalGate. func (s *PermissionService) SetApproval(a *ApprovalGate) { s.approval = a } +// SetAskUserFn sets the fallback interactive approval callback. +func (s *PermissionService) SetAskUserFn(fn func(question string) (string, error)) { s.askUserFn = fn } + // Approval returns the configured human-in-the-loop gate. func (s *PermissionService) Approval() *ApprovalGate { return s.approval } @@ -168,6 +207,15 @@ func (s *PermissionService) SetPermissionFn(fn func(PermissionRequest)) { s.perm.PromptFn = fn } +// PermissionFn returns the configured approval callback for sub-agent +// construction and legacy integrations. +func (s *PermissionService) PermissionFn() func(PermissionRequest) { + if s == nil { + return nil + } + return s.permissionFn +} + // MaxTurns returns the cap (0 = no cap). func (s *PermissionService) MaxTurns() int { return s.maxTurns } diff --git a/internal/engine/persistence_service.go b/internal/engine/persistence_service.go index ffc33c65..a00773fb 100644 --- a/internal/engine/persistence_service.go +++ b/internal/engine/persistence_service.go @@ -22,6 +22,8 @@ import ( type PersistenceService struct { // mu protects messages and system for concurrent access. mu sync.RWMutex + // stateMu protects compaction, token, and checkpoint metadata. + stateMu sync.RWMutex // messages is the full transcript (system + user + assistant + tool_use + tool_result). messages []types.EyrieMessage // system is the system prompt (mutable, agents append learned guidelines). @@ -303,34 +305,98 @@ func cloneJSONValue(value interface{}) interface{} { } // PinnedMessages returns the count of pinned messages. -func (s *PersistenceService) PinnedMessages() int { return s.pinnedMessages } +func (s *PersistenceService) PinnedMessages() int { + s.stateMu.RLock() + defer s.stateMu.RUnlock() + return s.pinnedMessages +} // SetPinnedMessages replaces the pinned count. -func (s *PersistenceService) SetPinnedMessages(n int) { s.pinnedMessages = n } +func (s *PersistenceService) SetPinnedMessages(n int) { + s.stateMu.Lock() + s.pinnedMessages = n + s.stateMu.Unlock() +} // AutoCompactThresholdPct returns the auto-compact threshold %. -func (s *PersistenceService) AutoCompactThresholdPct() int { return s.autoCompactThresholdPct } +func (s *PersistenceService) AutoCompactThresholdPct() int { + s.stateMu.RLock() + defer s.stateMu.RUnlock() + return s.autoCompactThresholdPct +} // SetAutoCompactThresholdPct replaces the auto-compact threshold %. func (s *PersistenceService) SetAutoCompactThresholdPct(pct int) { + s.stateMu.Lock() s.autoCompactThresholdPct = pct + s.stateMu.Unlock() } // ContextWindowCached returns the catalog context window. -func (s *PersistenceService) ContextWindowCached() int { return s.contextWindowCached } +func (s *PersistenceService) ContextWindowCached() int { + s.stateMu.RLock() + defer s.stateMu.RUnlock() + return s.contextWindowCached +} // SetContextWindowCached replaces the catalog context window. -func (s *PersistenceService) SetContextWindowCached(n int) { s.contextWindowCached = n } - -func (s *PersistenceService) AutoCompactor() *AutoCompactor { return s.autoCompactor } -func (s *PersistenceService) SetAutoCompactor(ac *AutoCompactor) { s.autoCompactor = ac } -func (s *PersistenceService) Files() *FileTracker { return s.files } -func (s *PersistenceService) SetFiles(files *FileTracker) { s.files = files } -func (s *PersistenceService) PersistID() string { return s.persistID } -func (s *PersistenceService) SetPersistID(id string) { s.persistID = id } -func (s *PersistenceService) LastPromptTokens() int { return s.lastPromptTokens } -func (s *PersistenceService) LastCompletionTokens() int { return s.lastCompletionTokens } +func (s *PersistenceService) SetContextWindowCached(n int) { + s.stateMu.Lock() + s.contextWindowCached = n + s.stateMu.Unlock() +} + +func (s *PersistenceService) AutoCompactor() *AutoCompactor { + s.stateMu.RLock() + defer s.stateMu.RUnlock() + return s.autoCompactor +} + +func (s *PersistenceService) SetAutoCompactor(ac *AutoCompactor) { + s.stateMu.Lock() + s.autoCompactor = ac + s.stateMu.Unlock() +} + +func (s *PersistenceService) Files() *FileTracker { + s.stateMu.RLock() + defer s.stateMu.RUnlock() + return s.files +} + +func (s *PersistenceService) SetFiles(files *FileTracker) { + s.stateMu.Lock() + s.files = files + s.stateMu.Unlock() +} + +func (s *PersistenceService) PersistID() string { + s.stateMu.RLock() + defer s.stateMu.RUnlock() + return s.persistID +} + +func (s *PersistenceService) SetPersistID(id string) { + s.stateMu.Lock() + s.persistID = id + s.stateMu.Unlock() +} + +func (s *PersistenceService) LastPromptTokens() int { + s.stateMu.RLock() + defer s.stateMu.RUnlock() + return s.lastPromptTokens +} + +func (s *PersistenceService) LastCompletionTokens() int { + s.stateMu.RLock() + defer s.stateMu.RUnlock() + return s.lastCompletionTokens +} + func (s *PersistenceService) SetTokenUsage(prompt, completion int) { + s.stateMu.Lock() + defer s.stateMu.Unlock() if prompt > 0 { s.lastPromptTokens = prompt } @@ -340,17 +406,37 @@ func (s *PersistenceService) SetTokenUsage(prompt, completion int) { } func (s *PersistenceService) TokenEstimateCache() (tokens, count, lastLen int) { + s.stateMu.RLock() + defer s.stateMu.RUnlock() return s.estTokensCache, s.estTokensMsgCount, s.estTokensLastLen } func (s *PersistenceService) SetTokenEstimateCache(tokens, count, lastLen int) { + s.stateMu.Lock() + defer s.stateMu.Unlock() s.estTokensCache, s.estTokensMsgCount, s.estTokensLastLen = tokens, count, lastLen } -func (s *PersistenceService) CheckpointManager() *session.CheckpointManager { return s.checkpointMgr } +func (s *PersistenceService) CheckpointManager() *session.CheckpointManager { + s.stateMu.RLock() + defer s.stateMu.RUnlock() + return s.checkpointMgr +} func (s *PersistenceService) SetCheckpointManager(cm *session.CheckpointManager) { + s.stateMu.Lock() + defer s.stateMu.Unlock() s.checkpointMgr = cm } -func (s *PersistenceService) OnCompaction() OnCompaction { return s.onCompaction } -func (s *PersistenceService) SetOnCompaction(fn OnCompaction) { s.onCompaction = fn } + +func (s *PersistenceService) OnCompaction() OnCompaction { + s.stateMu.RLock() + defer s.stateMu.RUnlock() + return s.onCompaction +} + +func (s *PersistenceService) SetOnCompaction(fn OnCompaction) { + s.stateMu.Lock() + s.onCompaction = fn + s.stateMu.Unlock() +} diff --git a/internal/engine/session.go b/internal/engine/session.go index 593aed0d..6225b726 100644 --- a/internal/engine/session.go +++ b/internal/engine/session.go @@ -412,7 +412,24 @@ func (s *Session) MemorySvc() *MemoryService { return s.memory } // Persistence returns the extracted PersistenceService (Phase 5). // Provides the messages slice and system prompt (read/write) with // the underlying RWMutex. -func (s *Session) Persistence() *PersistenceService { return s.persist } +func (s *Session) Persistence() *PersistenceService { + if s == nil { + return nil + } + if s.persist != nil { + return s.persist + } + // A handful of focused tests and compatibility integrations still build a + // Session literal. Lazily materialize the persistence service and import + // their legacy transcript once, so the service boundary remains total. + s.persist = NewPersistenceService(s.log) + s.persist.SetSystem(s.system) + s.persist.SetRawMessages(s.messages) + s.persist.SetPinnedMessages(s.PinnedMessages) + s.persist.SetAutoCompactThresholdPct(s.AutoCompactThresholdPct) + s.persist.SetContextWindowCached(s.ContextWindowCached) + return s.persist +} // Tools returns the extracted ToolService (Phase 6). func (s *Session) Tools() *ToolService { return s.tools } @@ -695,6 +712,9 @@ func (s *Session) ConvoHead() string { func (s *Session) AppendSystemContext(content string) { if p := s.Persistence(); p != nil { p.AppendSystemContext(content) + s.mu.Lock() + s.system = p.System() + s.mu.Unlock() } } @@ -703,6 +723,9 @@ func (s *Session) AppendSystemContext(content string) { func (s *Session) ReplaceSystemContextSection(header, content string) { if p := s.Persistence(); p != nil { p.ReplaceSystemContextSection(header, content) + s.mu.Lock() + s.system = p.System() + s.mu.Unlock() } } @@ -782,6 +805,9 @@ func (s *Session) SetContainerExecutor(ce tool.ContainerExecutor) { // call this instead of writing to the legacy s.AskUserFn field. func (s *Session) SetAskUserFn(fn func(question string) (string, error)) { s.AskUserFn = fn + if s.perms != nil { + s.perms.SetAskUserFn(fn) + } } // SetApproval sets the high-risk action gate. New code should @@ -808,6 +834,9 @@ func (s *Session) syncPermissionCompatibility() { if s.Approval != nil { s.perms.SetApproval(s.Approval) } + if s.AskUserFn != nil { + s.perms.SetAskUserFn(s.AskUserFn) + } } // SetConversationGraph attaches Hawk's product-owned conversation graph and diff --git a/internal/engine/stream_tool_exec.go b/internal/engine/stream_tool_exec.go index 77897095..80fc9c4c 100644 --- a/internal/engine/stream_tool_exec.go +++ b/internal/engine/stream_tool_exec.go @@ -148,6 +148,12 @@ type indexedToolCall struct { // executeToolCalls runs all tool calls and returns results. func (s *Session) executeToolCalls(ctx context.Context, toolCalls []types.ToolCall, ch chan<- StreamEvent, turnCount int, intentText string) []toolExecResult { + // Compatibility wrapper: ToolService owns batching, ordering, and + // concurrency. Keep this method for older in-package callers while they + // migrate to s.Tools().ExecuteAll. + if s.Tools() != nil && s.Tools().host != nil { + return s.Tools().ExecuteAll(ctx, toolCalls, ch, turnCount, intentText) + } // Estimate blast radius before execution. Use the schema-aware target // extractor when the tool is registered (so non-conventional argument // names like "target_path" or "destFile" are still picked up); fall back diff --git a/internal/engine/stream_usage.go b/internal/engine/stream_usage.go index b0af9efe..e1dc2ac4 100644 --- a/internal/engine/stream_usage.go +++ b/internal/engine/stream_usage.go @@ -77,9 +77,9 @@ func (s *Session) recordStreamUsage(ch chan<- StreamEvent, prompt, completion in provider = strings.TrimSpace(provider) model = strings.TrimSpace(model) s.RecordAPIUsage(prompt, completion) - costBefore := s.Cost.Total() - s.Cost.AddForModel(model, prompt, completion) - requestCost := s.Cost.Total() - costBefore + costBefore := s.CostValue().Total() + s.CostValue().AddForModel(model, prompt, completion) + requestCost := s.CostValue().Total() - costBefore if s.CostTracker != nil && model != "" { _ = s.CostTracker.Record(analytics.CostEntry{ Model: model, diff --git a/internal/engine/tool_service.go b/internal/engine/tool_service.go index c42bba8b..91ca3c66 100644 --- a/internal/engine/tool_service.go +++ b/internal/engine/tool_service.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "fmt" + "sync" "github.com/GrayCodeAI/hawk/internal/engine/diff" "github.com/GrayCodeAI/hawk/internal/observability/oteltrace" @@ -33,7 +34,7 @@ type ToolService struct { // Keeping this seam small lets callers migrate to ToolService without // reintroducing direct Session field access. type toolExecutionHost interface { - executeToolCalls(context.Context, []types.ToolCall, chan<- StreamEvent, int, string) []toolExecResult + executeSingleTool(context.Context, types.ToolCall, chan<- StreamEvent, int, string) toolExecResult } // NewToolService constructs a ToolService with the given registry. @@ -105,11 +106,52 @@ func (s *ToolService) ExecuteAll(ctx context.Context, calls []types.ToolCall, ch } return results } - return s.host.executeToolCalls(ctx, calls, ch, turn, intent) + plannedCalls := make([]PlannedCall, len(calls)) + concurrentCalls := make([]indexedToolCall, 0, len(calls)) + sequentialCalls := make([]indexedToolCall, 0, len(calls)) + for i, call := range calls { + targets := s.ExtractTargets(call) + plannedCalls[i] = PlannedCall{ToolName: call.Name, Args: call.Arguments, Targets: targets} + item := indexedToolCall{index: i, tc: call} + if tool.IsReadOnly(call.Name) { + concurrentCalls = append(concurrentCalls, item) + } else { + sequentialCalls = append(sequentialCalls, item) + } + } + if report := EstimateBlastRadius(plannedCalls); report.Radius.NeedsConfirmation() && ch != nil { + ch <- StreamEvent{Type: "blast_radius", Content: report.Message} + } + + results := make([]toolExecResult, len(calls)) + readOnlySem := make(chan struct{}, maxConcurrentReadOnlyToolCalls) + networkSem := make(chan struct{}, maxConcurrentNetworkReadOnlyToolCalls) + var wg sync.WaitGroup + for _, item := range concurrentCalls { + wg.Add(1) + go func(item indexedToolCall) { + defer wg.Done() + readOnlySem <- struct{}{} + defer func() { <-readOnlySem }() + if isNetworkReadOnlyTool(item.tc.Name) { + networkSem <- struct{}{} + defer func() { <-networkSem }() + } + results[item.index] = s.host.executeSingleTool(ctx, item.tc, ch, turn, intent) + }(item) + } + wg.Wait() + for _, item := range sequentialCalls { + results[item.index] = s.host.executeSingleTool(ctx, item.tc, ch, turn, intent) + } + return results } // ExtractTargets returns the file targets for a tool call. func (s *ToolService) ExtractTargets(tc types.ToolCall) []string { + if s == nil || s.registry == nil { + return extractTargets(tc) + } if t, ok := s.registry.Get(tc.Name); ok { return ExtractTargetsFromSchema(t, tc) } From 7c40a0ad4f0543de7936aae5a2a270a4553c4e7f Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Sun, 2 Aug 2026 00:03:32 +0530 Subject: [PATCH 27/49] refactor: harden session service boundaries --- docs/session-decomposition.md | 2 +- internal/daemon/daemon.go | 4 ++-- internal/engine/agent_session_tool.go | 7 +----- internal/engine/session.go | 17 ++++++++++++++ internal/engine/tool_service.go | 28 ++++++++++++++++++++++- internal/multiagent/worker.go | 8 +++---- internal/observability/oteltrace/trace.go | 5 ++-- 7 files changed, 55 insertions(+), 16 deletions(-) diff --git a/docs/session-decomposition.md b/docs/session-decomposition.md index 87d6a322..cbaf21d1 100644 --- a/docs/session-decomposition.md +++ b/docs/session-decomposition.md @@ -71,7 +71,7 @@ are compatibility aliases, not a second authoritative state store. **Owns:** `Memory MemoryRecaller`, `YaadBridge *memory.YaadBridge`, `EnhancedMemory *memory.EnhancedMemoryManager`, `SkillDistiller *memory.SkillDistiller`, `Sleeptime *memory.SleeptimeAgent`, `Activity *memory.ActivityTracker`, `AgentsAccum *prompts.AgentsAccum`, `FewShotStore *FewShotStore`, `AdaptivePrompt *AdaptivePrompt`. **Methods:** -- `RecallContext(ctx, lastUserMsg string, budget int) (string, error)` — unifies yaad + few-shot + agents-accum +- `RecallContext(ctx, lastUserMsg string, budget int) string` — unifies backend recall behind one nil-safe call - `Remember(ctx, content, category string)` — wraps memory.Remember - `RunSleeptimeConsolidation(ctx, provider ChatService, messages []types.EyrieMessage)` — background - `RunSkillDistillation(ctx, provider ChatService, ...)` — background diff --git a/internal/daemon/daemon.go b/internal/daemon/daemon.go index 60a82bfa..1f98f475 100644 --- a/internal/daemon/daemon.go +++ b/internal/daemon/daemon.go @@ -560,13 +560,13 @@ func (s *Server) handleChat(w http.ResponseWriter, r *http.Request) { } // Auto-approve permissions based on autonomy (non-interactive) - sess.PermissionFn = func(pr engine.PermissionRequest) { + sess.SetPermissionFn(func(pr engine.PermissionRequest) { cfg := engine.PresetConfig(sess.PermSvc().Autonomy()) allowed := !cfg.NeedsPermission(pr.ToolName, false) if pr.Response != nil { pr.Response <- allowed } - } + }) if req.MaxTurns > 0 { if setErr := sess.SetMaxTurns(req.MaxTurns); setErr != nil { diff --git a/internal/engine/agent_session_tool.go b/internal/engine/agent_session_tool.go index 104d1d38..05891724 100644 --- a/internal/engine/agent_session_tool.go +++ b/internal/engine/agent_session_tool.go @@ -30,12 +30,7 @@ func (s *Session) ensureBackgroundManager() *tool.BackgroundAgentManager { if s.Tools() == nil { return nil } - if bm := s.Tools().BackgroundManager(); bm != nil { - return bm - } - bm := tool.NewBackgroundAgentManager() - s.Tools().WithBackgroundManager(bm) - return bm + return s.Tools().EnsureBackgroundManager() } // spawnSubAgentRequest is the typed entrypoint used by the Agent tool. diff --git a/internal/engine/session.go b/internal/engine/session.go index 6225b726..f94bae5f 100644 --- a/internal/engine/session.go +++ b/internal/engine/session.go @@ -314,6 +314,14 @@ func NewSessionWithClient(chat ChatClient, provider, model, systemPrompt string, s.life.SetAgentsAccumulator(s.AgentsAccum) s.life.SetLintLoop(s.LintLoop) s.life.SetTestLoop(s.TestLoop) + s.services = &SessionServices{ + Chat: s.llm, + Permissions: s.perms, + LifecycleService: s.life, + MemoryService: s.memory, + Persist: s.persist, + ToolService: s.tools, + } // Alias legacy fields at the service instances so legacy readers see // the same state as new code that goes through the sub-service getters. @@ -810,6 +818,15 @@ func (s *Session) SetAskUserFn(fn func(question string) (string, error)) { } } +// SetPermissionFn configures the authoritative permission callback while +// keeping the deprecated Session field synchronized for older integrations. +func (s *Session) SetPermissionFn(fn func(PermissionRequest)) { + s.PermissionFn = fn + if s.perms != nil { + s.perms.SetPermissionFn(fn) + } +} + // SetApproval sets the high-risk action gate. New code should // call this instead of writing to the legacy s.Approval field. func (s *Session) SetApproval(a *ApprovalGate) { diff --git a/internal/engine/tool_service.go b/internal/engine/tool_service.go index 91ca3c66..2dc72fd1 100644 --- a/internal/engine/tool_service.go +++ b/internal/engine/tool_service.go @@ -23,6 +23,7 @@ type ToolService struct { containerRequired bool tracer *oteltrace.Tracer snapshots SnapshotTracker + bgMu sync.Mutex bgManager *tool.BackgroundAgentManager sandbox *diff.DiffSandbox } @@ -70,10 +71,28 @@ func (s *ToolService) WithSnapshots(snap SnapshotTracker) *ToolService { // WithBackgroundManager configures the background sub-agent manager. func (s *ToolService) WithBackgroundManager(bm *tool.BackgroundAgentManager) *ToolService { + s.bgMu.Lock() + defer s.bgMu.Unlock() s.bgManager = bm return s } +// EnsureBackgroundManager returns the configured background manager, creating +// one exactly once when the session has not supplied one. Tool execution may +// initialize this lazily from concurrent read-only calls, so the operation +// must be atomic at the service boundary. +func (s *ToolService) EnsureBackgroundManager() *tool.BackgroundAgentManager { + if s == nil { + return nil + } + s.bgMu.Lock() + defer s.bgMu.Unlock() + if s.bgManager == nil { + s.bgManager = tool.NewBackgroundAgentManager() + } + return s.bgManager +} + // Registry returns the tool registry. func (s *ToolService) Registry() *tool.Registry { return s.registry } @@ -196,7 +215,14 @@ func (s *ToolService) ExecuteOne(ctx context.Context, tc types.ToolCall, ch chan // BackgroundManager returns the background sub-agent manager, or nil // if background mode is not available. -func (s *ToolService) BackgroundManager() *tool.BackgroundAgentManager { return s.bgManager } +func (s *ToolService) BackgroundManager() *tool.BackgroundAgentManager { + if s == nil { + return nil + } + s.bgMu.Lock() + defer s.bgMu.Unlock() + return s.bgManager +} // ContainerRequired reports whether container-first mode is on. func (s *ToolService) ContainerRequired() bool { return s.containerRequired } diff --git a/internal/multiagent/worker.go b/internal/multiagent/worker.go index 66d4e5ec..8843d91f 100644 --- a/internal/multiagent/worker.go +++ b/internal/multiagent/worker.go @@ -54,11 +54,11 @@ func EngineWorker(provider, model, systemPrompt string) WorkerFunc { } // Auto-approve everything in mission workers - sess.PermissionFn = func(req engine.PermissionRequest) { + sess.SetPermissionFn(func(req engine.PermissionRequest) { if req.Response != nil { req.Response <- true } - } + }) sess.AddUser(workerPrompt) @@ -163,11 +163,11 @@ func ReadOnlyValidationWorker(provider, model, systemPrompt string) WorkerFunc { if setErr := sess.SetMaxTurns(30); setErr != nil { return nil, fmt.Errorf("set max turns: %w", setErr) } - sess.PermissionFn = func(req engine.PermissionRequest) { + sess.SetPermissionFn(func(req engine.PermissionRequest) { if req.Response != nil { req.Response <- true } - } + }) sess.AddUser(validationPrompt) diff --git a/internal/observability/oteltrace/trace.go b/internal/observability/oteltrace/trace.go index ab502b60..bb30ebfa 100644 --- a/internal/observability/oteltrace/trace.go +++ b/internal/observability/oteltrace/trace.go @@ -6,6 +6,7 @@ import ( "context" "fmt" "sync" + "sync/atomic" "time" ) @@ -134,6 +135,6 @@ func SpanFromContext(ctx context.Context) (*Span, bool) { var idCounter int64 func generateID() string { - idCounter++ - return fmt.Sprintf("trace-%d-%d", time.Now().UnixNano(), idCounter) + id := atomic.AddInt64(&idCounter, 1) + return fmt.Sprintf("trace-%d-%d", time.Now().UnixNano(), id) } From c9d0d3d514b5ddb65c0c2f5b18b197d7a38cf80d Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Sun, 2 Aug 2026 00:09:36 +0530 Subject: [PATCH 28/49] refactor: route compaction through persistence service --- internal/engine/compact_api_engine.go | 5 ++-- internal/engine/compact_auto.go | 29 ++++++++++--------- internal/engine/compact_micro_engine.go | 5 ++-- internal/engine/compact_provider_native.go | 19 ++++++------ .../engine/compact_session_memory_engine.go | 11 +++---- internal/engine/persistence_service.go | 7 +++-- .../persistence_service_deadlock_test.go | 17 +++++++++++ internal/engine/session.go | 10 +------ 8 files changed, 60 insertions(+), 43 deletions(-) diff --git a/internal/engine/compact_api_engine.go b/internal/engine/compact_api_engine.go index 18f6b925..d4765d10 100644 --- a/internal/engine/compact_api_engine.go +++ b/internal/engine/compact_api_engine.go @@ -20,8 +20,9 @@ func (s *APICompactStrategy) ShouldTrigger(msgs []types.EyrieMessage, tokenCount } func (s *APICompactStrategy) Compact(ctx context.Context, sess *Session) (*CompactResult, error) { - tokensBefore := EstimateTokens(sess.messages) - result := compact.APICompactMessages(sess.messages, DefaultAPICompactConfig()) + messages := sess.Persistence().RawMessages() + tokensBefore := EstimateTokens(messages) + result := compact.APICompactMessages(messages, DefaultAPICompactConfig()) tokensAfter := EstimateTokens(result) return &CompactResult{ diff --git a/internal/engine/compact_auto.go b/internal/engine/compact_auto.go index e8326892..5ad3d4c6 100644 --- a/internal/engine/compact_auto.go +++ b/internal/engine/compact_auto.go @@ -55,7 +55,7 @@ func (ac *AutoCompactor) ShouldAutoCompact(sess *Session) bool { return false } - tokenCount := EstimateTokens(sess.messages) + tokenCount := EstimateTokens(sess.Persistence().RawMessages()) threshold := ac.GetAutoCompactThreshold() return tokenCount >= threshold } @@ -67,7 +67,7 @@ func (ac *AutoCompactor) AutoCompactIfNeeded(ctx context.Context, sess *Session) return "", false } - tokensBefore := EstimateTokens(sess.messages) + tokensBefore := EstimateTokens(sess.Persistence().RawMessages()) strategy, err := ac.RunCompaction(ctx, sess) if err != nil { ac.mu.Lock() @@ -78,7 +78,7 @@ func (ac *AutoCompactor) AutoCompactIfNeeded(ctx context.Context, sess *Session) "failures": ac.consecutiveFailures, }) sess.compact() - tokensAfter := EstimateTokens(sess.messages) + tokensAfter := EstimateTokens(sess.Persistence().RawMessages()) sess.recordCompaction("truncate_fallback", tokensBefore, tokensAfter, false) return "truncate_fallback", true } @@ -86,15 +86,16 @@ func (ac *AutoCompactor) AutoCompactIfNeeded(ctx context.Context, sess *Session) ac.mu.Lock() ac.consecutiveFailures = 0 ac.mu.Unlock() - tokensAfter := EstimateTokens(sess.messages) + tokensAfter := EstimateTokens(sess.Persistence().RawMessages()) sess.recordCompaction(strategy, tokensBefore, tokensAfter, false) return strategy, true } // RunCompaction selects and executes the best compaction strategy. func (ac *AutoCompactor) RunCompaction(ctx context.Context, sess *Session) (string, error) { - tokenCount := EstimateTokens(sess.messages) - strategy := ac.registry.SelectStrategy(sess, sess.messages, tokenCount) + messages := sess.Persistence().RawMessages() + tokenCount := EstimateTokens(messages) + strategy := ac.registry.SelectStrategy(sess, messages, tokenCount) sess.log.Info("running compaction", map[string]interface{}{ "strategy": strategy.Name(), @@ -106,7 +107,7 @@ func (ac *AutoCompactor) RunCompaction(ctx context.Context, sess *Session) (stri return strategy.Name(), err } - sess.messages = result.Messages + sess.Persistence().SetMessages(result.Messages) ac.mu.Lock() ac.lastStrategy = result.Strategy ac.mu.Unlock() @@ -146,12 +147,13 @@ func (s *SmartCompactStrategy) ShouldTrigger(msgs []types.EyrieMessage, tokenCou } func (s *SmartCompactStrategy) Compact(ctx context.Context, sess *Session) (*CompactResult, error) { - tokensBefore := EstimateTokens(sess.messages) + tokensBefore := EstimateTokens(sess.Persistence().RawMessages()) sess.smartCompact() - tokensAfter := EstimateTokens(sess.messages) + messages := sess.Persistence().RawMessages() + tokensAfter := EstimateTokens(messages) return &CompactResult{ - Messages: sess.messages, + Messages: messages, TokensBefore: tokensBefore, TokensAfter: tokensAfter, Strategy: "smart", @@ -168,12 +170,13 @@ func (s *TruncateStrategy) ShouldTrigger(_ []types.EyrieMessage, tokenCount, thr } func (s *TruncateStrategy) Compact(ctx context.Context, sess *Session) (*CompactResult, error) { - tokensBefore := EstimateTokens(sess.messages) + tokensBefore := EstimateTokens(sess.Persistence().RawMessages()) sess.compact() - tokensAfter := EstimateTokens(sess.messages) + messages := sess.Persistence().RawMessages() + tokensAfter := EstimateTokens(messages) return &CompactResult{ - Messages: sess.messages, + Messages: messages, TokensBefore: tokensBefore, TokensAfter: tokensAfter, Strategy: "truncate", diff --git a/internal/engine/compact_micro_engine.go b/internal/engine/compact_micro_engine.go index 373c277f..d5719056 100644 --- a/internal/engine/compact_micro_engine.go +++ b/internal/engine/compact_micro_engine.go @@ -30,8 +30,9 @@ func (s *MicroCompactStrategy) ShouldTrigger(msgs []types.EyrieMessage, tokenCou } func (s *MicroCompactStrategy) Compact(ctx context.Context, sess *Session) (*CompactResult, error) { - tokensBefore := EstimateTokens(sess.messages) - result := compact.MicrocompactMessages(sess.messages, DefaultMicroCompactConfig()) + messages := sess.Persistence().RawMessages() + tokensBefore := EstimateTokens(messages) + result := compact.MicrocompactMessages(messages, DefaultMicroCompactConfig()) tokensAfter := EstimateTokens(result) return &CompactResult{ diff --git a/internal/engine/compact_provider_native.go b/internal/engine/compact_provider_native.go index 1c7978b9..00542b52 100644 --- a/internal/engine/compact_provider_native.go +++ b/internal/engine/compact_provider_native.go @@ -27,15 +27,16 @@ func (s *ProviderNativeCompactStrategy) Compact(ctx context.Context, sess *Sessi return nil, fmt.Errorf("no session client") } compactor, ok := sess.ChatLLM().Client().(nativeCompactionCapable) - if !ok || !compactor.NativeCompaction(ctx, sess.provider, sess.model) { + if !ok || !compactor.NativeCompaction(ctx, sess.ChatLLM().Provider(), sess.ChatLLM().Model()) { return nil, fmt.Errorf("provider native compaction not available") } - tokensBefore := EstimateTokens(sess.messages) + messagesBefore := sess.Persistence().RawMessages() + tokensBefore := EstimateTokens(messagesBefore) summary, err := compactor.CompactNative(ctx, gateway.NativeCompactionRequest{ - Provider: sess.provider, - Model: sess.model, - Messages: gateway.ToEngineMessages(sess.messages), + Provider: sess.ChatLLM().Provider(), + Model: sess.ChatLLM().Model(), + Messages: gateway.ToEngineMessages(messagesBefore), ContextWindow: sess.ContextWindowSize(), ThresholdPct: sess.compactThresholdPct(), MaxOutputTokens: 8192, @@ -45,10 +46,10 @@ func (s *ProviderNativeCompactStrategy) Compact(ctx context.Context, sess *Sessi } keepEnd := 6 - if keepEnd > len(sess.messages) { - keepEnd = len(sess.messages) + if keepEnd > len(messagesBefore) { + keepEnd = len(messagesBefore) } - tail := append([]types.EyrieMessage(nil), sess.messages[len(sess.messages)-keepEnd:]...) + tail := append([]types.EyrieMessage(nil), messagesBefore[len(messagesBefore)-keepEnd:]...) messages := append([]types.EyrieMessage{{Role: "user", Content: FormatCompactSummary(summary)}}, tail...) compact := &CompactResult{ Messages: messages, @@ -56,7 +57,7 @@ func (s *ProviderNativeCompactStrategy) Compact(ctx context.Context, sess *Sessi TokensAfter: EstimateTokens(messages), Strategy: "provider_native", } - sess.messages = compact.Messages + sess.Persistence().SetMessages(compact.Messages) return compact, nil } diff --git a/internal/engine/compact_session_memory_engine.go b/internal/engine/compact_session_memory_engine.go index 26e05a69..2c48a58f 100644 --- a/internal/engine/compact_session_memory_engine.go +++ b/internal/engine/compact_session_memory_engine.go @@ -36,17 +36,18 @@ func (s *SessionMemoryStrategy) Compact(ctx context.Context, sess *Session) (*Co return nil, fmt.Errorf("session memory is empty") } - tokensBefore := EstimateTokens(sess.messages) + messages := sess.Persistence().RawMessages() + tokensBefore := EstimateTokens(messages) cfg := DefaultSessionMemoryConfig() - keepIdx := compact.CalculateMessagesToKeepIndex(sess.messages, cfg) - keepIdx = compact.AdjustIndexToPreserveAPIInvariants(sess.messages, keepIdx) + keepIdx := compact.CalculateMessagesToKeepIndex(messages, cfg) + keepIdx = compact.AdjustIndexToPreserveAPIInvariants(messages, keepIdx) - if keepIdx >= len(sess.messages)-2 { + if keepIdx >= len(messages)-2 { return nil, fmt.Errorf("not enough messages to compact") } - kept := sess.messages[keepIdx:] + kept := messages[keepIdx:] kept = compact.FilterCompactBoundaries(kept) result := make([]types.EyrieMessage, 0, len(kept)+2) diff --git a/internal/engine/persistence_service.go b/internal/engine/persistence_service.go index a00773fb..6f0057b9 100644 --- a/internal/engine/persistence_service.go +++ b/internal/engine/persistence_service.go @@ -77,14 +77,15 @@ func (s *PersistenceService) Messages() []types.EyrieMessage { } // SetRawMessages replaces the message slice. Used by code paths -// that previously wrote to s.messages directly. Pass-by-reference -// to keep the slice header mutable. Safe on a nil receiver. +// that previously wrote to s.messages directly. The input is deep-copied so +// callers cannot mutate the live transcript after handing it to persistence. +// Safe on a nil receiver. func (s *PersistenceService) SetRawMessages(msgs []types.EyrieMessage) { if s == nil { return } s.mu.Lock() - s.messages = msgs + s.messages = cloneMessages(msgs) s.mu.Unlock() } diff --git a/internal/engine/persistence_service_deadlock_test.go b/internal/engine/persistence_service_deadlock_test.go index bb83b236..6d7b77b5 100644 --- a/internal/engine/persistence_service_deadlock_test.go +++ b/internal/engine/persistence_service_deadlock_test.go @@ -70,3 +70,20 @@ func TestPersistenceServiceSnapshotsAreDeepCopies(t *testing.T) { t.Fatal("nested tool argument mutation leaked into persistence") } } + +func TestPersistenceServiceSetRawMessagesCopiesInput(t *testing.T) { + ps := NewPersistenceService(nil) + input := []types.EyrieMessage{{ + Role: "assistant", + ToolUse: []types.ToolCall{{ + Arguments: map[string]interface{}{"nested": map[string]interface{}{"value": "safe"}}, + }}, + }} + ps.SetRawMessages(input) + input[0].ToolUse[0].Arguments["nested"].(map[string]interface{})["value"] = "mutated" + + got := ps.RawMessages()[0].ToolUse[0].Arguments["nested"].(map[string]interface{})["value"] + if got != "safe" { + t.Fatalf("SetRawMessages retained caller alias: got %v", got) + } +} diff --git a/internal/engine/session.go b/internal/engine/session.go index f94bae5f..733ddd54 100644 --- a/internal/engine/session.go +++ b/internal/engine/session.go @@ -592,7 +592,7 @@ func (s *Session) AddUser(content string) { // The imageType should be "image/png", "image/jpeg", etc. func (s *Session) AddUserWithImage(content string, imageBase64 string, imageType string) { if p := s.Persistence(); p != nil { - p.AddUser(content + " [image attached]") + p.AddUserWithImage(content, imageBase64, imageType) if graph := p.Graph(); graph != nil { parentID := "" if head, err := graph.Head(); err == nil && head != nil { @@ -601,14 +601,6 @@ func (s *Session) AddUserWithImage(content string, imageBase64 string, imageType _, _ = graph.Append(parentID, "user", content+" [image attached]") } } - s.mu.Lock() - msg := types.EyrieMessage{ - Role: "user", - Content: content, - Images: []string{"data:" + imageType + ";base64," + imageBase64}, - } - s.messages = append(s.messages, msg) - s.mu.Unlock() } func (s *Session) AddAssistant(content string) { From 6c52e44e0d78b0b88f04afaaa75718456965f510 Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Sun, 2 Aug 2026 00:10:24 +0530 Subject: [PATCH 29/49] refactor: route structured chat through chat service --- internal/engine/structured_output.go | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/internal/engine/structured_output.go b/internal/engine/structured_output.go index 81196280..0c71ddf3 100644 --- a/internal/engine/structured_output.go +++ b/internal/engine/structured_output.go @@ -37,11 +37,15 @@ func (e *SchemaError) Error() string { return "schema validation failed: " + e.R // schema must be a JSON Schema document (as a string). A blank schema disables // validation and behaves like an ordinary Chat call. func (s *Session) ChatStructured(ctx context.Context, msgs []types.EyrieMessage, opts types.ChatOptions, schema string) (*types.EyrieResponse, error) { + chat := s.ChatLLM() + if chat == nil { + return nil, fmt.Errorf("session: no chat service configured") + } if schema != "" { opts.ResponseFormat = &types.ResponseFormat{Type: "json_schema", Schema: schema} } - resp, err := s.client.Chat(ctx, msgs, opts) + resp, err := chat.Chat(ctx, msgs, opts) if err != nil { return resp, err } @@ -63,7 +67,7 @@ func (s *Session) ChatStructured(ctx context.Context, msgs []types.EyrieMessage, vErr.Error(), schema, )}, ) - retryResp, retryErr := s.client.Chat(ctx, retryMsgs, opts) + retryResp, retryErr := chat.Chat(ctx, retryMsgs, opts) if retryErr != nil { return resp, retryErr } From 9db134a64d4faaa0116786c349fd4b8890acce5a Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Sun, 2 Aug 2026 00:11:17 +0530 Subject: [PATCH 30/49] refactor: encapsulate spec stage transitions --- internal/engine/permission_service.go | 9 +++++++++ internal/engine/stream_tool_exec.go | 4 ++-- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/internal/engine/permission_service.go b/internal/engine/permission_service.go index 62c133f4..f96a4a07 100644 --- a/internal/engine/permission_service.go +++ b/internal/engine/permission_service.go @@ -231,6 +231,15 @@ func (s *PermissionService) Autonomy() AutonomyLevel { return s.perm.Autonomy } // SpecStage returns the active spec-workflow stage. func (s *PermissionService) SpecStage() SpecStage { return s.perm.Stage } +// AdvanceSpecStage records the next spec workflow transition through the +// permission service instead of exposing the underlying engine to callers. +func (s *PermissionService) AdvanceSpecStage(toolName string) { + if s == nil || s.perm == nil { + return + } + s.perm.AdvanceSpecStage(toolName) +} + // Memory returns the legacy PermissionMemory shim. The shim is // kept in sync with the engine's classification state; callers // that historically used `sess.Permissions.AllowSpec(...)` should diff --git a/internal/engine/stream_tool_exec.go b/internal/engine/stream_tool_exec.go index 80fc9c4c..4ccb6ae7 100644 --- a/internal/engine/stream_tool_exec.go +++ b/internal/engine/stream_tool_exec.go @@ -619,9 +619,9 @@ func (s *Session) executeSingleToolWithTool(ctx context.Context, tc types.ToolCa if !isErr { switch canonicalToolName(tc.Name) { case "Specify", "Plan", "Tasks": - s.Perm.AdvanceSpecStage(tc.Name) + s.PermSvc().AdvanceSpecStage(tc.Name) case "ApproveImplementation": - s.Perm.AdvanceSpecStage(tc.Name) + s.PermSvc().AdvanceSpecStage(tc.Name) output = "Spec approved — switched to implementation. You may now make changes." } } From 97b0068eccf88af23e7c18edd543b12f7e84c3f0 Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Sun, 2 Aug 2026 00:22:38 +0530 Subject: [PATCH 31/49] refactor: route runtime telemetry through facades --- internal/engine/compact_auto.go | 6 +++--- internal/engine/execution_graph_observations.go | 12 ++++++------ internal/engine/stream.go | 14 +++++++------- internal/engine/stream_tool_exec.go | 10 +++++----- 4 files changed, 21 insertions(+), 21 deletions(-) diff --git a/internal/engine/compact_auto.go b/internal/engine/compact_auto.go index 5ad3d4c6..4f9254bf 100644 --- a/internal/engine/compact_auto.go +++ b/internal/engine/compact_auto.go @@ -73,7 +73,7 @@ func (ac *AutoCompactor) AutoCompactIfNeeded(ctx context.Context, sess *Session) ac.mu.Lock() ac.consecutiveFailures++ ac.mu.Unlock() - sess.log.Warn("auto-compact failed", map[string]interface{}{ + sess.Logger().Warn("auto-compact failed", map[string]interface{}{ "error": err.Error(), "failures": ac.consecutiveFailures, }) @@ -97,7 +97,7 @@ func (ac *AutoCompactor) RunCompaction(ctx context.Context, sess *Session) (stri tokenCount := EstimateTokens(messages) strategy := ac.registry.SelectStrategy(sess, messages, tokenCount) - sess.log.Info("running compaction", map[string]interface{}{ + sess.Logger().Info("running compaction", map[string]interface{}{ "strategy": strategy.Name(), "tokens": tokenCount, }) @@ -112,7 +112,7 @@ func (ac *AutoCompactor) RunCompaction(ctx context.Context, sess *Session) (stri ac.lastStrategy = result.Strategy ac.mu.Unlock() - sess.log.Info("compaction complete", map[string]interface{}{ + sess.Logger().Info("compaction complete", map[string]interface{}{ "strategy": result.Strategy, "tokens_before": result.TokensBefore, "tokens_after": result.TokensAfter, diff --git a/internal/engine/execution_graph_observations.go b/internal/engine/execution_graph_observations.go index 98fa73f7..5d960827 100644 --- a/internal/engine/execution_graph_observations.go +++ b/internal/engine/execution_graph_observations.go @@ -29,7 +29,7 @@ func (s *Session) recordPolicyObservation(tc types.ToolCall, stage string, allow verdict.Source = "hawk." + strings.TrimSpace(stage) } if err := graphjournal.AppendPolicy(sessionID, tc.ID, stage, verdict, time.Now()); err != nil { - s.log.Warn("graph observation append failed", map[string]interface{}{ + s.Logger().Warn("graph observation append failed", map[string]interface{}{ "kind": graphjournal.KindPolicy, "stage": stage, }) @@ -81,7 +81,7 @@ func (s *Session) recordVerificationObservation(tc types.ToolCall, output string "plan-execution", time.Now(), ); err != nil { - s.log.Warn("graph observation append failed", map[string]interface{}{ + s.Logger().Warn("graph observation append failed", map[string]interface{}{ "kind": graphjournal.KindVerify, "stage": "verify-plan-execution", }) @@ -146,7 +146,7 @@ func (s *Session) recordTokCompressionObservation(source, stage string, stats to ) } if err != nil { - s.log.Warn("graph observation append failed", map[string]interface{}{ + s.Logger().Warn("graph observation append failed", map[string]interface{}{ "kind": graphjournal.KindRuntime, "stage": stage, }) @@ -186,7 +186,7 @@ func (s *Session) recordTokRedactionObservation(source string, matchCount int, t ) } if err != nil { - s.log.Warn("graph observation append failed", map[string]interface{}{ + s.Logger().Warn("graph observation append failed", map[string]interface{}{ "kind": graphjournal.KindRuntime, "stage": "response-redaction", }) @@ -246,7 +246,7 @@ func (s *Session) recordTokUsageBudgetObservation( ) } if err != nil { - s.log.Warn("graph observation append failed", map[string]interface{}{ + s.Logger().Warn("graph observation append failed", map[string]interface{}{ "kind": graphjournal.KindRuntime, "stage": "usage-budget", }) @@ -322,7 +322,7 @@ func (s *Session) recordEyrieOperationObservation( ) } if err != nil { - s.log.Warn("graph observation append failed", map[string]interface{}{ + s.Logger().Warn("graph observation append failed", map[string]interface{}{ "kind": graphjournal.KindRuntime, "stage": "model-generation", }) diff --git a/internal/engine/stream.go b/internal/engine/stream.go index 7a0bb5d1..f93b71b5 100644 --- a/internal/engine/stream.go +++ b/internal/engine/stream.go @@ -123,7 +123,7 @@ func (s *Session) agentLoop(ctx context.Context, ch chan<- StreamEvent) { } if compactStrategy, didCompact := s.ManageContextBeforeTurn(ctx); didCompact { tokensAfter := EstimateTokens(s.Persistence().RawMessages()) - s.log.Info("context compacted", map[string]interface{}{ + s.Logger().Info("context compacted", map[string]interface{}{ "strategy": compactStrategy, "messages": len(s.Persistence().RawMessages()), }) @@ -158,7 +158,7 @@ func (s *Session) agentLoop(ctx context.Context, ch chan<- StreamEvent) { ch <- StreamEvent{Type: "error", Content: "High-risk prompt injection detected. Message blocked."} return } - s.log.Warn("injection risk detected", map[string]interface{}{ + s.Logger().Warn("injection risk detected", map[string]interface{}{ "level": preResult.InjectionRisk.RiskLevel, }) } @@ -309,15 +309,15 @@ func (s *Session) agentLoop(ctx context.Context, ch chan<- StreamEvent) { managesResilience := clientManagesResilience(s.ChatLLM().Client()) result, err := s.ChatLLM().Stream(ctx, s.Persistence().RawMessages(), opts) apiDuration := time.Since(apiStart) - s.metrics.Timer("api.latency").Record(apiDuration) - s.metrics.Timer("api.last_latency").Record(apiDuration) + s.Metrics().Timer("api.latency").Record(apiDuration) + s.Metrics().Timer("api.last_latency").Record(apiDuration) if err != nil { // End trace span with error if loopSpan != nil { oteltrace.EndSpanWithError(loopSpan, err) } - s.log.Error("stream error", map[string]interface{}{ + s.Logger().Error("stream error", map[string]interface{}{ "error": err.Error(), }) ch <- StreamEvent{Type: "error", Content: err.Error()} @@ -356,7 +356,7 @@ func (s *Session) agentLoop(ctx context.Context, ch chan<- StreamEvent) { var changed bool resolvedProvider, resolvedModel, changed = updateResolvedRoute(resolvedProvider, resolvedModel, ev.Route) if changed { - s.log.Info("engine route selected", map[string]interface{}{ + s.Logger().Info("engine route selected", map[string]interface{}{ "provider": resolvedProvider, "model": resolvedModel, }) @@ -440,7 +440,7 @@ func (s *Session) agentLoop(ctx context.Context, ch chan<- StreamEvent) { break } retryReason := "transient stream error" - s.log.Warn("stream retry", map[string]interface{}{ + s.Logger().Warn("stream retry", map[string]interface{}{ "attempt": streamAttempt + 1, "reason": retryReason, "error": streamErr.Error(), diff --git a/internal/engine/stream_tool_exec.go b/internal/engine/stream_tool_exec.go index 4ccb6ae7..17d11f69 100644 --- a/internal/engine/stream_tool_exec.go +++ b/internal/engine/stream_tool_exec.go @@ -397,7 +397,7 @@ func (s *Session) executeSingleToolWithTool(ctx context.Context, tc types.ToolCa toolCancel() isErr := execErr != nil if isErr { - s.log.Warn("tool execution error", map[string]interface{}{ + s.Logger().Warn("tool execution error", map[string]interface{}{ "tool": tc.Name, "error": execErr.Error(), }) @@ -419,7 +419,7 @@ func (s *Session) executeSingleToolWithTool(ctx context.Context, tc types.ToolCa } } } else { - s.log.Info("tool executed", map[string]interface{}{ + s.Logger().Info("tool executed", map[string]interface{}{ "tool": tc.Name, "output": len(output), }) @@ -440,7 +440,7 @@ func (s *Session) executeSingleToolWithTool(ctx context.Context, tc types.ToolCa revertErr = os.WriteFile(preEditPath, []byte(preEditContent), 0o600) } if revertErr != nil { - s.log.Error("self-review revert failed; rejecting diff loudly", map[string]interface{}{ + s.Logger().Error("self-review revert failed; rejecting diff loudly", map[string]interface{}{ "path": preEditPath, "error": revertErr.Error(), }) @@ -626,9 +626,9 @@ func (s *Session) executeSingleToolWithTool(ctx context.Context, tc types.ToolCa } } - s.metrics.Counter("tools.executed").Inc() + s.Metrics().Counter("tools.executed").Inc() if isErr { - s.metrics.Counter("tools.errors").Inc() + s.Metrics().Counter("tools.errors").Inc() } if s.MemorySvc().Enhanced() != nil { From 37b4d6e38a44ef960584912602a46313410db115 Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Sun, 2 Aug 2026 00:31:47 +0530 Subject: [PATCH 32/49] refactor: move raw tool execution into service --- docs/session-decomposition.md | 6 +- internal/engine/session.go | 23 +++ internal/engine/stream_tool_exec.go | 248 ++++++++++++++-------------- internal/engine/tool_service.go | 145 +++++++++++++++- 4 files changed, 297 insertions(+), 125 deletions(-) diff --git a/docs/session-decomposition.md b/docs/session-decomposition.md index cbaf21d1..ffaa69a8 100644 --- a/docs/session-decomposition.md +++ b/docs/session-decomposition.md @@ -43,8 +43,10 @@ The refactor branch now enforces these boundaries: - `PermissionService` owns the approval gate and fallback ask-user callback; `Session.CheckApproval` is now only a compatibility facade. - `ToolService.ExecuteAll` owns batching, ordering, blast-radius reporting, - and read-only concurrency limits. The old Session method is a compatibility - wrapper for in-package callers. + and read-only concurrency limits. `ToolService.ExecuteOne` now owns raw + invocation boundaries: permission/approval, tracing, isolation, context + injection, lookup, timeout, and retry. Session retains only compatibility + post-processing hooks for the returned result. - The agent loop uses these service APIs for transport, persistence, memory, lifecycle, permission-stage, and tool-batch operations. diff --git a/internal/engine/session.go b/internal/engine/session.go index 733ddd54..5bc6f073 100644 --- a/internal/engine/session.go +++ b/internal/engine/session.go @@ -9,6 +9,7 @@ import ( "sync" "time" + agentcontracts "github.com/GrayCodeAI/hawk-core-contracts/agent" "github.com/GrayCodeAI/hawk/internal/provider/gateway" "github.com/GrayCodeAI/hawk/internal/types" @@ -310,6 +311,28 @@ func NewSessionWithClient(chat ChatClient, provider, model, systemPrompt string, s.persist = NewPersistenceService(log) s.persist.SetSystem(systemPrompt) s.tools = NewToolService(registry).WithExecutionHost(s) + s.tools.WithExecutionDeps(toolExecutionDeps{ + permissions: s.perms, + chat: s.llm, + memory: s.memory, + agentSpawn: func(ctx context.Context, req agentcontracts.SpawnRequest) (agentcontracts.SpawnResult, error) { + if s.AgentSpawnFn == nil { + return agentcontracts.SpawnResult{Status: agentcontracts.StatusFailed, Error: "agent spawning is unavailable"}, fmt.Errorf("agent spawning is unavailable") + } + return s.AgentSpawnFn(ctx, req) + }, + askUser: func(question string) (string, error) { + if s.AskUserFn == nil { + return "", fmt.Errorf("ask-user callback is unavailable") + } + return s.AskUserFn(question) + }, + readOnlyBash: s.readOnlyBash, + workingDir: s.workingDir, + syncPermissions: s.syncPermissionCompatibility, + checkApproval: s.CheckApproval, + recordPolicy: s.recordPolicyObservation, + }) s.refreshContextWindowCache() s.life.SetAgentsAccumulator(s.AgentsAccum) s.life.SetLintLoop(s.LintLoop) diff --git a/internal/engine/stream_tool_exec.go b/internal/engine/stream_tool_exec.go index 17d11f69..80a0677a 100644 --- a/internal/engine/stream_tool_exec.go +++ b/internal/engine/stream_tool_exec.go @@ -23,6 +23,8 @@ type toolExecResult struct { tc types.ToolCall output string isErr bool + err error + span *oteltrace.Span } // filePathArgKeys is the list of argument names that are conventionally @@ -260,107 +262,14 @@ func (s *Session) executeSingleTool(ctx context.Context, tc types.ToolCall, ch c } func (s *Session) executeSingleToolWithTool(ctx context.Context, tc types.ToolCall, override tool.Tool, ch chan<- StreamEvent, turnCount int, intentText string) toolExecResult { - ch <- StreamEvent{Type: "tool_use", ToolName: tc.Name, ToolID: tc.ID} - - if s.Tools().ContainerRequired() { - if s.Tools().ContainerExecutor() == nil || !s.Tools().ContainerExecutor().Running() { - msg := "Container not ready — tools are disabled until the sandbox is running." - ch <- StreamEvent{Type: "tool_result", ToolName: tc.Name, Content: msg} - return toolExecResult{tc: tc, output: msg, isErr: true} - } - } - var toolSpan *oteltrace.Span - if s.TracerValue() != nil { - _, toolSpan = oteltrace.StartToolSpan(ctx, s.TracerValue(), tc.Name, tc.ID) - } - - // Delegate to the extracted PermissionService (Phase 7 migration). - // s.PermSvc() is never nil because NewSessionWithClient always - // constructs it and aliases it to s.Perm via WithEngine(pe). The - // legacy s.Perm field is now a thin shim that reads the same - // engine. - // - // We still sync the legacy fields (PermissionFn, Autonomy) to the - // service before each call because external code (cmd/, daemon/, - // multiagent/) writes to those fields directly, and the engine - // only consults the values it holds. The sync is cheap (two - // pointer assignments) and removes a class of "settings lost" - // bugs when callers mutate the session after construction. - s.syncPermissionCompatibility() - granted, denyMsg := s.PermSvc().CheckTool(ctx, ToolCallInfo{ - Name: tc.Name, - ID: tc.ID, - Args: tc.Arguments, - }) - s.recordPolicyObservation(tc, "permission", granted, denyMsg) - if !granted { - ch <- StreamEvent{Type: "tool_result", ToolName: tc.Name, Content: denyMsg} - if toolSpan != nil { - toolSpan.SetTag("denied", "true") - toolSpan.Finish() - } - return toolExecResult{tc: tc, output: denyMsg, isErr: true} - } - - // Human-in-the-loop approval gate for high-risk actions (additive; no-op - // unless s.Approval is configured and enabled). See approval_gate.go. - approved, approvalDeny := s.CheckApproval(ctx, tc.Name, tc.Arguments) - if approval := s.PermSvc().Approval(); approval != nil && approval.Enabled { - s.recordPolicyObservation(tc, "approval", approved, approvalDeny) - } - if !approved { - ch <- StreamEvent{Type: "tool_result", ToolName: tc.Name, Content: approvalDeny} - if toolSpan != nil { - toolSpan.SetTag("approval_denied", "true") - toolSpan.Finish() - } - return toolExecResult{tc: tc, output: approvalDeny, isErr: true} - } - - hooks.ExecuteAsync(ctx, hooks.EventPreTool, map[string]interface{}{ - "tool": tc.Name, - "args": tc.Arguments, - }) - - inputJSON, _ := json.Marshal(tc.Arguments) - toolCtx := tool.WithToolContext(ctx, &tool.ToolContext{ - AgentSpawnFn: s.AgentSpawnFn, - AskUserFn: s.AskUserFn, - CommitMessageChatFn: func(chatCtx context.Context, prompt string) (string, error) { - if s.ChatLLM() == nil { - return "", fmt.Errorf("commit message model is unavailable") - } - resp, err := s.ChatLLM().Chat(chatCtx, []types.EyrieMessage{{Role: "user", Content: prompt}}, types.ChatOptions{ - Provider: s.ChatLLM().Provider(), - Model: s.ChatLLM().Model(), - MaxTokens: 256, - }) - if err != nil { - return "", err - } - if resp == nil { - return "", fmt.Errorf("commit message model returned no response") - } - return resp.Content, nil - }, - YaadBridge: s.MemorySvc().Yaad(), - SpecSlugGet: func() string { return s.PermSvc().SpecSlug() }, - SpecSlugSet: func(slug string) { s.PermSvc().SetSpecSlug(slug) }, - BackgroundManager: s.ensureBackgroundManager(), - ReadOnlyBash: s.readOnlyBash, - WorkingDir: s.workingDir, - }) - if s.Tools().ContainerExecutor() != nil && s.Tools().ContainerExecutor().Running() { - toolCtx = tool.WithContainerExecutor(toolCtx, s.Tools().ContainerExecutor()) - } - toolCtx, toolCancel := context.WithTimeout(toolCtx, toolTimeout(tc.Name)) - - // Self-Review Before Apply: capture file state before Write/Edit + var output string + var execErr error + var isErr bool canonicalPre := canonicalToolName(tc.Name) var preEditContent string var preEditPath string - if (canonicalPre == "Write" || canonicalPre == "Edit" || canonicalPre == "MultiEdit") && s.client != nil { + if (canonicalPre == "Write" || canonicalPre == "Edit" || canonicalPre == "MultiEdit") && s.ChatLLM() != nil { if p, ok := pathArgument(tc.Arguments); ok && p != "" { preEditPath = p if data, readErr := readFileContent(p); readErr == nil { @@ -368,34 +277,131 @@ func (s *Session) executeSingleToolWithTool(ctx context.Context, tc types.ToolCa } } } + if s.Tools() != nil && s.Tools().executionDepsReady() { + core := s.Tools().ExecuteOne(ctx, tc, override, ch, turnCount, intentText) + output, execErr, isErr, toolSpan = core.output, core.err, core.isErr, core.span + } else { + ch <- StreamEvent{Type: "tool_use", ToolName: tc.Name, ToolID: tc.ID} - // Apply the per-tool retry policy for transient errors. Tools can opt out - // by setting a zero-value RetryPolicy on themselves (via the - // RetryPolicyProvider interface) — Read/Write/Edit etc. don't opt out and - // get the default policy of 2 retries (3 attempts total) with 200ms→2s - // exponential backoff. - t := override - if t == nil { - var ok bool - if s.registry != nil { - t, ok = s.registry.Get(tc.Name) + if s.Tools().ContainerRequired() { + if s.Tools().ContainerExecutor() == nil || !s.Tools().ContainerExecutor().Running() { + msg := "Container not ready — tools are disabled until the sandbox is running." + ch <- StreamEvent{Type: "tool_result", ToolName: tc.Name, Content: msg} + return toolExecResult{tc: tc, output: msg, isErr: true} + } } - if !ok { - toolCancel() - output := fmt.Sprintf("Error: unknown tool: %s", tc.Name) - ch <- StreamEvent{Type: "tool_result", ToolName: tc.Name, Content: output} - return toolExecResult{tc: tc, output: output, isErr: true} + + if s.TracerValue() != nil { + _, toolSpan = oteltrace.StartToolSpan(ctx, s.TracerValue(), tc.Name, tc.ID) + } + + // Delegate to the extracted PermissionService (Phase 7 migration). + // s.PermSvc() is never nil because NewSessionWithClient always + // constructs it and aliases it to s.Perm via WithEngine(pe). The + // legacy s.Perm field is now a thin shim that reads the same + // engine. + // + // We still sync the legacy fields (PermissionFn, Autonomy) to the + // service before each call because external code (cmd/, daemon/, + // multiagent/) writes to those fields directly, and the engine + // only consults the values it holds. The sync is cheap (two + // pointer assignments) and removes a class of "settings lost" + // bugs when callers mutate the session after construction. + s.syncPermissionCompatibility() + granted, denyMsg := s.PermSvc().CheckTool(ctx, ToolCallInfo{ + Name: tc.Name, + ID: tc.ID, + Args: tc.Arguments, + }) + s.recordPolicyObservation(tc, "permission", granted, denyMsg) + if !granted { + ch <- StreamEvent{Type: "tool_result", ToolName: tc.Name, Content: denyMsg} + if toolSpan != nil { + toolSpan.SetTag("denied", "true") + toolSpan.Finish() + } + return toolExecResult{tc: tc, output: denyMsg, isErr: true} } + + // Human-in-the-loop approval gate for high-risk actions (additive; no-op + // unless s.Approval is configured and enabled). See approval_gate.go. + approved, approvalDeny := s.CheckApproval(ctx, tc.Name, tc.Arguments) + if approval := s.PermSvc().Approval(); approval != nil && approval.Enabled { + s.recordPolicyObservation(tc, "approval", approved, approvalDeny) + } + if !approved { + ch <- StreamEvent{Type: "tool_result", ToolName: tc.Name, Content: approvalDeny} + if toolSpan != nil { + toolSpan.SetTag("approval_denied", "true") + toolSpan.Finish() + } + return toolExecResult{tc: tc, output: approvalDeny, isErr: true} + } + + hooks.ExecuteAsync(ctx, hooks.EventPreTool, map[string]interface{}{ + "tool": tc.Name, + "args": tc.Arguments, + }) + + inputJSON, _ := json.Marshal(tc.Arguments) + toolCtx := tool.WithToolContext(ctx, &tool.ToolContext{ + AgentSpawnFn: s.AgentSpawnFn, + AskUserFn: s.AskUserFn, + CommitMessageChatFn: func(chatCtx context.Context, prompt string) (string, error) { + if s.ChatLLM() == nil { + return "", fmt.Errorf("commit message model is unavailable") + } + resp, err := s.ChatLLM().Chat(chatCtx, []types.EyrieMessage{{Role: "user", Content: prompt}}, types.ChatOptions{ + Provider: s.ChatLLM().Provider(), + Model: s.ChatLLM().Model(), + MaxTokens: 256, + }) + if err != nil { + return "", err + } + if resp == nil { + return "", fmt.Errorf("commit message model returned no response") + } + return resp.Content, nil + }, + YaadBridge: s.MemorySvc().Yaad(), + SpecSlugGet: func() string { return s.PermSvc().SpecSlug() }, + SpecSlugSet: func(slug string) { s.PermSvc().SetSpecSlug(slug) }, + BackgroundManager: s.ensureBackgroundManager(), + ReadOnlyBash: s.readOnlyBash, + WorkingDir: s.workingDir, + }) + if s.Tools().ContainerExecutor() != nil && s.Tools().ContainerExecutor().Running() { + toolCtx = tool.WithContainerExecutor(toolCtx, s.Tools().ContainerExecutor()) + } + toolCtx, toolCancel := context.WithTimeout(toolCtx, toolTimeout(tc.Name)) + + // Apply the per-tool retry policy for transient errors. Tools can opt out + // by setting a zero-value RetryPolicy on themselves (via the + // RetryPolicyProvider interface) — Read/Write/Edit etc. don't opt out and + // get the default policy of 2 retries (3 attempts total) with 200ms→2s + // exponential backoff. + t := override + if t == nil { + var ok bool + if s.registry != nil { + t, ok = s.registry.Get(tc.Name) + } + if !ok { + toolCancel() + output := fmt.Sprintf("Error: unknown tool: %s", tc.Name) + ch <- StreamEvent{Type: "tool_result", ToolName: tc.Name, Content: output} + return toolExecResult{tc: tc, output: output, isErr: true} + } + } + if rpp, ok := t.(tool.RetryPolicyProvider); ok { + output, execErr = tool.RetryExecutor(toolCtx, t, inputJSON, rpp.RetryPolicy()) + } else { + output, execErr = tool.RetryExecutor(toolCtx, t, inputJSON, tool.DefaultRetryPolicy()) + } + toolCancel() + isErr = execErr != nil } - var output string - var execErr error - if rpp, ok := t.(tool.RetryPolicyProvider); ok { - output, execErr = tool.RetryExecutor(toolCtx, t, inputJSON, rpp.RetryPolicy()) - } else { - output, execErr = tool.RetryExecutor(toolCtx, t, inputJSON, tool.DefaultRetryPolicy()) - } - toolCancel() - isErr := execErr != nil if isErr { s.Logger().Warn("tool execution error", map[string]interface{}{ "tool": tc.Name, diff --git a/internal/engine/tool_service.go b/internal/engine/tool_service.go index 2dc72fd1..5c8569f9 100644 --- a/internal/engine/tool_service.go +++ b/internal/engine/tool_service.go @@ -7,6 +7,8 @@ import ( "sync" "github.com/GrayCodeAI/hawk/internal/engine/diff" + "github.com/GrayCodeAI/hawk/internal/hooks" + "github.com/GrayCodeAI/hawk/internal/intelligence/memory" "github.com/GrayCodeAI/hawk/internal/observability/oteltrace" "github.com/GrayCodeAI/hawk/internal/tool" "github.com/GrayCodeAI/hawk/internal/types" @@ -26,6 +28,24 @@ type ToolService struct { bgMu sync.Mutex bgManager *tool.BackgroundAgentManager sandbox *diff.DiffSandbox + deps toolExecutionDeps +} + +// toolExecutionDeps contains the service-owned collaborators needed for one +// raw tool invocation. Keeping these dependencies on ToolService removes the +// permission, approval, tracing, timeout, and retry boundary from Session; +// post-call product hooks remain in Session until the next migration slice. +type toolExecutionDeps struct { + permissions *PermissionService + chat *ChatService + memory *MemoryService + agentSpawn tool.AgentSpawnFn + askUser func(string) (string, error) + readOnlyBash bool + workingDir string + syncPermissions func() + checkApproval func(context.Context, string, map[string]interface{}) (bool, string) + recordPolicy func(types.ToolCall, string, bool, string) } // toolExecutionHost is the narrow compatibility seam used while the @@ -50,6 +70,16 @@ func (s *ToolService) WithExecutionHost(host toolExecutionHost) *ToolService { return s } +// WithExecutionDeps binds the extracted service graph used by ExecuteOne. +func (s *ToolService) WithExecutionDeps(deps toolExecutionDeps) *ToolService { + s.deps = deps + return s +} + +func (s *ToolService) executionDepsReady() bool { + return s != nil && s.deps.permissions != nil +} + // WithContainerExecutor configures container isolation. func (s *ToolService) WithContainerExecutor(ce tool.ContainerExecutor, required bool) *ToolService { s.containerExecutor = ce @@ -166,6 +196,117 @@ func (s *ToolService) ExecuteAll(ctx context.Context, calls []types.ToolCall, ch return results } +// ExecuteOne performs the service-owned half of one tool invocation: event +// emission, container readiness, permission/approval, tracing, tool context, +// lookup, timeout, retry, and raw execution. Session remains responsible for +// compatibility post-processing of the returned result. +func (s *ToolService) ExecuteOne(ctx context.Context, tc types.ToolCall, override tool.Tool, ch chan<- StreamEvent, turn int, intent string) toolExecResult { + result := toolExecResult{tc: tc} + ch <- StreamEvent{Type: "tool_use", ToolName: tc.Name, ToolID: tc.ID} + if s.containerRequired && (s.containerExecutor == nil || !s.containerExecutor.Running()) { + msg := "Container not ready — tools are disabled until the sandbox is running." + ch <- StreamEvent{Type: "tool_result", ToolName: tc.Name, Content: msg} + result.output, result.isErr, result.err = msg, true, fmt.Errorf("%s", msg) + return result + } + var span *oteltrace.Span + if s.tracer != nil { + _, span = oteltrace.StartToolSpan(ctx, s.tracer, tc.Name, tc.ID) + } + finishDenied := func(tag string, msg string) toolExecResult { + ch <- StreamEvent{Type: "tool_result", ToolName: tc.Name, Content: msg} + if span != nil { + span.SetTag(tag, "true") + span.Finish() + } + result.output, result.isErr, result.err, result.span = msg, true, fmt.Errorf("%s", msg), nil + return result + } + if s.deps.syncPermissions != nil { + s.deps.syncPermissions() + } + if s.deps.permissions == nil { + return finishDenied("denied", "permission service is unavailable") + } + granted, denyMsg := s.deps.permissions.CheckTool(ctx, ToolCallInfo{Name: tc.Name, ID: tc.ID, Args: tc.Arguments}) + if s.deps.recordPolicy != nil { + s.deps.recordPolicy(tc, "permission", granted, denyMsg) + } + if !granted { + return finishDenied("denied", denyMsg) + } + approved, approvalDeny := true, "" + if s.deps.checkApproval != nil { + approved, approvalDeny = s.deps.checkApproval(ctx, tc.Name, tc.Arguments) + } + if approval := s.deps.permissions.Approval(); approval != nil && approval.Enabled && s.deps.recordPolicy != nil { + s.deps.recordPolicy(tc, "approval", approved, approvalDeny) + } + if !approved { + return finishDenied("approval_denied", approvalDeny) + } + hooks.ExecuteAsync(ctx, hooks.EventPreTool, map[string]interface{}{"tool": tc.Name, "args": tc.Arguments}) + inputJSON, _ := json.Marshal(tc.Arguments) + var commitChat func(context.Context, string) (string, error) + if s.deps.chat != nil { + commitChat = func(chatCtx context.Context, prompt string) (string, error) { + resp, err := s.deps.chat.Chat(chatCtx, []types.EyrieMessage{{Role: "user", Content: prompt}}, types.ChatOptions{Provider: s.deps.chat.Provider(), Model: s.deps.chat.Model(), MaxTokens: 256}) + if err != nil { + return "", err + } + if resp == nil { + return "", fmt.Errorf("commit message model returned no response") + } + return resp.Content, nil + } + } + var yaad *memory.YaadBridge + if s.deps.memory != nil { + yaad = s.deps.memory.Yaad() + } + toolCtx := tool.WithToolContext(ctx, &tool.ToolContext{ + AgentSpawnFn: s.deps.agentSpawn, + AskUserFn: s.deps.askUser, + CommitMessageChatFn: commitChat, + YaadBridge: yaad, + SpecSlugGet: func() string { return s.deps.permissions.SpecSlug() }, + SpecSlugSet: func(slug string) { s.deps.permissions.SetSpecSlug(slug) }, + BackgroundManager: s.EnsureBackgroundManager(), + ReadOnlyBash: s.deps.readOnlyBash, + WorkingDir: s.deps.workingDir, + }) + if s.containerExecutor != nil && s.containerExecutor.Running() { + toolCtx = tool.WithContainerExecutor(toolCtx, s.containerExecutor) + } + toolCtx, cancel := context.WithTimeout(toolCtx, toolTimeout(tc.Name)) + t := override + if t == nil && s.registry != nil { + var ok bool + t, ok = s.registry.Get(tc.Name) + if !ok { + cancel() + return finishDenied("error", fmt.Sprintf("Error: unknown tool: %s", tc.Name)) + } + } + if t == nil { + cancel() + return finishDenied("error", "Error: tool is unavailable") + } + var output string + var execErr error + if rpp, ok := t.(tool.RetryPolicyProvider); ok { + output, execErr = tool.RetryExecutor(toolCtx, t, inputJSON, rpp.RetryPolicy()) + } else { + output, execErr = tool.RetryExecutor(toolCtx, t, inputJSON, tool.DefaultRetryPolicy()) + } + cancel() + result.output, result.err, result.isErr, result.span = output, execErr, execErr != nil, span + if result.isErr { + result.output = fmt.Sprintf("Error: %s", execErr.Error()) + } + return result +} + // ExtractTargets returns the file targets for a tool call. func (s *ToolService) ExtractTargets(tc types.ToolCall) []string { if s == nil || s.registry == nil { @@ -183,10 +324,10 @@ func (s *ToolService) EstimateBlastRadius(planned []PlannedCall) *BlastRadiusRep return EstimateBlastRadius(planned) } -// ExecuteOne runs a single tool call with the configured isolation + +// ExecuteRegistered runs a single registered tool call with the configured isolation + // retry policy. Returns the (output, isErr) pair. The tool_result // StreamEvent is emitted on ch. -func (s *ToolService) ExecuteOne(ctx context.Context, tc types.ToolCall, ch chan<- StreamEvent) (string, bool) { +func (s *ToolService) ExecuteRegistered(ctx context.Context, tc types.ToolCall, ch chan<- StreamEvent) (string, bool) { if s.containerRequired { if s.containerExecutor == nil || !s.containerExecutor.Running() { msg := "Container not ready — tools are disabled until the sandbox is running." From 38719836396bc9daf959f6b32b0ea6ab3473dd1e Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Sun, 2 Aug 2026 00:34:05 +0530 Subject: [PATCH 33/49] refactor: centralize tool output normalization --- internal/engine/stream_tool_exec.go | 22 +--------------------- internal/engine/tool_service.go | 28 ++++++++++++++++++++++++++++ internal/engine/tool_service_test.go | 11 +++++++++++ 3 files changed, 40 insertions(+), 21 deletions(-) create mode 100644 internal/engine/tool_service_test.go diff --git a/internal/engine/stream_tool_exec.go b/internal/engine/stream_tool_exec.go index 80a0677a..17e5fa33 100644 --- a/internal/engine/stream_tool_exec.go +++ b/internal/engine/stream_tool_exec.go @@ -576,27 +576,7 @@ func (s *Session) executeSingleToolWithTool(ctx context.Context, tc types.ToolCa } } - maxChars := 50000 - if window := s.ContextWindowSize(); window > 0 { - dynamic := window * 20 / 100 * 4 - if dynamic < 5000 { - dynamic = 5000 - } - if dynamic < maxChars { - maxChars = dynamic - } - } - compressBudget := maxChars / 2 - if len(output) > compressBudget { - compressed, tokens := CompressForContext(output, compressBudget/4) - if tokens > 0 && tokens < CountTokensFast(output) { - output = compressed - } - } - if len(output) > maxChars { - output = output[:maxChars] + "\n... (truncated)" - } - output = maybeSpillToolOutput(output, canonical, tc.ID) + output = s.Tools().NormalizeOutput(output, canonical, tc.ID, s.ContextWindowSize()) if s.LifecycleSvc().Pipeline() != nil { var execErr error diff --git a/internal/engine/tool_service.go b/internal/engine/tool_service.go index 5c8569f9..64480699 100644 --- a/internal/engine/tool_service.go +++ b/internal/engine/tool_service.go @@ -307,6 +307,34 @@ func (s *ToolService) ExecuteOne(ctx context.Context, tc types.ToolCall, overrid return result } +// NormalizeOutput applies the deterministic context-safety policy to a tool +// result before it is persisted or sent to the model. Keeping this in the +// tool service makes output limits consistent for agent-loop and slash-command +// execution paths. +func (s *ToolService) NormalizeOutput(output, canonicalTool, toolID string, contextWindow int) string { + maxChars := 50000 + if contextWindow > 0 { + dynamic := contextWindow * 20 / 100 * 4 + if dynamic < 5000 { + dynamic = 5000 + } + if dynamic < maxChars { + maxChars = dynamic + } + } + compressBudget := maxChars / 2 + if len(output) > compressBudget { + compressed, tokens := CompressForContext(output, compressBudget/4) + if tokens > 0 && tokens < CountTokensFast(output) { + output = compressed + } + } + if len(output) > maxChars { + output = output[:maxChars] + "\n... (truncated)" + } + return maybeSpillToolOutput(output, canonicalTool, toolID) +} + // ExtractTargets returns the file targets for a tool call. func (s *ToolService) ExtractTargets(tc types.ToolCall) []string { if s == nil || s.registry == nil { diff --git a/internal/engine/tool_service_test.go b/internal/engine/tool_service_test.go new file mode 100644 index 00000000..2dcde895 --- /dev/null +++ b/internal/engine/tool_service_test.go @@ -0,0 +1,11 @@ +package engine + +import "testing" + +func TestToolServiceNormalizeOutputKeepsSmallResults(t *testing.T) { + service := NewToolService(nil) + const want = "short tool result" + if got := service.NormalizeOutput(want, "Read", "call-1", 128_000); got != want { + t.Fatalf("NormalizeOutput changed a small result: got %q, want %q", got, want) + } +} From 2f5cc994e32bef00fa1c0f8635c6e27e1a77e7b3 Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Sun, 2 Aug 2026 00:37:54 +0530 Subject: [PATCH 34/49] refactor: centralize tool result completion --- docs/session-decomposition.md | 4 +- internal/engine/session.go | 13 +++--- internal/engine/stream_tool_exec.go | 42 ++---------------- internal/engine/tool_service.go | 68 ++++++++++++++++++++++++----- 4 files changed, 71 insertions(+), 56 deletions(-) diff --git a/docs/session-decomposition.md b/docs/session-decomposition.md index ffaa69a8..72633c70 100644 --- a/docs/session-decomposition.md +++ b/docs/session-decomposition.md @@ -46,7 +46,9 @@ The refactor branch now enforces these boundaries: and read-only concurrency limits. `ToolService.ExecuteOne` now owns raw invocation boundaries: permission/approval, tracing, isolation, context injection, lookup, timeout, and retry. Session retains only compatibility - post-processing hooks for the returned result. + mutation/learning hooks for the returned result; `CompleteResult` owns + spec transitions, counters, enhanced-memory notification, post-tool hooks, + verification observation, span closure, and result emission. - The agent loop uses these service APIs for transport, persistence, memory, lifecycle, permission-stage, and tool-batch operations. diff --git a/internal/engine/session.go b/internal/engine/session.go index 5bc6f073..0da7c847 100644 --- a/internal/engine/session.go +++ b/internal/engine/session.go @@ -310,7 +310,7 @@ func NewSessionWithClient(chat ChatClient, provider, model, systemPrompt string, s.memory = NewMemoryService(log) s.persist = NewPersistenceService(log) s.persist.SetSystem(systemPrompt) - s.tools = NewToolService(registry).WithExecutionHost(s) + s.tools = NewToolService(registry).WithExecutionHost(s).WithMetrics(s.metrics) s.tools.WithExecutionDeps(toolExecutionDeps{ permissions: s.perms, chat: s.llm, @@ -327,11 +327,12 @@ func NewSessionWithClient(chat ChatClient, provider, model, systemPrompt string, } return s.AskUserFn(question) }, - readOnlyBash: s.readOnlyBash, - workingDir: s.workingDir, - syncPermissions: s.syncPermissionCompatibility, - checkApproval: s.CheckApproval, - recordPolicy: s.recordPolicyObservation, + readOnlyBash: s.readOnlyBash, + workingDir: s.workingDir, + syncPermissions: s.syncPermissionCompatibility, + checkApproval: s.CheckApproval, + recordPolicy: s.recordPolicyObservation, + recordVerification: s.recordVerificationObservation, }) s.refreshContextWindowCache() s.life.SetAgentsAccumulator(s.AgentsAccum) diff --git a/internal/engine/stream_tool_exec.go b/internal/engine/stream_tool_exec.go index 17e5fa33..a4299a46 100644 --- a/internal/engine/stream_tool_exec.go +++ b/internal/engine/stream_tool_exec.go @@ -597,45 +597,9 @@ func (s *Session) executeSingleToolWithTool(ctx context.Context, tc types.ToolCa } } - // Spec-stage transitions driven by the model's spec workflow tools. - // Reaching this point means the tool was granted by the permission - // engine — for ApproveImplementation specifically, that always meant a - // real user prompt (see PermissionEngine.CheckTool's spec gate), so this - // is the approval handoff into Implementing. - if !isErr { - switch canonicalToolName(tc.Name) { - case "Specify", "Plan", "Tasks": - s.PermSvc().AdvanceSpecStage(tc.Name) - case "ApproveImplementation": - s.PermSvc().AdvanceSpecStage(tc.Name) - output = "Spec approved — switched to implementation. You may now make changes." - } - } - - s.Metrics().Counter("tools.executed").Inc() - if isErr { - s.Metrics().Counter("tools.errors").Inc() - } - - if s.MemorySvc().Enhanced() != nil { - s.MemorySvc().Enhanced().OnToolResult(tc.Name, tc.Arguments, output, isErr) - } - - hooks.ExecuteAsync(ctx, hooks.EventPostTool, map[string]interface{}{ - "tool": tc.Name, - "output": output, - "is_err": isErr, - }) - - s.recordVerificationObservation(tc, output, isErr) - ch <- StreamEvent{Type: "tool_result", ToolName: tc.Name, Content: output} - if toolSpan != nil { - if isErr { - toolSpan.SetTag("error", "true") - } - toolSpan.Finish() - } - return toolExecResult{tc: tc, output: output, isErr: isErr} + return s.Tools().CompleteResult(ctx, toolExecResult{ + tc: tc, output: output, isErr: isErr, err: execErr, span: toolSpan, + }, ch) } // shouldReflect determines if the Reflector should analyze a tool failure. diff --git a/internal/engine/tool_service.go b/internal/engine/tool_service.go index 64480699..517e74d8 100644 --- a/internal/engine/tool_service.go +++ b/internal/engine/tool_service.go @@ -9,6 +9,7 @@ import ( "github.com/GrayCodeAI/hawk/internal/engine/diff" "github.com/GrayCodeAI/hawk/internal/hooks" "github.com/GrayCodeAI/hawk/internal/intelligence/memory" + "github.com/GrayCodeAI/hawk/internal/observability/metrics" "github.com/GrayCodeAI/hawk/internal/observability/oteltrace" "github.com/GrayCodeAI/hawk/internal/tool" "github.com/GrayCodeAI/hawk/internal/types" @@ -29,6 +30,7 @@ type ToolService struct { bgManager *tool.BackgroundAgentManager sandbox *diff.DiffSandbox deps toolExecutionDeps + metrics *metrics.Registry } // toolExecutionDeps contains the service-owned collaborators needed for one @@ -36,16 +38,17 @@ type ToolService struct { // permission, approval, tracing, timeout, and retry boundary from Session; // post-call product hooks remain in Session until the next migration slice. type toolExecutionDeps struct { - permissions *PermissionService - chat *ChatService - memory *MemoryService - agentSpawn tool.AgentSpawnFn - askUser func(string) (string, error) - readOnlyBash bool - workingDir string - syncPermissions func() - checkApproval func(context.Context, string, map[string]interface{}) (bool, string) - recordPolicy func(types.ToolCall, string, bool, string) + permissions *PermissionService + chat *ChatService + memory *MemoryService + agentSpawn tool.AgentSpawnFn + askUser func(string) (string, error) + readOnlyBash bool + workingDir string + syncPermissions func() + checkApproval func(context.Context, string, map[string]interface{}) (bool, string) + recordPolicy func(types.ToolCall, string, bool, string) + recordVerification func(types.ToolCall, string, bool) } // toolExecutionHost is the narrow compatibility seam used while the @@ -76,6 +79,12 @@ func (s *ToolService) WithExecutionDeps(deps toolExecutionDeps) *ToolService { return s } +// WithMetrics attaches the registry used for tool execution counters. +func (s *ToolService) WithMetrics(registry *metrics.Registry) *ToolService { + s.metrics = registry + return s +} + func (s *ToolService) executionDepsReady() bool { return s != nil && s.deps.permissions != nil } @@ -335,6 +344,45 @@ func (s *ToolService) NormalizeOutput(output, canonicalTool, toolID string, cont return maybeSpillToolOutput(output, canonicalTool, toolID) } +// CompleteResult owns the service-level completion contract after Session's +// domain-specific post-processing has finished. +func (s *ToolService) CompleteResult(ctx context.Context, result toolExecResult, ch chan<- StreamEvent) toolExecResult { + output, isErr := result.output, result.isErr + if !isErr && s.deps.permissions != nil { + switch canonicalToolName(result.tc.Name) { + case "Specify", "Plan", "Tasks": + s.deps.permissions.AdvanceSpecStage(result.tc.Name) + case "ApproveImplementation": + s.deps.permissions.AdvanceSpecStage(result.tc.Name) + output = "Spec approved — switched to implementation. You may now make changes." + } + } + if s.metrics != nil { + s.metrics.Counter("tools.executed").Inc() + if isErr { + s.metrics.Counter("tools.errors").Inc() + } + } + if s.deps.memory != nil && s.deps.memory.Enhanced() != nil { + s.deps.memory.Enhanced().OnToolResult(result.tc.Name, result.tc.Arguments, output, isErr) + } + hooks.ExecuteAsync(ctx, hooks.EventPostTool, map[string]interface{}{ + "tool": result.tc.Name, "output": output, "is_err": isErr, + }) + if s.deps.recordVerification != nil { + s.deps.recordVerification(result.tc, output, isErr) + } + ch <- StreamEvent{Type: "tool_result", ToolName: result.tc.Name, Content: output} + if result.span != nil { + if isErr { + result.span.SetTag("error", "true") + } + result.span.Finish() + } + result.output = output + return result +} + // ExtractTargets returns the file targets for a tool call. func (s *ToolService) ExtractTargets(tc types.ToolCall) []string { if s == nil || s.registry == nil { From 3c3fee3440559c4712a6a80240f5f2cb33793000 Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Sun, 2 Aug 2026 00:42:30 +0530 Subject: [PATCH 35/49] refactor: move tool post-processing into service --- docs/session-decomposition.md | 3 +- internal/engine/session.go | 2 + internal/engine/stream_tool_exec.go | 193 ++++++++++++++-------------- internal/engine/tool_service.go | 124 ++++++++++++++++++ 4 files changed, 228 insertions(+), 94 deletions(-) diff --git a/docs/session-decomposition.md b/docs/session-decomposition.md index 72633c70..bb9c9c9f 100644 --- a/docs/session-decomposition.md +++ b/docs/session-decomposition.md @@ -46,7 +46,8 @@ The refactor branch now enforces these boundaries: and read-only concurrency limits. `ToolService.ExecuteOne` now owns raw invocation boundaries: permission/approval, tracing, isolation, context injection, lookup, timeout, and retry. Session retains only compatibility - mutation/learning hooks for the returned result; `CompleteResult` owns + fallback hooks for direct-literal compatibility; `PostProcess` now owns + mutation, validation, sandbox, lint, and pipeline hooks. `CompleteResult` owns spec transitions, counters, enhanced-memory notification, post-tool hooks, verification observation, span closure, and result emission. - The agent loop uses these service APIs for transport, persistence, memory, diff --git a/internal/engine/session.go b/internal/engine/session.go index 0da7c847..2fd2506a 100644 --- a/internal/engine/session.go +++ b/internal/engine/session.go @@ -333,6 +333,8 @@ func NewSessionWithClient(chat ChatClient, provider, model, systemPrompt string, checkApproval: s.CheckApproval, recordPolicy: s.recordPolicyObservation, recordVerification: s.recordVerificationObservation, + lifecycle: s.life, + appendSystem: s.AppendSystemContext, }) s.refreshContextWindowCache() s.life.SetAgentsAccumulator(s.AgentsAccum) diff --git a/internal/engine/stream_tool_exec.go b/internal/engine/stream_tool_exec.go index a4299a46..efd5d6d5 100644 --- a/internal/engine/stream_tool_exec.go +++ b/internal/engine/stream_tool_exec.go @@ -473,130 +473,137 @@ func (s *Session) executeSingleToolWithTool(ctx context.Context, tc types.ToolCa } } - if s.LifecycleSvc().Limits() != nil { - s.LifecycleSvc().Limits().RecordToolCall(tc.Name) - } - - canonical := canonicalToolName(tc.Name) - if s.LifecycleSvc().Beliefs() != nil && (canonical == "Read" || canonical == "Grep" || canonical == "Glob" || canonical == "LS") { - subject := tc.Name - if p, ok := pathArgument(tc.Arguments); ok { - subject = p - } - contentSummary := output - if len(contentSummary) > 200 { - contentSummary = contentSummary[:200] + if !s.Tools().executionDepsReady() { + if s.LifecycleSvc().Limits() != nil { + s.LifecycleSvc().Limits().RecordToolCall(tc.Name) } - s.LifecycleSvc().Beliefs().Record("file_purpose", subject, contentSummary, turnCount) - } - if s.MemorySvc().Enhanced() != nil && (canonical == "Read" || canonical == "Edit" || canonical == "Write") { - if p, ok := pathArgument(tc.Arguments); ok && p != "" { - if proactiveCtx := s.MemorySvc().Enhanced().ProactiveContextForFile(p); proactiveCtx != "" { - s.AppendSystemContext(proactiveCtx) + canonical := canonicalToolName(tc.Name) + if s.LifecycleSvc().Beliefs() != nil && (canonical == "Read" || canonical == "Grep" || canonical == "Glob" || canonical == "LS") { + subject := tc.Name + if p, ok := pathArgument(tc.Arguments); ok { + subject = p } + contentSummary := output + if len(contentSummary) > 200 { + contentSummary = contentSummary[:200] + } + s.LifecycleSvc().Beliefs().Record("file_purpose", subject, contentSummary, turnCount) } - } - if s.LifecycleSvc().Beliefs() != nil && (canonical == "Write" || canonical == "Edit") { - if p, ok := pathArgument(tc.Arguments); ok { - s.LifecycleSvc().Beliefs().Invalidate(p) + if s.MemorySvc().Enhanced() != nil && (canonical == "Read" || canonical == "Edit" || canonical == "Write") { + if p, ok := pathArgument(tc.Arguments); ok && p != "" { + if proactiveCtx := s.MemorySvc().Enhanced().ProactiveContextForFile(p); proactiveCtx != "" { + s.AppendSystemContext(proactiveCtx) + } + } } - } - // Auto-accumulate learnings into Hawk user state. - if s.LifecycleSvc().AgentsAccum() != nil && !isErr && (canonical == "Write" || canonical == "Edit") { - if p, ok := pathArgument(tc.Arguments); ok && p != "" { - pattern := prompts.ExtractPattern(tc.Name, p, output) - s.LifecycleSvc().AgentsAccum().Record(intentText, pattern, []string{p}) - // Flush periodically (every 5 learnings) - if err := s.LifecycleSvc().AgentsAccum().Flush(); err != nil { - slog.Warn("failed to flush agents accumulator", "error", err) + if s.LifecycleSvc().Beliefs() != nil && (canonical == "Write" || canonical == "Edit") { + if p, ok := pathArgument(tc.Arguments); ok { + s.LifecycleSvc().Beliefs().Invalidate(p) } } - } - if s.LifecycleSvc().Critic() != nil && !isErr && (canonical == "Write" || canonical == "Edit") { - if p, ok := pathArgument(tc.Arguments); ok { - origContent := "" - if data, readErr := readFileContent(p); readErr == nil { - origContent = data - } - verdict := s.LifecycleSvc().Critic().PreScreenPatch(origContent, output, intentText) - if s.LifecycleSvc().Critic().ShouldBlock(verdict) { - issueStr := strings.Join(verdict.Issues, "; ") - output = fmt.Sprintf("Patch rejected by validator: %s. Try again.", issueStr) - isErr = true + // Auto-accumulate learnings into Hawk user state. + if s.LifecycleSvc().AgentsAccum() != nil && !isErr && (canonical == "Write" || canonical == "Edit") { + if p, ok := pathArgument(tc.Arguments); ok && p != "" { + pattern := prompts.ExtractPattern(tc.Name, p, output) + s.LifecycleSvc().AgentsAccum().Record(intentText, pattern, []string{p}) + // Flush periodically (every 5 learnings) + if err := s.LifecycleSvc().AgentsAccum().Flush(); err != nil { + slog.Warn("failed to flush agents accumulator", "error", err) + } } } - } - if s.LifecycleSvc().Shadow() != nil && !isErr && (canonical == "Write" || canonical == "Edit") { - if p, ok := pathArgument(tc.Arguments); ok { - validationErrs := s.LifecycleSvc().Shadow().ValidateEdit(p, output) - if len(validationErrs) > 0 { - var warnings []string - for _, ve := range validationErrs { - warnings = append(warnings, ve.Message) + if s.LifecycleSvc().Critic() != nil && !isErr && (canonical == "Write" || canonical == "Edit") { + if p, ok := pathArgument(tc.Arguments); ok { + origContent := "" + if data, readErr := readFileContent(p); readErr == nil { + origContent = data + } + verdict := s.LifecycleSvc().Critic().PreScreenPatch(origContent, output, intentText) + if s.LifecycleSvc().Critic().ShouldBlock(verdict) { + issueStr := strings.Join(verdict.Issues, "; ") + output = fmt.Sprintf("Patch rejected by validator: %s. Try again.", issueStr) + isErr = true } - output += fmt.Sprintf("\n\nValidation warnings: %s", strings.Join(warnings, "; ")) } } - } - sandboxIntercepted := false - if s.Tools().Sandbox() != nil && s.Tools().Sandbox().IsEnabled() && !isErr && (canonical == "Write" || canonical == "Edit") { - if p, ok := pathArgument(tc.Arguments); ok { - origContent := "" - if data, readErr := readFileContent(p); readErr == nil { - origContent = data + if s.LifecycleSvc().Shadow() != nil && !isErr && (canonical == "Write" || canonical == "Edit") { + if p, ok := pathArgument(tc.Arguments); ok { + validationErrs := s.LifecycleSvc().Shadow().ValidateEdit(p, output) + if len(validationErrs) > 0 { + var warnings []string + for _, ve := range validationErrs { + warnings = append(warnings, ve.Message) + } + output += fmt.Sprintf("\n\nValidation warnings: %s", strings.Join(warnings, "; ")) + } } - action := "overwrite" - if canonical == "Edit" { - action = "edit" + } + + sandboxIntercepted := false + if s.Tools().Sandbox() != nil && s.Tools().Sandbox().IsEnabled() && !isErr && (canonical == "Write" || canonical == "Edit") { + if p, ok := pathArgument(tc.Arguments); ok { + origContent := "" + if data, readErr := readFileContent(p); readErr == nil { + origContent = data + } + action := "overwrite" + if canonical == "Edit" { + action = "edit" + } + s.Tools().Sandbox().Stage(p, action, origContent, output) + output = fmt.Sprintf("Change staged for review (%s: %s)", action, p) + sandboxIntercepted = true } - s.Tools().Sandbox().Stage(p, action, origContent, output) - output = fmt.Sprintf("Change staged for review (%s: %s)", action, p) - sandboxIntercepted = true } - } - if s.LifecycleSvc().LintLoop() != nil && s.LifecycleSvc().LintLoop().Enabled && !isErr && !sandboxIntercepted && (canonical == "Write" || canonical == "Edit") { - if p, ok := pathArgument(tc.Arguments); ok { - count := s.LifecycleSvc().LintLoop().ReflectionCount(p) - if s.LifecycleSvc().LintLoop().ShouldRetry(count) { - if lintResult, lintErr := s.LifecycleSvc().LintLoop().RunLint(p); lintErr == nil && lintResult != nil { - reflected := s.LifecycleSvc().LintLoop().BuildReflectedMessage(lintResult) - if reflected != "" { - s.LifecycleSvc().LintLoop().RecordReflection(p) - output += "\n\n" + reflected + if s.LifecycleSvc().LintLoop() != nil && s.LifecycleSvc().LintLoop().Enabled && !isErr && !sandboxIntercepted && (canonical == "Write" || canonical == "Edit") { + if p, ok := pathArgument(tc.Arguments); ok { + count := s.LifecycleSvc().LintLoop().ReflectionCount(p) + if s.LifecycleSvc().LintLoop().ShouldRetry(count) { + if lintResult, lintErr := s.LifecycleSvc().LintLoop().RunLint(p); lintErr == nil && lintResult != nil { + reflected := s.LifecycleSvc().LintLoop().BuildReflectedMessage(lintResult) + if reflected != "" { + s.LifecycleSvc().LintLoop().RecordReflection(p) + output += "\n\n" + reflected + } } } } } - } - output = s.Tools().NormalizeOutput(output, canonical, tc.ID, s.ContextWindowSize()) + output = s.Tools().NormalizeOutput(output, canonical, tc.ID, s.ContextWindowSize()) - if s.LifecycleSvc().Pipeline() != nil { - var execErr error - if isErr { - execErr = fmt.Errorf("%s", output) - } - toolResult := s.LifecycleSvc().Pipeline().PostToolExecution(tc.Name, tc.Arguments, output, execErr) - if toolResult != nil { - if toolResult.StallWarning != "" { - output += "\n\n" + toolResult.StallWarning + if s.LifecycleSvc().Pipeline() != nil { + var execErr error + if isErr { + execErr = fmt.Errorf("%s", output) } - if toolResult.LintErrors != "" { - output += "\n\nLint: " + toolResult.LintErrors - } - if toolResult.RecoveryAction != "" && toolResult.ShouldRetry { - output += "\n\nRecovery suggestion: " + toolResult.RecoveryAction + toolResult := s.LifecycleSvc().Pipeline().PostToolExecution(tc.Name, tc.Arguments, output, execErr) + if toolResult != nil { + if toolResult.StallWarning != "" { + output += "\n\n" + toolResult.StallWarning + } + if toolResult.LintErrors != "" { + output += "\n\nLint: " + toolResult.LintErrors + } + if toolResult.RecoveryAction != "" && toolResult.ShouldRetry { + output += "\n\nRecovery suggestion: " + toolResult.RecoveryAction + } } } } - + if s.Tools().executionDepsReady() { + processed := s.Tools().PostProcess(ctx, toolExecResult{ + tc: tc, output: output, isErr: isErr, err: execErr, span: toolSpan, + }, turnCount, intentText, s.ContextWindowSize()) + return s.Tools().CompleteResult(ctx, processed, ch) + } return s.Tools().CompleteResult(ctx, toolExecResult{ tc: tc, output: output, isErr: isErr, err: execErr, span: toolSpan, }, ch) diff --git a/internal/engine/tool_service.go b/internal/engine/tool_service.go index 517e74d8..66adf388 100644 --- a/internal/engine/tool_service.go +++ b/internal/engine/tool_service.go @@ -4,6 +4,8 @@ import ( "context" "encoding/json" "fmt" + "log/slog" + "strings" "sync" "github.com/GrayCodeAI/hawk/internal/engine/diff" @@ -11,6 +13,7 @@ import ( "github.com/GrayCodeAI/hawk/internal/intelligence/memory" "github.com/GrayCodeAI/hawk/internal/observability/metrics" "github.com/GrayCodeAI/hawk/internal/observability/oteltrace" + "github.com/GrayCodeAI/hawk/internal/prompts" "github.com/GrayCodeAI/hawk/internal/tool" "github.com/GrayCodeAI/hawk/internal/types" ) @@ -49,6 +52,8 @@ type toolExecutionDeps struct { checkApproval func(context.Context, string, map[string]interface{}) (bool, string) recordPolicy func(types.ToolCall, string, bool, string) recordVerification func(types.ToolCall, string, bool) + lifecycle *LifecycleService + appendSystem func(string) } // toolExecutionHost is the narrow compatibility seam used while the @@ -344,6 +349,125 @@ func (s *ToolService) NormalizeOutput(output, canonicalTool, toolID string, cont return maybeSpillToolOutput(output, canonicalTool, toolID) } +// PostProcess applies the domain mutation/validation hooks that follow a raw +// tool invocation. It is intentionally separate from CompleteResult so the +// final event contract remains uniform even when a hook changes the output or +// converts a successful mutation into an error. +func (s *ToolService) PostProcess(ctx context.Context, result toolExecResult, turn int, intent string, contextWindow int) toolExecResult { + output, isErr := result.output, result.isErr + canonical := canonicalToolName(result.tc.Name) + life := s.deps.lifecycle + if life != nil && life.Limits() != nil { + life.Limits().RecordToolCall(result.tc.Name) + } + if life != nil && life.Beliefs() != nil && (canonical == "Read" || canonical == "Grep" || canonical == "Glob" || canonical == "LS") { + subject := result.tc.Name + if p, ok := pathArgument(result.tc.Arguments); ok { + subject = p + } + contentSummary := output + if len(contentSummary) > 200 { + contentSummary = contentSummary[:200] + } + life.Beliefs().Record("file_purpose", subject, contentSummary, turn) + } + if s.deps.memory != nil && s.deps.memory.Enhanced() != nil && (canonical == "Read" || canonical == "Edit" || canonical == "Write") { + if p, ok := pathArgument(result.tc.Arguments); ok && p != "" { + if proactiveCtx := s.deps.memory.Enhanced().ProactiveContextForFile(p); proactiveCtx != "" && s.deps.appendSystem != nil { + s.deps.appendSystem(proactiveCtx) + } + } + } + if life != nil && life.Beliefs() != nil && (canonical == "Write" || canonical == "Edit") { + if p, ok := pathArgument(result.tc.Arguments); ok { + life.Beliefs().Invalidate(p) + } + } + if life != nil && life.AgentsAccum() != nil && !isErr && (canonical == "Write" || canonical == "Edit") { + if p, ok := pathArgument(result.tc.Arguments); ok && p != "" { + pattern := prompts.ExtractPattern(result.tc.Name, p, output) + life.AgentsAccum().Record(intent, pattern, []string{p}) + if err := life.AgentsAccum().Flush(); err != nil { + slog.Warn("failed to flush agents accumulator", "error", err) + } + } + } + if life != nil && life.Critic() != nil && !isErr && (canonical == "Write" || canonical == "Edit") { + if p, ok := pathArgument(result.tc.Arguments); ok { + origContent := "" + if data, readErr := readFileContent(p); readErr == nil { + origContent = data + } + verdict := life.Critic().PreScreenPatch(origContent, output, intent) + if life.Critic().ShouldBlock(verdict) { + output = fmt.Sprintf("Patch rejected by validator: %s. Try again.", strings.Join(verdict.Issues, "; ")) + isErr = true + } + } + } + if life != nil && life.Shadow() != nil && !isErr && (canonical == "Write" || canonical == "Edit") { + if p, ok := pathArgument(result.tc.Arguments); ok { + validationErrs := life.Shadow().ValidateEdit(p, output) + if len(validationErrs) > 0 { + warnings := make([]string, 0, len(validationErrs)) + for _, ve := range validationErrs { + warnings = append(warnings, ve.Message) + } + output += fmt.Sprintf("\n\nValidation warnings: %s", strings.Join(warnings, "; ")) + } + } + } + sandboxIntercepted := false + if s.sandbox != nil && s.sandbox.IsEnabled() && !isErr && (canonical == "Write" || canonical == "Edit") { + if p, ok := pathArgument(result.tc.Arguments); ok { + origContent := "" + if data, readErr := readFileContent(p); readErr == nil { + origContent = data + } + action := "overwrite" + if canonical == "Edit" { + action = "edit" + } + s.sandbox.Stage(p, action, origContent, output) + output = fmt.Sprintf("Change staged for review (%s: %s)", action, p) + sandboxIntercepted = true + } + } + if life != nil && life.LintLoop() != nil && life.LintLoop().Enabled && !isErr && !sandboxIntercepted && (canonical == "Write" || canonical == "Edit") { + if p, ok := pathArgument(result.tc.Arguments); ok { + count := life.LintLoop().ReflectionCount(p) + if life.LintLoop().ShouldRetry(count) { + if lintResult, lintErr := life.LintLoop().RunLint(p); lintErr == nil && lintResult != nil { + if reflected := life.LintLoop().BuildReflectedMessage(lintResult); reflected != "" { + life.LintLoop().RecordReflection(p) + output += "\n\n" + reflected + } + } + } + } + } + output = s.NormalizeOutput(output, canonical, result.tc.ID, contextWindow) + if life != nil && life.Pipeline() != nil { + var execErr error + if isErr { + execErr = fmt.Errorf("%s", output) + } + if toolResult := life.Pipeline().PostToolExecution(result.tc.Name, result.tc.Arguments, output, execErr); toolResult != nil { + if toolResult.StallWarning != "" { + output += "\n\n" + toolResult.StallWarning + } + if toolResult.LintErrors != "" { + output += "\n\nLint: " + toolResult.LintErrors + } + if toolResult.RecoveryAction != "" && toolResult.ShouldRetry { + output += "\n\nRecovery suggestion: " + toolResult.RecoveryAction + } + } + } + result.output, result.isErr = output, isErr + return result +} + // CompleteResult owns the service-level completion contract after Session's // domain-specific post-processing has finished. func (s *ToolService) CompleteResult(ctx context.Context, result toolExecResult, ch chan<- StreamEvent) toolExecResult { From 8d1c765538ad5f97e96f9de91ef758912e00a5c8 Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Sun, 2 Aug 2026 00:49:48 +0530 Subject: [PATCH 36/49] refactor: remove legacy tool execution fallback --- docs/session-decomposition.md | 3 +- internal/engine/session.go | 2 +- internal/engine/stream_tool_exec.go | 401 ++-------------------------- internal/engine/tool_service.go | 40 +-- 4 files changed, 32 insertions(+), 414 deletions(-) diff --git a/docs/session-decomposition.md b/docs/session-decomposition.md index bb9c9c9f..daf482e7 100644 --- a/docs/session-decomposition.md +++ b/docs/session-decomposition.md @@ -45,8 +45,7 @@ The refactor branch now enforces these boundaries: - `ToolService.ExecuteAll` owns batching, ordering, blast-radius reporting, and read-only concurrency limits. `ToolService.ExecuteOne` now owns raw invocation boundaries: permission/approval, tracing, isolation, context - injection, lookup, timeout, and retry. Session retains only compatibility - fallback hooks for direct-literal compatibility; `PostProcess` now owns + injection, lookup, timeout, and retry. `PostProcess` now owns mutation, validation, sandbox, lint, and pipeline hooks. `CompleteResult` owns spec transitions, counters, enhanced-memory notification, post-tool hooks, verification observation, span closure, and result emission. diff --git a/internal/engine/session.go b/internal/engine/session.go index 2fd2506a..41083088 100644 --- a/internal/engine/session.go +++ b/internal/engine/session.go @@ -310,7 +310,7 @@ func NewSessionWithClient(chat ChatClient, provider, model, systemPrompt string, s.memory = NewMemoryService(log) s.persist = NewPersistenceService(log) s.persist.SetSystem(systemPrompt) - s.tools = NewToolService(registry).WithExecutionHost(s).WithMetrics(s.metrics) + s.tools = NewToolService(registry).WithMetrics(s.metrics) s.tools.WithExecutionDeps(toolExecutionDeps{ permissions: s.perms, chat: s.llm, diff --git a/internal/engine/stream_tool_exec.go b/internal/engine/stream_tool_exec.go index efd5d6d5..5054ade9 100644 --- a/internal/engine/stream_tool_exec.go +++ b/internal/engine/stream_tool_exec.go @@ -2,20 +2,15 @@ package engine import ( "context" - "encoding/json" "fmt" - "log/slog" "os" "slices" "strings" - "sync" "github.com/GrayCodeAI/hawk/internal/tool" "github.com/GrayCodeAI/hawk/internal/types" - hooks "github.com/GrayCodeAI/hawk/internal/hooks" "github.com/GrayCodeAI/hawk/internal/observability/oteltrace" - "github.com/GrayCodeAI/hawk/internal/prompts" ) // toolExecResult holds the output of a single tool execution. @@ -150,81 +145,10 @@ type indexedToolCall struct { // executeToolCalls runs all tool calls and returns results. func (s *Session) executeToolCalls(ctx context.Context, toolCalls []types.ToolCall, ch chan<- StreamEvent, turnCount int, intentText string) []toolExecResult { - // Compatibility wrapper: ToolService owns batching, ordering, and - // concurrency. Keep this method for older in-package callers while they - // migrate to s.Tools().ExecuteAll. - if s.Tools() != nil && s.Tools().host != nil { - return s.Tools().ExecuteAll(ctx, toolCalls, ch, turnCount, intentText) + if s.Tools() == nil { + return nil } - // Estimate blast radius before execution. Use the schema-aware target - // extractor when the tool is registered (so non-conventional argument - // names like "target_path" or "destFile" are still picked up); fall back - // to the conventional extractor otherwise. - plannedCalls := make([]PlannedCall, len(toolCalls)) - concurrentCalls := make([]indexedToolCall, 0, len(toolCalls)) - sequentialCalls := make([]indexedToolCall, 0, len(toolCalls)) - for i, tc := range toolCalls { - var targets []string - if s.registry != nil { - if t, ok := s.registry.Get(tc.Name); ok { - targets = ExtractTargetsFromSchema(t, tc) - } else { - targets = extractTargets(tc) - } - } else { - targets = extractTargets(tc) - } - plannedCalls[i] = PlannedCall{ - ToolName: tc.Name, - Args: tc.Arguments, - Targets: targets, - } - item := indexedToolCall{index: i, tc: tc} - if tool.IsReadOnly(tc.Name) { - concurrentCalls = append(concurrentCalls, item) - } else { - sequentialCalls = append(sequentialCalls, item) - } - } - blastReport := EstimateBlastRadius(plannedCalls) - if blastReport.Radius.NeedsConfirmation() { - // Emit blast radius event for TUI display - ch <- StreamEvent{ - Type: "blast_radius", - Content: blastReport.Message, - } - } - - results := make([]toolExecResult, len(toolCalls)) - readOnlySem := make(chan struct{}, maxConcurrentReadOnlyToolCalls) - networkSem := make(chan struct{}, maxConcurrentNetworkReadOnlyToolCalls) - var wg sync.WaitGroup - var mu sync.Mutex - - for _, item := range concurrentCalls { - wg.Add(1) - go func(item indexedToolCall) { - defer wg.Done() - readOnlySem <- struct{}{} - defer func() { <-readOnlySem }() - if isNetworkReadOnlyTool(item.tc.Name) { - networkSem <- struct{}{} - defer func() { <-networkSem }() - } - mu.Lock() - results[item.index] = s.executeSingleTool(ctx, item.tc, ch, turnCount, intentText) - mu.Unlock() - }(item) - } - wg.Wait() - - for _, item := range sequentialCalls { - mu.Lock() - results[item.index] = s.executeSingleTool(ctx, item.tc, ch, turnCount, intentText) - mu.Unlock() - } - - return results + return s.Tools().ExecuteAll(ctx, toolCalls, ch, turnCount, intentText) } func isNetworkReadOnlyTool(name string) bool { @@ -262,13 +186,13 @@ func (s *Session) executeSingleTool(ctx context.Context, tc types.ToolCall, ch c } func (s *Session) executeSingleToolWithTool(ctx context.Context, tc types.ToolCall, override tool.Tool, ch chan<- StreamEvent, turnCount int, intentText string) toolExecResult { - var toolSpan *oteltrace.Span - var output string - var execErr error - var isErr bool + if s.Tools() == nil { + msg := "session tool service is not initialized" + ch <- StreamEvent{Type: "tool_result", ToolName: tc.Name, Content: msg} + return toolExecResult{tc: tc, output: msg, isErr: true, err: fmt.Errorf("%s", msg)} + } canonicalPre := canonicalToolName(tc.Name) - var preEditContent string - var preEditPath string + var preEditContent, preEditPath string if (canonicalPre == "Write" || canonicalPre == "Edit" || canonicalPre == "MultiEdit") && s.ChatLLM() != nil { if p, ok := pathArgument(tc.Arguments); ok && p != "" { preEditPath = p @@ -277,168 +201,26 @@ func (s *Session) executeSingleToolWithTool(ctx context.Context, tc types.ToolCa } } } - if s.Tools() != nil && s.Tools().executionDepsReady() { - core := s.Tools().ExecuteOne(ctx, tc, override, ch, turnCount, intentText) - output, execErr, isErr, toolSpan = core.output, core.err, core.isErr, core.span - } else { - ch <- StreamEvent{Type: "tool_use", ToolName: tc.Name, ToolID: tc.ID} - - if s.Tools().ContainerRequired() { - if s.Tools().ContainerExecutor() == nil || !s.Tools().ContainerExecutor().Running() { - msg := "Container not ready — tools are disabled until the sandbox is running." - ch <- StreamEvent{Type: "tool_result", ToolName: tc.Name, Content: msg} - return toolExecResult{tc: tc, output: msg, isErr: true} - } - } - - if s.TracerValue() != nil { - _, toolSpan = oteltrace.StartToolSpan(ctx, s.TracerValue(), tc.Name, tc.ID) - } - - // Delegate to the extracted PermissionService (Phase 7 migration). - // s.PermSvc() is never nil because NewSessionWithClient always - // constructs it and aliases it to s.Perm via WithEngine(pe). The - // legacy s.Perm field is now a thin shim that reads the same - // engine. - // - // We still sync the legacy fields (PermissionFn, Autonomy) to the - // service before each call because external code (cmd/, daemon/, - // multiagent/) writes to those fields directly, and the engine - // only consults the values it holds. The sync is cheap (two - // pointer assignments) and removes a class of "settings lost" - // bugs when callers mutate the session after construction. - s.syncPermissionCompatibility() - granted, denyMsg := s.PermSvc().CheckTool(ctx, ToolCallInfo{ - Name: tc.Name, - ID: tc.ID, - Args: tc.Arguments, - }) - s.recordPolicyObservation(tc, "permission", granted, denyMsg) - if !granted { - ch <- StreamEvent{Type: "tool_result", ToolName: tc.Name, Content: denyMsg} - if toolSpan != nil { - toolSpan.SetTag("denied", "true") - toolSpan.Finish() - } - return toolExecResult{tc: tc, output: denyMsg, isErr: true} - } - - // Human-in-the-loop approval gate for high-risk actions (additive; no-op - // unless s.Approval is configured and enabled). See approval_gate.go. - approved, approvalDeny := s.CheckApproval(ctx, tc.Name, tc.Arguments) - if approval := s.PermSvc().Approval(); approval != nil && approval.Enabled { - s.recordPolicyObservation(tc, "approval", approved, approvalDeny) - } - if !approved { - ch <- StreamEvent{Type: "tool_result", ToolName: tc.Name, Content: approvalDeny} - if toolSpan != nil { - toolSpan.SetTag("approval_denied", "true") - toolSpan.Finish() - } - return toolExecResult{tc: tc, output: approvalDeny, isErr: true} - } - - hooks.ExecuteAsync(ctx, hooks.EventPreTool, map[string]interface{}{ - "tool": tc.Name, - "args": tc.Arguments, - }) - - inputJSON, _ := json.Marshal(tc.Arguments) - toolCtx := tool.WithToolContext(ctx, &tool.ToolContext{ - AgentSpawnFn: s.AgentSpawnFn, - AskUserFn: s.AskUserFn, - CommitMessageChatFn: func(chatCtx context.Context, prompt string) (string, error) { - if s.ChatLLM() == nil { - return "", fmt.Errorf("commit message model is unavailable") - } - resp, err := s.ChatLLM().Chat(chatCtx, []types.EyrieMessage{{Role: "user", Content: prompt}}, types.ChatOptions{ - Provider: s.ChatLLM().Provider(), - Model: s.ChatLLM().Model(), - MaxTokens: 256, - }) - if err != nil { - return "", err - } - if resp == nil { - return "", fmt.Errorf("commit message model returned no response") - } - return resp.Content, nil - }, - YaadBridge: s.MemorySvc().Yaad(), - SpecSlugGet: func() string { return s.PermSvc().SpecSlug() }, - SpecSlugSet: func(slug string) { s.PermSvc().SetSpecSlug(slug) }, - BackgroundManager: s.ensureBackgroundManager(), - ReadOnlyBash: s.readOnlyBash, - WorkingDir: s.workingDir, - }) - if s.Tools().ContainerExecutor() != nil && s.Tools().ContainerExecutor().Running() { - toolCtx = tool.WithContainerExecutor(toolCtx, s.Tools().ContainerExecutor()) - } - toolCtx, toolCancel := context.WithTimeout(toolCtx, toolTimeout(tc.Name)) - - // Apply the per-tool retry policy for transient errors. Tools can opt out - // by setting a zero-value RetryPolicy on themselves (via the - // RetryPolicyProvider interface) — Read/Write/Edit etc. don't opt out and - // get the default policy of 2 retries (3 attempts total) with 200ms→2s - // exponential backoff. - t := override - if t == nil { - var ok bool - if s.registry != nil { - t, ok = s.registry.Get(tc.Name) - } - if !ok { - toolCancel() - output := fmt.Sprintf("Error: unknown tool: %s", tc.Name) - ch <- StreamEvent{Type: "tool_result", ToolName: tc.Name, Content: output} - return toolExecResult{tc: tc, output: output, isErr: true} - } - } - if rpp, ok := t.(tool.RetryPolicyProvider); ok { - output, execErr = tool.RetryExecutor(toolCtx, t, inputJSON, rpp.RetryPolicy()) - } else { - output, execErr = tool.RetryExecutor(toolCtx, t, inputJSON, tool.DefaultRetryPolicy()) - } - toolCancel() - isErr = execErr != nil - } + core := s.Tools().ExecuteOne(ctx, tc, override, ch, turnCount, intentText) + output, execErr, isErr := core.output, core.err, core.isErr if isErr { - s.Logger().Warn("tool execution error", map[string]interface{}{ - "tool": tc.Name, - "error": execErr.Error(), - }) + s.Logger().Warn("tool execution error", map[string]interface{}{"tool": tc.Name, "error": execErr.Error()}) output = fmt.Sprintf("Error: %s", execErr.Error()) if s.LifecycleSvc().Backtrack() != nil { s.LifecycleSvc().Backtrack().MarkOutcome(turnCount, "failure") } - - // LLM Reflection on Failure: ask the model WHY this failed if s.LifecycleSvc().Reflector() != nil && shouldReflect(tc.Name, execErr) { 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\n"+ - "Try a different approach based on this analysis.", - reflection.WhatFailed, reflection.WhyFailed, reflection.WhatToDo) + 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) } } } else { - s.Logger().Info("tool executed", map[string]interface{}{ - "tool": tc.Name, - "output": len(output), - }) - - // Self-Review Before Apply: for Write/Edit, ask LLM to review changes - if preEditPath != "" && s.client != nil && shouldSelfReview(tc.Name) { + s.Logger().Info("tool executed", map[string]interface{}{"tool": tc.Name, "output": len(output)}) + if preEditPath != "" && s.ChatLLM() != nil && shouldSelfReview(tc.Name) { if newContent, readErr := readFileContent(preEditPath); readErr == nil && newContent != preEditContent { reviewResult, reviewErr := ReviewBeforeWrite(ctx, s.ChatLLM().Client(), s.ChatLLM().Model(), intentText, preEditPath, preEditContent, newContent) if reviewErr == nil && reviewResult != nil && !reviewResult.Approved { - // Revert the file to its original state. If revert fails we - // MUST surface that as a hard tool error: silently leaving - // the rejected diff on disk would let a downstream turn - // build on top of code the LLM just said was wrong. var revertErr error if preEditContent == "" { revertErr = os.Remove(preEditPath) @@ -446,167 +228,26 @@ func (s *Session) executeSingleToolWithTool(ctx context.Context, tc types.ToolCa revertErr = os.WriteFile(preEditPath, []byte(preEditContent), 0o600) } if revertErr != nil { - s.Logger().Error("self-review revert failed; rejecting diff loudly", map[string]interface{}{ - "path": preEditPath, - "error": revertErr.Error(), - }) - output = fmt.Sprintf("Self-review rejected the change AND the revert failed: %s. "+ - "Original review issues: %s. Manual intervention required.", - revertErr.Error(), strings.Join(reviewResult.Issues, "; ")) - isErr = true + s.Logger().Error("self-review revert failed; rejecting diff loudly", map[string]interface{}{"path": preEditPath, "error": revertErr.Error()}) + output = fmt.Sprintf("Self-review rejected the change AND the revert failed: %s. Original review issues: %s. Manual intervention required.", revertErr.Error(), strings.Join(reviewResult.Issues, "; ")) } else { issueStr := "Self-review found issues: " + strings.Join(reviewResult.Issues, "; ") if len(reviewResult.Suggestions) > 0 { issueStr += ". Suggestions: " + strings.Join(reviewResult.Suggestions, "; ") } output = issueStr + ". Please fix these issues and try again." - isErr = true } + isErr = true } else if reviewErr == nil && reviewResult != nil && reviewResult.Approved { - // Append diff summary to output for TUI display - diffSummary := generateDiffSummary(preEditContent, newContent, preEditPath) - if diffSummary != "" { + if diffSummary := generateDiffSummary(preEditContent, newContent, preEditPath); diffSummary != "" { output += "\n" + diffSummary } } } } } - - if !s.Tools().executionDepsReady() { - if s.LifecycleSvc().Limits() != nil { - s.LifecycleSvc().Limits().RecordToolCall(tc.Name) - } - - canonical := canonicalToolName(tc.Name) - if s.LifecycleSvc().Beliefs() != nil && (canonical == "Read" || canonical == "Grep" || canonical == "Glob" || canonical == "LS") { - subject := tc.Name - if p, ok := pathArgument(tc.Arguments); ok { - subject = p - } - contentSummary := output - if len(contentSummary) > 200 { - contentSummary = contentSummary[:200] - } - s.LifecycleSvc().Beliefs().Record("file_purpose", subject, contentSummary, turnCount) - } - - if s.MemorySvc().Enhanced() != nil && (canonical == "Read" || canonical == "Edit" || canonical == "Write") { - if p, ok := pathArgument(tc.Arguments); ok && p != "" { - if proactiveCtx := s.MemorySvc().Enhanced().ProactiveContextForFile(p); proactiveCtx != "" { - s.AppendSystemContext(proactiveCtx) - } - } - } - - if s.LifecycleSvc().Beliefs() != nil && (canonical == "Write" || canonical == "Edit") { - if p, ok := pathArgument(tc.Arguments); ok { - s.LifecycleSvc().Beliefs().Invalidate(p) - } - } - - // Auto-accumulate learnings into Hawk user state. - if s.LifecycleSvc().AgentsAccum() != nil && !isErr && (canonical == "Write" || canonical == "Edit") { - if p, ok := pathArgument(tc.Arguments); ok && p != "" { - pattern := prompts.ExtractPattern(tc.Name, p, output) - s.LifecycleSvc().AgentsAccum().Record(intentText, pattern, []string{p}) - // Flush periodically (every 5 learnings) - if err := s.LifecycleSvc().AgentsAccum().Flush(); err != nil { - slog.Warn("failed to flush agents accumulator", "error", err) - } - } - } - - if s.LifecycleSvc().Critic() != nil && !isErr && (canonical == "Write" || canonical == "Edit") { - if p, ok := pathArgument(tc.Arguments); ok { - origContent := "" - if data, readErr := readFileContent(p); readErr == nil { - origContent = data - } - verdict := s.LifecycleSvc().Critic().PreScreenPatch(origContent, output, intentText) - if s.LifecycleSvc().Critic().ShouldBlock(verdict) { - issueStr := strings.Join(verdict.Issues, "; ") - output = fmt.Sprintf("Patch rejected by validator: %s. Try again.", issueStr) - isErr = true - } - } - } - - if s.LifecycleSvc().Shadow() != nil && !isErr && (canonical == "Write" || canonical == "Edit") { - if p, ok := pathArgument(tc.Arguments); ok { - validationErrs := s.LifecycleSvc().Shadow().ValidateEdit(p, output) - if len(validationErrs) > 0 { - var warnings []string - for _, ve := range validationErrs { - warnings = append(warnings, ve.Message) - } - output += fmt.Sprintf("\n\nValidation warnings: %s", strings.Join(warnings, "; ")) - } - } - } - - sandboxIntercepted := false - if s.Tools().Sandbox() != nil && s.Tools().Sandbox().IsEnabled() && !isErr && (canonical == "Write" || canonical == "Edit") { - if p, ok := pathArgument(tc.Arguments); ok { - origContent := "" - if data, readErr := readFileContent(p); readErr == nil { - origContent = data - } - action := "overwrite" - if canonical == "Edit" { - action = "edit" - } - s.Tools().Sandbox().Stage(p, action, origContent, output) - output = fmt.Sprintf("Change staged for review (%s: %s)", action, p) - sandboxIntercepted = true - } - } - - if s.LifecycleSvc().LintLoop() != nil && s.LifecycleSvc().LintLoop().Enabled && !isErr && !sandboxIntercepted && (canonical == "Write" || canonical == "Edit") { - if p, ok := pathArgument(tc.Arguments); ok { - count := s.LifecycleSvc().LintLoop().ReflectionCount(p) - if s.LifecycleSvc().LintLoop().ShouldRetry(count) { - if lintResult, lintErr := s.LifecycleSvc().LintLoop().RunLint(p); lintErr == nil && lintResult != nil { - reflected := s.LifecycleSvc().LintLoop().BuildReflectedMessage(lintResult) - if reflected != "" { - s.LifecycleSvc().LintLoop().RecordReflection(p) - output += "\n\n" + reflected - } - } - } - } - } - - output = s.Tools().NormalizeOutput(output, canonical, tc.ID, s.ContextWindowSize()) - - if s.LifecycleSvc().Pipeline() != nil { - var execErr error - if isErr { - execErr = fmt.Errorf("%s", output) - } - toolResult := s.LifecycleSvc().Pipeline().PostToolExecution(tc.Name, tc.Arguments, output, execErr) - if toolResult != nil { - if toolResult.StallWarning != "" { - output += "\n\n" + toolResult.StallWarning - } - if toolResult.LintErrors != "" { - output += "\n\nLint: " + toolResult.LintErrors - } - if toolResult.RecoveryAction != "" && toolResult.ShouldRetry { - output += "\n\nRecovery suggestion: " + toolResult.RecoveryAction - } - } - } - } - if s.Tools().executionDepsReady() { - processed := s.Tools().PostProcess(ctx, toolExecResult{ - tc: tc, output: output, isErr: isErr, err: execErr, span: toolSpan, - }, turnCount, intentText, s.ContextWindowSize()) - return s.Tools().CompleteResult(ctx, processed, ch) - } - return s.Tools().CompleteResult(ctx, toolExecResult{ - tc: tc, output: output, isErr: isErr, err: execErr, span: toolSpan, - }, ch) + processed := s.Tools().PostProcess(ctx, toolExecResult{tc: tc, output: output, isErr: isErr, err: execErr, span: core.span}, turnCount, intentText, s.ContextWindowSize()) + return s.Tools().CompleteResult(ctx, processed, ch) } // shouldReflect determines if the Reflector should analyze a tool failure. diff --git a/internal/engine/tool_service.go b/internal/engine/tool_service.go index 66adf388..25acc56c 100644 --- a/internal/engine/tool_service.go +++ b/internal/engine/tool_service.go @@ -24,7 +24,6 @@ import ( // god-object decomposition (see docs/session-decomposition.md). type ToolService struct { registry *tool.Registry - host toolExecutionHost containerExecutor tool.ContainerExecutor containerRequired bool tracer *oteltrace.Tracer @@ -56,28 +55,11 @@ type toolExecutionDeps struct { appendSystem func(string) } -// toolExecutionHost is the narrow compatibility seam used while the -// historical post-tool pipeline is moved out of Session. It deliberately -// exposes only batch execution; policy, persistence, and lifecycle state are -// still obtained from their dedicated services by the host implementation. -// Keeping this seam small lets callers migrate to ToolService without -// reintroducing direct Session field access. -type toolExecutionHost interface { - executeSingleTool(context.Context, types.ToolCall, chan<- StreamEvent, int, string) toolExecResult -} - // NewToolService constructs a ToolService with the given registry. func NewToolService(registry *tool.Registry) *ToolService { return &ToolService{registry: registry} } -// WithExecutionHost attaches the session-independent execution seam. It is -// set once during session construction and is safe to replace in tests. -func (s *ToolService) WithExecutionHost(host toolExecutionHost) *ToolService { - s.host = host - return s -} - // WithExecutionDeps binds the extracted service graph used by ExecuteOne. func (s *ToolService) WithExecutionDeps(deps toolExecutionDeps) *ToolService { s.deps = deps @@ -90,10 +72,6 @@ func (s *ToolService) WithMetrics(registry *metrics.Registry) *ToolService { return s } -func (s *ToolService) executionDepsReady() bool { - return s != nil && s.deps.permissions != nil -} - // WithContainerExecutor configures container isolation. func (s *ToolService) WithContainerExecutor(ce tool.ContainerExecutor, required bool) *ToolService { s.containerExecutor = ce @@ -155,13 +133,13 @@ func (s *ToolService) Classify(calls []types.ToolCall) (concurrent, sequential [ // ExecuteAll runs the complete tool batch pipeline. The service owns the // public operation and callers no longer need to reach into Session's -// unexported execution method. A missing host produces deterministic error -// results instead of panicking, which keeps isolated service tests useful. +// unexported execution method. An unconfigured service produces deterministic +// errors instead of panicking. func (s *ToolService) ExecuteAll(ctx context.Context, calls []types.ToolCall, ch chan<- StreamEvent, turn int, intent string) []toolExecResult { - if s == nil || s.host == nil { + if s == nil || s.deps.permissions == nil { results := make([]toolExecResult, len(calls)) for i, call := range calls { - msg := "tool execution host is unavailable" + msg := "tool execution service is unavailable" results[i] = toolExecResult{tc: call, output: msg, isErr: true} if ch != nil { ch <- StreamEvent{Type: "tool_result", ToolName: call.Name, Content: msg} @@ -200,20 +178,20 @@ func (s *ToolService) ExecuteAll(ctx context.Context, calls []types.ToolCall, ch networkSem <- struct{}{} defer func() { <-networkSem }() } - results[item.index] = s.host.executeSingleTool(ctx, item.tc, ch, turn, intent) + results[item.index] = s.ExecuteOne(ctx, item.tc, nil, ch, turn, intent) }(item) } wg.Wait() for _, item := range sequentialCalls { - results[item.index] = s.host.executeSingleTool(ctx, item.tc, ch, turn, intent) + results[item.index] = s.ExecuteOne(ctx, item.tc, nil, ch, turn, intent) } return results } -// ExecuteOne performs the service-owned half of one tool invocation: event +// ExecuteOne performs the service-owned tool invocation: event // emission, container readiness, permission/approval, tracing, tool context, -// lookup, timeout, retry, and raw execution. Session remains responsible for -// compatibility post-processing of the returned result. +// lookup, timeout, retry, and raw execution. PostProcess and CompleteResult +// own the remaining result lifecycle. func (s *ToolService) ExecuteOne(ctx context.Context, tc types.ToolCall, override tool.Tool, ch chan<- StreamEvent, turn int, intent string) toolExecResult { result := toolExecResult{tc: tc} ch <- StreamEvent{Type: "tool_use", ToolName: tc.Name, ToolID: tc.ID} From 2680b4eaf5def0b6b9bf28825000aa8f032cad6c Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Sun, 2 Aug 2026 00:57:16 +0530 Subject: [PATCH 37/49] refactor: route permissions through service boundary --- cmd/chat_update.go | 28 +++++++++++++++------------ cmd/permissions_center.go | 15 +++++--------- cmd/permissions_center_test.go | 8 ++++---- cmd/statusbar.go | 9 +++++---- internal/acp/server.go | 2 +- internal/engine/council.go | 2 +- internal/engine/permission_service.go | 17 ++++++++++++++++ internal/engine/session.go | 27 +------------------------- internal/engine/session_services.go | 6 +----- 9 files changed, 51 insertions(+), 63 deletions(-) diff --git a/cmd/chat_update.go b/cmd/chat_update.go index aaac1c11..405b23db 100644 --- a/cmd/chat_update.go +++ b/cmd/chat_update.go @@ -618,8 +618,8 @@ func (m chatModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { req.Response <- true m.permReq = nil m.permTimeoutAt = time.Time{} - if m.session != nil && m.session.Perm != nil && m.session.Perm.AutoMode != nil { - m.session.Perm.AutoMode.Record(req.ToolName, req.Summary, true) + if m.session != nil && m.session.PermSvc() != nil && m.session.PermSvc().AutoMode() != nil { + m.session.PermSvc().AutoMode().Record(req.ToolName, req.Summary, true) } m.messages = append(m.messages, displayMsg{role: "system", content: icons.CheckBold() + " Allowed"}) case "n", "N": @@ -627,8 +627,8 @@ func (m chatModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { req.Response <- false m.permReq = nil m.permTimeoutAt = time.Time{} - if m.session != nil && m.session.Perm != nil && m.session.Perm.AutoMode != nil { - m.session.Perm.AutoMode.Record(req.ToolName, req.Summary, false) + if m.session != nil && m.session.PermSvc() != nil && m.session.PermSvc().AutoMode() != nil { + m.session.PermSvc().AutoMode().Record(req.ToolName, req.Summary, false) } m.messages = append(m.messages, displayMsg{role: "system", content: icons.CloseThick() + " Denied"}) case "a", "A": @@ -638,10 +638,12 @@ func (m chatModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { req.Response <- true m.permReq = nil m.permTimeoutAt = time.Time{} - if m.session != nil && m.session.Perm != nil { - m.session.Perm.Memory.AlwaysAllowPattern(toolName + ":*") - if m.session.Perm.AutoMode != nil { - m.session.Perm.AutoMode.Record(toolName, summary, true) + if m.session != nil && m.session.PermSvc() != nil { + if mem := m.session.PermSvc().Memory(); mem != nil { + mem.AlwaysAllowPattern(toolName + ":*") + } + if m.session.PermSvc().AutoMode() != nil { + m.session.PermSvc().AutoMode().Record(toolName, summary, true) } } m.messages = append(m.messages, displayMsg{role: "system", content: icons.CheckBold() + " Always allowed: " + toolName + " (all)"}) @@ -652,10 +654,12 @@ func (m chatModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { req.Response <- false m.permReq = nil m.permTimeoutAt = time.Time{} - if m.session != nil && m.session.Perm != nil { - m.session.Perm.Memory.AlwaysDeny(toolName) - if m.session.Perm.AutoMode != nil { - m.session.Perm.AutoMode.Record(toolName, summary, false) + if m.session != nil && m.session.PermSvc() != nil { + if mem := m.session.PermSvc().Memory(); mem != nil { + mem.AlwaysDeny(toolName) + } + if m.session.PermSvc().AutoMode() != nil { + m.session.PermSvc().AutoMode().Record(toolName, summary, false) } } m.messages = append(m.messages, displayMsg{role: "system", content: icons.CloseThick() + " Always denied: " + toolName}) diff --git a/cmd/permissions_center.go b/cmd/permissions_center.go index a645f94a..dc3a396e 100644 --- a/cmd/permissions_center.go +++ b/cmd/permissions_center.go @@ -103,10 +103,10 @@ func specStageLabel(sess *engine.Session) string { // currentSpecStage returns the session's active spec stage, or // SpecStageNone if the session (or its permission engine) isn't set up yet. func currentSpecStage(sess *engine.Session) engine.SpecStage { - if sess == nil || sess.Perm == nil { + if sess == nil || sess.PermSvc() == nil { return engine.SpecStageNone } - return sess.Perm.Stage + return sess.PermSvc().SpecStage() } // currentDryRun returns whether the session's dry-run kill switch is @@ -115,10 +115,10 @@ func currentSpecStage(sess *engine.Session) engine.SpecStage { // nil for sessions built via a raw struct literal (e.g. in tests) rather // than NewSession. func currentDryRun(sess *engine.Session) bool { - if sess == nil || sess.Perm == nil { + if sess == nil || sess.PermSvc() == nil { return false } - return sess.Perm.DryRun + return sess.PermSvc().DryRun() } func autonomyCommandHelp() string { @@ -234,14 +234,9 @@ func rebuildSessionPermissionRules(sess *engine.Session, settings hawkconfig.Set mem := sess.PermSvc().Memory() if mem == nil { mem = engine.NewPermissionMemory() - if sess.Perm != nil { - sess.Perm.Memory = mem - } + sess.PermSvc().SetMemory(mem) } mem.Reset() - if sess.Perm != nil && sess.Perm.Memory == nil { - sess.Perm.Memory = mem - } for _, spec := range settings.AutoAllow { mem.AllowSpec(spec) } diff --git a/cmd/permissions_center_test.go b/cmd/permissions_center_test.go index ce701118..c5b643ac 100644 --- a/cmd/permissions_center_test.go +++ b/cmd/permissions_center_test.go @@ -46,11 +46,11 @@ func TestEffectivePermissionRules(t *testing.T) { } func TestAutonomyCenterSummary(t *testing.T) { - perm := engine.NewPermissionEngine() - perm.Autonomy = engine.AutonomySemi - perm.Stage = engine.SpecStageSpecify + sess := engine.NewSession("test", "test-model", "system", nil) + sess.PermSvc().SetAutonomy(engine.AutonomySemi) + sess.PermSvc().SetSpecStage(engine.SpecStageSpecify) model := &chatModel{ - session: &engine.Session{Autonomy: engine.AutonomySemi, Perm: perm}, + session: sess, settings: hawkconfig.Settings{ Sandbox: "workspace", AllowedTools: []string{"Bash(git:*)"}, diff --git a/cmd/statusbar.go b/cmd/statusbar.go index 70e08bbf..3c29c68e 100644 --- a/cmd/statusbar.go +++ b/cmd/statusbar.go @@ -176,16 +176,17 @@ func renderStatusBarLeft(m *chatModel) string { // specStageForStatus returns a short spec stage indicator for the status bar, // or empty string if no spec workflow is active. func specStageForStatus(m *chatModel) string { - if m == nil || m.session == nil || m.session.Perm == nil { + if m == nil || m.session == nil || m.session.PermSvc() == nil { return "" } - stage := m.session.Perm.Stage + stage := m.session.PermSvc().SpecStage() if stage == engine.SpecStageNone { return "" } label := specStageDisplayName(stage) - if stage == engine.SpecStageImplementing && m.session.Perm.Phases > 0 { - return fmt.Sprintf("%s %s %d/%d", icons.FileDocument(), label, m.session.Perm.Phase, m.session.Perm.Phases) + phase, phases := m.session.PermSvc().SpecPhaseProgress() + if stage == engine.SpecStageImplementing && phases > 0 { + return fmt.Sprintf("%s %s %d/%d", icons.FileDocument(), label, phase, phases) } return fmt.Sprintf("%s %s", icons.FileDocument(), label) } diff --git a/internal/acp/server.go b/internal/acp/server.go index b9c46c91..a82e9173 100644 --- a/internal/acp/server.go +++ b/internal/acp/server.go @@ -185,7 +185,7 @@ func (s *Server) handleSessionNew(msg rpcMessage) { s.mu.Unlock() // Route tool-permission prompts to the client for this session. - sess.PermissionFn = s.permissionFnFor(id) + sess.SetPermissionFn(s.permissionFnFor(id)) s.reply(msg.ID, map[string]any{"sessionId": id}) } diff --git a/internal/engine/council.go b/internal/engine/council.go index 6dcb1fce..9fc3052a 100644 --- a/internal/engine/council.go +++ b/internal/engine/council.go @@ -182,7 +182,7 @@ func buildChairmanPrompt(query string, responses []CouncilResponse, rankings []C // councilQuery queries a specific model using the session's client infrastructure. func councilQuery(ctx context.Context, sess *Session, modelName, prompt string) (string, error) { - sub := sess.SubSession(modelName, sess.system, sess.registry) + sub := sess.SubSession(modelName, sess.Persistence().System(), sess.Tools().Registry()) if sess.LifecycleSvc() != nil { sess.LifecycleSvc().Limits().SetMaxTurns(1) } diff --git a/internal/engine/permission_service.go b/internal/engine/permission_service.go index f96a4a07..08d9dab2 100644 --- a/internal/engine/permission_service.go +++ b/internal/engine/permission_service.go @@ -231,6 +231,14 @@ func (s *PermissionService) Autonomy() AutonomyLevel { return s.perm.Autonomy } // SpecStage returns the active spec-workflow stage. func (s *PermissionService) SpecStage() SpecStage { return s.perm.Stage } +// SpecPhaseProgress returns the current and total implementation phases. +func (s *PermissionService) SpecPhaseProgress() (current, total int) { + if s == nil || s.perm == nil { + return 0, 0 + } + return s.perm.Phase, s.perm.Phases +} + // AdvanceSpecStage records the next spec workflow transition through the // permission service instead of exposing the underlying engine to callers. func (s *PermissionService) AdvanceSpecStage(toolName string) { @@ -246,6 +254,15 @@ func (s *PermissionService) AdvanceSpecStage(toolName string) { // migrate to `sess.PermSvc().Memory().AllowSpec(...)`. func (s *PermissionService) Memory() *PermissionMemory { return s.memory } +// SetMemory replaces the session's permission-memory policy store. +func (s *PermissionService) SetMemory(m *PermissionMemory) { + if s == nil || s.perm == nil { + return + } + s.memory = m + s.perm.Memory = m +} + // AutoMode returns the legacy AutoModeState shim. func (s *PermissionService) AutoMode() *permissions.AutoModeState { return s.autoMode } diff --git a/internal/engine/session.go b/internal/engine/session.go index 41083088..1abc8625 100644 --- a/internal/engine/session.go +++ b/internal/engine/session.go @@ -95,12 +95,7 @@ type Session struct { memory *MemoryService persist *PersistenceService tools *ToolService - // services is the canonical composition root for the extracted runtime - // collaborators. Legacy fields remain on Session only as compatibility - // shims while callers migrate to Services()/SubServices(). - services *SessionServices - - Perm *PermissionEngine // extracted permission subsystem + Perm *PermissionEngine // extracted permission subsystem // Backward-compatible accessors below (will be removed after full migration) // // Deprecated: use s.PermSvc() (Phase 2 sub-service) for all of: @@ -340,15 +335,6 @@ func NewSessionWithClient(chat ChatClient, provider, model, systemPrompt string, s.life.SetAgentsAccumulator(s.AgentsAccum) s.life.SetLintLoop(s.LintLoop) s.life.SetTestLoop(s.TestLoop) - s.services = &SessionServices{ - Chat: s.llm, - Permissions: s.perms, - LifecycleService: s.life, - MemoryService: s.memory, - Persist: s.persist, - ToolService: s.tools, - } - // Alias legacy fields at the service instances so legacy readers see // the same state as new code that goes through the sub-service getters. // After this point, mutations to the sub-service internal state @@ -530,17 +516,6 @@ func (s *Session) SubServices() SubServices { if s == nil { return SubServices{} } - services := s.Services() - if services != nil { - return SubServices{ - LLM: services.Chat, - Perms: services.Permissions, - Life: services.LifecycleService, - Memory: services.MemoryService, - Persistence: services.Persist, - Tools: services.ToolService, - } - } return SubServices{ LLM: s.llm, Perms: s.perms, diff --git a/internal/engine/session_services.go b/internal/engine/session_services.go index 2de16c2e..925446aa 100644 --- a/internal/engine/session_services.go +++ b/internal/engine/session_services.go @@ -324,11 +324,7 @@ func (s *Session) Services() *SessionServices { if s == nil { return nil } - ss := s.services - if ss == nil { - ss = &SessionServices{} - s.services = ss - } + ss := &SessionServices{} // Refresh the compatibility views on every call. The canonical service // pointers are stable, but legacy callers may configure their fields // after construction (for example /config wiring memory or lifecycle). From 09ab8695ad397089426a4989fc4e7162cb0c1469 Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Sun, 2 Aug 2026 00:59:13 +0530 Subject: [PATCH 38/49] fix: harden permission service boundaries --- internal/engine/permission_service.go | 132 ++++++++++++++++++++++---- 1 file changed, 113 insertions(+), 19 deletions(-) diff --git a/internal/engine/permission_service.go b/internal/engine/permission_service.go index 08d9dab2..38949385 100644 --- a/internal/engine/permission_service.go +++ b/internal/engine/permission_service.go @@ -68,6 +68,12 @@ func NewPermissionService(log *logger.Logger) *PermissionService { // WithEngine replaces the underlying PermissionEngine. Used by tests // and by callers that want a pre-configured engine. func (s *PermissionService) WithEngine(pe *PermissionEngine) *PermissionService { + if s == nil { + return s + } + if pe == nil { + pe = NewPermissionEngine() + } s.perm = pe s.memory = pe.Memory s.autoMode = pe.AutoMode @@ -84,6 +90,9 @@ func (s *PermissionService) Engine() *PermissionEngine { return s.perm } // The caller (engine/stream_tool_exec.go) handles the tool_result // event emission and the post-call side effects. func (s *PermissionService) CheckTool(ctx context.Context, info ToolCallInfo) (bool, string) { + if s == nil || s.perm == nil { + return false, "permission service is unavailable" + } granted, denyMsg := s.perm.CheckTool(ctx, info) if !granted { s.log.Warn("permission denied", map[string]interface{}{ @@ -153,38 +162,75 @@ func (s *PermissionService) CheckApproval(_ context.Context, toolName string, ar } // SetMaxTurns caps the agent loop's turn count. -func (s *PermissionService) SetMaxTurns(turns int) { s.maxTurns = turns } +func (s *PermissionService) SetMaxTurns(turns int) { + if s != nil { + s.maxTurns = turns + } +} // SetMaxBudgetUSD caps the agent loop's spend in USD. -func (s *PermissionService) SetMaxBudgetUSD(usd float64) { s.maxBudgetUSD = usd } +func (s *PermissionService) SetMaxBudgetUSD(usd float64) { + if s != nil { + s.maxBudgetUSD = usd + } +} // SetAllowedDirs sets the directories the agent may write to. -func (s *PermissionService) SetAllowedDirs(dirs []string) { s.allowedDirs = dirs } +func (s *PermissionService) SetAllowedDirs(dirs []string) { + if s != nil { + s.allowedDirs = append([]string(nil), dirs...) + } +} // SetAutonomy sets the agent's autonomy level. Writes directly to the // underlying PermissionEngine — the same field CheckTool reads — rather // than a separate shadow field, so the change actually takes effect. -func (s *PermissionService) SetAutonomy(level AutonomyLevel) { s.perm.Autonomy = level } +func (s *PermissionService) SetAutonomy(level AutonomyLevel) { + if s != nil && s.perm != nil { + s.perm.Autonomy = level + } +} // SetSpecStage sets the independent spec-workflow stage. Also writes // directly to the engine, same reasoning as SetAutonomy. -func (s *PermissionService) SetSpecStage(stage SpecStage) { s.perm.Stage = stage } +func (s *PermissionService) SetSpecStage(stage SpecStage) { + if s != nil && s.perm != nil { + s.perm.Stage = stage + } +} // SetDryRun toggles the global kill switch: when true, every tool call is // denied unconditionally, regardless of tier or spec stage. -func (s *PermissionService) SetDryRun(dryRun bool) { s.perm.DryRun = dryRun } +func (s *PermissionService) SetDryRun(dryRun bool) { + if s != nil && s.perm != nil { + s.perm.DryRun = dryRun + } +} // DryRun reports whether the kill switch is active. -func (s *PermissionService) DryRun() bool { return s.perm.DryRun } +func (s *PermissionService) DryRun() bool { return s != nil && s.perm != nil && s.perm.DryRun } // SetApproval replaces the ApprovalGate. -func (s *PermissionService) SetApproval(a *ApprovalGate) { s.approval = a } +func (s *PermissionService) SetApproval(a *ApprovalGate) { + if s != nil { + s.approval = a + } +} // SetAskUserFn sets the fallback interactive approval callback. -func (s *PermissionService) SetAskUserFn(fn func(question string) (string, error)) { s.askUserFn = fn } +func (s *PermissionService) SetAskUserFn(fn func(question string) (string, error)) { + if s != nil { + s.askUserFn = fn + } +} // Approval returns the configured human-in-the-loop gate. -func (s *PermissionService) Approval() *ApprovalGate { return s.approval } +func (s *PermissionService) Approval() *ApprovalGate { + if s == nil { + return nil + } + return s.approval +} // SpecSlug returns the active specification identifier. func (s *PermissionService) SpecSlug() string { @@ -203,6 +249,9 @@ func (s *PermissionService) SetSpecSlug(slug string) { // SetPermissionFn replaces the user-callback. func (s *PermissionService) SetPermissionFn(fn func(PermissionRequest)) { + if s == nil || s.perm == nil { + return + } s.permissionFn = fn s.perm.PromptFn = fn } @@ -217,19 +266,44 @@ func (s *PermissionService) PermissionFn() func(PermissionRequest) { } // MaxTurns returns the cap (0 = no cap). -func (s *PermissionService) MaxTurns() int { return s.maxTurns } +func (s *PermissionService) MaxTurns() int { + if s == nil { + return 0 + } + return s.maxTurns +} // MaxBudgetUSD returns the cap. -func (s *PermissionService) MaxBudgetUSD() float64 { return s.maxBudgetUSD } +func (s *PermissionService) MaxBudgetUSD() float64 { + if s == nil { + return 0 + } + return s.maxBudgetUSD +} // AllowedDirs returns the write-allowlist. -func (s *PermissionService) AllowedDirs() []string { return s.allowedDirs } +func (s *PermissionService) AllowedDirs() []string { + if s == nil { + return nil + } + return append([]string(nil), s.allowedDirs...) +} // Autonomy returns the autonomy level. -func (s *PermissionService) Autonomy() AutonomyLevel { return s.perm.Autonomy } +func (s *PermissionService) Autonomy() AutonomyLevel { + if s == nil || s.perm == nil { + return 0 + } + return s.perm.Autonomy +} // SpecStage returns the active spec-workflow stage. -func (s *PermissionService) SpecStage() SpecStage { return s.perm.Stage } +func (s *PermissionService) SpecStage() SpecStage { + if s == nil || s.perm == nil { + return SpecStageNone + } + return s.perm.Stage +} // SpecPhaseProgress returns the current and total implementation phases. func (s *PermissionService) SpecPhaseProgress() (current, total int) { @@ -252,7 +326,12 @@ func (s *PermissionService) AdvanceSpecStage(toolName string) { // kept in sync with the engine's classification state; callers // that historically used `sess.Permissions.AllowSpec(...)` should // migrate to `sess.PermSvc().Memory().AllowSpec(...)`. -func (s *PermissionService) Memory() *PermissionMemory { return s.memory } +func (s *PermissionService) Memory() *PermissionMemory { + if s == nil { + return nil + } + return s.memory +} // SetMemory replaces the session's permission-memory policy store. func (s *PermissionService) SetMemory(m *PermissionMemory) { @@ -264,13 +343,28 @@ func (s *PermissionService) SetMemory(m *PermissionMemory) { } // AutoMode returns the legacy AutoModeState shim. -func (s *PermissionService) AutoMode() *permissions.AutoModeState { return s.autoMode } +func (s *PermissionService) AutoMode() *permissions.AutoModeState { + if s == nil { + return nil + } + return s.autoMode +} // Classifier returns the legacy Classifier shim. -func (s *PermissionService) Classifier() *permissions.Classifier { return s.classifier } +func (s *PermissionService) Classifier() *permissions.Classifier { + if s == nil { + return nil + } + return s.classifier +} // BypassKill returns the legacy BypassKillswitch shim. -func (s *PermissionService) BypassKill() *permissions.BypassKillswitch { return s.bypassKill } +func (s *PermissionService) BypassKill() *permissions.BypassKillswitch { + if s == nil { + return nil + } + return s.bypassKill +} // IsZero reports whether this service has been fully configured. // A zero PermissionService has no approval gate and no custom permission From 1f55f75ee8be77a3a4bf8c17d04f6d5af21f1dbb Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Sun, 2 Aug 2026 01:01:33 +0530 Subject: [PATCH 39/49] refactor: remove obsolete session services bridge --- .../memory_service_test_helpers_test.go | 16 + internal/engine/session.go | 8 +- internal/engine/session_services.go | 384 ----------------- internal/engine/session_services_test.go | 406 ------------------ 4 files changed, 17 insertions(+), 797 deletions(-) create mode 100644 internal/engine/memory_service_test_helpers_test.go delete mode 100644 internal/engine/session_services.go delete mode 100644 internal/engine/session_services_test.go diff --git a/internal/engine/memory_service_test_helpers_test.go b/internal/engine/memory_service_test_helpers_test.go new file mode 100644 index 00000000..8b8be53f --- /dev/null +++ b/internal/engine/memory_service_test_helpers_test.go @@ -0,0 +1,16 @@ +package engine + +// mockMemoryRecaller is the minimal in-memory backend used by memory-service +// tests. It intentionally lives beside those tests rather than in the removed +// SessionServices compatibility test. +type mockMemoryRecaller struct{} + +func (m *mockMemoryRecaller) Recall(query string, tokenBudget int) (string, error) { + return "recalled: " + query, nil +} + +func (m *mockMemoryRecaller) Remember(content, category string) error { + return nil +} + +var _ MemoryRecaller = (*mockMemoryRecaller)(nil) diff --git a/internal/engine/session.go b/internal/engine/session.go index 1abc8625..f59409a8 100644 --- a/internal/engine/session.go +++ b/internal/engine/session.go @@ -485,7 +485,7 @@ func (s *Session) ContainerRequired() bool { // SubServices is the composed view of the 6 sub-services extracted // in Phases 1-6 of the god-object decomposition. New code should -// prefer the SubServices() accessor over the legacy Session fields. +// prefer the SubServices() accessor over direct Session state. // Existing code (cmd/, daemon/, multiagent/, …) continues to use // the legacy fields until they're migrated. // @@ -493,12 +493,6 @@ func (s *Session) ContainerRequired() bool { // sub-services are concrete types; this keeps the API discoverable // via godoc and avoids the indirection cost of interface dispatch // on the agent-loop hot path. -// -// Note: this is distinct from the older *SessionServices returned -// by Services(), which is a bridge view over the LEGACY fields -// (CoreLoop, SafetyLayer, Intelligence, etc.). SubServices is the -// new canonical view; SessionServices will be removed once legacy -// migration is complete. type SubServices struct { LLM *ChatService Perms *PermissionService diff --git a/internal/engine/session_services.go b/internal/engine/session_services.go deleted file mode 100644 index 925446aa..00000000 --- a/internal/engine/session_services.go +++ /dev/null @@ -1,384 +0,0 @@ -package engine - -// session_services.go defines the composed service architecture that Session -// should evolve toward. These structs group related concerns and provide a -// cleaner API surface for new code, while existing Session usage remains -// unchanged. -// -// Migration path: -// 1. New code calls session.Services() to get the composed view. -// 2. Gradually move logic from Session methods into service methods. -// 3. Once all callers use Services(), flatten Session to hold only *SessionServices. - -import ( - "github.com/GrayCodeAI/hawk/internal/types" - - "github.com/GrayCodeAI/hawk/internal/engine/branching" - "github.com/GrayCodeAI/hawk/internal/intelligence/memory" - "github.com/GrayCodeAI/hawk/internal/observability/logger" - "github.com/GrayCodeAI/hawk/internal/observability/metrics" - "github.com/GrayCodeAI/hawk/internal/observability/oteltrace" - "github.com/GrayCodeAI/hawk/internal/permissions" - "github.com/GrayCodeAI/hawk/internal/session" - "github.com/GrayCodeAI/hawk/internal/tool" -) - -// --------------------------------------------------------------------------- -// CoreLoop manages the conversation and tool execution cycle. -// --------------------------------------------------------------------------- - -// CoreLoop encapsulates the agent loop: sending messages to the LLM, -// executing tool calls, and accumulating the conversation history. -type CoreLoop struct { - Client ChatClient - Registry *tool.Registry - Messages []types.EyrieMessage - Provider string - Model string - System string - Log *logger.Logger - MaxTurns int -} - -// --------------------------------------------------------------------------- -// SafetyLayer manages permissions, sandbox, and safety limits. -// --------------------------------------------------------------------------- - -// SafetyLayer groups all mechanisms that prevent the agent from causing harm: -// permission checks, sandboxed file edits, rate/size limits, and path protection. -type SafetyLayer struct { - Perm *PermissionEngine - Sandbox *DiffSandbox - Limits *LimitTracker - Autonomy AutonomyLevel - Protected *ProtectedPaths -} - -// IsPermitted is a nil-safe convenience that delegates to the PermissionEngine. -func (sl *SafetyLayer) IsPermitted(action string) bool { - if sl == nil || sl.Perm == nil { - return false - } - return sl.Perm.Classifier != nil -} - -// --------------------------------------------------------------------------- -// Intelligence manages beliefs, memory, and context augmentation. -// --------------------------------------------------------------------------- - -// Intelligence groups all knowledge-oriented subsystems that make the agent -// smarter: persistent memory, belief tracking, file relevance, and skill -// extraction. -type Intelligence struct { - Beliefs *BeliefState - Memory MemoryRecaller - YaadBridge *memory.YaadBridge - Enhanced *memory.EnhancedMemoryManager - FileMentions *FileMentionDetector - Sleeptime *memory.SleeptimeAgent - Activity *memory.ActivityTracker - SkillDistill *memory.SkillDistiller -} - -// fileMentionDetectorPlaceholder is kept for documentation; see file_mentions.go -// for the actual FileMentionDetector type. - -// --------------------------------------------------------------------------- -// Optimizer manages cost tracking, cascade routing, and budgets. -// --------------------------------------------------------------------------- - -// Optimizer groups cost-related subsystems: tracking spend, routing requests -// to cheaper models when possible, and enforcing budget ceilings. -type Optimizer struct { - Cost Cost - CostTracker *CostTracker - Cascade *branching.CascadeRouter - MaxBudget float64 -} - -// WithinBudget returns true if the session has not exceeded MaxBudget. -// Nil-safe: returns true (no limit) when Optimizer is nil. -func (o *Optimizer) WithinBudget() bool { - if o == nil { - return true - } - if o.MaxBudget <= 0 { - return true - } - return o.Cost.TotalCostUSD < o.MaxBudget -} - -// --------------------------------------------------------------------------- -// Observability manages tracing, metrics, and logging. -// --------------------------------------------------------------------------- - -// Observability groups telemetry and diagnostics so that tracing, metrics, -// and structured logging are co-located. -type Observability struct { - Tracer *oteltrace.Tracer - Metrics *metrics.Registry - Log *logger.Logger -} - -// --------------------------------------------------------------------------- -// SessionServices is the composed container replacing the Session god object. -// --------------------------------------------------------------------------- - -// SessionServices is the new composed container that groups Session's 30+ -// fields into coherent sub-services. Use Session.Services() to obtain this -// view from existing code. -type SessionServices struct { - // Canonical extracted services. These are the authoritative runtime - // collaborators for sessions created by NewSessionWithClient. The - // grouped views below remain during the compatibility migration so older - // callers can move incrementally without creating a second object graph. - Chat *ChatService - Permissions *PermissionService - LifecycleService *LifecycleService - MemoryService *MemoryService - Persist *PersistenceService - ToolService *ToolService - - Core *CoreLoop - Safety *SafetyLayer - Intel *Intelligence - Optim *Optimizer - Observe *Observability - - // Advanced features (optional, nil when unused) - Lifecycle *SessionLifecycle - Reflector *Reflector - Critic *Critic - Backtrack *BacktrackEngine - Shadow *branching.ShadowWorkspace - ConversationGraph *session.ConversationGraph - Plan *PlanState - Teach TeachConfig - Trajectory *TrajectoryDistiller - Snapshots SnapshotTracker - LintLoop *LintLoop -} - -// lintLoopPlaceholder is kept for documentation; see lint_loop.go -// for the actual LintLoop type. - -// --------------------------------------------------------------------------- -// Functional options for NewSessionServices -// --------------------------------------------------------------------------- - -// ServiceOption configures a SessionServices during construction. -type ServiceOption func(*SessionServices) - -// WithProvider sets the LLM provider and model on the CoreLoop. -func WithProvider(provider, model string) ServiceOption { - return func(ss *SessionServices) { - if ss.Core == nil { - ss.Core = &CoreLoop{} - } - ss.Core.Provider = provider - ss.Core.Model = model - } -} - -// WithTools sets the tool registry on the CoreLoop. -func WithTools(registry *tool.Registry) ServiceOption { - return func(ss *SessionServices) { - if ss.Core == nil { - ss.Core = &CoreLoop{} - } - ss.Core.Registry = registry - } -} - -// WithMemory sets the MemoryRecaller on the Intelligence service. -func WithMemory(mem MemoryRecaller) ServiceOption { - return func(ss *SessionServices) { - if ss.Intel == nil { - ss.Intel = &Intelligence{} - } - ss.Intel.Memory = mem - } -} - -// WithSandbox sets the DiffSandbox on the SafetyLayer. -func WithSandbox(sandbox *DiffSandbox) ServiceOption { - return func(ss *SessionServices) { - if ss.Safety == nil { - ss.Safety = &SafetyLayer{} - } - ss.Safety.Sandbox = sandbox - } -} - -// WithTracing sets the Tracer on the Observability service. -func WithTracing(tracer *oteltrace.Tracer) ServiceOption { - return func(ss *SessionServices) { - if ss.Observe == nil { - ss.Observe = &Observability{} - } - ss.Observe.Tracer = tracer - } -} - -// WithCascade sets the CascadeRouter on the Optimizer. -func WithCascade(cascade *branching.CascadeRouter) ServiceOption { - return func(ss *SessionServices) { - if ss.Optim == nil { - ss.Optim = &Optimizer{} - } - ss.Optim.Cascade = cascade - } -} - -// WithGuardian configures the SafetyLayer with a permissions.Guardian. -// It wraps the Guardian into the existing PermissionEngine structure. -func WithGuardian(guardian *permissions.Guardian) ServiceOption { - return func(ss *SessionServices) { - if ss.Safety == nil { - ss.Safety = &SafetyLayer{} - } - if ss.Safety.Perm == nil { - ss.Safety.Perm = NewPermissionEngine() - } - // The Guardian is stored in the PermissionEngine for downstream use. - // This bridges the new permissions.Guardian with the legacy PermissionEngine. - _ = guardian // stored when PermissionEngine gains a Guardian field - } -} - -// WithLogger sets the logger on both CoreLoop and Observability. -func WithLogger(log *logger.Logger) ServiceOption { - return func(ss *SessionServices) { - if ss.Core == nil { - ss.Core = &CoreLoop{} - } - ss.Core.Log = log - if ss.Observe == nil { - ss.Observe = &Observability{} - } - ss.Observe.Log = log - } -} - -// WithMaxBudget sets the maximum budget on the Optimizer. -func WithMaxBudget(budget float64) ServiceOption { - return func(ss *SessionServices) { - if ss.Optim == nil { - ss.Optim = &Optimizer{} - } - ss.Optim.MaxBudget = budget - } -} - -// --------------------------------------------------------------------------- -// Constructor -// --------------------------------------------------------------------------- - -// NewSessionServices creates a SessionServices with defaults and applies -// the given functional options. -func NewSessionServices(opts ...ServiceOption) *SessionServices { - log := logger.Default() - ss := &SessionServices{ - Permissions: NewPermissionService(log), - LifecycleService: NewLifecycleService(log), - MemoryService: NewMemoryService(log), - Persist: NewPersistenceService(log), - ToolService: NewToolService(nil), - Core: &CoreLoop{ - Log: log, - }, - Safety: &SafetyLayer{ - Perm: NewPermissionEngine(), - Limits: NewLimitTracker(DefaultLimits()), - }, - Intel: &Intelligence{ - Beliefs: NewBeliefState(), - }, - Optim: &Optimizer{}, - Observe: &Observability{ - Tracer: oteltrace.NewTracer(), - Metrics: metrics.NewRegistry(), - Log: log, - }, - Backtrack: NewBacktrackEngine(), - } - - for _, opt := range opts { - opt(ss) - } - - return ss -} - -// --------------------------------------------------------------------------- -// Bridge: Session -> SessionServices -// --------------------------------------------------------------------------- - -// Services returns a SessionServices view of the existing Session struct. -// This bridges legacy code (which manipulates Session fields directly) with -// new code (which prefers the composed service interface). -// -// The returned *SessionServices references the same underlying objects as -// Session, so mutations are visible in both directions. -func (s *Session) Services() *SessionServices { - if s == nil { - return nil - } - ss := &SessionServices{} - // Refresh the compatibility views on every call. The canonical service - // pointers are stable, but legacy callers may configure their fields - // after construction (for example /config wiring memory or lifecycle). - ss.Chat = s.llm - ss.Permissions = s.perms - ss.LifecycleService = s.life - ss.MemoryService = s.memory - ss.Persist = s.persist - ss.ToolService = s.tools - ss.Core = &CoreLoop{ - Client: s.client, - Registry: s.registry, - Messages: s.Persistence().RawMessages(), - Provider: s.provider, - Model: s.model, - System: s.Persistence().System(), - Log: s.log, - MaxTurns: s.LifecycleSvc().Limits().MaxTurns(), - } - ss.Safety = &SafetyLayer{ - Perm: s.Perm, - Sandbox: s.Tools().Sandbox(), - Limits: s.LifecycleSvc().Limits(), - Autonomy: s.Autonomy, - } - ss.Intel = &Intelligence{ - Beliefs: s.LifecycleSvc().Beliefs(), - Memory: s.MemorySvc().Memory(), - YaadBridge: s.MemorySvc().Yaad(), - Enhanced: s.MemorySvc().Enhanced(), - Sleeptime: s.MemorySvc().Sleeptime(), - Activity: s.MemorySvc().Activity(), - SkillDistill: s.MemorySvc().SkillDistiller(), - } - ss.Optim = &Optimizer{ - Cost: Cost{Model: s.Cost.Model, PromptTokens: s.Cost.PromptTokens, CompletionTokens: s.Cost.CompletionTokens, TotalCostUSD: s.Cost.TotalCostUSD}, - CostTracker: s.CostTracker, - Cascade: s.LifecycleSvc().Cascade(), - MaxBudget: s.LifecycleSvc().Limits().MaxBudgetUSD(), - } - ss.Observe = &Observability{ - Tracer: s.Tracer, - Metrics: s.metrics, - Log: s.log, - } - ss.Lifecycle = s.LifecycleSvc().Lifecycle() - ss.Reflector = s.LifecycleSvc().Reflector() - ss.Critic = s.LifecycleSvc().Critic() - ss.Backtrack = s.LifecycleSvc().Backtrack() - ss.Shadow = s.LifecycleSvc().Shadow() - ss.ConversationGraph = s.Persistence().Graph() - ss.Plan = s.Plan - ss.Teach = s.Teach - ss.Trajectory = s.Trajectory - ss.Snapshots = s.Snapshots - return ss -} diff --git a/internal/engine/session_services_test.go b/internal/engine/session_services_test.go deleted file mode 100644 index 34b51a1d..00000000 --- a/internal/engine/session_services_test.go +++ /dev/null @@ -1,406 +0,0 @@ -package engine - -import ( - "testing" - - "github.com/GrayCodeAI/hawk/internal/engine/branching" - "github.com/GrayCodeAI/hawk/internal/intelligence/memory" - "github.com/GrayCodeAI/hawk/internal/observability/metrics" - "github.com/GrayCodeAI/hawk/internal/observability/oteltrace" - "github.com/GrayCodeAI/hawk/internal/tool" -) - -// --------------------------------------------------------------------------- -// NewSessionServices tests -// --------------------------------------------------------------------------- - -func TestNewSessionServices_Defaults(t *testing.T) { - ss := NewSessionServices() - - if ss.Core == nil { - t.Fatal("Core should not be nil with defaults") - } - if ss.Core.Log == nil { - t.Error("Core.Log should default to logger.Default()") - } - - if ss.Safety == nil { - t.Fatal("Safety should not be nil with defaults") - } - if ss.Safety.Perm == nil { - t.Error("Safety.Perm should be initialized") - } - if ss.Safety.Limits == nil { - t.Error("Safety.Limits should be initialized") - } - - if ss.Intel == nil { - t.Fatal("Intel should not be nil with defaults") - } - if ss.Intel.Beliefs == nil { - t.Error("Intel.Beliefs should be initialized") - } - - if ss.Optim == nil { - t.Fatal("Optim should not be nil with defaults") - } - - if ss.Observe == nil { - t.Fatal("Observe should not be nil with defaults") - } - if ss.Observe.Tracer == nil { - t.Error("Observe.Tracer should be initialized") - } - if ss.Observe.Metrics == nil { - t.Error("Observe.Metrics should be initialized") - } - - if ss.Backtrack == nil { - t.Error("Backtrack should be initialized with defaults") - } -} - -func TestNewSessionServices_WithProvider(t *testing.T) { - ss := NewSessionServices( - WithProvider("anthropic", "claude-opus-4-20250514"), - ) - - if ss.Core.Provider != "anthropic" { - t.Errorf("expected provider 'anthropic', got %q", ss.Core.Provider) - } - if ss.Core.Model != "claude-opus-4-20250514" { - t.Errorf("expected model 'claude-opus-4-20250514', got %q", ss.Core.Model) - } -} - -func TestNewSessionServices_WithTools(t *testing.T) { - reg := tool.NewRegistry() - ss := NewSessionServices(WithTools(reg)) - - if ss.Core.Registry != reg { - t.Error("expected registry to be set on Core") - } -} - -func TestNewSessionServices_WithMemory(t *testing.T) { - mem := &mockMemoryRecaller{} - ss := NewSessionServices(WithMemory(mem)) - - if ss.Intel.Memory != mem { - t.Error("expected memory to be set on Intel") - } -} - -func TestNewSessionServices_WithSandbox(t *testing.T) { - sb := &DiffSandbox{} - ss := NewSessionServices(WithSandbox(sb)) - - if ss.Safety.Sandbox != sb { - t.Error("expected sandbox to be set on Safety") - } -} - -func TestNewSessionServices_WithTracing(t *testing.T) { - tracer := oteltrace.NewTracer() - ss := NewSessionServices(WithTracing(tracer)) - - if ss.Observe.Tracer != tracer { - t.Error("expected tracer to be set on Observe") - } -} - -func TestNewSessionServices_WithCascade(t *testing.T) { - cascade := &branching.CascadeRouter{Enabled: true} - ss := NewSessionServices(WithCascade(cascade)) - - if ss.Optim.Cascade != cascade { - t.Error("expected cascade to be set on Optim") - } - if !ss.Optim.Cascade.Enabled { - t.Error("expected cascade.Enabled to be true") - } -} - -func TestNewSessionServices_WithMaxBudget(t *testing.T) { - ss := NewSessionServices(WithMaxBudget(5.0)) - - if ss.Optim.MaxBudget != 5.0 { - t.Errorf("expected MaxBudget 5.0, got %f", ss.Optim.MaxBudget) - } -} - -func TestNewSessionServices_MultipleOptions(t *testing.T) { - reg := tool.NewRegistry() - mem := &mockMemoryRecaller{} - tracer := oteltrace.NewTracer() - - ss := NewSessionServices( - WithProvider("openai", "gpt-4o"), - WithTools(reg), - WithMemory(mem), - WithTracing(tracer), - WithMaxBudget(10.0), - ) - - if ss.Core.Provider != "openai" { - t.Errorf("expected provider 'openai', got %q", ss.Core.Provider) - } - if ss.Core.Model != "gpt-4o" { - t.Errorf("expected model 'gpt-4o', got %q", ss.Core.Model) - } - if ss.Core.Registry != reg { - t.Error("expected registry set") - } - if ss.Intel.Memory != mem { - t.Error("expected memory set") - } - if ss.Observe.Tracer != tracer { - t.Error("expected tracer set") - } - if ss.Optim.MaxBudget != 10.0 { - t.Errorf("expected MaxBudget 10.0, got %f", ss.Optim.MaxBudget) - } -} - -// --------------------------------------------------------------------------- -// Services() bridge tests -// --------------------------------------------------------------------------- - -func TestSession_Services_Bridge(t *testing.T) { - reg := tool.NewRegistry() - s := NewSession("anthropic", "claude-sonnet-4-20250514", "You are helpful.", reg) - s.LifecycleSvc().Limits().SetMaxBudgetUSD(3.50) - s.Autonomy = AutonomyFull - s.Tools().SetSandbox(&DiffSandbox{}) - s.MemorySvc().SetMemory(&mockMemoryRecaller{}) - s.MemorySvc().SetYaad(&memory.YaadBridge{}) - s.LifecycleSvc().SetCascade(&branching.CascadeRouter{Enabled: true}) - - svc := s.Services() - - // Core mappings - if svc.Core.Provider != "anthropic" { - t.Errorf("Core.Provider: expected 'anthropic', got %q", svc.Core.Provider) - } - if svc.Core.Model != "claude-sonnet-4-20250514" { - t.Errorf("Core.Model: expected 'claude-sonnet-4-20250514', got %q", svc.Core.Model) - } - if svc.Core.System != "You are helpful." { - t.Errorf("Core.System: expected system prompt, got %q", svc.Core.System) - } - if svc.Core.Registry != reg { - t.Error("Core.Registry should reference same registry") - } - - // Safety mappings - if svc.Safety.Perm != s.Perm { - t.Error("Safety.Perm should reference same PermissionEngine") - } - if svc.Safety.Sandbox != s.Tools().Sandbox() { - t.Error("Safety.Sandbox should reference same DiffSandbox") - } - if svc.Safety.Limits != s.LifecycleSvc().Limits() { - t.Error("Safety.Limits should reference same LimitTracker") - } - if svc.Safety.Autonomy != AutonomyFull { - t.Error("Safety.Autonomy should be AutonomyFull") - } - - // Intel mappings - if svc.Intel.Beliefs != s.LifecycleSvc().Beliefs() { - t.Error("Intel.Beliefs should reference same BeliefState") - } - if svc.Intel.Memory != s.MemorySvc().Memory() { - t.Error("Intel.Memory should reference same MemoryRecaller") - } - if svc.Intel.YaadBridge != s.MemorySvc().Yaad() { - t.Error("Intel.YaadBridge should reference same YaadBridge") - } - - // Optim mappings - if svc.Optim.MaxBudget != 3.50 { - t.Errorf("Optim.MaxBudget: expected 3.50, got %f", svc.Optim.MaxBudget) - } - if svc.Optim.Cascade != s.LifecycleSvc().Cascade() { - t.Error("Optim.Cascade should reference same CascadeRouter") - } - - // Observe mappings - if svc.Observe.Tracer != s.Tracer { - t.Error("Observe.Tracer should reference same Tracer") - } - if svc.Observe.Metrics != s.Metrics() { - t.Error("Observe.Metrics should reference same metrics.Registry") - } - - // Advanced features - if svc.Backtrack != s.LifecycleSvc().Backtrack() { - t.Error("Backtrack should reference same engine") - } -} - -func TestSession_Services_NilAdvancedFeatures(t *testing.T) { - s := NewSession("openai", "gpt-4o", "test", tool.NewRegistry()) - svc := s.Services() - - // These are optional and should be nil when not configured - if svc.Lifecycle != nil { - t.Error("Lifecycle should be nil when not set") - } - if svc.Reflector != nil { - t.Error("Reflector should be nil when not set") - } - if svc.Critic != nil { - t.Error("Critic should be nil when not set") - } - if svc.Shadow != nil { - t.Error("Shadow should be nil when not set") - } - if svc.ConversationGraph != nil { - t.Error("ConversationGraph should be nil when not set") - } - if svc.Plan != nil { - t.Error("Plan should be nil when not set") - } - if svc.Snapshots != nil { - t.Error("Snapshots should be nil when not set") - } -} - -// --------------------------------------------------------------------------- -// Nil-safety tests: sub-services handle nil gracefully -// --------------------------------------------------------------------------- - -func TestSafetyLayer_NilSafe(t *testing.T) { - var sl *SafetyLayer - - // Calling IsPermitted on nil SafetyLayer should not panic - if sl.IsPermitted("write") { - t.Error("nil SafetyLayer should deny permissions") - } - - // Non-nil SafetyLayer with nil Perm - sl = &SafetyLayer{} - if sl.IsPermitted("write") { - t.Error("SafetyLayer with nil Perm should deny permissions") - } -} - -func TestOptimizer_NilSafe(t *testing.T) { - var o *Optimizer - - // Calling WithinBudget on nil Optimizer should not panic - if !o.WithinBudget() { - t.Error("nil Optimizer should return true (no limit)") - } - - // Zero budget means unlimited - o = &Optimizer{} - if !o.WithinBudget() { - t.Error("zero MaxBudget should mean unlimited") - } - - // Within budget - o = &Optimizer{MaxBudget: 10.0, Cost: Cost{TotalCostUSD: 5.0}} - if !o.WithinBudget() { - t.Error("5.0 < 10.0 should be within budget") - } - - // Over budget - o = &Optimizer{MaxBudget: 10.0, Cost: Cost{TotalCostUSD: 15.0}} - if o.WithinBudget() { - t.Error("15.0 > 10.0 should be over budget") - } -} - -func TestSessionServices_NilSubservices(t *testing.T) { - // A SessionServices where everything is nil should not panic on access - ss := &SessionServices{} - - if ss.Core != nil { - t.Log("Core is nil, that's fine") - } - if ss.Safety != nil { - t.Log("Safety is nil, that's fine") - } - if ss.Intel != nil { - t.Log("Intel is nil, that's fine") - } - if ss.Optim != nil { - t.Log("Optim is nil, that's fine") - } - if ss.Observe != nil { - t.Log("Observe is nil, that's fine") - } - - // Verify nil Optimizer convenience method works - if !ss.Optim.WithinBudget() { - t.Error("nil Optim.WithinBudget() should return true") - } -} - -func TestObservability_NilFields(t *testing.T) { - obs := &Observability{} - - // Accessing nil fields should be safe (no method calls, just nil checks) - if obs.Tracer != nil { - t.Error("expected nil Tracer") - } - if obs.Metrics != nil { - t.Error("expected nil Metrics") - } - if obs.Log != nil { - t.Error("expected nil Log") - } -} - -func TestIntelligence_NilFields(t *testing.T) { - intel := &Intelligence{} - - if intel.Beliefs != nil { - t.Error("expected nil Beliefs") - } - if intel.Memory != nil { - t.Error("expected nil Memory") - } - if intel.YaadBridge != nil { - t.Error("expected nil YaadBridge") - } - if intel.Enhanced != nil { - t.Error("expected nil Enhanced") - } - if intel.Sleeptime != nil { - t.Error("expected nil Sleeptime") - } - if intel.Activity != nil { - t.Error("expected nil Activity") - } - if intel.SkillDistill != nil { - t.Error("expected nil SkillDistill") - } -} - -// --------------------------------------------------------------------------- -// Helpers -// --------------------------------------------------------------------------- - -// mockMemoryRecaller implements MemoryRecaller for testing. -type mockMemoryRecaller struct{} - -func (m *mockMemoryRecaller) Recall(query string, tokenBudget int) (string, error) { - return "recalled: " + query, nil -} - -func (m *mockMemoryRecaller) Remember(content, category string) error { - return nil -} - -// Ensure mockMemoryRecaller satisfies the interface at compile time. -var _ MemoryRecaller = (*mockMemoryRecaller)(nil) - -// Suppress unused import warnings for packages used only in type assertions. -var ( - _ *memory.YaadBridge = nil - _ *metrics.Registry = nil - _ *oteltrace.Tracer = nil -) From 8b703abf370d37b5c9c5736b9d7646bd629970bf Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Sun, 2 Aug 2026 01:21:59 +0530 Subject: [PATCH 40/49] refactor: remove session permission and state aliases --- cmd/chat_permission_keys_test.go | 2 +- docs/session-decomposition.md | 22 ++- internal/engine/approval_gate.go | 1 - internal/engine/approval_gate_test.go | 52 +++--- internal/engine/context_governor_test.go | 4 +- internal/engine/engine_integration_test.go | 20 +-- internal/engine/integration_test.go | 4 +- internal/engine/session.go | 195 +++++---------------- internal/engine/session_h3_h4_test.go | 40 +---- internal/engine/spec_mode_test.go | 28 +-- internal/engine/sub_service_wiring_test.go | 25 +-- internal/engine/tool_service.go | 4 - 12 files changed, 121 insertions(+), 276 deletions(-) diff --git a/cmd/chat_permission_keys_test.go b/cmd/chat_permission_keys_test.go index 101945ef..940d28af 100644 --- a/cmd/chat_permission_keys_test.go +++ b/cmd/chat_permission_keys_test.go @@ -42,7 +42,7 @@ func TestPermissionAlwaysAllowDoesNotNilDeref(t *testing.T) { if got := lastSystemMessage(cm.messages); !strings.Contains(got, "Always allowed: Bash") { t.Fatalf("unexpected always-allow message: %q", got) } - decision := cm.session.Perm.Memory.Check("Bash", "anything") + decision := cm.session.PermSvc().Memory().Check("Bash", "anything") if decision == nil || !*decision { t.Fatal("expected Bash:* always-allow rule to be recorded") } diff --git a/docs/session-decomposition.md b/docs/session-decomposition.md index daf482e7..752cd4fb 100644 --- a/docs/session-decomposition.md +++ b/docs/session-decomposition.md @@ -1,8 +1,9 @@ # Session God-Object Decomposition — Design and Migration Status -> Status: **IN PROGRESS** — the canonical service graph and first runtime -> migrations are implemented; compatibility shims remain while the remaining -> Session call sites are moved. +> Status: **IN PROGRESS** — the canonical service graph is active and the +> runtime execution path no longer uses legacy permission or tool fallbacks. +> Remaining work is limited to moving the last non-authoritative Session fields +> into their owning services. > Author: opencode session > Date: 2026-06-12 > Scope: `hawk/internal/engine/session.go` (the 35-collaborator `Session` struct) @@ -31,8 +32,8 @@ The `agentLoop` should consume these sub-services as named dependencies — no i The refactor branch now enforces these boundaries: -- `SessionServices` is the canonical composition root for the six extracted - services; `SubServices()` and `Services()` reference the same instances. +- `SubServices()` is the canonical composition root for the six extracted + services. The obsolete `SessionServices` bridge has been removed. - `PersistenceService` owns immutable transcript snapshots, system-context mutations, compaction metadata, token accounting, and checkpoint identity. Returned messages are deep copies, including nested tool arguments. @@ -40,8 +41,8 @@ The refactor branch now enforces these boundaries: adaptive feedback, model cascade access, and quality-loop handles. - `MemoryService` owns recall fallback, Yaad/enhanced-memory finalization, and session summaries. -- `PermissionService` owns the approval gate and fallback ask-user callback; - `Session.CheckApproval` is now only a compatibility facade. +- `PermissionService` owns the approval gate and ask-user callback; + `Session.CheckApproval` is a thin orchestration facade with no state sync. - `ToolService.ExecuteAll` owns batching, ordering, blast-radius reporting, and read-only concurrency limits. `ToolService.ExecuteOne` now owns raw invocation boundaries: permission/approval, tracing, isolation, context @@ -52,8 +53,11 @@ The refactor branch now enforces these boundaries: - The agent loop uses these service APIs for transport, persistence, memory, lifecycle, permission-stage, and tool-batch operations. -Legacy fields remain until all external and in-package callers migrate. They -are compatibility aliases, not a second authoritative state store. +The permission aliases (`Perm`, `Permissions`, `AutoMode`, `Classifier`, +`BypassKill`, `PermissionFn`, `Approval`, and `Autonomy`) have been removed from +`Session`. Remaining lifecycle, memory, and persistence fields are being moved +incrementally; service state is authoritative and no fallback execution path +exists. ## Proposed Decomposition diff --git a/internal/engine/approval_gate.go b/internal/engine/approval_gate.go index 97fafcd3..748f1de4 100644 --- a/internal/engine/approval_gate.go +++ b/internal/engine/approval_gate.go @@ -168,7 +168,6 @@ func (s *Session) CheckApproval(ctx context.Context, toolName string, args map[s if s == nil || s.PermSvc() == nil { return false, "permission service is unavailable" } - s.syncPermissionCompatibility() return s.PermSvc().CheckApproval(ctx, toolName, args) } diff --git a/internal/engine/approval_gate_test.go b/internal/engine/approval_gate_test.go index e6d5d2f0..2722ae67 100644 --- a/internal/engine/approval_gate_test.go +++ b/internal/engine/approval_gate_test.go @@ -7,14 +7,14 @@ import ( func TestApprovalGate_Disabled_NoOp(t *testing.T) { s := NewSession("test", "m", "", nil) - s.Autonomy = AutonomyYOLO + s.PermSvc().SetAutonomy(AutonomyYOLO) // No gate configured: high-risk action proceeds (default behavior unchanged). ok, _ := s.CheckApproval(context.Background(), "Bash", map[string]interface{}{"command": "rm -rf /tmp/x"}) if !ok { t.Fatal("nil gate should be a no-op (allow)") } - s.Approval = &ApprovalGate{Enabled: false} + s.SetApproval(&ApprovalGate{Enabled: false}) ok, _ = s.CheckApproval(context.Background(), "Bash", map[string]interface{}{"command": "rm -rf /tmp/x"}) if !ok { t.Fatal("disabled gate should be a no-op (allow)") @@ -24,8 +24,8 @@ func TestApprovalGate_Disabled_NoOp(t *testing.T) { func TestApprovalGate_FlaggedDestructiveRequiresApproval(t *testing.T) { approvedCalls := 0 s := NewSession("test", "m", "", nil) - s.Autonomy = AutonomyFull // above MaxAutoApprove default (supervised) - s.Approval = &ApprovalGate{ + s.PermSvc().SetAutonomy(AutonomyFull) // above MaxAutoApprove default (supervised) + s.SetApproval(&ApprovalGate{ Enabled: true, ConfirmFn: func(req ApprovalRequest) ApprovalResponse { approvedCalls++ @@ -34,7 +34,7 @@ func TestApprovalGate_FlaggedDestructiveRequiresApproval(t *testing.T) { } return ApprovalReject // human denies }, - } + }) ok, msg := s.CheckApproval(context.Background(), "Bash", map[string]interface{}{"command": "rm -rf build/"}) if ok { @@ -50,11 +50,11 @@ func TestApprovalGate_FlaggedDestructiveRequiresApproval(t *testing.T) { func TestApprovalGate_HumanApproves(t *testing.T) { s := NewSession("test", "m", "", nil) - s.Autonomy = AutonomyFull - s.Approval = &ApprovalGate{ + s.PermSvc().SetAutonomy(AutonomyFull) + s.SetApproval(&ApprovalGate{ Enabled: true, ConfirmFn: func(req ApprovalRequest) ApprovalResponse { return ApprovalApprove }, - } + }) ok, _ := s.CheckApproval(context.Background(), "Bash", map[string]interface{}{"command": "curl http://example.com"}) if !ok { t.Fatal("action should proceed when human approves") @@ -64,12 +64,12 @@ func TestApprovalGate_HumanApproves(t *testing.T) { func TestApprovalGate_AutoApproveThreshold(t *testing.T) { called := false s := NewSession("test", "m", "", nil) - s.Autonomy = AutonomyBasic // <= MaxAutoApprove - s.Approval = &ApprovalGate{ + s.PermSvc().SetAutonomy(AutonomyBasic) // <= MaxAutoApprove + s.SetApproval(&ApprovalGate{ Enabled: true, MaxAutoApprove: AutonomySemi, ConfirmFn: func(req ApprovalRequest) ApprovalResponse { called = true; return ApprovalReject }, - } + }) ok, _ := s.CheckApproval(context.Background(), "Bash", map[string]interface{}{"command": "rm -rf x"}) if !ok { t.Fatal("within auto-approve threshold the action should proceed without prompting") @@ -82,11 +82,11 @@ func TestApprovalGate_AutoApproveThreshold(t *testing.T) { func TestApprovalGate_NonRiskyActionNotGated(t *testing.T) { called := false s := NewSession("test", "m", "", nil) - s.Autonomy = AutonomyYOLO - s.Approval = &ApprovalGate{ + s.PermSvc().SetAutonomy(AutonomyYOLO) + s.SetApproval(&ApprovalGate{ Enabled: true, ConfirmFn: func(req ApprovalRequest) ApprovalResponse { called = true; return ApprovalReject }, - } + }) // A plain read-style command is not high-risk. ok, _ := s.CheckApproval(context.Background(), "Bash", map[string]interface{}{"command": "ls -la"}) if !ok { @@ -100,12 +100,12 @@ func TestApprovalGate_NonRiskyActionNotGated(t *testing.T) { func TestApprovalGate_CategoryFilter(t *testing.T) { called := false s := NewSession("test", "m", "", nil) - s.Autonomy = AutonomyFull - s.Approval = &ApprovalGate{ + s.PermSvc().SetAutonomy(AutonomyFull) + s.SetApproval(&ApprovalGate{ Enabled: true, Categories: map[ApprovalCategory]bool{ApprovalNetwork: true}, // only network gated ConfirmFn: func(req ApprovalRequest) ApprovalResponse { called = true; return ApprovalReject }, - } + }) // File deletion is not in the enabled category set => allowed. ok, _ := s.CheckApproval(context.Background(), "Bash", map[string]interface{}{"command": "rm -rf x"}) if !ok { @@ -127,13 +127,13 @@ func TestApprovalGate_CategoryFilter(t *testing.T) { func TestApprovalGate_FlaggedTool(t *testing.T) { s := NewSession("test", "m", "", nil) - s.Autonomy = AutonomyFull + s.PermSvc().SetAutonomy(AutonomyFull) denied := false - s.Approval = &ApprovalGate{ + s.SetApproval(&ApprovalGate{ Enabled: true, FlaggedTools: map[string]ApprovalCategory{"Write": ApprovalExternalAPI}, ConfirmFn: func(req ApprovalRequest) ApprovalResponse { denied = true; return ApprovalReject }, - } + }) ok, _ := s.CheckApproval(context.Background(), "Write", map[string]interface{}{"file_path": "/x"}) if ok { t.Fatal("flagged tool should require approval") @@ -145,9 +145,9 @@ func TestApprovalGate_FlaggedTool(t *testing.T) { func TestApprovalGate_FailClosedNoHandler(t *testing.T) { s := NewSession("test", "m", "", nil) - s.Autonomy = AutonomyFull - s.AskUserFn = nil - s.Approval = &ApprovalGate{Enabled: true} // no ConfirmFn, no AskUserFn + s.PermSvc().SetAutonomy(AutonomyFull) + s.SetAskUserFn(nil) + s.SetApproval(&ApprovalGate{Enabled: true}) // no ConfirmFn, no AskUserFn ok, msg := s.CheckApproval(context.Background(), "Bash", map[string]interface{}{"command": "rm -rf x"}) if ok { t.Fatal("with no confirmation handler the gate must fail closed (deny)") @@ -159,9 +159,9 @@ func TestApprovalGate_FailClosedNoHandler(t *testing.T) { func TestApprovalGate_FallbackAskUserFn(t *testing.T) { s := NewSession("test", "m", "", nil) - s.Autonomy = AutonomyFull - s.AskUserFn = func(q string) (string, error) { return "yes", nil } - s.Approval = &ApprovalGate{Enabled: true} + s.PermSvc().SetAutonomy(AutonomyFull) + s.SetAskUserFn(func(q string) (string, error) { return "yes", nil }) + s.SetApproval(&ApprovalGate{Enabled: true}) ok, _ := s.CheckApproval(context.Background(), "Bash", map[string]interface{}{"command": "rm -rf x"}) if !ok { t.Fatal("AskUserFn returning yes should approve") diff --git a/internal/engine/context_governor_test.go b/internal/engine/context_governor_test.go index 9a5f6e37..9572dbc6 100644 --- a/internal/engine/context_governor_test.go +++ b/internal/engine/context_governor_test.go @@ -19,8 +19,8 @@ func TestResolveModelContextWindow_Fallback(t *testing.T) { func TestSession_compactConfig_ThresholdPct(t *testing.T) { s := NewSession("", "test-model", "sys", nil) - s.AutoCompactThresholdPct = 85 - s.ContextWindowCached = 100_000 + s.Persistence().SetAutoCompactThresholdPct(85) + s.Persistence().SetContextWindowCached(100_000) cfg := s.compactConfig() // Compaction triggers at 85% of a 100k window → 85k tokens. want := 85_000 diff --git a/internal/engine/engine_integration_test.go b/internal/engine/engine_integration_test.go index c0f9e718..4bc0ccb9 100644 --- a/internal/engine/engine_integration_test.go +++ b/internal/engine/engine_integration_test.go @@ -249,39 +249,39 @@ func TestIntegration_PermissionFlow(t *testing.T) { } // Test that the permission memory grants correctly. - sess.Permissions.AlwaysAllow("Write") - decision := sess.Permissions.Check("Write", "/tmp/test.txt") + sess.PermSvc().Memory().AlwaysAllow("Write") + decision := sess.PermSvc().Memory().Check("Write", "/tmp/test.txt") if decision == nil || !*decision { t.Fatal("Write should be allowed after AlwaysAllow") } // Test deny takes priority over allow. - sess.Permissions.DenySpec("Write(*.env)") - decision = sess.Permissions.Check("Write", "prod.env") + sess.PermSvc().Memory().DenySpec("Write(*.env)") + decision = sess.PermSvc().Memory().Check("Write", "prod.env") if decision == nil || *decision { t.Fatal("Write to .env should be denied even with broad allow") } // Test permission function callback. permCalled := false - sess.PermissionFn = func(req PermissionRequest) { + sess.SetPermissionFn(func(req PermissionRequest) { permCalled = true req.Response <- true - } + }) // Create a fresh permission memory to test the callback flow. - sess.Permissions = NewPermissionMemory() - decision = sess.Permissions.Check("Write", "/tmp/new-file.txt") + sess.PermSvc().SetMemory(NewPermissionMemory()) + decision = sess.PermSvc().Memory().Check("Write", "/tmp/new-file.txt") if decision != nil { t.Fatal("fresh permission memory should return nil (ask user)") } // The actual callback is invoked inside agentLoop; we test it's wired correctly. - if sess.PermissionFn == nil { + if sess.PermSvc().PermissionFn() == nil { t.Fatal("PermissionFn should be set") } // Simulate calling the permission function. resp := make(chan bool, 1) - sess.PermissionFn(PermissionRequest{ + sess.PermSvc().PermissionFn()(PermissionRequest{ PermissionRequest: contracts.PermissionRequest{ ToolName: "Write", ToolID: "test-id", diff --git a/internal/engine/integration_test.go b/internal/engine/integration_test.go index 7b6f381d..c31e401b 100644 --- a/internal/engine/integration_test.go +++ b/internal/engine/integration_test.go @@ -54,8 +54,8 @@ func TestSessionLifecycle(t *testing.T) { // Test allowed dirs sess.SetAllowedDirs([]string{"/tmp", "/home"}) - if len(sess.AllowedDirs) != 2 { - t.Fatalf("expected 2 allowed dirs, got %d", len(sess.AllowedDirs)) + if len(sess.PermSvc().AllowedDirs()) != 2 { + t.Fatalf("expected 2 allowed dirs, got %d", len(sess.PermSvc().AllowedDirs())) } } diff --git a/internal/engine/session.go b/internal/engine/session.go index f59409a8..9fcb47cd 100644 --- a/internal/engine/session.go +++ b/internal/engine/session.go @@ -18,7 +18,6 @@ import ( "github.com/GrayCodeAI/hawk/internal/observability/logger" "github.com/GrayCodeAI/hawk/internal/observability/metrics" "github.com/GrayCodeAI/hawk/internal/observability/oteltrace" - "github.com/GrayCodeAI/hawk/internal/permissions" "github.com/GrayCodeAI/hawk/internal/plugin" "github.com/GrayCodeAI/hawk/internal/prompts" modelPkg "github.com/GrayCodeAI/hawk/internal/provider/routing" @@ -57,17 +56,9 @@ type SnapshotTracker interface { // persist *PersistenceService (Phase 5: conversation store) // tools *ToolService (Phase 6: tool execution) // -// The legacy fields (client, provider, model, Router, -// DeploymentRouting, RateLimiter, Perm, Permissions, AutoMode, -// Classifier, BypassKill, MaxTurns, MaxBudgetUSD, AllowedDirs, -// PermissionFn, Autonomy, Approval, Memory, YaadBridge, EnhancedMemory, -// messages, system, Cascade, Lifecycle, Reflector, CostTracker, -// Beliefs, Critic, Backtrack, Limits, Trajectory, Shadow, etc.) stay -// on Session for backward compat with code that reads them directly. -// They are all thin forwarders to the new sub-services. The agent -// loop (stream.go) is being migrated to use the sub-services one -// call site at a time. Once every call site is migrated, the -// legacy fields will be removed. +// Session retains only orchestration state and integrations that do not yet +// have a dedicated service. Permission, tool execution, transcript, memory, +// and lifecycle state are owned by the corresponding services below. type Session struct { mu sync.RWMutex client ChatClient @@ -95,37 +86,15 @@ type Session struct { memory *MemoryService persist *PersistenceService tools *ToolService - Perm *PermissionEngine // extracted permission subsystem - // Backward-compatible accessors below (will be removed after full migration) - // - // Deprecated: use s.PermSvc() (Phase 2 sub-service) for all of: - // Permissions, AutoMode, Classifier, BypassKill, PermissionFn. - Permissions *PermissionMemory // use Perm.Memory - AutoMode *permissions.AutoModeState // use Perm.AutoMode - Classifier *permissions.Classifier // use Perm.Classifier - BypassKill *permissions.BypassKillswitch // use Perm.BypassKill - // - // Deprecated: use s.LifecycleSvc() (Phase 3 sub-service) for: - // MaxBudgetUSD, AllowedDirs, Memory, YaadBridge, - // EnhancedMemory, Cascade, Lifecycle, Reflector, CostTracker, - // ConversationGraph, Sleeptime, Activity, SkillDistiller, AutoCompactor, - // FewShotStore, AdaptivePrompt. - AllowedDirs []string - PermissionFn func(PermissionRequest) // use Perm.PromptFn - // - // Deprecated: use s.MemorySvc() (Phase 4 sub-service) for: - // Memory, YaadBridge, EnhancedMemory. + // Permission and approval state is owned exclusively by PermissionService. AgentSpawnFn tool.AgentSpawnFn AskUserFn func(question string) (string, error) // readOnlyBash gates Bash via ExploreBashAllowed for explore/plan subagents. readOnlyBash bool // workingDir is the preferred cwd for tools (worktree isolation). - workingDir string - Memory MemoryRecaller - YaadBridge *memory.YaadBridge - EnhancedMemory *memory.EnhancedMemoryManager - SettingsGet func(key string) (string, bool) - SettingsSet func(key, value string) error + workingDir string + SettingsGet func(key string) (string, bool) + SettingsSet func(key, value string) error PinnedMessages int // messages to protect from compaction (from /pin) AutoCompactThresholdPct int // token % to trigger auto-compact (default 85) @@ -187,31 +156,23 @@ type Session struct { // Steering -> s.Persistence().Steering() // Snapshots -> legacy field; not yet on Persistence // Tracer -> legacy field; oteltrace.NewTracer() for new code - Autonomy AutonomyLevel // autonomy.go — permission level - Sandbox *DiffSandbox // diffsandbox.go — staged file changes - Plan *PlanState // subtask.go — user-activated plan - Beliefs *BeliefState // belief.go — discovered knowledge - Critic *Critic // critic.go — patch pre-screening - Backtrack *BacktrackEngine // backtrack.go — decision recording - Limits *LimitTracker // limits.go — safety limits - Teach TeachConfig // teach.go — explanation depth - Trajectory *TrajectoryDistiller // trajectory.go — multi-run distillation - Shadow *branching.ShadowWorkspace // shadow.go — edit pre-validation - Snapshots SnapshotTracker // snapshot integration for auto-tracking - ConversationGraph *session.ConversationGraph // Hawk-owned conversation branching/forking - Sleeptime *memory.SleeptimeAgent // sleeptime.go — background memory consolidation - Activity *memory.ActivityTracker // activity.go — memory save nudging (Engram pattern) - SkillDistiller *memory.SkillDistiller // skill_distill.go — auto-skill extraction - Tracer *oteltrace.Tracer // oteltrace.go — distributed tracing spans - LintLoop *LintLoop // lint_loop.go — auto lint-fix reflected messages - TestLoop *TestLoop // test_loop.go — auto test-fix loop - FileMentions *FileMentionDetector // file_mentions.go — detect referenced files - ResponseCache *ResponseCache // response_cache.go — cache similar prompts - Pipeline *IntegrationPipeline // integration.go — unified feature orchestration - Files *FileTracker // compact_files.go — cumulative file tracking across compactions - Steering *SteeringQueue // steering.go — user guidance injection between tool batches - RateLimiter *ratelimit.Limiter // ratelimit — token bucket for LLM API calls - AgentsAccum *prompts.AgentsAccumulator // agents_accumulator.go — auto-capture learnings + Sandbox *DiffSandbox // diffsandbox.go — staged file changes + Plan *PlanState // subtask.go — user-activated plan + Critic *Critic // critic.go — patch pre-screening + // Backtrack and limits are owned by LifecycleService. + Teach TeachConfig // teach.go — explanation depth + Trajectory *TrajectoryDistiller // trajectory.go — multi-run distillation + Shadow *branching.ShadowWorkspace // shadow.go — edit pre-validation + Snapshots SnapshotTracker // snapshot integration for auto-tracking + Sleeptime *memory.SleeptimeAgent // sleeptime.go — background memory consolidation + Activity *memory.ActivityTracker // activity.go — memory save nudging (Engram pattern) + SkillDistiller *memory.SkillDistiller // skill_distill.go — auto-skill extraction + Tracer *oteltrace.Tracer // oteltrace.go — distributed tracing spans + LintLoop *LintLoop // lint_loop.go — auto lint-fix reflected messages + TestLoop *TestLoop // test_loop.go — auto test-fix loop + FileMentions *FileMentionDetector // file_mentions.go — detect referenced files + Files *FileTracker // compact_files.go — cumulative file tracking across compactions + RateLimiter *ratelimit.Limiter // ratelimit — token bucket for LLM API calls // Few-shot learning and prompt optimization // @@ -225,10 +186,6 @@ type Session struct { // model output is validated against it. See structured_output.go. OutputSchema string // structured_output.go — JSON schema for constrained output - // Approval, when non-nil and enabled, gates high-risk tool actions behind an - // explicit human confirmation. Nil keeps existing behavior unchanged. - Approval *ApprovalGate // approval_gate.go — human-in-the-loop gate - // smartSkills caches loaded SmartSkills for auto-discovery per-turn. smartSkills []plugin.SmartSkill } @@ -246,31 +203,20 @@ func NewSessionWithClient(chat ChatClient, provider, model, systemPrompt string, if provider == "" || model == "" { slog.Debug("NewSessionWithClient called with empty provider or model", "provider", provider, "model", model) } - pe := NewPermissionEngine() log := logger.Default() s := &Session{ - client: chat, - registry: registry, - provider: provider, - model: model, - system: systemPrompt, - log: log, - metrics: metrics.NewRegistry(), - Perm: pe, - Permissions: pe.Memory, - AutoMode: pe.AutoMode, - Classifier: pe.Classifier, - BypassKill: pe.BypassKill, - Beliefs: NewBeliefState(), - Backtrack: NewBacktrackEngine(), - Limits: NewLimitTracker(DefaultLimits()), - Tracer: oteltrace.NewTracer(), - LintLoop: NewLintLoop(), - TestLoop: NewTestLoop(), - FileMentions: NewFileMentionDetector("."), - ResponseCache: NewResponseCache(1000, 24*time.Hour), - Pipeline: NewIntegrationPipeline(), - RateLimiter: ratelimit.PerSecond(10), + client: chat, + registry: registry, + provider: provider, + model: model, + system: systemPrompt, + log: log, + metrics: metrics.NewRegistry(), + Tracer: oteltrace.NewTracer(), + LintLoop: NewLintLoop(), + TestLoop: NewTestLoop(), + FileMentions: NewFileMentionDetector("."), + RateLimiter: ratelimit.PerSecond(10), } s.Cost.Model = model s.AutoCompactThresholdPct = DefaultAutoCompactThresholdPct @@ -278,20 +224,12 @@ func NewSessionWithClient(chat ChatClient, provider, model, systemPrompt string, // Initialize agents accumulator for project learnings. cwd, _ := os.Getwd() - s.AgentsAccum = prompts.NewAgentsAccumulator(cwd) + agentsAccum := prompts.NewAgentsAccumulator(cwd) // ----------------------------------------------------------------------- // Wire the 6 sub-services extracted in Phases 1-6 of the god-object // decomposition (see docs/session-decomposition.md). New code should - // prefer the sub-service getters (s.ChatLLM(), s.PermSvc(), etc.) over - // the legacy fields. The legacy fields stay on Session for backward - // compat with external code (cmd/, daemon/, multiagent/, etc.) that - // reads them directly. They will be removed in a follow-up cleanup PR - // once all call sites are migrated. - // - // For each service whose state is also held as a Session field, we - // point the Session field at the service's instance so reads stay - // in sync (the two are aliases, not duplicates). + // prefer the sub-service getters (s.ChatLLM(), s.PermSvc(), etc.). // ----------------------------------------------------------------------- s.llm = NewChatService(chat, ChatServiceConfig{ Provider: provider, @@ -300,7 +238,7 @@ func NewSessionWithClient(chat ChatClient, provider, model, systemPrompt string, RateLimiter: s.RateLimiter, Metrics: s.metrics, }) - s.perms = NewPermissionService(log).WithEngine(pe) + s.perms = NewPermissionService(log) s.life = NewLifecycleService(log) s.memory = NewMemoryService(log) s.persist = NewPersistenceService(log) @@ -324,7 +262,6 @@ func NewSessionWithClient(chat ChatClient, provider, model, systemPrompt string, }, readOnlyBash: s.readOnlyBash, workingDir: s.workingDir, - syncPermissions: s.syncPermissionCompatibility, checkApproval: s.CheckApproval, recordPolicy: s.recordPolicyObservation, recordVerification: s.recordVerificationObservation, @@ -332,29 +269,9 @@ func NewSessionWithClient(chat ChatClient, provider, model, systemPrompt string, appendSystem: s.AppendSystemContext, }) s.refreshContextWindowCache() - s.life.SetAgentsAccumulator(s.AgentsAccum) + s.life.SetAgentsAccumulator(agentsAccum) s.life.SetLintLoop(s.LintLoop) s.life.SetTestLoop(s.TestLoop) - // Alias legacy fields at the service instances so legacy readers see - // the same state as new code that goes through the sub-service getters. - // After this point, mutations to the sub-service internal state - // (e.g., s.memory.SetMemory(...)) need a corresponding write to the - // legacy field — see the various Set* helpers (SetConversationGraph, - // SetSnapshots, etc.) which perform the dual write. - s.Limits = s.life.Limits() - s.Beliefs = s.life.Beliefs() - s.Backtrack = s.life.Backtrack() - s.ResponseCache = s.life.ResponseCache() - s.Pipeline = s.life.Pipeline() - // Fields read by AddUser/AddAssistant/AddUserWithImage/ForkConversation/ - // SwitchBranch: alias them so legacy direct-field reads return - // the sub-service state. - s.Memory = s.memory.Memory() - s.YaadBridge = s.memory.Yaad() - s.EnhancedMemory = s.memory.Enhanced() - s.ConversationGraph = s.persist.Graph() - s.Steering = s.persist.Steering() - return s } @@ -731,9 +648,8 @@ func (s *Session) SetLogger(l *logger.Logger) { // SetAllowedDirs sets directories that file tools are allowed to access. func (s *Session) SetAllowedDirs(dirs []string) { - s.AllowedDirs = append([]string(nil), dirs...) if s.perms != nil { - s.perms.SetAllowedDirs(append([]string(nil), dirs...)) + s.perms.SetAllowedDirs(dirs) } } @@ -805,48 +721,23 @@ func (s *Session) SetAskUserFn(fn func(question string) (string, error)) { } } -// SetPermissionFn configures the authoritative permission callback while -// keeping the deprecated Session field synchronized for older integrations. +// SetPermissionFn configures the permission callback on PermissionService. func (s *Session) SetPermissionFn(fn func(PermissionRequest)) { - s.PermissionFn = fn if s.perms != nil { s.perms.SetPermissionFn(fn) } } -// SetApproval sets the high-risk action gate. New code should -// call this instead of writing to the legacy s.Approval field. +// SetApproval sets the high-risk action gate on PermissionService. func (s *Session) SetApproval(a *ApprovalGate) { - s.Approval = a if s.perms != nil { s.perms.SetApproval(a) } } -// syncPermissionCompatibility copies legacy callback fields into the -// authoritative permission service for callers that have not migrated yet. -func (s *Session) syncPermissionCompatibility() { - if s == nil || s.perms == nil { - return - } - if s.PermissionFn != nil { - s.perms.SetPermissionFn(s.PermissionFn) - } - if s.Autonomy != 0 { - s.perms.SetAutonomy(s.Autonomy) - } - if s.Approval != nil { - s.perms.SetApproval(s.Approval) - } - if s.AskUserFn != nil { - s.perms.SetAskUserFn(s.AskUserFn) - } -} - // SetConversationGraph attaches Hawk's product-owned conversation graph and // seeds it from an already-resumed linear transcript when the graph is new. func (s *Session) SetConversationGraph(graph *session.ConversationGraph) { - s.ConversationGraph = graph if s.persist != nil { s.persist.SetGraph(graph) if graph != nil && graph.Empty() { diff --git a/internal/engine/session_h3_h4_test.go b/internal/engine/session_h3_h4_test.go index 092479bc..ac4b09d4 100644 --- a/internal/engine/session_h3_h4_test.go +++ b/internal/engine/session_h3_h4_test.go @@ -9,8 +9,7 @@ import ( "github.com/GrayCodeAI/hawk/internal/types" ) -// TestSession_SetConversationGraph_DualWrite guards the product-owned graph -// wiring between Session and PersistenceService. +// TestSession_SetConversationGraph guards graph ownership by PersistenceService. func TestSession_SetConversationGraph_DualWrite(t *testing.T) { t.Parallel() mc := newMockClient() @@ -29,54 +28,25 @@ func TestSession_SetConversationGraph_DualWrite(t *testing.T) { t.Cleanup(func() { _ = graph.Close() }) s.SetConversationGraph(graph) - if s.ConversationGraph == nil { - t.Error("s.ConversationGraph is nil after SetConversationGraph") - } if s.Persistence().Graph() == nil { t.Error("s.Persistence().Graph() is nil after SetConversationGraph") } - if s.Persistence().Graph() != s.ConversationGraph { - t.Error("persistence graph and session graph should be the same instance") - } } -// TestSession_NewSessionWithClient_AliasesMemoryFields is a -// regression guard for the H3 fix: NewSessionWithClient should -// alias the 5 fields read by AddUser/AddAssistant/AddUserWithImage/ -// ForkConversation/SwitchBranch from the sub-service getters, so -// legacy direct-field reads return the sub-service state. -func TestSession_NewSessionWithClient_AliasesMemoryFields(t *testing.T) { +func TestSession_NewSessionWithClient_WiresMemoryAndPersistenceServices(t *testing.T) { t.Parallel() mc := newMockClient() s := newMockSession(mc) - // All five fields must be wired to the sub-service. The sub- - // services start empty, so we just assert the aliasing didn't - // return nil pointers. + // The services start empty; verify their canonical ownership directly. if s.persist == nil { t.Fatal("s.persist is nil; NewSessionWithClient must wire the persistence service") } - if got := s.persist.Graph(); got != s.ConversationGraph { - t.Errorf("s.persist.Graph() = %v, want same as s.ConversationGraph = %v", got, s.ConversationGraph) - } - if got := s.persist.Steering(); got != s.Steering { - t.Errorf("s.persist.Steering() = %v, want same as s.Steering = %v", got, s.Steering) - } if s.memory == nil { t.Fatal("s.memory is nil; NewSessionWithClient must wire the memory service") } - // Memory/Yaad/Enhanced default to nil (no backend installed); - // the aliasing is what we care about: when SetMemory is called, - // the legacy field should pick up the new value through the - // constructor's aliasing pass. - if got := s.memory.Memory(); got != s.Memory { - t.Errorf("s.memory.Memory() = %v, want same as s.Memory = %v", got, s.Memory) - } - if got := s.memory.Yaad(); got != s.YaadBridge { - t.Errorf("s.memory.Yaad() = %v, want same as s.YaadBridge = %v", got, s.YaadBridge) - } - if got := s.memory.Enhanced(); got != s.EnhancedMemory { - t.Errorf("s.memory.Enhanced() = %v, want same as s.EnhancedMemory = %v", got, s.EnhancedMemory) + if !s.memory.IsZero() { + t.Error("memory service should start unconfigured") } } diff --git a/internal/engine/spec_mode_test.go b/internal/engine/spec_mode_test.go index 9b4823c1..7c1437e5 100644 --- a/internal/engine/spec_mode_test.go +++ b/internal/engine/spec_mode_test.go @@ -26,7 +26,7 @@ func newSpecModeSession(approveImplement bool) (*Session, *int) { ) s := NewSession("", "", "test", registry) prompts := 0 - s.PermissionFn = func(req PermissionRequest) { + s.SetPermissionFn(func(req PermissionRequest) { prompts++ allow := true if req.ToolName == "ApproveImplementation" { @@ -35,7 +35,7 @@ func newSpecModeSession(approveImplement bool) (*Session, *int) { if req.Response != nil { req.Response <- allow } - } + }) return s, &prompts } @@ -52,8 +52,8 @@ func TestSpecMode_SpecifyAdvancesStage(t *testing.T) { s, _ := newSpecModeSession(true) s.PermSvc().SetSpecStage(SpecStageSpecify) runSpecTool(t, s, "Specify", map[string]interface{}{"title": "test", "spec": "problem statement"}) - if s.Perm.Stage != SpecStageSpecify { - t.Errorf("expected stage Specify after Specify tool, got %v", s.Perm.Stage) + if s.PermSvc().SpecStage() != SpecStageSpecify { + t.Errorf("expected stage Specify after Specify tool, got %v", s.PermSvc().SpecStage()) } } @@ -63,13 +63,13 @@ func TestSpecMode_PlanTasksAdvanceStage(t *testing.T) { runSpecTool(t, s, "Specify", map[string]interface{}{"title": "test", "spec": "problem statement"}) runSpecTool(t, s, "Plan", map[string]interface{}{"plan": "technical approach"}) - if s.Perm.Stage != SpecStagePlan { - t.Errorf("expected stage Plan after Plan tool, got %v", s.Perm.Stage) + if s.PermSvc().SpecStage() != SpecStagePlan { + t.Errorf("expected stage Plan after Plan tool, got %v", s.PermSvc().SpecStage()) } runSpecTool(t, s, "Tasks", map[string]interface{}{"tasks": "task breakdown"}) - if s.Perm.Stage != SpecStageTasks { - t.Errorf("expected stage Tasks after Tasks tool, got %v", s.Perm.Stage) + if s.PermSvc().SpecStage() != SpecStageTasks { + t.Errorf("expected stage Tasks after Tasks tool, got %v", s.PermSvc().SpecStage()) } } @@ -132,8 +132,8 @@ func TestSpecMode_ApproveImplementationAlwaysPrompts(t *testing.T) { if *prompts == 0 { t.Errorf("expected an approval prompt on ApproveImplementation even at AutonomyYOLO") } - if s.Perm.Stage != SpecStageImplementing { - t.Errorf("expected stage Implementing after approval, got %v", s.Perm.Stage) + if s.PermSvc().SpecStage() != SpecStageImplementing { + t.Errorf("expected stage Implementing after approval, got %v", s.PermSvc().SpecStage()) } if !strings.Contains(strings.ToLower(res.output), "implementation") { t.Errorf("expected implementation confirmation, got %q", res.output) @@ -148,8 +148,8 @@ func TestSpecMode_ApproveImplementationDeniedStaysGated(t *testing.T) { if !res.isErr { t.Errorf("denied ApproveImplementation should report an error result to keep the gate closed") } - if s.Perm.Stage != SpecStageTasks { - t.Errorf("expected to stay at Tasks stage after denial, got %v", s.Perm.Stage) + if s.PermSvc().SpecStage() != SpecStageTasks { + t.Errorf("expected to stay at Tasks stage after denial, got %v", s.PermSvc().SpecStage()) } } @@ -173,14 +173,14 @@ func TestSpecMode_ApprovalPromptShowsSpecContent(t *testing.T) { s.PermSvc().SetSpecStage(SpecStageSpecify) var lastSummary string - s.PermissionFn = func(req PermissionRequest) { + s.SetPermissionFn(func(req PermissionRequest) { if req.ToolName == "ApproveImplementation" { lastSummary = req.Summary } if req.Response != nil { req.Response <- true } - } + }) runSpecTool(t, s, "Specify", map[string]interface{}{"title": "approval preview test", "spec": "unique spec marker xyz123"}) runSpecTool(t, s, "Plan", map[string]interface{}{"plan": "unique plan marker abc456"}) diff --git a/internal/engine/sub_service_wiring_test.go b/internal/engine/sub_service_wiring_test.go index 53682b55..9aec8fa7 100644 --- a/internal/engine/sub_service_wiring_test.go +++ b/internal/engine/sub_service_wiring_test.go @@ -44,34 +44,19 @@ func TestSession_NewSessionWithClient_WiresAllSubServices(t *testing.T) { if s.PermSvc().Engine() == nil { t.Error("PermSvc().Engine() should not be nil") } - // The service's engine must be the same instance as the legacy - // s.Perm field — that's the Phase 7 aliasing contract. - if s.PermSvc().Engine() != s.Perm { - t.Error("PermSvc().Engine() should be the same instance as s.Perm") + if s.PermSvc().Engine() == nil { + t.Error("PermSvc().Engine() should be initialized") } - // LifecycleService: limits, beliefs, backtrack, response cache, pipeline. + // LifecycleService owns limits, beliefs, backtrack, response cache, pipeline. if s.LifecycleSvc() == nil { t.Fatal("LifecycleSvc() should not be nil after NewSessionWithClient") } if s.LifecycleSvc().Limits() == nil { t.Error("LifecycleSvc().Limits() should not be nil") } - // The legacy fields must point at the service's instances. - if s.Limits != s.LifecycleSvc().Limits() { - t.Error("s.Limits should be the same instance as LifecycleSvc().Limits()") - } - if s.Beliefs != s.LifecycleSvc().Beliefs() { - t.Error("s.Beliefs should be the same instance as LifecycleSvc().Beliefs()") - } - if s.Backtrack != s.LifecycleSvc().Backtrack() { - t.Error("s.Backtrack should be the same instance as LifecycleSvc().Backtrack()") - } - if s.ResponseCache != s.LifecycleSvc().ResponseCache() { - t.Error("s.ResponseCache should be the same instance as LifecycleSvc().ResponseCache()") - } - if s.Pipeline != s.LifecycleSvc().Pipeline() { - t.Error("s.Pipeline should be the same instance as LifecycleSvc().Pipeline()") + if s.LifecycleSvc().Beliefs() == nil || s.LifecycleSvc().Backtrack() == nil || s.LifecycleSvc().ResponseCache() == nil || s.LifecycleSvc().Pipeline() == nil { + t.Error("LifecycleService collaborators should be initialized") } // MemoryService: empty by default (no memory wired). diff --git a/internal/engine/tool_service.go b/internal/engine/tool_service.go index 25acc56c..c149b388 100644 --- a/internal/engine/tool_service.go +++ b/internal/engine/tool_service.go @@ -47,7 +47,6 @@ type toolExecutionDeps struct { askUser func(string) (string, error) readOnlyBash bool workingDir string - syncPermissions func() checkApproval func(context.Context, string, map[string]interface{}) (bool, string) recordPolicy func(types.ToolCall, string, bool, string) recordVerification func(types.ToolCall, string, bool) @@ -214,9 +213,6 @@ func (s *ToolService) ExecuteOne(ctx context.Context, tc types.ToolCall, overrid result.output, result.isErr, result.err, result.span = msg, true, fmt.Errorf("%s", msg), nil return result } - if s.deps.syncPermissions != nil { - s.deps.syncPermissions() - } if s.deps.permissions == nil { return finishDenied("denied", "permission service is unavailable") } From 587574cb0e0867755c7abb4aa687cd03ae87517a Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Sun, 2 Aug 2026 01:23:33 +0530 Subject: [PATCH 41/49] refactor: move compaction state into persistence service --- internal/engine/context_governor.go | 4 --- internal/engine/session.go | 51 +++++++++-------------------- 2 files changed, 15 insertions(+), 40 deletions(-) diff --git a/internal/engine/context_governor.go b/internal/engine/context_governor.go index a3861c56..83b80c50 100644 --- a/internal/engine/context_governor.go +++ b/internal/engine/context_governor.go @@ -52,11 +52,9 @@ func (s *Session) EnsureAutoCompactor() { } if p.AutoCompactor() != nil { p.AutoCompactor().Configure(s.compactConfig()) - s.AutoCompactor = p.AutoCompactor() return } p.SetAutoCompactor(NewAutoCompactor(s.compactConfig())) - s.AutoCompactor = p.AutoCompactor() } func (s *Session) compactThresholdPct() int { @@ -94,7 +92,6 @@ func (s *Session) refreshContextWindowCache() { return } if s.Persistence() == nil { - s.ContextWindowCached = 0 } else { s.SetContextWindowCached(0) } @@ -104,7 +101,6 @@ func (s *Session) refreshContextWindowCache() { } if info, ok := modelPkg.Find(model); ok && info.ContextSize > 0 { if s.Persistence() == nil { - s.ContextWindowCached = info.ContextSize } else { s.SetContextWindowCached(info.ContextSize) } diff --git a/internal/engine/session.go b/internal/engine/session.go index 9fcb47cd..1e60e691 100644 --- a/internal/engine/session.go +++ b/internal/engine/session.go @@ -96,20 +96,16 @@ type Session struct { SettingsGet func(key string) (string, bool) SettingsSet func(key, value string) error - PinnedMessages int // messages to protect from compaction (from /pin) - AutoCompactThresholdPct int // token % to trigger auto-compact (default 85) - ContextWindowCached int // catalog context window; 0 → governor default - AutoCompactor *AutoCompactor - persistID string - lastPromptTokens int - lastCompletionTokens int - estTokensCache int - estTokensMsgCount int - estTokensLastLen int - tokUsage *tok.UsageTracker - checkpointMgr *session.CheckpointManager - OnCompaction OnCompaction - Verbose bool // show tool calls, timing, token counts in output + persistID string + lastPromptTokens int + lastCompletionTokens int + estTokensCache int + estTokensMsgCount int + estTokensLastLen int + tokUsage *tok.UsageTracker + checkpointMgr *session.CheckpointManager + OnCompaction OnCompaction + Verbose bool // show tool calls, timing, token counts in output // 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. GLMThinkingEnabled *bool @@ -219,7 +215,6 @@ func NewSessionWithClient(chat ChatClient, provider, model, systemPrompt string, RateLimiter: ratelimit.PerSecond(10), } s.Cost.Model = model - s.AutoCompactThresholdPct = DefaultAutoCompactThresholdPct s.refreshContextWindowCache() // Initialize agents accumulator for project learnings. @@ -242,6 +237,7 @@ func NewSessionWithClient(chat ChatClient, provider, model, systemPrompt string, s.life = NewLifecycleService(log) s.memory = NewMemoryService(log) s.persist = NewPersistenceService(log) + s.persist.SetAutoCompactThresholdPct(DefaultAutoCompactThresholdPct) s.persist.SetSystem(systemPrompt) s.tools = NewToolService(registry).WithMetrics(s.metrics) s.tools.WithExecutionDeps(toolExecutionDeps{ @@ -362,9 +358,6 @@ func (s *Session) Persistence() *PersistenceService { s.persist = NewPersistenceService(s.log) s.persist.SetSystem(s.system) s.persist.SetRawMessages(s.messages) - s.persist.SetPinnedMessages(s.PinnedMessages) - s.persist.SetAutoCompactThresholdPct(s.AutoCompactThresholdPct) - s.persist.SetContextWindowCached(s.ContextWindowCached) return s.persist } @@ -654,20 +647,14 @@ func (s *Session) SetAllowedDirs(dirs []string) { } // SetAutoCompactThresholdPct sets the auto-compact threshold. -// New code should call this instead of writing to the legacy -// s.AutoCompactThresholdPct field directly. func (s *Session) SetAutoCompactThresholdPct(pct int) { - s.AutoCompactThresholdPct = pct if s.persist != nil { s.persist.SetAutoCompactThresholdPct(pct) } } -// SetPinnedMessages sets the number of recent messages that are -// protected from compaction. New code should call this instead of -// writing to the legacy s.PinnedMessages field directly. +// SetPinnedMessages sets the number of recent messages protected from compaction. func (s *Session) SetPinnedMessages(n int) { - s.PinnedMessages = n if s.persist != nil { s.persist.SetPinnedMessages(n) } @@ -756,27 +743,19 @@ func (s *Session) SetConversationGraph(graph *session.ConversationGraph) { } } -// SetContextWindowCached sets the catalog context window. New code -// should call this instead of writing to the legacy -// s.ContextWindowCached field directly. +// SetContextWindowCached sets the catalog context window. func (s *Session) SetContextWindowCached(n int) { - s.ContextWindowCached = n if s.persist != nil { s.persist.SetContextWindowCached(n) } } // ContextWindowCachedValue returns the cached context window size. -// New code should call this instead of reading s.ContextWindowCached -// directly. Falls back to the legacy field for back-compat with -// code paths that still write to s.ContextWindowCached. func (s *Session) ContextWindowCachedValue() int { if s.persist != nil { - if w := s.persist.ContextWindowCached(); w > 0 { - return w - } + return s.persist.ContextWindowCached() } - return s.ContextWindowCached + return 0 } // CostValue returns the session's cost accumulator (a pointer From e176e63304df34eb38b7e866f8b6c6c68e50be75 Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Sun, 2 Aug 2026 01:25:30 +0530 Subject: [PATCH 42/49] refactor: remove lifecycle aliases from session --- internal/engine/session.go | 17 +++++------------ 1 file changed, 5 insertions(+), 12 deletions(-) diff --git a/internal/engine/session.go b/internal/engine/session.go index 1e60e691..f3662604 100644 --- a/internal/engine/session.go +++ b/internal/engine/session.go @@ -20,7 +20,6 @@ import ( "github.com/GrayCodeAI/hawk/internal/observability/oteltrace" "github.com/GrayCodeAI/hawk/internal/plugin" "github.com/GrayCodeAI/hawk/internal/prompts" - modelPkg "github.com/GrayCodeAI/hawk/internal/provider/routing" "github.com/GrayCodeAI/hawk/internal/resilience/ratelimit" "github.com/GrayCodeAI/hawk/internal/session" "github.com/GrayCodeAI/hawk/internal/snapshot" @@ -110,14 +109,8 @@ type Session struct { // (applied only when provider is zai_payg or zai_coding). nil leaves the model default. GLMThinkingEnabled *bool - // Cost optimization - // - // Deprecated: use s.LifecycleSvc() (Phase 3 sub-service) for: - // Cascade, Lifecycle, Reflector, CostTracker. - Cascade *branching.CascadeRouter // cascade.go — model tier routing - Lifecycle *SessionLifecycle // lifecycle.go — self-improvement loop - Reflector *Reflector // reflect.go — verbal self-reflection - CostTracker *CostTracker // cost_tracker.go — per-request cost persistence + // Cost tracking remains a session-level accounting value. + CostTracker *CostTracker // cost_tracker.go — per-request cost persistence // Advanced features // @@ -446,12 +439,12 @@ func (s *Session) SetModel(model string) { // syncCascadeDefaultModel keeps the cascade router aligned after /config model picks. func (s *Session) syncCascadeDefaultModel() { - if s == nil || s.Cascade == nil { + if s == nil || s.LifecycleSvc() == nil || s.LifecycleSvc().Cascade() == nil { return } if m := strings.TrimSpace(s.model); m != "" { - s.Cascade.DefaultModel = m - s.Cascade.Roles = modelPkg.DefaultRoles(m) + cascade := s.LifecycleSvc().Cascade() + cascade.DefaultModel = m } } From a715df113a68b3dafd367be3b34ea85af98b707e Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Sun, 2 Aug 2026 01:26:50 +0530 Subject: [PATCH 43/49] refactor: move cost tracking into lifecycle service --- internal/engine/lifecycle_service.go | 5 ++++- internal/engine/session.go | 3 --- internal/engine/stream_usage.go | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/internal/engine/lifecycle_service.go b/internal/engine/lifecycle_service.go index 85b8450d..b049bbdf 100644 --- a/internal/engine/lifecycle_service.go +++ b/internal/engine/lifecycle_service.go @@ -60,7 +60,8 @@ type LifecycleService struct { lintLoop *LintLoop testLoop *TestLoop // session-level lifecycle hook. - lifecycle *SessionLifecycle + lifecycle *SessionLifecycle + costTracker *CostTracker // log is the session logger. log *logger.Logger } @@ -250,5 +251,7 @@ func (s *LifecycleService) ResponseCache() *ResponseCache { return s.responseCa func (s *LifecycleService) Pipeline() *IntegrationPipeline { return s.pipeline } func (s *LifecycleService) Steering() *SteeringQueue { return s.steering } func (s *LifecycleService) Lifecycle() *SessionLifecycle { return s.lifecycle } +func (s *LifecycleService) CostTracker() *CostTracker { return s.costTracker } +func (s *LifecycleService) SetCostTracker(c *CostTracker) { s.costTracker = c } func (s *LifecycleService) LintLoop() *LintLoop { return s.lintLoop } func (s *LifecycleService) TestLoop() *TestLoop { return s.testLoop } diff --git a/internal/engine/session.go b/internal/engine/session.go index f3662604..b2b6697f 100644 --- a/internal/engine/session.go +++ b/internal/engine/session.go @@ -109,9 +109,6 @@ type Session struct { // (applied only when provider is zai_payg or zai_coding). nil leaves the model default. GLMThinkingEnabled *bool - // Cost tracking remains a session-level accounting value. - CostTracker *CostTracker // cost_tracker.go — per-request cost persistence - // Advanced features // // Deprecated: most of these have been folded into sub-services; diff --git a/internal/engine/stream_usage.go b/internal/engine/stream_usage.go index e1dc2ac4..b2b9f388 100644 --- a/internal/engine/stream_usage.go +++ b/internal/engine/stream_usage.go @@ -80,8 +80,8 @@ func (s *Session) recordStreamUsage(ch chan<- StreamEvent, prompt, completion in costBefore := s.CostValue().Total() s.CostValue().AddForModel(model, prompt, completion) requestCost := s.CostValue().Total() - costBefore - if s.CostTracker != nil && model != "" { - _ = s.CostTracker.Record(analytics.CostEntry{ + if tracker := s.LifecycleSvc().CostTracker(); tracker != nil && model != "" { + _ = tracker.Record(analytics.CostEntry{ Model: model, TaskType: taskType, InputTokens: prompt, From 5f3e50da96ecefc8eff695d9415d7f5ac07fd856 Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Sun, 2 Aug 2026 01:31:49 +0530 Subject: [PATCH 44/49] refactor: keep rate limiting inside chat service --- internal/engine/session.go | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/internal/engine/session.go b/internal/engine/session.go index b2b6697f..aaa08f0c 100644 --- a/internal/engine/session.go +++ b/internal/engine/session.go @@ -129,7 +129,6 @@ type Session struct { // Sleeptime -> s.MemorySvc().Sleeptime() // Activity -> s.MemorySvc().Activity() // SkillDistiller -> s.MemorySvc().SkillDistiller() - // RateLimiter -> s.RateLimiter (legacy field; not yet on ChatLLM) // AgentsAccum -> s.LifecycleSvc().AgentsAccum() // FewShotStore -> s.LifecycleSvc().FewShotStore() // AdaptivePrompt -> s.LifecycleSvc().AdaptivePrompt() @@ -158,7 +157,6 @@ type Session struct { TestLoop *TestLoop // test_loop.go — auto test-fix loop FileMentions *FileMentionDetector // file_mentions.go — detect referenced files Files *FileTracker // compact_files.go — cumulative file tracking across compactions - RateLimiter *ratelimit.Limiter // ratelimit — token bucket for LLM API calls // Few-shot learning and prompt optimization // @@ -202,8 +200,8 @@ func NewSessionWithClient(chat ChatClient, provider, model, systemPrompt string, LintLoop: NewLintLoop(), TestLoop: NewTestLoop(), FileMentions: NewFileMentionDetector("."), - RateLimiter: ratelimit.PerSecond(10), } + rateLimiter := ratelimit.PerSecond(10) s.Cost.Model = model s.refreshContextWindowCache() @@ -220,7 +218,7 @@ func NewSessionWithClient(chat ChatClient, provider, model, systemPrompt string, Provider: provider, Model: model, DeploymentRouting: deploymentRouting, - RateLimiter: s.RateLimiter, + RateLimiter: rateLimiter, Metrics: s.metrics, }) s.perms = NewPermissionService(log) From b7ba3a34c39274a93f0606aa28cc028f66a1a2ce Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Sun, 2 Aug 2026 01:41:57 +0530 Subject: [PATCH 45/49] refactor: centralize tracing and chat options --- internal/engine/session.go | 17 +++++++---------- internal/engine/tool_service.go | 8 ++++++++ 2 files changed, 15 insertions(+), 10 deletions(-) diff --git a/internal/engine/session.go b/internal/engine/session.go index aaa08f0c..de4e86f5 100644 --- a/internal/engine/session.go +++ b/internal/engine/session.go @@ -152,7 +152,6 @@ type Session struct { Sleeptime *memory.SleeptimeAgent // sleeptime.go — background memory consolidation Activity *memory.ActivityTracker // activity.go — memory save nudging (Engram pattern) SkillDistiller *memory.SkillDistiller // skill_distill.go — auto-skill extraction - Tracer *oteltrace.Tracer // oteltrace.go — distributed tracing spans LintLoop *LintLoop // lint_loop.go — auto lint-fix reflected messages TestLoop *TestLoop // test_loop.go — auto test-fix loop FileMentions *FileMentionDetector // file_mentions.go — detect referenced files @@ -165,11 +164,6 @@ type Session struct { FewShotStore *FewShotStore // scaffold/fewshot.go — successful pattern collection AdaptivePrompt *AdaptivePrompt // adaptive_prompt.go — user preference learning - // OutputSchema, when non-empty, requests a JSON-schema-constrained response. - // It is plumbed into eyrie's ChatOptions.ResponseFormat (json_schema) and the - // model output is validated against it. See structured_output.go. - OutputSchema string // structured_output.go — JSON schema for constrained output - // smartSkills caches loaded SmartSkills for auto-discovery per-turn. smartSkills []plugin.SmartSkill } @@ -196,7 +190,6 @@ func NewSessionWithClient(chat ChatClient, provider, model, systemPrompt string, system: systemPrompt, log: log, metrics: metrics.NewRegistry(), - Tracer: oteltrace.NewTracer(), LintLoop: NewLintLoop(), TestLoop: NewTestLoop(), FileMentions: NewFileMentionDetector("."), @@ -227,7 +220,7 @@ func NewSessionWithClient(chat ChatClient, provider, model, systemPrompt string, s.persist = NewPersistenceService(log) s.persist.SetAutoCompactThresholdPct(DefaultAutoCompactThresholdPct) s.persist.SetSystem(systemPrompt) - s.tools = NewToolService(registry).WithMetrics(s.metrics) + s.tools = NewToolService(registry).WithMetrics(s.metrics).WithTracer(oteltrace.NewTracer()) s.tools.WithExecutionDeps(toolExecutionDeps{ permissions: s.perms, chat: s.llm, @@ -310,7 +303,12 @@ func (s *Session) Metrics() *metrics.Registry { return s.metrics } func (s *Session) Logger() *logger.Logger { return s.log } // TracerValue returns the session tracer through the observability boundary. -func (s *Session) TracerValue() *oteltrace.Tracer { return s.Tracer } +func (s *Session) TracerValue() *oteltrace.Tracer { + if s == nil || s.tools == nil { + return nil + } + return s.tools.Tracer() +} // ChatLLM returns the extracted ChatService (Phase 1 of the god-object // decomposition). New code should prefer this over the legacy Client / @@ -651,7 +649,6 @@ func (s *Session) SetPinnedMessages(n int) { // SetThinkingEnabled sets the generic host thinking/reasoning toggle on // the ChatService (the source of truth). func (s *Session) SetThinkingEnabled(v *bool) { - s.GLMThinkingEnabled = v // keep legacy field in sync if s.llm != nil { s.llm.SetThinkingEnabled(v) } diff --git a/internal/engine/tool_service.go b/internal/engine/tool_service.go index c149b388..716ab694 100644 --- a/internal/engine/tool_service.go +++ b/internal/engine/tool_service.go @@ -84,6 +84,14 @@ func (s *ToolService) WithTracer(t *oteltrace.Tracer) *ToolService { return s } +// Tracer returns the tool/runtime tracer shared by session loop spans. +func (s *ToolService) Tracer() *oteltrace.Tracer { + if s == nil { + return nil + } + return s.tracer +} + // WithSnapshots configures the snapshot tracker. func (s *ToolService) WithSnapshots(snap SnapshotTracker) *ToolService { s.snapshots = snap From 69af8ec6a6c283380a20f2906869a503863ee7b8 Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Sun, 2 Aug 2026 01:47:43 +0530 Subject: [PATCH 46/49] refactor: complete session facade ownership cleanup --- internal/engine/agent_session_tool.go | 8 ++-- internal/engine/context_compaction.go | 2 - internal/engine/lifecycle_service.go | 31 +++++++++---- internal/engine/magic.go | 3 +- internal/engine/permission_service.go | 7 +++ internal/engine/session.go | 63 ++++++--------------------- internal/engine/tool_service.go | 14 ++++++ 7 files changed, 63 insertions(+), 65 deletions(-) diff --git a/internal/engine/agent_session_tool.go b/internal/engine/agent_session_tool.go index 05891724..6afc4990 100644 --- a/internal/engine/agent_session_tool.go +++ b/internal/engine/agent_session_tool.go @@ -20,9 +20,9 @@ import ( // Modes: explore (read-only research), plan (read-only planning), general-purpose (full tools). func (s *Session) WireAgentTool() { _ = s.ensureBackgroundManager() - s.AgentSpawnFn = func(ctx context.Context, req agentcontracts.SpawnRequest) (agentcontracts.SpawnResult, error) { + s.Tools().SetAgentSpawnFn(func(ctx context.Context, req agentcontracts.SpawnRequest) (agentcontracts.SpawnResult, error) { return s.spawnSubAgentRequest(ctx, req, 0) - } + }) } // ensureBackgroundManager lazily attaches a BackgroundAgentManager on ToolService. @@ -177,9 +177,9 @@ func (s *Session) spawnSubAgent(ctx context.Context, norm agentcontracts.Normali sub.AddUser(prompt) // Propagate parent agent spawn so nested agents work, and share bg manager. - sub.AgentSpawnFn = func(ctx context.Context, req agentcontracts.SpawnRequest) (agentcontracts.SpawnResult, error) { + sub.Tools().SetAgentSpawnFn(func(ctx context.Context, req agentcontracts.SpawnRequest) (agentcontracts.SpawnResult, error) { return s.spawnSubAgentRequest(ctx, req, depth+1) - } + }) if bm := s.ensureBackgroundManager(); bm != nil && sub.Tools() != nil { sub.Tools().WithBackgroundManager(bm) } diff --git a/internal/engine/context_compaction.go b/internal/engine/context_compaction.go index 318cf6e5..d39d7708 100644 --- a/internal/engine/context_compaction.go +++ b/internal/engine/context_compaction.go @@ -106,8 +106,6 @@ func (s *Session) notifyCompaction(ev CompactionEvent) { } if fn := s.Persistence().OnCompaction(); fn != nil { fn(ev) - } else if s.OnCompaction != nil { - s.OnCompaction(ev) } s.saveCompactionCheckpoint() } diff --git a/internal/engine/lifecycle_service.go b/internal/engine/lifecycle_service.go index b049bbdf..03a8f927 100644 --- a/internal/engine/lifecycle_service.go +++ b/internal/engine/lifecycle_service.go @@ -62,6 +62,9 @@ type LifecycleService struct { // session-level lifecycle hook. lifecycle *SessionLifecycle costTracker *CostTracker + teach TeachConfig + trajectory *TrajectoryDistiller + verbose bool // log is the session logger. log *logger.Logger } @@ -247,11 +250,23 @@ func (s *LifecycleService) AgentsAccum() *prompts.AgentsAccumulator { return s.a // SetAgentsAccumulator attaches the project-learning accumulator. func (s *LifecycleService) SetAgentsAccumulator(a *prompts.AgentsAccumulator) { s.agentsAccum = a } -func (s *LifecycleService) ResponseCache() *ResponseCache { return s.responseCache } -func (s *LifecycleService) Pipeline() *IntegrationPipeline { return s.pipeline } -func (s *LifecycleService) Steering() *SteeringQueue { return s.steering } -func (s *LifecycleService) Lifecycle() *SessionLifecycle { return s.lifecycle } -func (s *LifecycleService) CostTracker() *CostTracker { return s.costTracker } -func (s *LifecycleService) SetCostTracker(c *CostTracker) { s.costTracker = c } -func (s *LifecycleService) LintLoop() *LintLoop { return s.lintLoop } -func (s *LifecycleService) TestLoop() *TestLoop { return s.testLoop } +func (s *LifecycleService) ResponseCache() *ResponseCache { return s.responseCache } +func (s *LifecycleService) Pipeline() *IntegrationPipeline { return s.pipeline } +func (s *LifecycleService) Steering() *SteeringQueue { return s.steering } +func (s *LifecycleService) Lifecycle() *SessionLifecycle { return s.lifecycle } +func (s *LifecycleService) CostTracker() *CostTracker { return s.costTracker } +func (s *LifecycleService) SetCostTracker(c *CostTracker) { s.costTracker = c } +func (s *LifecycleService) Teach() TeachConfig { return s.teach } +func (s *LifecycleService) SetTeach(t TeachConfig) { s.teach = t } +func (s *LifecycleService) Trajectory() *TrajectoryDistiller { return s.trajectory } +func (s *LifecycleService) SetTrajectory(t *TrajectoryDistiller) { s.trajectory = t } +func (s *LifecycleService) ToggleVerbose() bool { + if s == nil { + return false + } + s.verbose = !s.verbose + return s.verbose +} +func (s *LifecycleService) Verbose() bool { return s != nil && s.verbose } +func (s *LifecycleService) LintLoop() *LintLoop { return s.lintLoop } +func (s *LifecycleService) TestLoop() *TestLoop { return s.testLoop } diff --git a/internal/engine/magic.go b/internal/engine/magic.go index 32bb7515..f72240f1 100644 --- a/internal/engine/magic.go +++ b/internal/engine/magic.go @@ -203,8 +203,7 @@ func magicTokens(session *Session, _ string) string { func magicVerbose(session *Session, _ string) string { session.mu.Lock() - session.Verbose = !session.Verbose - state := session.Verbose + state := session.LifecycleSvc().ToggleVerbose() session.mu.Unlock() if state { diff --git a/internal/engine/permission_service.go b/internal/engine/permission_service.go index 38949385..d8a656fd 100644 --- a/internal/engine/permission_service.go +++ b/internal/engine/permission_service.go @@ -224,6 +224,13 @@ func (s *PermissionService) SetAskUserFn(fn func(question string) (string, error } } +func (s *PermissionService) AskUserFn() func(string) (string, error) { + if s == nil { + return nil + } + return s.askUserFn +} + // Approval returns the configured human-in-the-loop gate. func (s *PermissionService) Approval() *ApprovalGate { if s == nil { diff --git a/internal/engine/session.go b/internal/engine/session.go index de4e86f5..f109a295 100644 --- a/internal/engine/session.go +++ b/internal/engine/session.go @@ -13,8 +13,6 @@ import ( "github.com/GrayCodeAI/hawk/internal/provider/gateway" "github.com/GrayCodeAI/hawk/internal/types" - "github.com/GrayCodeAI/hawk/internal/engine/branching" - "github.com/GrayCodeAI/hawk/internal/intelligence/memory" "github.com/GrayCodeAI/hawk/internal/observability/logger" "github.com/GrayCodeAI/hawk/internal/observability/metrics" "github.com/GrayCodeAI/hawk/internal/observability/oteltrace" @@ -86,14 +84,10 @@ type Session struct { persist *PersistenceService tools *ToolService // Permission and approval state is owned exclusively by PermissionService. - AgentSpawnFn tool.AgentSpawnFn - AskUserFn func(question string) (string, error) // readOnlyBash gates Bash via ExploreBashAllowed for explore/plan subagents. readOnlyBash bool // workingDir is the preferred cwd for tools (worktree isolation). - workingDir string - SettingsGet func(key string) (string, bool) - SettingsSet func(key, value string) error + workingDir string persistID string lastPromptTokens int @@ -103,11 +97,8 @@ type Session struct { estTokensLastLen int tokUsage *tok.UsageTracker checkpointMgr *session.CheckpointManager - OnCompaction OnCompaction - Verbose bool // show tool calls, timing, token counts in output // 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. - GLMThinkingEnabled *bool // Advanced features // @@ -141,28 +132,7 @@ type Session struct { // Steering -> s.Persistence().Steering() // Snapshots -> legacy field; not yet on Persistence // Tracer -> legacy field; oteltrace.NewTracer() for new code - Sandbox *DiffSandbox // diffsandbox.go — staged file changes - Plan *PlanState // subtask.go — user-activated plan - Critic *Critic // critic.go — patch pre-screening // Backtrack and limits are owned by LifecycleService. - Teach TeachConfig // teach.go — explanation depth - Trajectory *TrajectoryDistiller // trajectory.go — multi-run distillation - Shadow *branching.ShadowWorkspace // shadow.go — edit pre-validation - Snapshots SnapshotTracker // snapshot integration for auto-tracking - Sleeptime *memory.SleeptimeAgent // sleeptime.go — background memory consolidation - Activity *memory.ActivityTracker // activity.go — memory save nudging (Engram pattern) - SkillDistiller *memory.SkillDistiller // skill_distill.go — auto-skill extraction - LintLoop *LintLoop // lint_loop.go — auto lint-fix reflected messages - TestLoop *TestLoop // test_loop.go — auto test-fix loop - FileMentions *FileMentionDetector // file_mentions.go — detect referenced files - Files *FileTracker // compact_files.go — cumulative file tracking across compactions - - // Few-shot learning and prompt optimization - // - // Deprecated: use s.LifecycleSvc() (Phase 3 sub-service) for: - // FewShotStore, AdaptivePrompt. - FewShotStore *FewShotStore // scaffold/fewshot.go — successful pattern collection - AdaptivePrompt *AdaptivePrompt // adaptive_prompt.go — user preference learning // smartSkills caches loaded SmartSkills for auto-discovery per-turn. smartSkills []plugin.SmartSkill @@ -183,16 +153,13 @@ func NewSessionWithClient(chat ChatClient, provider, model, systemPrompt string, } log := logger.Default() s := &Session{ - client: chat, - registry: registry, - provider: provider, - model: model, - system: systemPrompt, - log: log, - metrics: metrics.NewRegistry(), - LintLoop: NewLintLoop(), - TestLoop: NewTestLoop(), - FileMentions: NewFileMentionDetector("."), + client: chat, + registry: registry, + provider: provider, + model: model, + system: systemPrompt, + log: log, + metrics: metrics.NewRegistry(), } rateLimiter := ratelimit.PerSecond(10) s.Cost.Model = model @@ -226,16 +193,16 @@ func NewSessionWithClient(chat ChatClient, provider, model, systemPrompt string, chat: s.llm, memory: s.memory, agentSpawn: func(ctx context.Context, req agentcontracts.SpawnRequest) (agentcontracts.SpawnResult, error) { - if s.AgentSpawnFn == nil { + if s.tools.AgentSpawnFn() == nil { return agentcontracts.SpawnResult{Status: agentcontracts.StatusFailed, Error: "agent spawning is unavailable"}, fmt.Errorf("agent spawning is unavailable") } - return s.AgentSpawnFn(ctx, req) + return s.tools.AgentSpawnFn()(ctx, req) }, askUser: func(question string) (string, error) { - if s.AskUserFn == nil { + if s.perms.AskUserFn() == nil { return "", fmt.Errorf("ask-user callback is unavailable") } - return s.AskUserFn(question) + return s.perms.AskUserFn()(question) }, readOnlyBash: s.readOnlyBash, workingDir: s.workingDir, @@ -247,8 +214,8 @@ func NewSessionWithClient(chat ChatClient, provider, model, systemPrompt string, }) s.refreshContextWindowCache() s.life.SetAgentsAccumulator(agentsAccum) - s.life.SetLintLoop(s.LintLoop) - s.life.SetTestLoop(s.TestLoop) + s.life.SetLintLoop(NewLintLoop()) + s.life.SetTestLoop(NewTestLoop()) return s } @@ -662,7 +629,6 @@ func (s *Session) SetGLMThinkingEnabled(v *bool) { // SetSnapshots attaches the snapshot tracker. New code should call // this instead of writing to the legacy s.Snapshots field directly. func (s *Session) SetSnapshots(snap *snapshot.Tracker) { - s.Snapshots = snap if s.tools != nil { s.tools.WithSnapshots(snap) } @@ -687,7 +653,6 @@ func (s *Session) SetContainerExecutor(ce tool.ContainerExecutor) { // SetAskUserFn sets the user-prompt callback. New code should // call this instead of writing to the legacy s.AskUserFn field. func (s *Session) SetAskUserFn(fn func(question string) (string, error)) { - s.AskUserFn = fn if s.perms != nil { s.perms.SetAskUserFn(fn) } diff --git a/internal/engine/tool_service.go b/internal/engine/tool_service.go index 716ab694..9582b25b 100644 --- a/internal/engine/tool_service.go +++ b/internal/engine/tool_service.go @@ -27,6 +27,7 @@ type ToolService struct { containerExecutor tool.ContainerExecutor containerRequired bool tracer *oteltrace.Tracer + agentSpawn tool.AgentSpawnFn snapshots SnapshotTracker bgMu sync.Mutex bgManager *tool.BackgroundAgentManager @@ -35,6 +36,19 @@ type ToolService struct { metrics *metrics.Registry } +func (s *ToolService) SetAgentSpawnFn(fn tool.AgentSpawnFn) { + if s != nil { + s.agentSpawn = fn + } +} + +func (s *ToolService) AgentSpawnFn() tool.AgentSpawnFn { + if s == nil { + return nil + } + return s.agentSpawn +} + // toolExecutionDeps contains the service-owned collaborators needed for one // raw tool invocation. Keeping these dependencies on ToolService removes the // permission, approval, tracing, timeout, and retry boundary from Session; From 5a77de952cdd3383128d554b25ee494691477ba7 Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Sun, 2 Aug 2026 02:42:56 +0530 Subject: [PATCH 47/49] feat: harden cli commands and scrollbar UX --- cmd/chat_commands.go | 25 +++++++++++++++++++++---- cmd/chat_commands_test.go | 11 +++++++++++ cmd/chat_scrollbar.go | 26 ++++++++++++++++++-------- cmd/chat_scrollbar_test.go | 18 ++++++++++++++++++ cmd/slash_commands_test.go | 23 +++++++++++++++++++++++ 5 files changed, 91 insertions(+), 12 deletions(-) diff --git a/cmd/chat_commands.go b/cmd/chat_commands.go index e4b725db..0d06b536 100644 --- a/cmd/chat_commands.go +++ b/cmd/chat_commands.go @@ -306,9 +306,11 @@ func slashSuggestions(input string) []string { if !strings.HasPrefix(v, "/") || strings.Contains(v, " ") { return nil } + v = strings.ToLower(v) var out []string seen := map[string]bool{} - for _, c := range allSlashCommands { + for _, c := range slashCommands() { + c = strings.ToLower(c) if strings.HasPrefix(c, v) { seen[c] = true desc := slashDescriptions[c] @@ -319,7 +321,15 @@ func slashSuggestions(input string) []string { } } } - for alias, target := range slashAliases() { + aliases := slashAliases() + aliasNames := make([]string, 0, len(aliases)) + for alias := range aliases { + aliasNames = append(aliasNames, alias) + } + sort.Strings(aliasNames) + for _, alias := range aliasNames { + target := aliases[alias] + alias = strings.ToLower(alias) if strings.HasPrefix(alias, v) && !seen[target] { seen[alias] = true out = append(out, alias+" → "+target) @@ -348,7 +358,14 @@ func applySlashSuggestion(input string) string { func (m *chatModel) handleCommand(text string) (tea.Model, tea.Cmd) { parts := strings.Fields(text) - cmd := parts[0] + if len(parts) == 0 { + return m, nil + } + rawCmd := parts[0] + cmd := rawCmd + if strings.HasPrefix(cmd, "/") { + cmd = strings.ToLower(cmd) + } // Track the last command for context-aware tips and recent-command history. if strings.HasPrefix(cmd, "/") { @@ -377,7 +394,7 @@ func (m *chatModel) handleCommand(text string) (tea.Model, tea.Cmd) { } // Fallback: plugin commands and unknown-command error. - if m.pluginRuntime != nil && m.pluginRuntime.IsCommand(cmd[1:]) { + if strings.HasPrefix(cmd, "/") && m.pluginRuntime != nil && m.pluginRuntime.IsCommand(cmd[1:]) { out, err := m.pluginRuntime.ExecuteCommand(cmd[1:], parts[1:]) if err != nil { m.messages = append(m.messages, displayMsg{role: "error", content: err.Error()}) diff --git a/cmd/chat_commands_test.go b/cmd/chat_commands_test.go index e513d7e3..78d2022d 100644 --- a/cmd/chat_commands_test.go +++ b/cmd/chat_commands_test.go @@ -51,6 +51,17 @@ func TestHandleCommandAddDir(t *testing.T) { } } +func TestHandleCommandEmptyInputIsNoOp(t *testing.T) { + m := &chatModel{} + model, cmd := m.handleCommand(" \n\t") + if cmd != nil { + t.Fatal("empty command should not schedule a tea command") + } + if model != m || len(m.messages) != 0 { + t.Fatal("empty command should leave the model unchanged") + } +} + func TestLocalSlashCommands(t *testing.T) { preserveCLICompilerVersionState(t) version = "test-version" diff --git a/cmd/chat_scrollbar.go b/cmd/chat_scrollbar.go index b06ea181..aa536cc0 100644 --- a/cmd/chat_scrollbar.go +++ b/cmd/chat_scrollbar.go @@ -11,14 +11,17 @@ const scrollbarWidth = 1 // Scrollbar glyph palette — tuned to look premium in dark terminals. const ( - scrollbarTrackGlyph = " " // blank track — the gutter itself provides the visual rhythm + scrollbarTrackGlyph = "│" // dim track keeps the scroll position legible at a glance scrollbarThumbGlyph = "┃" // heavy vertical line for the thumb (visible without reading as a solid block) scrollbarTopGlyph = "╷" // cap at the very top of the track scrollbarBottomGlyph = "╵" // cap at the very bottom of the track ) // scrollbarThumbStyle — Talon Gold thumb so it reads as a brand control. -var scrollbarThumbStyle = lipgloss.NewStyle().Foreground(hawkColor) +var ( + scrollbarThumbStyle = lipgloss.NewStyle().Foreground(hawkColor) + scrollbarTrackStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("238")) +) // chatHasOverflow reports whether chat content exceeds the viewport height. func (m chatModel) chatHasOverflow() bool { @@ -41,10 +44,10 @@ func (m chatModel) chatScrollbarVisible() bool { // When chat content exceeds the viewport height, one column is reserved for the scrollbar slider // so text wrapping remains completely stable and smooth as the user scrolls up and down. func (m chatModel) chatViewportWidth(totalWidth int) int { - if totalWidth < 20 { - return 80 + if totalWidth <= 0 { + return 0 } - if m.chatHasOverflow() { + if m.chatHasOverflow() && totalWidth > scrollbarWidth { return totalWidth - scrollbarWidth } return totalWidth @@ -60,11 +63,14 @@ func (m chatModel) chatViewportWidth(totalWidth int) int { // // Returns an empty string when there is no overflow. func (m chatModel) renderScrollbar() string { + return m.renderScrollbarHeight(m.viewport.Height()) +} + +func (m chatModel) renderScrollbarHeight(vpH int) string { if !m.chatHasOverflow() { return "" } - vpH := m.viewport.Height() totalLines := m.contentLines if vpH <= 0 || totalLines <= 0 { return "" @@ -108,8 +114,12 @@ func (m chatModel) renderScrollbar() string { for row := 0; row < vpH; row++ { if row >= thumbTop && row <= thumbBottom { sb.WriteString(scrollbarThumbStyle.Render(scrollbarThumbGlyph)) + } else if row == 0 { + sb.WriteString(scrollbarTrackStyle.Render(scrollbarTopGlyph)) + } else if row == vpH-1 { + sb.WriteString(scrollbarTrackStyle.Render(scrollbarBottomGlyph)) } else { - sb.WriteString(" ") + sb.WriteString(scrollbarTrackStyle.Render(scrollbarTrackGlyph)) } if row < vpH-1 { sb.WriteByte('\n') @@ -157,7 +167,7 @@ func (m chatModel) renderChatPane() string { return padToHeight(chatView, vpH) } - scrollbar := m.renderScrollbar() + scrollbar := m.renderScrollbarHeight(vpH) if scrollbar == "" { return padToHeight(chatView, vpH) } diff --git a/cmd/chat_scrollbar_test.go b/cmd/chat_scrollbar_test.go index 7cf96409..2307c34a 100644 --- a/cmd/chat_scrollbar_test.go +++ b/cmd/chat_scrollbar_test.go @@ -50,6 +50,16 @@ func TestChatViewportWidth_NoScrollbar(t *testing.T) { } } +func TestChatViewportWidth_NarrowTerminalClampsWithoutJump(t *testing.T) { + m := chatModel{viewport: viewportWithSize(8, 4), contentLines: 100} + if got := m.chatViewportWidth(8); got != 7 { + t.Fatalf("narrow overflowing viewport width = %d, want 7", got) + } + if got := m.chatViewportWidth(1); got != 1 { + t.Fatalf("single-column viewport width = %d, want 1", got) + } +} + func TestRenderScrollbar_TopAndBottom(t *testing.T) { m := chatModel{ viewport: viewportWithSize(80, 10), @@ -92,6 +102,14 @@ func TestRenderScrollbar_SlightOverflowUsesLargeThumb(t *testing.T) { } } +func TestRenderScrollbar_UsesTrackOutsideThumb(t *testing.T) { + m := chatModel{viewport: viewportWithSize(80, 10), contentLines: 100} + lines := strings.Split(m.renderScrollbar(), "\n") + if !strings.Contains(lines[5], scrollbarTrackGlyph) { + t.Fatalf("expected track glyph outside thumb, got %q", lines[5]) + } +} + func TestRenderChatPane_PaddedWidth(t *testing.T) { m := chatModel{ viewport: viewportWithSize(19, 4), diff --git a/cmd/slash_commands_test.go b/cmd/slash_commands_test.go index 121a35a2..ce2287a9 100644 --- a/cmd/slash_commands_test.go +++ b/cmd/slash_commands_test.go @@ -109,6 +109,29 @@ func TestApplySlashSuggestion(t *testing.T) { } } +func TestSlashSuggestionsIncludeRegisteredCommandsAndAreDeterministic(t *testing.T) { + first := strings.Join(slashSuggestions("/"), "\n") + second := strings.Join(slashSuggestions("/"), "\n") + if first != second { + t.Fatal("slash suggestions changed between identical calls") + } + for _, sub := range subcommandRegistry.All() { + if sub.Name() == "" { + continue + } + found := false + for _, suggestion := range slashSuggestions("/") { + if strings.HasPrefix(suggestion, "/"+sub.Name()+" ") || suggestion == "/"+sub.Name() { + found = true + break + } + } + if !found { + t.Fatalf("missing suggestion for registered command /%s", sub.Name()) + } + } +} + func TestStalenessFormatReport(t *testing.T) { t.Parallel() report := stalenessFormatReport(nil) From 4d8e2d386dc4c2f9668570b7f80b56985b510233 Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Sun, 2 Aug 2026 03:06:51 +0530 Subject: [PATCH 48/49] fix: reconcile permission service boundaries --- internal/engine/permission_service.go | 68 +++++++++++++++++++-- internal/engine/safety/permission_engine.go | 8 +++ internal/engine/spec_mode_test.go | 6 +- internal/engine/stream_tool_exec.go | 1 - internal/engine/tool_service.go | 4 +- 5 files changed, 78 insertions(+), 9 deletions(-) diff --git a/internal/engine/permission_service.go b/internal/engine/permission_service.go index 418d6741..9dff3d73 100644 --- a/internal/engine/permission_service.go +++ b/internal/engine/permission_service.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "strings" + "sync" "github.com/GrayCodeAI/hawk/internal/engine/safety" "github.com/GrayCodeAI/hawk/internal/observability/logger" @@ -100,10 +101,10 @@ func (s *PermissionService) CheckTool(ctx context.Context, info ToolCallInfo) (b if !granted { s.log.Warn("permission denied", map[string]interface{}{ "tool": info.Name, - "reason": string(d.Reason), + "reason": denyMsg, }) } - return d.Outcome == safety.DecisionAllow, d.Message + return granted, denyMsg } // CheckToolDecision evaluates a request and exposes stable decision metadata. @@ -246,9 +247,14 @@ func (s *PermissionService) SetAllowedDirs(dirs []string) { // underlying PermissionEngine — the same field CheckTool reads — rather // than a separate shadow field, so the change actually takes effect. func (s *PermissionService) SetAutonomy(level AutonomyLevel) { - if s != nil && s.perm != nil { - s.perm.Autonomy = level + if s == nil || s.perm == nil { + return } + s.mu.Lock() + defer s.mu.Unlock() + s.perm.Autonomy = level + s.perm.AutonomyExplicit = true + s.perm.Revision++ } // SetSpecStage sets the independent spec-workflow stage. Also writes @@ -267,6 +273,27 @@ func (s *PermissionService) SetDryRun(dryRun bool) { } } +// SetSandboxMode updates the sandbox policy used for subsequent tool calls. +func (s *PermissionService) SetSandboxMode(mode sandbox.Mode) { + if s == nil || s.perm == nil { + return + } + s.mu.Lock() + defer s.mu.Unlock() + s.perm.SandboxMode = mode + s.perm.Revision++ +} + +// SandboxMode returns the active sandbox policy. +func (s *PermissionService) SandboxMode() sandbox.Mode { + if s == nil || s.perm == nil { + return sandbox.Mode("") + } + s.mu.RLock() + defer s.mu.RUnlock() + return s.perm.SandboxMode +} + // DryRun reports whether the kill switch is active. func (s *PermissionService) DryRun() bool { return s != nil && s.perm != nil && s.perm.DryRun } @@ -389,6 +416,39 @@ func (s *PermissionService) AdvanceSpecStage(toolName string) { s.perm.AdvanceSpecStage(toolName) } +// ResetSpec clears the active spec workflow. +func (s *PermissionService) ResetSpec() { + if s == nil || s.perm == nil { + return + } + s.mu.Lock() + defer s.mu.Unlock() + w := safety.SpecWorkflow{Stage: s.perm.Stage, Slug: s.perm.SpecSlug} + w.Reset() + s.perm.Stage, s.perm.SpecSlug = w.Stage, w.Slug + s.perm.Revision++ +} + +// AutonomyExplicit reports whether the autonomy tier was explicitly chosen. +func (s *PermissionService) AutonomyExplicit() bool { + if s == nil || s.perm == nil { + return false + } + s.mu.RLock() + defer s.mu.RUnlock() + return s.perm.AutonomyExplicit +} + +// SpecProgress returns the workflow stage and phase counters atomically. +func (s *PermissionService) SpecProgress() (SpecStage, int, int) { + if s == nil || s.perm == nil { + return SpecStageNone, 0, 0 + } + s.mu.RLock() + defer s.mu.RUnlock() + return s.perm.Stage, s.perm.Phase, s.perm.Phases +} + // Memory returns the legacy PermissionMemory shim. The shim is // kept in sync with the engine's classification state; callers // that historically used `sess.Permissions.AllowSpec(...)` should diff --git a/internal/engine/safety/permission_engine.go b/internal/engine/safety/permission_engine.go index 0e5978a1..385c42fc 100644 --- a/internal/engine/safety/permission_engine.go +++ b/internal/engine/safety/permission_engine.go @@ -477,6 +477,14 @@ func (pe *PermissionEngine) PhaseProgress() string { // model just executed successfully. Called by stream_tool_exec.go — plays // the same role ApplyToolState played for the old Plan Mode. func (pe *PermissionEngine) AdvanceSpecStage(name string) { + if canonicalToolName(name) == "SpecReset" { + pe.Stage = SpecStageNone + pe.SpecSlug = "" + pe.Phase = 0 + pe.Phases = 0 + pe.Revision++ + return + } w := SpecWorkflow{Stage: pe.Stage, Slug: pe.SpecSlug} if err := w.Transition(name, pe.SpecSlug); err != nil { return diff --git a/internal/engine/spec_mode_test.go b/internal/engine/spec_mode_test.go index 3f521db1..7841f037 100644 --- a/internal/engine/spec_mode_test.go +++ b/internal/engine/spec_mode_test.go @@ -237,11 +237,11 @@ func TestSpecMode_ResetClearsStageAndSlug(t *testing.T) { s, _ := newSpecModeSession(true) s.PermSvc().SetSpecStage(SpecStageSpecify) runSpecTool(t, s, "Specify", map[string]interface{}{"title": "reset-test", "spec": "content"}) - if s.Perm.SpecSlug == "" { + if s.PermSvc().SpecSlug() == "" { t.Fatal("Specify should set an active slug") } runSpecTool(t, s, "SpecReset", map[string]interface{}{}) - if s.PermSvc().SpecStage() != SpecStageNone || s.Perm.SpecSlug != "" { - t.Fatalf("reset left stage=%v slug=%q", s.PermSvc().SpecStage(), s.Perm.SpecSlug) + if s.PermSvc().SpecStage() != SpecStageNone || s.PermSvc().SpecSlug() != "" { + t.Fatalf("reset left stage=%v slug=%q", s.PermSvc().SpecStage(), s.PermSvc().SpecSlug()) } } diff --git a/internal/engine/stream_tool_exec.go b/internal/engine/stream_tool_exec.go index fa28c6e8..5054ade9 100644 --- a/internal/engine/stream_tool_exec.go +++ b/internal/engine/stream_tool_exec.go @@ -7,7 +7,6 @@ import ( "slices" "strings" - "github.com/GrayCodeAI/hawk/internal/engine/safety" "github.com/GrayCodeAI/hawk/internal/tool" "github.com/GrayCodeAI/hawk/internal/types" diff --git a/internal/engine/tool_service.go b/internal/engine/tool_service.go index 9582b25b..21afc11d 100644 --- a/internal/engine/tool_service.go +++ b/internal/engine/tool_service.go @@ -281,6 +281,8 @@ func (s *ToolService) ExecuteOne(ctx context.Context, tc types.ToolCall, overrid YaadBridge: yaad, SpecSlugGet: func() string { return s.deps.permissions.SpecSlug() }, SpecSlugSet: func(slug string) { s.deps.permissions.SetSpecSlug(slug) }, + AllowedDirectories: s.deps.permissions.AllowedDirs(), + SandboxMode: s.deps.permissions.SandboxMode(), BackgroundManager: s.EnsureBackgroundManager(), ReadOnlyBash: s.deps.readOnlyBash, WorkingDir: s.deps.workingDir, @@ -470,7 +472,7 @@ func (s *ToolService) CompleteResult(ctx context.Context, result toolExecResult, output, isErr := result.output, result.isErr if !isErr && s.deps.permissions != nil { switch canonicalToolName(result.tc.Name) { - case "Specify", "Plan", "Tasks": + case "Specify", "Plan", "Tasks", "SpecReset": s.deps.permissions.AdvanceSpecStage(result.tc.Name) case "ApproveImplementation": s.deps.permissions.AdvanceSpecStage(result.tc.Name) From 425e16dc000e9362ec8f68a1da528fd55c12a807 Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Sun, 2 Aug 2026 03:56:23 +0530 Subject: [PATCH 49/49] fix(engine): lock allowedDirs in SetAllowedDirs to fix data race SetAllowedDirs wrote allowedDirs without holding the service mutex while PolicySnapshot/EvaluateTool read it under RLock, triggering a data race under -race (caught by TestPermissionService_ConcurrentPolicyUpdates). --- internal/engine/permission_service.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/internal/engine/permission_service.go b/internal/engine/permission_service.go index 9dff3d73..9d1ef0be 100644 --- a/internal/engine/permission_service.go +++ b/internal/engine/permission_service.go @@ -239,6 +239,8 @@ func (s *PermissionService) SetMaxBudgetUSD(usd float64) { // SetAllowedDirs sets the directories the agent may write to. func (s *PermissionService) SetAllowedDirs(dirs []string) { if s != nil { + s.mu.Lock() + defer s.mu.Unlock() s.allowedDirs = append([]string(nil), dirs...) } }