diff --git a/cmd/autonomy_picker.go b/cmd/autonomy_picker.go index 09c0e9de..bf2a2598 100644 --- a/cmd/autonomy_picker.go +++ b/cmd/autonomy_picker.go @@ -3,16 +3,17 @@ package cmd import ( "strings" + "github.com/GrayCodeAI/rho/internal/engine/safety" + "charm.land/bubbles/v2/textinput" tea "charm.land/bubbletea/v2" lipgloss "charm.land/lipgloss/v2" - "github.com/GrayCodeAI/rho/internal/engine" "github.com/mattn/go-runewidth" ) // autonomyPickerEntry is one selectable row in the picker. type autonomyPickerEntry struct { - Level engine.AutonomyLevel + Level safety.AutonomyLevel Name string Description string } @@ -22,12 +23,12 @@ type autonomyPickerEntry struct { // containerAutonomyTiers (the Ctrl+L cycle), which deliberately excludes // Supervised so repeated key-presses can't land you in max-friction mode by // accident — the picker is a deliberate selection, so Supervised is fine here. -var allAutonomyTiers = []engine.AutonomyLevel{ - engine.AutonomySupervised, - engine.AutonomyBasic, - engine.AutonomySemi, - engine.AutonomyFull, - engine.AutonomyYOLO, +var allAutonomyTiers = []safety.AutonomyLevel{ + safety.AutonomySupervised, + safety.AutonomyBasic, + safety.AutonomySemi, + safety.AutonomyFull, + safety.AutonomyYOLO, } // AutonomyPicker is a Ctrl+L-adjacent quick-select overlay for choosing a @@ -68,7 +69,7 @@ func NewAutonomyPicker(width int) *AutonomyPicker { } // Open opens the picker, pre-selecting the currently active tier. -func (ap *AutonomyPicker) Open(current engine.AutonomyLevel) { +func (ap *AutonomyPicker) Open(current safety.AutonomyLevel) { ap.open = true ap.input.SetValue("") ap.input.Focus() diff --git a/cmd/autonomy_picker_test.go b/cmd/autonomy_picker_test.go index b5bd4df3..e03dd772 100644 --- a/cmd/autonomy_picker_test.go +++ b/cmd/autonomy_picker_test.go @@ -3,8 +3,9 @@ package cmd import ( "testing" + "github.com/GrayCodeAI/rho/internal/engine/safety" + tea "charm.land/bubbletea/v2" - "github.com/GrayCodeAI/rho/internal/engine" "github.com/mattn/go-runewidth" ) @@ -44,9 +45,9 @@ func TestAutonomyPicker_HasAllFiveTiers(t *testing.T) { if len(ap.entries) != 5 { t.Fatalf("expected 5 tiers, got %d", len(ap.entries)) } - wantOrder := []engine.AutonomyLevel{ - engine.AutonomySupervised, engine.AutonomyBasic, engine.AutonomySemi, - engine.AutonomyFull, engine.AutonomyYOLO, + wantOrder := []safety.AutonomyLevel{ + safety.AutonomySupervised, safety.AutonomyBasic, safety.AutonomySemi, + safety.AutonomyFull, safety.AutonomyYOLO, } for i, level := range wantOrder { if ap.entries[i].Level != level { @@ -57,25 +58,25 @@ func TestAutonomyPicker_HasAllFiveTiers(t *testing.T) { func TestAutonomyPicker_OpenPreselectsCurrentTier(t *testing.T) { ap := NewAutonomyPicker(80) - ap.Open(engine.AutonomyFull) + ap.Open(safety.AutonomyFull) if !ap.IsOpen() { t.Fatal("expected picker to be open") } sel := ap.Selected() - if sel == nil || sel.Level != engine.AutonomyFull { + if sel == nil || sel.Level != safety.AutonomyFull { t.Fatalf("expected preselected tier AutonomyFull, got %+v", sel) } } func TestAutonomyPicker_EnterSelectsAndCloses(t *testing.T) { ap := NewAutonomyPicker(80) - ap.Open(engine.AutonomySupervised) + ap.Open(safety.AutonomySupervised) chosen, handled := ap.Update(tea.KeyPressMsg{Code: tea.KeyDown}) if !handled || chosen != nil { t.Fatalf("KeyDown should navigate, not select: chosen=%v handled=%v", chosen, handled) } - if ap.Selected().Level != engine.AutonomyBasic { + if ap.Selected().Level != safety.AutonomyBasic { t.Fatalf("expected selection to move to AutonomyBasic, got %v", ap.Selected().Level) } @@ -83,7 +84,7 @@ func TestAutonomyPicker_EnterSelectsAndCloses(t *testing.T) { if !handled { t.Fatal("expected Enter to be handled") } - if chosen == nil || chosen.Level != engine.AutonomyBasic { + if chosen == nil || chosen.Level != safety.AutonomyBasic { t.Fatalf("expected chosen AutonomyBasic, got %v", chosen) } if ap.IsOpen() { @@ -93,7 +94,7 @@ func TestAutonomyPicker_EnterSelectsAndCloses(t *testing.T) { func TestAutonomyPicker_EscClosesWithoutSelecting(t *testing.T) { ap := NewAutonomyPicker(80) - ap.Open(engine.AutonomyBasic) + ap.Open(safety.AutonomyBasic) chosen, handled := ap.Update(tea.KeyPressMsg{Code: tea.KeyEsc}) if !handled || chosen != nil { @@ -106,12 +107,12 @@ func TestAutonomyPicker_EscClosesWithoutSelecting(t *testing.T) { func TestAutonomyPicker_FilterByName(t *testing.T) { ap := NewAutonomyPicker(80) - ap.Open(engine.AutonomyBasic) + ap.Open(safety.AutonomyBasic) for _, r := range "scout" { ap.Update(tea.KeyPressMsg{Code: r, Text: string(r)}) } - if len(ap.filtered) != 1 || ap.filtered[0].Level != engine.AutonomyBasic { + if len(ap.filtered) != 1 || ap.filtered[0].Level != safety.AutonomyBasic { t.Fatalf("expected filter 'scout' to match only Basic tier, got %+v", ap.filtered) } } diff --git a/cmd/autonomy_tiers.go b/cmd/autonomy_tiers.go index 03405cdd..4e5f3c65 100644 --- a/cmd/autonomy_tiers.go +++ b/cmd/autonomy_tiers.go @@ -4,20 +4,21 @@ import ( "fmt" "image/color" + "github.com/GrayCodeAI/rho/internal/engine/safety" + lipgloss "charm.land/lipgloss/v2" - "github.com/GrayCodeAI/rho/internal/engine" ) // Five container autonomy tiers (Scout → Builder → Operator → Autonomous → Always Ask). // Supervised ("Always Ask") is included in the Ctrl+L cycle but requires a // deliberate double-press to land on (see chat_update.go ctrl+l handling) so // repeated key-presses can't accidentally drop the user into max-friction mode. -var containerAutonomyTiers = []engine.AutonomyLevel{ - engine.AutonomyBasic, - engine.AutonomySemi, - engine.AutonomyFull, - engine.AutonomyYOLO, - engine.AutonomySupervised, +var containerAutonomyTiers = []safety.AutonomyLevel{ + safety.AutonomyBasic, + safety.AutonomySemi, + safety.AutonomyFull, + safety.AutonomyYOLO, + safety.AutonomySupervised, } var containerAutonomyTierNames = []string{ @@ -29,14 +30,14 @@ var containerAutonomyTierNames = []string{ } // DefaultContainerAutonomy is the tier applied when the Docker container becomes ready. -const DefaultContainerAutonomy = engine.AutonomySemi +const DefaultContainerAutonomy = safety.AutonomySemi // yoloConfirmToken is the exact string a user must type (case-insensitive) to // confirm entry into YOLO ("Autonomous") unattended mode via the picker. const yoloConfirmToken = "continue" -func autonomyTierName(level engine.AutonomyLevel) string { - if level == engine.AutonomySupervised { +func autonomyTierName(level safety.AutonomyLevel) string { + if level == safety.AutonomySupervised { return "Always Ask" } for i, l := range containerAutonomyTiers { @@ -47,7 +48,7 @@ func autonomyTierName(level engine.AutonomyLevel) string { return "Builder" } -func autonomyTierIndex(level engine.AutonomyLevel) int { +func autonomyTierIndex(level safety.AutonomyLevel) int { for i, l := range containerAutonomyTiers { if l == level { return i @@ -60,11 +61,11 @@ func autonomyTierIndex(level engine.AutonomyLevel) int { // Supervised ("Always Ask") — repeated Ctrl+L wraps YOLO → Basic. Use // nextAutonomyTierIncludingSupervised when the user explicitly confirms they // want the cautious tier. -func nextAutonomyTier(level engine.AutonomyLevel) engine.AutonomyLevel { +func nextAutonomyTier(level safety.AutonomyLevel) safety.AutonomyLevel { idx := autonomyTierIndex(level) for { idx = (idx + 1) % len(containerAutonomyTiers) - if containerAutonomyTiers[idx] != engine.AutonomySupervised { + if containerAutonomyTiers[idx] != safety.AutonomySupervised { return containerAutonomyTiers[idx] } } @@ -72,74 +73,74 @@ func nextAutonomyTier(level engine.AutonomyLevel) engine.AutonomyLevel { // nextAutonomyTierIncludingSupervised returns the next tier with Supervised // included in the cycle (used after the user confirms via double-press). -func nextAutonomyTierIncludingSupervised(level engine.AutonomyLevel) engine.AutonomyLevel { +func nextAutonomyTierIncludingSupervised(level safety.AutonomyLevel) safety.AutonomyLevel { return containerAutonomyTiers[(autonomyTierIndex(level)+1)%len(containerAutonomyTiers)] } // isSupervisedPending reports whether the next regular cycle step would land // on Supervised (i.e. the current tier is YOLO). The UI uses this to prompt // for confirmation. -func isSupervisedPending(level engine.AutonomyLevel) bool { - return level == engine.AutonomyYOLO +func isSupervisedPending(level safety.AutonomyLevel) bool { + return level == safety.AutonomyYOLO } // autonomyTierDescription is short copy shown when the user changes tier (ctrl+L). -func autonomyTierDescription(level engine.AutonomyLevel) string { +func autonomyTierDescription(level safety.AutonomyLevel) string { switch level { - case engine.AutonomySupervised: + case safety.AutonomySupervised: return "Prompts for permission on every tool call" - case engine.AutonomyBasic: + case safety.AutonomyBasic: return "Explore only — edits and commands ask first" - case engine.AutonomySemi: + case safety.AutonomySemi: return "File changes auto-approve — commands ask first" - case engine.AutonomyFull: + case safety.AutonomyFull: return "Commands auto-run — risky actions ask first" - case engine.AutonomyYOLO: + case safety.AutonomyYOLO: return "Minimal prompts — only the highest-risk actions stop" default: return "File changes auto-approve — commands ask first" } } -func autonomyTierColor(level engine.AutonomyLevel) color.Color { +func autonomyTierColor(level safety.AutonomyLevel) color.Color { switch level { - case engine.AutonomySupervised: + case safety.AutonomySupervised: return lipgloss.Color("#9E9E9E") // matches textMuted's dark value - case engine.AutonomyBasic: + case safety.AutonomyBasic: return tierInspect - case engine.AutonomySemi: + case safety.AutonomySemi: return tierEdit - case engine.AutonomyFull: + case safety.AutonomyFull: return tierRun - case engine.AutonomyYOLO: + case safety.AutonomyYOLO: return tierTrust default: return tierEdit } } -func autonomyTierStyle(level engine.AutonomyLevel) lipgloss.Style { +func autonomyTierStyle(level safety.AutonomyLevel) lipgloss.Style { return lipgloss.NewStyle().Foreground(autonomyTierColor(level)).Bold(true).Inline(true) } -func renderAutonomyTierLabel(level engine.AutonomyLevel) string { +func renderAutonomyTierLabel(level safety.AutonomyLevel) string { return autonomyTierStyle(level).Render(autonomyTierName(level)) } -func formatAutonomyTierMessage(level engine.AutonomyLevel) string { +func formatAutonomyTierMessage(level safety.AutonomyLevel) string { return fmt.Sprintf("Autonomy %s — %s", renderAutonomyTierLabel(level), autonomyTierDescription(level)) } -func autonomyFromSettings(n int) engine.AutonomyLevel { +func autonomyFromSettings(n int) safety.AutonomyLevel { switch n { case 1: - return engine.AutonomyBasic + return safety.AutonomyBasic case 2: - return engine.AutonomySemi + return safety.AutonomySemi case 3: - return engine.AutonomyFull + return safety.AutonomyFull case 4: - return engine.AutonomyYOLO + return safety.AutonomyYOLO default: return 0 } diff --git a/cmd/autonomy_tiers_copy_test.go b/cmd/autonomy_tiers_copy_test.go index 1181678b..f0fb1b5a 100644 --- a/cmd/autonomy_tiers_copy_test.go +++ b/cmd/autonomy_tiers_copy_test.go @@ -4,18 +4,18 @@ import ( "strings" "testing" - "github.com/GrayCodeAI/rho/internal/engine" + "github.com/GrayCodeAI/rho/internal/engine/safety" ) func TestAutonomyTierDescriptions_PlainLanguage(t *testing.T) { cases := []struct { - level engine.AutonomyLevel + level safety.AutonomyLevel need []string }{ - {engine.AutonomyBasic, []string{"Explore only", "commands ask"}}, - {engine.AutonomySemi, []string{"File changes auto-approve", "commands ask"}}, - {engine.AutonomyFull, []string{"Commands auto-run", "risky"}}, - {engine.AutonomyYOLO, []string{"Minimal prompts", "highest-risk"}}, + {safety.AutonomyBasic, []string{"Explore only", "commands ask"}}, + {safety.AutonomySemi, []string{"File changes auto-approve", "commands ask"}}, + {safety.AutonomyFull, []string{"Commands auto-run", "risky"}}, + {safety.AutonomyYOLO, []string{"Minimal prompts", "highest-risk"}}, } for _, tc := range cases { desc := autonomyTierDescription(tc.level) @@ -28,7 +28,7 @@ func TestAutonomyTierDescriptions_PlainLanguage(t *testing.T) { } func TestFormatAutonomyTierMessage_NoArrowJargon(t *testing.T) { - msg := formatAutonomyTierMessage(engine.AutonomyFull) + msg := formatAutonomyTierMessage(safety.AutonomyFull) if strings.Contains(msg, "→") { t.Fatalf("expected no arrow jargon, got %q", msg) } diff --git a/cmd/autonomy_tiers_test.go b/cmd/autonomy_tiers_test.go index 050954d3..0cd28911 100644 --- a/cmd/autonomy_tiers_test.go +++ b/cmd/autonomy_tiers_test.go @@ -4,35 +4,35 @@ import ( "fmt" "testing" - "github.com/GrayCodeAI/rho/internal/engine" + "github.com/GrayCodeAI/rho/internal/engine/safety" ) func TestAutonomyTierNames(t *testing.T) { - if got := autonomyTierName(engine.AutonomyBasic); got != "Scout" { + if got := autonomyTierName(safety.AutonomyBasic); got != "Scout" { t.Fatalf("Basic = %q, want Scout", got) } - if got := autonomyTierName(engine.AutonomySemi); got != "Builder" { + if got := autonomyTierName(safety.AutonomySemi); got != "Builder" { t.Fatalf("Semi = %q, want Builder", got) } - if got := autonomyTierName(engine.AutonomyFull); got != "Operator" { + if got := autonomyTierName(safety.AutonomyFull); got != "Operator" { t.Fatalf("Full = %q, want Operator", got) } - if got := autonomyTierName(engine.AutonomyYOLO); got != "Autonomous" { + if got := autonomyTierName(safety.AutonomyYOLO); got != "Autonomous" { t.Fatalf("YOLO = %q, want Autonomous", got) } } func TestNextAutonomyTier(t *testing.T) { - if nextAutonomyTier(engine.AutonomyYOLO) != engine.AutonomyBasic { + if nextAutonomyTier(safety.AutonomyYOLO) != safety.AutonomyBasic { t.Fatal("expected Autonomous -> Scout wrap") } - if nextAutonomyTier(engine.AutonomySemi) != engine.AutonomyFull { + if nextAutonomyTier(safety.AutonomySemi) != safety.AutonomyFull { t.Fatal("expected Builder -> Operator") } } func TestAutonomyFromSettings(t *testing.T) { - if autonomyFromSettings(2) != engine.AutonomySemi { + if autonomyFromSettings(2) != safety.AutonomySemi { t.Fatal("settings autonomy 2 should map to Builder/Semi") } if autonomyFromSettings(0) != 0 { @@ -41,11 +41,11 @@ func TestAutonomyFromSettings(t *testing.T) { } func TestAutonomyTierColorsDistinct(t *testing.T) { - levels := []engine.AutonomyLevel{ - engine.AutonomyBasic, - engine.AutonomySemi, - engine.AutonomyFull, - engine.AutonomyYOLO, + levels := []safety.AutonomyLevel{ + safety.AutonomyBasic, + safety.AutonomySemi, + safety.AutonomyFull, + safety.AutonomyYOLO, } seen := make(map[string]bool) for _, l := range levels { diff --git a/cmd/chat.go b/cmd/chat.go index b937255d..e1889524 100644 --- a/cmd/chat.go +++ b/cmd/chat.go @@ -16,6 +16,8 @@ import ( "syscall" "time" + "github.com/GrayCodeAI/rho/internal/engine/safety" + "golang.org/x/term" "charm.land/bubbles/v2/spinner" @@ -318,7 +320,7 @@ func newChatModelWithRegistry(ref *progRef, systemPrompt string, settings rhocon startup.EndPhase("newChatModel:welcome") // Wire permission system - sess.PermSvc().SetPermissionFn(func(req engine.PermissionRequest) { + sess.PermSvc().SetPermissionFn(func(req safety.PermissionRequest) { ref.Send(permissionAskMsg{req: req}) }) @@ -326,7 +328,7 @@ func newChatModelWithRegistry(ref *progRef, systemPrompt string, settings rhocon // of the permission engine; falls back to AskUserFn for confirmation. sess.SetApproval(&engine.ApprovalGate{ Enabled: true, - MaxAutoApprove: engine.AutonomySemi, + MaxAutoApprove: safety.AutonomySemi, }) // Wire ask_user tool (5-minute timeout matches permission prompts). diff --git a/cmd/chat_config_security.go b/cmd/chat_config_security.go index 91334dfd..fff73285 100644 --- a/cmd/chat_config_security.go +++ b/cmd/chat_config_security.go @@ -4,17 +4,17 @@ import ( "strings" "sync" - "github.com/GrayCodeAI/rho/internal/engine" + "github.com/GrayCodeAI/rho/internal/engine/safety" ) var ( configNoticeRedactorOnce sync.Once - configNoticeRedactor *engine.OutputRedactor + configNoticeRedactor *safety.OutputRedactor ) -func configNoticeRedact() *engine.OutputRedactor { +func configNoticeRedact() *safety.OutputRedactor { configNoticeRedactorOnce.Do(func() { - configNoticeRedactor = engine.NewOutputRedactor() + configNoticeRedactor = safety.NewOutputRedactor() }) return configNoticeRedactor } diff --git a/cmd/chat_model.go b/cmd/chat_model.go index 4b064446..88c756e6 100644 --- a/cmd/chat_model.go +++ b/cmd/chat_model.go @@ -7,6 +7,8 @@ import ( "sync" "time" + "github.com/GrayCodeAI/rho/internal/engine/safety" + "charm.land/bubbles/v2/spinner" "charm.land/bubbles/v2/textarea" "charm.land/bubbles/v2/textinput" @@ -161,7 +163,7 @@ type ( loopTickMsg struct{ command string } toolUseMsg struct{ name, id string } toolResultMsg struct{ name, content string } - permissionAskMsg struct{ req engine.PermissionRequest } + permissionAskMsg struct{ req safety.PermissionRequest } permissionPromptTimeoutMsg struct{ seq int } thinkingMsg string blastRadiusMsg struct{ message string } @@ -223,7 +225,7 @@ type chatModel struct { turnHadAssistantOutput bool // current turn produced assistant text turnHadToolActivity bool // current turn produced tool activity messageQueue []string // queued messages while agent is working - permReq *engine.PermissionRequest // pending permission prompt + permReq *safety.PermissionRequest // pending permission prompt permReqSeq int permTimeoutAt time.Time // deadline for the active permission prompt (zero = none) askReq *askUserMsg // pending ask_user prompt diff --git a/cmd/chat_permission_keys_test.go b/cmd/chat_permission_keys_test.go index c5c1ba39..b18393f2 100644 --- a/cmd/chat_permission_keys_test.go +++ b/cmd/chat_permission_keys_test.go @@ -5,14 +5,15 @@ import ( "testing" "time" + "github.com/GrayCodeAI/rho/internal/engine/safety" + tea "charm.land/bubbletea/v2" contracts "github.com/GrayCodeAI/rho/internal/contracts/policy" - "github.com/GrayCodeAI/rho/internal/engine" ) func TestPermissionAlwaysAllowDoesNotNilDeref(t *testing.T) { m := newTestChatModel() - req := engine.PermissionRequest{ + req := safety.PermissionRequest{ PermissionRequest: contracts.PermissionRequest{ ToolName: "Bash", Summary: "git -C /tmp status", @@ -50,7 +51,7 @@ func TestPermissionAlwaysAllowDoesNotNilDeref(t *testing.T) { func TestPermissionAlwaysDenyDoesNotNilDeref(t *testing.T) { m := newTestChatModel() - req := engine.PermissionRequest{ + req := safety.PermissionRequest{ PermissionRequest: contracts.PermissionRequest{ ToolName: "Bash", Summary: "rm -rf /", diff --git a/cmd/chat_prompt_timeout_test.go b/cmd/chat_prompt_timeout_test.go index 3459635a..3d5a655d 100644 --- a/cmd/chat_prompt_timeout_test.go +++ b/cmd/chat_prompt_timeout_test.go @@ -4,13 +4,14 @@ import ( "strings" "testing" + "github.com/GrayCodeAI/rho/internal/engine/safety" + contracts "github.com/GrayCodeAI/rho/internal/contracts/policy" - "github.com/GrayCodeAI/rho/internal/engine" ) func TestPermissionPromptTimeoutClearsStaleState(t *testing.T) { m := newTestChatModel() - req := engine.PermissionRequest{ + req := safety.PermissionRequest{ PermissionRequest: contracts.PermissionRequest{ ToolName: "Bash", Summary: "run git status", @@ -88,7 +89,7 @@ func TestPromptTimeoutIgnoresNewerPrompt(t *testing.T) { func TestStreamErrClearsInteractivePromptState(t *testing.T) { m := newTestChatModel() - req := engine.PermissionRequest{ + req := safety.PermissionRequest{ PermissionRequest: contracts.PermissionRequest{ ToolName: "Bash", Summary: "run git status", diff --git a/cmd/chat_subcommand_shell_test.go b/cmd/chat_subcommand_shell_test.go index d18fef64..aa10dfbe 100644 --- a/cmd/chat_subcommand_shell_test.go +++ b/cmd/chat_subcommand_shell_test.go @@ -4,12 +4,12 @@ import ( "strings" "testing" - "github.com/GrayCodeAI/rho/internal/engine" + "github.com/GrayCodeAI/rho/internal/engine/safety" ) func TestRunSubcommand_UsesBashToolForSafeCommand(t *testing.T) { m := newTestChatModel() - m.session.PermSvc().SetAutonomy(engine.AutonomyYOLO) + m.session.PermSvc().SetAutonomy(safety.AutonomyYOLO) result, _ := (&runSubcommand{}).Handle(m, []string{"printf", "hello"}, "/run printf hello") cm := requireChatModel(t, result) @@ -32,7 +32,7 @@ func TestRunSubcommand_UsesBashToolForSafeCommand(t *testing.T) { func TestRunSubcommand_RespectsSpecStageGate(t *testing.T) { m := newTestChatModel() - m.session.PermSvc().SetSpecStage(engine.SpecStageSpecify) + m.session.PermSvc().SetSpecStage(safety.SpecStageSpecify) result, _ := (&runSubcommand{}).Handle(m, []string{"printf", "hello"}, "/run printf hello") cm := requireChatModel(t, result) @@ -54,7 +54,7 @@ func TestRunSubcommand_RespectsSpecStageGate(t *testing.T) { func TestRunSubcommand_BashToolBlocksEnvDumpEvenWhenBypassed(t *testing.T) { m := newTestChatModel() - m.session.PermSvc().SetAutonomy(engine.AutonomyYOLO) + m.session.PermSvc().SetAutonomy(safety.AutonomyYOLO) result, _ := (&runSubcommand{}).Handle(m, []string{"env"}, "/run env") cm := requireChatModel(t, result) @@ -73,7 +73,7 @@ func TestRunSubcommand_BashToolBlocksEnvDumpEvenWhenBypassed(t *testing.T) { func TestTestSubcommand_DetectsNonzeroExit(t *testing.T) { m := newTestChatModel() - m.session.PermSvc().SetAutonomy(engine.AutonomyYOLO) + m.session.PermSvc().SetAutonomy(safety.AutonomyYOLO) result, _ := (&testSubcommand{}).Handle(m, []string{"false"}, "/test false") cm := requireChatModel(t, result) diff --git a/cmd/chat_subcommand_spec.go b/cmd/chat_subcommand_spec.go index ae9e0ba0..185948c6 100644 --- a/cmd/chat_subcommand_spec.go +++ b/cmd/chat_subcommand_spec.go @@ -4,9 +4,10 @@ import ( "fmt" "strings" + "github.com/GrayCodeAI/rho/internal/engine/safety" + tea "charm.land/bubbletea/v2" - "github.com/GrayCodeAI/rho/internal/engine" "github.com/GrayCodeAI/rho/internal/spec" ) @@ -55,7 +56,7 @@ func (s *specSubcommand) Handle(m *chatModel, args []string, text string) (tea.M return handleSpecConfig(m, arg) } - m.session.PermSvc().SetSpecStage(engine.SpecStageProposal) + m.session.PermSvc().SetSpecStage(safety.SpecStageProposal) m.messages = append(m.messages, displayMsg{role: "system", content: "Spec workflow started — Write/Edit/Bash are gated. Start with Proposal, then Specify + Design (parallel), then Plan, Tasks, and ApproveImplementation."}) if arg == "" { return m, nil diff --git a/cmd/chat_submit.go b/cmd/chat_submit.go index 43bbe4a7..e687871f 100644 --- a/cmd/chat_submit.go +++ b/cmd/chat_submit.go @@ -9,6 +9,8 @@ import ( "strings" "time" + "github.com/GrayCodeAI/rho/internal/engine/safety" + tea "charm.land/bubbletea/v2" rhoconfig "github.com/GrayCodeAI/rho/internal/config" @@ -31,10 +33,10 @@ func (m chatModel) submitUserMessage() (chatModel, tea.Cmd) { m.input.Reset() m.viewDirty = true if strings.EqualFold(text, yoloConfirmToken) && m.session != nil { - m.session.PermSvc().SetAutonomy(engine.AutonomyYOLO) - m.settings.Autonomy = permissionTierSettingValue(engine.AutonomyYOLO) + m.session.PermSvc().SetAutonomy(safety.AutonomyYOLO) + m.settings.Autonomy = permissionTierSettingValue(safety.AutonomyYOLO) m.settings.AutonomyExplicit = true - m.messages = append(m.messages, displayMsg{role: "system", content: formatAutonomyTierMessage(engine.AutonomyYOLO) + " — enabled. " + icons.CloseThick() + " You will not be prompted for permission."}) + m.messages = append(m.messages, displayMsg{role: "system", content: formatAutonomyTierMessage(safety.AutonomyYOLO) + " — enabled. " + icons.CloseThick() + " You will not be prompted for permission."}) } else { m.messages = append(m.messages, displayMsg{role: "system", content: "Autonomy change cancelled — stayed on the previous tier."}) } diff --git a/cmd/chat_update.go b/cmd/chat_update.go index 393b93b2..075cb66a 100644 --- a/cmd/chat_update.go +++ b/cmd/chat_update.go @@ -8,11 +8,12 @@ import ( "time" "unicode/utf8" + "github.com/GrayCodeAI/rho/internal/engine/safety" + "charm.land/bubbles/v2/spinner" tea "charm.land/bubbletea/v2" rhoconfig "github.com/GrayCodeAI/rho/internal/config" - "github.com/GrayCodeAI/rho/internal/engine" "github.com/GrayCodeAI/rho/internal/session" "github.com/GrayCodeAI/rho/internal/spec" "github.com/GrayCodeAI/rho/internal/tool" @@ -558,7 +559,7 @@ func (m chatModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // YOLO ("Autonomous") is unattended mode: require a typed // confirmation instead of a single Enter, so a stray key // cannot silently drop the session into never-ask. - if chosen.Level == engine.AutonomyYOLO { + if chosen.Level == safety.AutonomyYOLO { m.pendingYOLOConfirm = true m.messages = append(m.messages, displayMsg{role: "system", content: fmt.Sprintf("Autonomy tier → %s — this enables unattended mode (never prompts for permission). Type %s then Enter to confirm, or type anything else to cancel.", chosen.Name, yoloConfirmToken)}) m.viewDirty = true @@ -602,7 +603,7 @@ func (m chatModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if chosen != nil && m.session != nil { switch chosen.Action { case specActionStart: - m.session.PermSvc().SetSpecStage(engine.SpecStageProposal) + m.session.PermSvc().SetSpecStage(safety.SpecStageProposal) m.messages = append(m.messages, displayMsg{role: "system", content: "Spec workflow started — Write/Edit/Bash are gated. Start with Proposal, then Specify + Design (parallel), then Plan, Tasks, and ApproveImplementation."}) case specActionStatus: m.messages = append(m.messages, displayMsg{role: "system", content: fmt.Sprintf("Spec stage: %s", specStageLabel(m.session))}) @@ -979,7 +980,7 @@ func (m chatModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.updateViewportContent() return m, nil } - var nextTier engine.AutonomyLevel + var nextTier safety.AutonomyLevel if m.supervisedPending && isSupervisedPending(current) { nextTier = nextAutonomyTierIncludingSupervised(current) } else { @@ -1281,7 +1282,7 @@ func (m chatModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.permTimeoutAt = time.Now().Add(5 * time.Minute) // Display-only enrichment (risk + why). Keep req.Summary as ToolSummary // for AutoMode / memory matching after y/n/a/d. - permBody := engine.FormatPermissionDisplay(msg.req.ToolName, msg.req.Summary) + permBody := safety.FormatPermissionDisplay(msg.req.ToolName, msg.req.Summary) m.messages = append(m.messages, displayMsg{role: "permission", content: permBody, timeoutAt: m.permTimeoutAt}) m.viewDirty = true m.updateViewportContent() diff --git a/cmd/chat_yolo_confirm_test.go b/cmd/chat_yolo_confirm_test.go index 4df3e49a..b9abe7c6 100644 --- a/cmd/chat_yolo_confirm_test.go +++ b/cmd/chat_yolo_confirm_test.go @@ -5,7 +5,7 @@ import ( "strings" "testing" - "github.com/GrayCodeAI/rho/internal/engine" + "github.com/GrayCodeAI/rho/internal/engine/safety" ) func TestYOLOConfirm_PendingConsumesNextInput(t *testing.T) { @@ -18,7 +18,7 @@ func TestYOLOConfirm_PendingConsumesNextInput(t *testing.T) { if next.pendingYOLOConfirm { t.Error("pendingYOLOConfirm should be cleared after any submission") } - if next.session.PermSvc().Autonomy() == engine.AutonomyYOLO { + if next.session.PermSvc().Autonomy() == safety.AutonomyYOLO { t.Error("autonomy should NOT be YOLO after a non-matching confirmation") } if len(next.messages) == 0 || !strings.Contains(next.messages[len(next.messages)-1].content, "cancelled") { @@ -35,7 +35,7 @@ func TestYOLOConfirm_ExactTokenEnables(t *testing.T) { if next.pendingYOLOConfirm { t.Error("pendingYOLOConfirm should be cleared after confirmation") } - if next.session.PermSvc().Autonomy() != engine.AutonomyYOLO { + if next.session.PermSvc().Autonomy() != safety.AutonomyYOLO { t.Errorf("autonomy = %v, want YOLO after matching confirmation", next.session.PermSvc().Autonomy()) } } @@ -46,7 +46,7 @@ func TestYOLOConfirm_CaseInsensitive(t *testing.T) { m.input.SetValue(strings.ToUpper(yoloConfirmToken)) next, _ := m.submitUserMessage() - if next.session.PermSvc().Autonomy() != engine.AutonomyYOLO { + if next.session.PermSvc().Autonomy() != safety.AutonomyYOLO { t.Error("confirmation token should match case-insensitively") } } @@ -57,7 +57,7 @@ func TestYOLOConfirm_DoesNotLeakIntoNormalSubmit(t *testing.T) { m.input.SetValue(yoloConfirmToken) next, _ := m.submitUserMessage() - if next.session.PermSvc().Autonomy() == engine.AutonomyYOLO { + if next.session.PermSvc().Autonomy() == safety.AutonomyYOLO { t.Error("autonomy should not change without a pending confirmation") } } diff --git a/cmd/daemon.go b/cmd/daemon.go index 6951e2fd..24af20d4 100644 --- a/cmd/daemon.go +++ b/cmd/daemon.go @@ -14,6 +14,8 @@ import ( "syscall" "time" + "github.com/GrayCodeAI/rho/internal/engine/safety" + rhoconfig "github.com/GrayCodeAI/rho/internal/config" "github.com/GrayCodeAI/rho/internal/daemon" "github.com/GrayCodeAI/rho/internal/engine" @@ -401,7 +403,7 @@ func generateDaemonAPIKey() (string, error) { // env var into the server-side autonomy cap. An empty value leaves the // default cap (AutonomySemi) in place; invalid values fail closed at // "supervised" rather than silently allowing full autonomy. -func daemonAutonomyFromFlag(s string) engine.AutonomyLevel { +func daemonAutonomyFromFlag(s string) safety.AutonomyLevel { s = strings.TrimSpace(s) if s == "" { s = os.Getenv("RHO_DAEMON_AUTONOMY") @@ -411,17 +413,17 @@ func daemonAutonomyFromFlag(s string) engine.AutonomyLevel { } switch strings.ToLower(strings.TrimSpace(s)) { case "0", "supervised": - return engine.AutonomySupervised + return safety.AutonomySupervised case "1", "basic": - return engine.AutonomyBasic + return safety.AutonomyBasic case "2", "semi", "accept_edits", "acceptedits": - return engine.AutonomySemi + return safety.AutonomySemi case "3", "full": - return engine.AutonomyFull + return safety.AutonomyFull case "4", "yolo", "dont_ask", "dontask": - return engine.AutonomyYOLO + return safety.AutonomyYOLO default: - return engine.AutonomySupervised + return safety.AutonomySupervised } } diff --git a/cmd/exec.go b/cmd/exec.go index 604edfd9..97de2607 100644 --- a/cmd/exec.go +++ b/cmd/exec.go @@ -14,8 +14,9 @@ import ( "time" "unicode/utf8" + "github.com/GrayCodeAI/rho/internal/engine/safety" + rhoconfig "github.com/GrayCodeAI/rho/internal/config" - "github.com/GrayCodeAI/rho/internal/engine" "github.com/GrayCodeAI/rho/internal/errhint" "github.com/GrayCodeAI/rho/internal/multiagent/agents" "github.com/GrayCodeAI/rho/internal/notify" @@ -237,7 +238,7 @@ func runExec(_ *cobra.Command, args []string) error { // Apply autonomy level if execAutoLevel != "" { - sess.PermSvc().SetAutonomy(engine.ParseAutonomyLevel(execAutoLevel)) + sess.PermSvc().SetAutonomy(safety.ParseAutonomyLevel(execAutoLevel)) } // Prompt-injection guard: when the prompt originates from an untrusted @@ -246,7 +247,7 @@ func runExec(_ *cobra.Command, args []string) error { // cannot drive writes or Bash. Maintainers can opt out with // RHO_GHA_TRUST_EVENT=1. if ghaCtx.Active && !ghaCtx.Trusted { - const ceiling = engine.AutonomyBasic + const ceiling = safety.AutonomyBasic if sess.PermSvc().Autonomy() > ceiling { fmt.Fprintf(os.Stderr, "%s\n", auditTint(fmt.Sprintf("rho: untrusted GitHub event (author_association=%q); capping autonomy at %s", ghaCtx.AuthorAssociation, ceiling), warnAmber)) sess.PermSvc().SetAutonomy(ceiling) @@ -254,8 +255,8 @@ func runExec(_ *cobra.Command, args []string) error { } // In exec mode, auto-approve based on autonomy level (no TUI to ask) - sess.PermSvc().SetPermissionFn(func(req engine.PermissionRequest) { - cfg := engine.PresetConfig(sess.PermSvc().Autonomy()) + sess.PermSvc().SetPermissionFn(func(req safety.PermissionRequest) { + cfg := safety.PresetConfig(sess.PermSvc().Autonomy()) allowed := !cfg.NeedsPermission(req.ToolName, false) if req.Response != nil { req.Response <- allowed @@ -935,12 +936,12 @@ func execOnceInWorktree(prompt string, attemptIdx int) (*ExecResult, error) { } if execAutoLevel != "" { - sess.PermSvc().SetAutonomy(engine.ParseAutonomyLevel(execAutoLevel)) + sess.PermSvc().SetAutonomy(safety.ParseAutonomyLevel(execAutoLevel)) } else { - sess.PermSvc().SetAutonomy(engine.AutonomyFull) + sess.PermSvc().SetAutonomy(safety.AutonomyFull) } - sess.PermSvc().SetPermissionFn(func(req engine.PermissionRequest) { - cfg := engine.PresetConfig(sess.PermSvc().Autonomy()) + sess.PermSvc().SetPermissionFn(func(req safety.PermissionRequest) { + cfg := safety.PresetConfig(sess.PermSvc().Autonomy()) allowed := !cfg.NeedsPermission(req.ToolName, false) if req.Response != nil { req.Response <- allowed diff --git a/cmd/mission.go b/cmd/mission.go index d70c55c0..a34c46d7 100644 --- a/cmd/mission.go +++ b/cmd/mission.go @@ -9,8 +9,9 @@ import ( "strings" "time" + "github.com/GrayCodeAI/rho/internal/engine/safety" + rhoconfig "github.com/GrayCodeAI/rho/internal/config" - "github.com/GrayCodeAI/rho/internal/engine" mission "github.com/GrayCodeAI/rho/internal/multiagent" "github.com/GrayCodeAI/rho/internal/observability/logger" "github.com/GrayCodeAI/rho/internal/tool" @@ -66,7 +67,7 @@ func runMission(_ *cobra.Command, args []string) error { effectiveModel = missionModel } - autonomy := engine.ParseAutonomyLevel(missionAuto) + autonomy := safety.ParseAutonomyLevel(missionAuto) cfg := mission.Config{ MaxWorkers: missionWorkers, @@ -206,7 +207,7 @@ func planWithLLM(ctx context.Context, prompt, provider, model string, settings r return nil, err } sess.PermSvc().SetMaxTurns(1) - sess.PermSvc().SetPermissionFn(func(req engine.PermissionRequest) { + sess.PermSvc().SetPermissionFn(func(req safety.PermissionRequest) { if req.Response != nil { req.Response <- true } diff --git a/cmd/options.go b/cmd/options.go index 3dac14d0..7738395c 100644 --- a/cmd/options.go +++ b/cmd/options.go @@ -9,6 +9,8 @@ import ( "os" "strings" + "github.com/GrayCodeAI/rho/internal/engine/safety" + "github.com/GrayCodeAI/rho/internal/engine/scaffold" rhoconfig "github.com/GrayCodeAI/rho/internal/config" @@ -393,14 +395,14 @@ func configureSessionStartup(sess *engine.Session, settings rhoconfig.Settings, sess.EnsureAutoCompactor() if settings.AutonomyExplicit { - sess.PermSvc().SetAutonomy(engine.AutonomySupervised) + sess.PermSvc().SetAutonomy(safety.AutonomySupervised) } else 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) + sess.PermSvc().SetAutonomy(safety.AutonomyYOLO) } // Per-model thinking preference (Setup → Models Think column), with diff --git a/cmd/permissions_center.go b/cmd/permissions_center.go index 88462827..df80036b 100644 --- a/cmd/permissions_center.go +++ b/cmd/permissions_center.go @@ -6,44 +6,46 @@ import ( "strings" "time" + "github.com/GrayCodeAI/rho/internal/engine/safety" + tea "charm.land/bubbletea/v2" rhoconfig "github.com/GrayCodeAI/rho/internal/config" "github.com/GrayCodeAI/rho/internal/engine" ) -func normalizePermissionTier(raw string) (engine.AutonomyLevel, string, bool) { +func normalizePermissionTier(raw string) (safety.AutonomyLevel, string, bool) { switch strings.ToLower(strings.TrimSpace(raw)) { case "always_ask", "always-ask", "supervised", "ask": - return engine.AutonomySupervised, "Always Ask", true + return safety.AutonomySupervised, "Always Ask", true case "scout", "basic", "read": - return engine.AutonomyBasic, "Scout", true + return safety.AutonomyBasic, "Scout", true case "builder", "semi", "edit": - return engine.AutonomySemi, "Builder", true + return safety.AutonomySemi, "Builder", true case "operator", "full", "run": - return engine.AutonomyFull, "Operator", true + return safety.AutonomyFull, "Operator", true case "autonomous", "yolo", "auto": - return engine.AutonomyYOLO, "Autonomous", true + return safety.AutonomyYOLO, "Autonomous", true default: return 0, "", false } } -func permissionTierSettingValue(level engine.AutonomyLevel) int { +func permissionTierSettingValue(level safety.AutonomyLevel) int { switch level { - case engine.AutonomyBasic: + case safety.AutonomyBasic: return 1 - case engine.AutonomySemi: + case safety.AutonomySemi: return 2 - case engine.AutonomyFull: + case safety.AutonomyFull: return 3 - case engine.AutonomyYOLO: + case safety.AutonomyYOLO: return 4 default: return 0 } } -func effectivePermissionTier(sess *engine.Session) engine.AutonomyLevel { +func effectivePermissionTier(sess *engine.Session) safety.AutonomyLevel { if sess == nil { return DefaultContainerAutonomy } @@ -72,17 +74,17 @@ func normalizePermissionSandbox(raw string) (string, string, bool) { } } -func permissionBehaviorSummary(level engine.AutonomyLevel) string { +func permissionBehaviorSummary(level safety.AutonomyLevel) string { switch level { - case engine.AutonomySupervised: + case safety.AutonomySupervised: return "prompts for every tool call" - case engine.AutonomyBasic: + case safety.AutonomyBasic: return "reads auto-approve; edits and commands ask first" - case engine.AutonomySemi: + case safety.AutonomySemi: return "reads and file changes auto-approve; commands ask first" - case engine.AutonomyFull: + case safety.AutonomyFull: return "reads, edits, and normal commands auto-run; risky actions ask first" - case engine.AutonomyYOLO: + case safety.AutonomyYOLO: return "minimal prompts; only highest-risk actions stop" default: return "prompts for every tool call" @@ -96,9 +98,9 @@ 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 { +func currentSpecStage(sess *engine.Session) safety.SpecStage { if sess == nil || sess.PermSvc() == nil { - return engine.SpecStageNone + return safety.SpecStageNone } return sess.PermSvc().SpecStage() } @@ -142,7 +144,7 @@ func parseBypassFlags(args []string) (scope []string, expires time.Time, reason // markOverridden returns " *" if the flag was explicitly overridden by the // user, so the profile display can mark customized flags. -func markOverridden(profile *engine.AutonomyProfile, flag string) string { +func markOverridden(profile *safety.AutonomyProfile, flag string) string { if profile != nil && profile.IsOverridden(flag) { return " *" } @@ -302,7 +304,7 @@ func rebuildSessionPermissionRules(sess *engine.Session, settings rhoconfig.Sett } mem := perm.Memory() if mem == nil { - mem = engine.NewPermissionMemory() + mem = safety.NewPermissionMemory() sess.PermSvc().SetMemory(mem) } mem.Reset() @@ -323,7 +325,7 @@ func rebuildSessionPermissionRules(sess *engine.Session, settings rhoconfig.Sett } } -func savePermissionSettings(scope string, settings rhoconfig.Settings, level engine.AutonomyLevel) (string, error) { +func savePermissionSettings(scope string, settings rhoconfig.Settings, level safety.AutonomyLevel) (string, error) { scope = strings.ToLower(strings.TrimSpace(scope)) if scope == "" { scope = "global" @@ -362,7 +364,7 @@ func resetPermissionCenter(m *chatModel) { m.settings.AutoAllow = nil m.settings.AllowedTools = nil m.settings.DisallowedTools = nil - m.session.PermSvc().SetSpecStage(engine.SpecStageNone) + m.session.PermSvc().SetSpecStage(safety.SpecStageNone) m.session.PermSvc().SetDryRun(false) rebuildSessionPermissionRules(m.session, m.settings) } diff --git a/cmd/permissions_center_test.go b/cmd/permissions_center_test.go index 67e72696..55fd61a1 100644 --- a/cmd/permissions_center_test.go +++ b/cmd/permissions_center_test.go @@ -4,21 +4,23 @@ import ( "strings" "testing" + "github.com/GrayCodeAI/rho/internal/engine/safety" + rhoconfig "github.com/GrayCodeAI/rho/internal/config" "github.com/GrayCodeAI/rho/internal/engine" ) func TestNormalizePermissionTier(t *testing.T) { level, label, ok := normalizePermissionTier("always_ask") - if !ok || level != engine.AutonomySupervised || label != "Always Ask" { + if !ok || level != safety.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" { + if !ok || level != safety.AutonomyFull || label != "Operator" { t.Fatalf("operator = (%v, %q, %v)", level, label, ok) } level, label, ok = normalizePermissionTier("auto") - if !ok || level != engine.AutonomyYOLO || label != "Autonomous" { + if !ok || level != safety.AutonomyYOLO || label != "Autonomous" { t.Fatalf("auto = (%v, %q, %v)", level, label, ok) } } @@ -51,8 +53,8 @@ func TestEffectivePermissionRules(t *testing.T) { func TestAutonomyCenterSummary(t *testing.T) { sess := engine.NewSession("test", "test-model", "system", nil) - sess.PermSvc().SetAutonomy(engine.AutonomySemi) - sess.PermSvc().SetSpecStage(engine.SpecStageSpecify) + sess.PermSvc().SetAutonomy(safety.AutonomySemi) + sess.PermSvc().SetSpecStage(safety.SpecStageSpecify) model := &chatModel{ session: sess, settings: rhoconfig.Settings{ @@ -70,14 +72,14 @@ func TestAutonomyCenterSummary(t *testing.T) { func TestPermissionTierSettingValue(t *testing.T) { cases := []struct { - level engine.AutonomyLevel + level safety.AutonomyLevel want int }{ - {engine.AutonomySupervised, 0}, - {engine.AutonomyBasic, 1}, - {engine.AutonomySemi, 2}, - {engine.AutonomyFull, 3}, - {engine.AutonomyYOLO, 4}, + {safety.AutonomySupervised, 0}, + {safety.AutonomyBasic, 1}, + {safety.AutonomySemi, 2}, + {safety.AutonomyFull, 3}, + {safety.AutonomyYOLO, 4}, } for _, c := range cases { if got := permissionTierSettingValue(c.level); got != c.want { @@ -104,20 +106,20 @@ func TestEffectivePermissionTier_ReadsThroughRealSession(t *testing.T) { t.Fatalf("unset autonomy: got %v, want default %v", got, DefaultContainerAutonomy) } - sess.PermSvc().SetAutonomy(engine.AutonomyFull) - if got := effectivePermissionTier(sess); got != engine.AutonomyFull { - t.Fatalf("explicit Full: got %v, want %v", got, engine.AutonomyFull) + sess.PermSvc().SetAutonomy(safety.AutonomyFull) + if got := effectivePermissionTier(sess); got != safety.AutonomyFull { + t.Fatalf("explicit Full: got %v, want %v", got, safety.AutonomyFull) } - sess.PermSvc().SetAutonomy(engine.AutonomySupervised) - if got := effectivePermissionTier(sess); got != engine.AutonomySupervised { - t.Fatalf("explicit Supervised: got %v, want %v", got, engine.AutonomySupervised) + sess.PermSvc().SetAutonomy(safety.AutonomySupervised) + if got := effectivePermissionTier(sess); got != safety.AutonomySupervised { + t.Fatalf("explicit Supervised: got %v, want %v", got, safety.AutonomySupervised) } } func TestPermissionBehaviorSummary(t *testing.T) { seen := make(map[string]bool) - for _, level := range []engine.AutonomyLevel{ - engine.AutonomyBasic, engine.AutonomySemi, engine.AutonomyFull, engine.AutonomyYOLO, + for _, level := range []safety.AutonomyLevel{ + safety.AutonomyBasic, safety.AutonomySemi, safety.AutonomyFull, safety.AutonomyYOLO, } { summary := permissionBehaviorSummary(level) if summary == "" { @@ -132,13 +134,13 @@ func TestPermissionBehaviorSummary(t *testing.T) { func TestResetPermissionCenter(t *testing.T) { sess := engine.NewSession("", "test-model", "you are helpful", nil) - sess.PermSvc().SetAutonomy(engine.AutonomyYOLO) - sess.PermSvc().SetSpecStage(engine.SpecStageTasks) + sess.PermSvc().SetAutonomy(safety.AutonomyYOLO) + sess.PermSvc().SetSpecStage(safety.SpecStageTasks) sess.PermSvc().SetDryRun(true) model := &chatModel{ session: sess, settings: rhoconfig.Settings{ - Autonomy: permissionTierSettingValue(engine.AutonomyYOLO), + Autonomy: permissionTierSettingValue(safety.AutonomyYOLO), AutoAllow: []string{"Read"}, AllowedTools: []string{"Bash(git:*)"}, DisallowedTools: []string{"Bash(rm -rf *)"}, @@ -150,7 +152,7 @@ func TestResetPermissionCenter(t *testing.T) { if got := sess.PermSvc().Autonomy(); got != DefaultContainerAutonomy { t.Errorf("autonomy = %v, want default %v", got, DefaultContainerAutonomy) } - if got := sess.PermSvc().SpecStage(); got != engine.SpecStageNone { + if got := sess.PermSvc().SpecStage(); got != safety.SpecStageNone { t.Errorf("spec stage = %v, want None", got) } if sess.PermSvc().DryRun() { @@ -166,11 +168,11 @@ func TestHandleAutonomyCommand_Tier(t *testing.T) { model := &chatModel{session: sess} updated, _ := model.handleAutonomyCommand([]string{"autonomy", "tier", "operator"}) - if got := updated.session.PermSvc().Autonomy(); got != engine.AutonomyFull { + if got := updated.session.PermSvc().Autonomy(); got != safety.AutonomyFull { t.Fatalf("after tier operator: autonomy = %v, want Full", got) } - if updated.settings.Autonomy != permissionTierSettingValue(engine.AutonomyFull) { - t.Fatalf("settings.Autonomy = %d, want %d", updated.settings.Autonomy, permissionTierSettingValue(engine.AutonomyFull)) + if updated.settings.Autonomy != permissionTierSettingValue(safety.AutonomyFull) { + t.Fatalf("settings.Autonomy = %d, want %d", updated.settings.Autonomy, permissionTierSettingValue(safety.AutonomyFull)) } updated, _ = updated.handleAutonomyCommand([]string{"autonomy", "tier", "not-a-tier"}) @@ -178,7 +180,7 @@ func TestHandleAutonomyCommand_Tier(t *testing.T) { if last.role != "error" { t.Fatalf("invalid tier: expected error message, got role %q", last.role) } - if got := updated.session.PermSvc().Autonomy(); got != engine.AutonomyFull { + if got := updated.session.PermSvc().Autonomy(); got != safety.AutonomyFull { t.Fatalf("invalid tier should not change autonomy: got %v, want Full unchanged", got) } diff --git a/cmd/power.go b/cmd/power.go index 7bcfe672..2a7b1adb 100644 --- a/cmd/power.go +++ b/cmd/power.go @@ -3,6 +3,8 @@ package cmd import ( "fmt" + "github.com/GrayCodeAI/rho/internal/engine/safety" + "github.com/GrayCodeAI/rho/internal/engine" "github.com/GrayCodeAI/rho/internal/provider/routing" ) @@ -213,7 +215,7 @@ func ApplyPowerLevel(sess *engine.Session, level int) { // Configure autonomy based on power level if config.AutoApply { - sess.PermSvc().SetAutonomy(engine.AutonomySemi) + sess.PermSvc().SetAutonomy(safety.AutonomySemi) } } diff --git a/cmd/prompt_input.go b/cmd/prompt_input.go index acf6c293..cd946ef9 100644 --- a/cmd/prompt_input.go +++ b/cmd/prompt_input.go @@ -7,6 +7,8 @@ import ( "os" "strings" + "github.com/GrayCodeAI/rho/internal/engine/safety" + "github.com/GrayCodeAI/rho/internal/engine" ) @@ -50,7 +52,7 @@ func (p promptInput) readLine(prompt string) (string, error) { } func configureInteractivePrompts(sess *engine.Session, input promptInput) { - sess.PermSvc().SetPermissionFn(func(req engine.PermissionRequest) { + sess.PermSvc().SetPermissionFn(func(req safety.PermissionRequest) { answer, err := input.readLine(fmt.Sprintf("\nAllow %s: %s [y/N] ", req.ToolName, req.Summary)) if err != nil { _, _ = fmt.Fprintf(os.Stderr, "\nAllow %s: %s [y/N] (denied: %v)\n", req.ToolName, req.Summary, err) diff --git a/cmd/spec_picker.go b/cmd/spec_picker.go index 528220a8..0ce22b46 100644 --- a/cmd/spec_picker.go +++ b/cmd/spec_picker.go @@ -4,10 +4,11 @@ import ( "fmt" "strings" + "github.com/GrayCodeAI/rho/internal/engine/safety" + "charm.land/bubbles/v2/textinput" tea "charm.land/bubbletea/v2" lipgloss "charm.land/lipgloss/v2" - "github.com/GrayCodeAI/rho/internal/engine" "github.com/mattn/go-runewidth" ) @@ -53,7 +54,7 @@ type SpecPicker struct { filtered []specPickerEntry sel int width int - stage engine.SpecStage + stage safety.SpecStage } // NewSpecPicker creates a new spec workflow picker. @@ -72,7 +73,7 @@ func NewSpecPicker(width int) *SpecPicker { } // Open opens the picker, recording the current stage for the header line. -func (sp *SpecPicker) Open(stage engine.SpecStage) { +func (sp *SpecPicker) Open(stage safety.SpecStage) { sp.open = true sp.input.SetValue("") sp.input.Focus() @@ -205,19 +206,19 @@ func (sp *SpecPicker) Render(viewWidth int) string { // specStageDisplayName mirrors specStageLabel but takes a stage value // directly, since the picker tracks the stage snapshot from when it opened // rather than a *engine.Session. -func specStageDisplayName(stage engine.SpecStage) string { +func specStageDisplayName(stage safety.SpecStage) string { switch stage { - case engine.SpecStageProposal: + case safety.SpecStageProposal: return "Proposal" - case engine.SpecStageSpecify: + case safety.SpecStageSpecify: return "Specify" - case engine.SpecStageDesign: + case safety.SpecStageDesign: return "Design" - case engine.SpecStagePlan: + case safety.SpecStagePlan: return "Plan" - case engine.SpecStageTasks: + case safety.SpecStageTasks: return "Tasks" - case engine.SpecStageImplementing: + case safety.SpecStageImplementing: return "Implementing" default: return "None" diff --git a/cmd/spec_picker_test.go b/cmd/spec_picker_test.go index e0b1fe3d..6738584e 100644 --- a/cmd/spec_picker_test.go +++ b/cmd/spec_picker_test.go @@ -3,8 +3,9 @@ package cmd import ( "testing" + "github.com/GrayCodeAI/rho/internal/engine/safety" + tea "charm.land/bubbletea/v2" - "github.com/GrayCodeAI/rho/internal/engine" ) func TestSpecPicker_HasSevenActions(t *testing.T) { @@ -22,18 +23,18 @@ func TestSpecPicker_HasSevenActions(t *testing.T) { func TestSpecPicker_OpenRecordsStage(t *testing.T) { sp := NewSpecPicker(80) - sp.Open(engine.SpecStagePlan) + sp.Open(safety.SpecStagePlan) if !sp.IsOpen() { t.Fatal("expected picker to be open") } - if sp.stage != engine.SpecStagePlan { + if sp.stage != safety.SpecStagePlan { t.Fatalf("expected stage SpecStagePlan, got %v", sp.stage) } } func TestSpecPicker_EnterSelectsAndCloses(t *testing.T) { sp := NewSpecPicker(80) - sp.Open(engine.SpecStageNone) + sp.Open(safety.SpecStageNone) chosen, handled := sp.Update(tea.KeyPressMsg{Code: tea.KeyDown}) if !handled || chosen != nil { @@ -57,7 +58,7 @@ func TestSpecPicker_EnterSelectsAndCloses(t *testing.T) { func TestSpecPicker_EscClosesWithoutSelecting(t *testing.T) { sp := NewSpecPicker(80) - sp.Open(engine.SpecStageNone) + sp.Open(safety.SpecStageNone) chosen, handled := sp.Update(tea.KeyPressMsg{Code: tea.KeyEsc}) if !handled || chosen != nil { @@ -70,7 +71,7 @@ func TestSpecPicker_EscClosesWithoutSelecting(t *testing.T) { func TestSpecPicker_FilterByName(t *testing.T) { sp := NewSpecPicker(80) - sp.Open(engine.SpecStageNone) + sp.Open(safety.SpecStageNone) for _, r := range "reset" { sp.Update(tea.KeyPressMsg{Code: r, Text: string(r)}) @@ -96,7 +97,7 @@ func TestChatSpecSubcommand_WithDescriptionStartsDirectly(t *testing.T) { if cm.specPicker != nil && cm.specPicker.IsOpen() { t.Error("expected /spec with a description to start directly, not open the picker") } - if currentSpecStage(cm.session) != engine.SpecStageProposal { + if currentSpecStage(cm.session) != safety.SpecStageProposal { t.Fatalf("expected stage SpecStageProposal, got %v", currentSpecStage(cm.session)) } } diff --git a/cmd/statusbar.go b/cmd/statusbar.go index f7cbc6d4..84377401 100644 --- a/cmd/statusbar.go +++ b/cmd/statusbar.go @@ -7,6 +7,8 @@ import ( "sync" "time" + "github.com/GrayCodeAI/rho/internal/engine/safety" + "github.com/GrayCodeAI/rho/internal/engine/cost" tea "charm.land/bubbletea/v2" @@ -14,7 +16,6 @@ import ( "golang.org/x/text/language" "golang.org/x/text/message" - "github.com/GrayCodeAI/rho/internal/engine" "github.com/GrayCodeAI/rho/internal/engine/git" "github.com/GrayCodeAI/rho/internal/ui/icons" ) @@ -310,12 +311,12 @@ func specStageForStatus(m *chatModel) string { return "" } stage := m.session.PermSvc().SpecStage() - if stage == engine.SpecStageNone { + if stage == safety.SpecStageNone { return "" } label := specStageDisplayName(stage) phase, phases := m.session.PermSvc().SpecPhaseProgress() - if stage == engine.SpecStageImplementing && phases > 0 { + if stage == safety.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/cmd/statusbar_test.go b/cmd/statusbar_test.go index eab7a32b..cc437ce8 100644 --- a/cmd/statusbar_test.go +++ b/cmd/statusbar_test.go @@ -5,6 +5,8 @@ import ( "testing" "time" + "github.com/GrayCodeAI/rho/internal/engine/safety" + lipgloss "charm.land/lipgloss/v2" "github.com/GrayCodeAI/rho/internal/engine" ) @@ -37,7 +39,7 @@ func TestRenderStatusBar_SingleLineAtWidth80(t *testing.T) { func TestRenderStatusBarSecondaryRight_OmitsDuplicatedAutonomy(t *testing.T) { sess := engine.NewSession("", "test-model", "system", nil) - sess.PermSvc().SetAutonomy(engine.AutonomyBasic) + sess.PermSvc().SetAutonomy(safety.AutonomyBasic) if got := renderStatusBarSecondaryRight(&chatModel{session: sess}); strings.Contains(got, "Scout") { t.Fatalf("secondary status = %q, want autonomy shown only in top footer", got) diff --git a/cmd/vibe.go b/cmd/vibe.go index ccafaa36..3f3b6401 100644 --- a/cmd/vibe.go +++ b/cmd/vibe.go @@ -9,6 +9,8 @@ import ( "strings" "time" + "github.com/GrayCodeAI/rho/internal/engine/safety" + "github.com/GrayCodeAI/rho/internal/engine" ) @@ -79,7 +81,7 @@ func VibeLoop(ctx context.Context, sess *engine.Session, prompt string, config V } // Configure session for full autonomy - sess.PermSvc().SetAutonomy(engine.AutonomyYOLO) + sess.PermSvc().SetAutonomy(safety.AutonomyYOLO) currentPrompt := prompt diff --git a/internal/acp/server.go b/internal/acp/server.go index eb7c2b1c..21de5935 100644 --- a/internal/acp/server.go +++ b/internal/acp/server.go @@ -20,6 +20,8 @@ import ( "sync" "time" + "github.com/GrayCodeAI/rho/internal/engine/safety" + "github.com/GrayCodeAI/rho/internal/attachment" "github.com/GrayCodeAI/rho/internal/engine" "github.com/GrayCodeAI/rho/internal/session" @@ -588,8 +590,8 @@ func (s *Server) handleCancel(msg rpcMessage) { // permissionFnFor returns a PermissionFn that asks the ACP client to approve a // tool call via session/request_permission, falling back to denial on timeout. -func (s *Server) permissionFnFor(sessionID string) func(engine.PermissionRequest) { - return func(req engine.PermissionRequest) { +func (s *Server) permissionFnFor(sessionID string) func(safety.PermissionRequest) { + return func(req safety.PermissionRequest) { params := map[string]any{ "sessionId": sessionID, "toolCall": map[string]any{ diff --git a/internal/daemon/daemon.go b/internal/daemon/daemon.go index 5906a635..903164c9 100644 --- a/internal/daemon/daemon.go +++ b/internal/daemon/daemon.go @@ -22,6 +22,8 @@ import ( "sync" "time" + "github.com/GrayCodeAI/rho/internal/engine/safety" + "github.com/GrayCodeAI/rho/internal/engine" "github.com/GrayCodeAI/rho/internal/netutil" "github.com/GrayCodeAI/rho/internal/observability/metrics" @@ -142,7 +144,7 @@ type Server struct { // maxAutonomy caps the autonomy tier clients may request (server-side // policy; zero means DefaultMaxAutonomy). The daemon has no human to // approve permission prompts, so full/YOLO must be operator-opted-in. - maxAutonomy engine.AutonomyLevel + maxAutonomy safety.AutonomyLevel } // ReadyResponse is the JSON response from GET /v1/ready. @@ -180,7 +182,7 @@ type Config struct { // escalate to full/YOLO autonomy on their own. Zero means the default // cap (AutonomySemi) applies; set explicitly (e.g. to AutonomyFull) // only for trusted, operator-owned deployments. - MaxAutonomy engine.AutonomyLevel `json:"-"` + MaxAutonomy safety.AutonomyLevel `json:"-"` // GraphLedger durably retains accepted POST /v1/graph/sync payloads for // idempotency across daemon restarts. If nil, the server uses an // in-memory ledger (survives only the process lifetime). @@ -192,7 +194,7 @@ type Config struct { // auto-approves reads and writes but still gates Bash behind permission, // which is the most permissive setting that remains safe without a human // in the loop. -const DefaultMaxAutonomy = engine.AutonomySemi +const DefaultMaxAutonomy = safety.AutonomySemi // DefaultConfig returns reasonable defaults. func DefaultConfig() Config { @@ -813,7 +815,7 @@ func (s *Server) handleChat(w http.ResponseWriter, r *http.Request) { // client must not be able to escalate to full/YOLO autonomy on its own. // Operators opt in to higher tiers explicitly via Config.MaxAutonomy. if req.Autonomy != "" { - requested := engine.ParseAutonomyLevel(req.Autonomy) + requested := safety.ParseAutonomyLevel(req.Autonomy) max := s.maxAutonomy if max == 0 { max = DefaultMaxAutonomy @@ -829,8 +831,8 @@ func (s *Server) handleChat(w http.ResponseWriter, r *http.Request) { } // Auto-approve permissions based on autonomy (non-interactive) - sess.SetPermissionFn(func(pr engine.PermissionRequest) { - cfg := engine.PresetConfig(sess.PermSvc().Autonomy()) + sess.SetPermissionFn(func(pr safety.PermissionRequest) { + cfg := safety.PresetConfig(sess.PermSvc().Autonomy()) allowed := !cfg.NeedsPermission(pr.ToolName, false) if pr.Response != nil { pr.Response <- allowed diff --git a/internal/engine/agent_session_tool.go b/internal/engine/agent_session_tool.go index f74f6779..52b5f19a 100644 --- a/internal/engine/agent_session_tool.go +++ b/internal/engine/agent_session_tool.go @@ -7,6 +7,8 @@ import ( "strings" "time" + "github.com/GrayCodeAI/rho/internal/engine/safety" + agentcontracts "github.com/GrayCodeAI/rho/internal/contracts/agent" engagent "github.com/GrayCodeAI/rho/internal/engine/agent" @@ -203,7 +205,7 @@ func (s *Session) spawnSubAgent(ctx context.Context, norm agentcontracts.Normali sub.PermSvc().SetAskUserFn(func(question string) (string, error) { return "", fmt.Errorf("subagent cannot prompt user for interactive input") }) - sub.SetPermissionFn(func(req PermissionRequest) { + sub.SetPermissionFn(func(req safety.PermissionRequest) { if req.Response != nil { req.Response <- false } diff --git a/internal/engine/approval_gate.go b/internal/engine/approval_gate.go index 8ea9f6fd..a9a1b679 100644 --- a/internal/engine/approval_gate.go +++ b/internal/engine/approval_gate.go @@ -5,6 +5,8 @@ import ( "strconv" "strings" "sync" + + "github.com/GrayCodeAI/rho/internal/engine/safety" ) // approval_gate.go implements human-in-the-loop approval gates for high-risk @@ -66,7 +68,7 @@ type ApprovalGate struct { // auto-approved without prompting. Levels strictly above this still gate. // Defaults to the zero value (AutonomySupervised), meaning every level above // supervised gates flagged actions. - MaxAutoApprove AutonomyLevel + MaxAutoApprove safety.AutonomyLevel // ConfirmFn asks the human to confirm an action. The typed ApprovalResponse // lets callers return ApprovalApproveForSession to skip future prompts for // the same category. When nil the gate falls back to Session.AskUserFn. diff --git a/internal/engine/approval_gate_test.go b/internal/engine/approval_gate_test.go index 7ea58cb6..5d599d4b 100644 --- a/internal/engine/approval_gate_test.go +++ b/internal/engine/approval_gate_test.go @@ -3,11 +3,13 @@ package engine import ( "context" "testing" + + "github.com/GrayCodeAI/rho/internal/engine/safety" ) func TestApprovalGate_Disabled_NoOp(t *testing.T) { s := NewSession("test", "m", "", nil) - s.PermSvc().SetAutonomy(AutonomyYOLO) + s.PermSvc().SetAutonomy(safety.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 { @@ -24,7 +26,7 @@ func TestApprovalGate_Disabled_NoOp(t *testing.T) { func TestApprovalGate_FlaggedDestructiveRequiresApproval(t *testing.T) { approvedCalls := 0 s := NewSession("test", "m", "", nil) - s.PermSvc().SetAutonomy(AutonomyFull) // above MaxAutoApprove default (supervised) + s.PermSvc().SetAutonomy(safety.AutonomyFull) // above MaxAutoApprove default (supervised) s.SetApproval(&ApprovalGate{ Enabled: true, ConfirmFn: func(req ApprovalRequest) ApprovalResponse { @@ -50,7 +52,7 @@ func TestApprovalGate_FlaggedDestructiveRequiresApproval(t *testing.T) { func TestApprovalGate_HumanApproves(t *testing.T) { s := NewSession("test", "m", "", nil) - s.PermSvc().SetAutonomy(AutonomyFull) + s.PermSvc().SetAutonomy(safety.AutonomyFull) s.SetApproval(&ApprovalGate{ Enabled: true, ConfirmFn: func(req ApprovalRequest) ApprovalResponse { return ApprovalApprove }, @@ -64,10 +66,10 @@ func TestApprovalGate_HumanApproves(t *testing.T) { func TestApprovalGate_AutoApproveThreshold(t *testing.T) { called := false s := NewSession("test", "m", "", nil) - s.PermSvc().SetAutonomy(AutonomyBasic) // <= MaxAutoApprove + s.PermSvc().SetAutonomy(safety.AutonomyBasic) // <= MaxAutoApprove s.SetApproval(&ApprovalGate{ Enabled: true, - MaxAutoApprove: AutonomySemi, + MaxAutoApprove: safety.AutonomySemi, ConfirmFn: func(req ApprovalRequest) ApprovalResponse { called = true; return ApprovalReject }, }) ok, _ := s.CheckApproval(context.Background(), "Bash", map[string]interface{}{"command": "rm -rf x"}) @@ -82,7 +84,7 @@ func TestApprovalGate_AutoApproveThreshold(t *testing.T) { func TestApprovalGate_NonRiskyActionNotGated(t *testing.T) { called := false s := NewSession("test", "m", "", nil) - s.PermSvc().SetAutonomy(AutonomyYOLO) + s.PermSvc().SetAutonomy(safety.AutonomyYOLO) s.SetApproval(&ApprovalGate{ Enabled: true, ConfirmFn: func(req ApprovalRequest) ApprovalResponse { called = true; return ApprovalReject }, @@ -100,7 +102,7 @@ func TestApprovalGate_NonRiskyActionNotGated(t *testing.T) { func TestApprovalGate_CategoryFilter(t *testing.T) { called := false s := NewSession("test", "m", "", nil) - s.PermSvc().SetAutonomy(AutonomyFull) + s.PermSvc().SetAutonomy(safety.AutonomyFull) s.SetApproval(&ApprovalGate{ Enabled: true, Categories: map[ApprovalCategory]bool{ApprovalNetwork: true}, // only network gated @@ -127,7 +129,7 @@ func TestApprovalGate_CategoryFilter(t *testing.T) { func TestApprovalGate_FlaggedTool(t *testing.T) { s := NewSession("test", "m", "", nil) - s.PermSvc().SetAutonomy(AutonomyFull) + s.PermSvc().SetAutonomy(safety.AutonomyFull) denied := false s.SetApproval(&ApprovalGate{ Enabled: true, @@ -145,7 +147,7 @@ func TestApprovalGate_FlaggedTool(t *testing.T) { func TestApprovalGate_FailClosedNoHandler(t *testing.T) { s := NewSession("test", "m", "", nil) - s.PermSvc().SetAutonomy(AutonomyFull) + s.PermSvc().SetAutonomy(safety.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"}) @@ -159,7 +161,7 @@ func TestApprovalGate_FailClosedNoHandler(t *testing.T) { func TestApprovalGate_FallbackAskUserFn(t *testing.T) { s := NewSession("test", "m", "", nil) - s.PermSvc().SetAutonomy(AutonomyFull) + s.PermSvc().SetAutonomy(safety.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"}) @@ -170,7 +172,7 @@ func TestApprovalGate_FallbackAskUserFn(t *testing.T) { func TestApprovalGate_ApproveForNDefaultsToFive(t *testing.T) { s := NewSession("test", "m", "", nil) - s.PermSvc().SetAutonomy(AutonomyFull) + s.PermSvc().SetAutonomy(safety.AutonomyFull) confirmations := 0 s.SetApproval(&ApprovalGate{ Enabled: true, @@ -201,7 +203,7 @@ func TestApprovalGate_SQLWriteCategory(t *testing.T) { for _, toolName := range []string{"SQL", "sql", "sql_query"} { t.Run(toolName, func(t *testing.T) { s := NewSession("test", "m", "", nil) - s.PermSvc().SetAutonomy(AutonomyFull) + s.PermSvc().SetAutonomy(safety.AutonomyFull) s.SetApproval(&ApprovalGate{ Enabled: true, ConfirmFn: func(req ApprovalRequest) ApprovalResponse { @@ -223,7 +225,7 @@ func TestApprovalGate_WaterfallDispatch(t *testing.T) { t.Run("waterfall answers", func(t *testing.T) { confirmCalled := false s := NewSession("test", "m", "", nil) - s.PermSvc().SetAutonomy(AutonomyFull) + s.PermSvc().SetAutonomy(safety.AutonomyFull) wf := NewApprovalWaterfall() wf.Add(func(_ context.Context, req ApprovalRequest) (ApprovalResponse, bool) { if req.ToolName != "Bash" { @@ -250,7 +252,7 @@ func TestApprovalGate_WaterfallDispatch(t *testing.T) { t.Run("empty waterfall fail-closed", func(t *testing.T) { s := NewSession("test", "m", "", nil) - s.PermSvc().SetAutonomy(AutonomyFull) + s.PermSvc().SetAutonomy(safety.AutonomyFull) s.SetApproval(&ApprovalGate{ Enabled: true, Waterfall: NewApprovalWaterfall(), // no deciders -> deny diff --git a/internal/engine/auto_commit_wire_test.go b/internal/engine/auto_commit_wire_test.go index bf5e8ab4..0df314b2 100644 --- a/internal/engine/auto_commit_wire_test.go +++ b/internal/engine/auto_commit_wire_test.go @@ -7,6 +7,8 @@ import ( "path/filepath" "testing" + "github.com/GrayCodeAI/rho/internal/engine/safety" + "github.com/GrayCodeAI/rho/internal/tool" "github.com/GrayCodeAI/rho/internal/types" ) @@ -31,7 +33,7 @@ func (t *autoCommitCaptureTool) Execute(ctx context.Context, _ json.RawMessage) func TestSetAutoCommit_PropagatesToToolContext(t *testing.T) { cap := &autoCommitCaptureTool{} sess := NewSession("test", "test", "sys", tool.NewRegistry(cap)) - sess.PermSvc().SetAutonomy(AutonomyYOLO) + sess.PermSvc().SetAutonomy(safety.AutonomyYOLO) if sess.AutoCommit() { t.Fatal("default auto-commit should be off") } diff --git a/internal/engine/control_plane_test.go b/internal/engine/control_plane_test.go index 6739e299..ca3a0a1d 100644 --- a/internal/engine/control_plane_test.go +++ b/internal/engine/control_plane_test.go @@ -6,6 +6,8 @@ import ( "strings" "testing" + "github.com/GrayCodeAI/rho/internal/engine/safety" + "github.com/GrayCodeAI/rho/internal/tool" "github.com/GrayCodeAI/rho/internal/types" ) @@ -60,7 +62,7 @@ func TestToolSearchSelectPromotes(t *testing.T) { reg := tool.NewRegistry(tool.FileReadTool{}, tool.ImpactTool{}, tool.ToolSearchTool{}) reg.EnableLazyModelSurface([]string{"Read", "ToolSearch"}) sess := NewSession("test", "test", "sys", reg) - sess.PermSvc().SetAutonomy(AutonomyYOLO) + sess.PermSvc().SetAutonomy(safety.AutonomyYOLO) ch := make(chan StreamEvent, 8) input, _ := json.Marshal(map[string]interface{}{"query": "select:Impact"}) res := sess.executeSingleTool(context.Background(), types.ToolCall{ diff --git a/internal/engine/dry_run_test.go b/internal/engine/dry_run_test.go index 3b46a65d..35cbe5a8 100644 --- a/internal/engine/dry_run_test.go +++ b/internal/engine/dry_run_test.go @@ -3,6 +3,8 @@ package engine import ( "context" "testing" + + "github.com/GrayCodeAI/rho/internal/engine/safety" ) // TestDryRun_DeniesEverythingRegardlessOfTierOrStage covers the dry-run @@ -11,10 +13,10 @@ import ( // when a spec-workflow tool would otherwise always be allowed. func TestDryRun_DeniesEverythingRegardlessOfTierOrStage(t *testing.T) { sess := newTestSession() - sess.PermSvc().SetAutonomy(AutonomyYOLO) + sess.PermSvc().SetAutonomy(safety.AutonomyYOLO) sess.PermSvc().SetDryRun(true) - granted, deny := sess.PermSvc().CheckTool(context.Background(), ToolCallInfo{Name: "Read", Args: map[string]interface{}{"path": "x.txt"}}) + granted, deny := sess.PermSvc().CheckTool(context.Background(), safety.ToolCallInfo{Name: "Read", Args: map[string]interface{}{"path": "x.txt"}}) if granted { t.Fatal("dry-run should deny Read even at AutonomyYOLO") } @@ -24,8 +26,8 @@ func TestDryRun_DeniesEverythingRegardlessOfTierOrStage(t *testing.T) { // Even spec-workflow tools, which are otherwise always allowed while a // stage is active, must be denied under dry-run. - sess.PermSvc().SetSpecStage(SpecStageSpecify) - granted, _ = sess.PermSvc().CheckTool(context.Background(), ToolCallInfo{Name: "Specify", Args: map[string]interface{}{}}) + sess.PermSvc().SetSpecStage(safety.SpecStageSpecify) + granted, _ = sess.PermSvc().CheckTool(context.Background(), safety.ToolCallInfo{Name: "Specify", Args: map[string]interface{}{}}) if granted { t.Fatal("dry-run should deny Specify even during an active spec stage") } @@ -33,11 +35,11 @@ func TestDryRun_DeniesEverythingRegardlessOfTierOrStage(t *testing.T) { func TestDryRun_OffRestoresNormalBehavior(t *testing.T) { sess := newTestSession() - sess.PermSvc().SetAutonomy(AutonomyYOLO) + sess.PermSvc().SetAutonomy(safety.AutonomyYOLO) sess.PermSvc().SetDryRun(true) sess.PermSvc().SetDryRun(false) - granted, _ := sess.PermSvc().CheckTool(context.Background(), ToolCallInfo{Name: "Read", Args: map[string]interface{}{"path": "x.txt"}}) + granted, _ := sess.PermSvc().CheckTool(context.Background(), safety.ToolCallInfo{Name: "Read", Args: map[string]interface{}{"path": "x.txt"}}) if !granted { t.Fatal("expected Read to be allowed again once dry-run is turned off") } diff --git a/internal/engine/engine_integration_test.go b/internal/engine/engine_integration_test.go index 2372ed48..cfb174fb 100644 --- a/internal/engine/engine_integration_test.go +++ b/internal/engine/engine_integration_test.go @@ -5,6 +5,8 @@ import ( "testing" "time" + "github.com/GrayCodeAI/rho/internal/engine/safety" + "github.com/GrayCodeAI/rho/internal/engine/cost" contracts "github.com/GrayCodeAI/rho/internal/contracts/policy" @@ -218,20 +220,20 @@ func TestIntegration_PermissionFlow(t *testing.T) { sess := newTestSession() // file_write always needs permission. - if !ToolNeedsPermission("Write", nil) { + if !safety.ToolNeedsPermission("Write", nil) { t.Fatal("Write should need permission") } - if !ToolNeedsPermission("Edit", nil) { + if !safety.ToolNeedsPermission("Edit", nil) { t.Fatal("Edit should need permission") } // Safe bash commands do NOT need permission. - if ToolNeedsPermission("Bash", map[string]interface{}{"command": "echo hello"}) { + if safety.ToolNeedsPermission("Bash", map[string]interface{}{"command": "echo hello"}) { t.Fatal("safe echo should not need permission") } // Dangerous bash commands DO need permission. - if !ToolNeedsPermission("Bash", map[string]interface{}{"command": "rm -rf /"}) { + if !safety.ToolNeedsPermission("Bash", map[string]interface{}{"command": "rm -rf /"}) { t.Fatal("destructive command should need permission") } @@ -251,13 +253,13 @@ func TestIntegration_PermissionFlow(t *testing.T) { // Test permission function callback. permCalled := false - sess.SetPermissionFn(func(req PermissionRequest) { + sess.SetPermissionFn(func(req safety.PermissionRequest) { permCalled = true req.Response <- true }) // Create a fresh permission memory to test the callback flow. - sess.PermSvc().SetMemory(NewPermissionMemory()) + sess.PermSvc().SetMemory(safety.NewPermissionMemory()) decision = sess.PermSvc().Memory().Check("Write", "/tmp/new-file.txt") if decision != nil { t.Fatal("fresh permission memory should return nil (ask user)") @@ -268,7 +270,7 @@ func TestIntegration_PermissionFlow(t *testing.T) { } // Simulate calling the permission function. resp := make(chan bool, 1) - sess.PermSvc().PermissionFn()(PermissionRequest{ + sess.PermSvc().PermissionFn()(safety.PermissionRequest{ PermissionRequest: contracts.PermissionRequest{ ToolName: "Write", ToolID: "test-id", @@ -392,32 +394,32 @@ func TestIntegration_TrustTiersAndSpecStage(t *testing.T) { ctx := context.Background() // AutonomyYOLO allows everything. - sess.PermSvc().SetAutonomy(AutonomyYOLO) - granted, _ := sess.PermSvc().CheckTool(ctx, ToolCallInfo{Name: "Write", Args: map[string]interface{}{"path": "x.txt"}}) + sess.PermSvc().SetAutonomy(safety.AutonomyYOLO) + granted, _ := sess.PermSvc().CheckTool(ctx, safety.ToolCallInfo{Name: "Write", Args: map[string]interface{}{"path": "x.txt"}}) if !granted { t.Fatal("AutonomyYOLO should allow Write") } // AutonomySemi allows Write/Edit but not Bash (falls through to prompt). - sess.PermSvc().SetAutonomy(AutonomySemi) - sess.PermSvc().SetPermissionFn(func(req PermissionRequest) { + sess.PermSvc().SetAutonomy(safety.AutonomySemi) + sess.PermSvc().SetPermissionFn(func(req safety.PermissionRequest) { if req.Response != nil { req.Response <- false } }) - granted, _ = sess.PermSvc().CheckTool(ctx, ToolCallInfo{Name: "Write", Args: map[string]interface{}{"path": "x.txt"}}) + granted, _ = sess.PermSvc().CheckTool(ctx, safety.ToolCallInfo{Name: "Write", Args: map[string]interface{}{"path": "x.txt"}}) if !granted { t.Fatal("AutonomySemi should allow Write") } - granted, _ = sess.PermSvc().CheckTool(ctx, ToolCallInfo{Name: "Bash", Args: map[string]interface{}{"command": "rm -rf /"}}) + granted, _ = sess.PermSvc().CheckTool(ctx, safety.ToolCallInfo{Name: "Bash", Args: map[string]interface{}{"command": "rm -rf /"}}) if granted { t.Fatal("AutonomySemi should ask (and here deny) for Bash") } // Spec stage gate denies Write regardless of tier, even at YOLO. - sess.PermSvc().SetAutonomy(AutonomyYOLO) - sess.PermSvc().SetSpecStage(SpecStageSpecify) - granted, denyMsg := sess.PermSvc().CheckTool(ctx, ToolCallInfo{Name: "Write", Args: map[string]interface{}{"path": "x.txt"}}) + sess.PermSvc().SetAutonomy(safety.AutonomyYOLO) + sess.PermSvc().SetSpecStage(safety.SpecStageSpecify) + granted, denyMsg := sess.PermSvc().CheckTool(ctx, safety.ToolCallInfo{Name: "Write", Args: map[string]interface{}{"path": "x.txt"}}) if granted { t.Fatal("spec stage should deny Write even at AutonomyYOLO") } @@ -426,13 +428,13 @@ func TestIntegration_TrustTiersAndSpecStage(t *testing.T) { } // Reads remain unrestricted during spec stage. - granted, _ = sess.PermSvc().CheckTool(ctx, ToolCallInfo{Name: "Read", Args: map[string]interface{}{"path": "x.txt"}}) + granted, _ = sess.PermSvc().CheckTool(ctx, safety.ToolCallInfo{Name: "Read", Args: map[string]interface{}{"path": "x.txt"}}) if !granted { t.Fatal("reads should be unrestricted during spec stage") } // Specify/Plan/Tasks tools are always allowed during spec stage. - granted, _ = sess.PermSvc().CheckTool(ctx, ToolCallInfo{Name: "Specify", Args: map[string]interface{}{}}) + granted, _ = sess.PermSvc().CheckTool(ctx, safety.ToolCallInfo{Name: "Specify", Args: map[string]interface{}{}}) if !granted { t.Fatal("Specify should always be allowed during spec stage") } diff --git a/internal/engine/engine_test.go b/internal/engine/engine_test.go index 42e76d46..ab9987df 100644 --- a/internal/engine/engine_test.go +++ b/internal/engine/engine_test.go @@ -4,11 +4,13 @@ import ( "context" "testing" + "github.com/GrayCodeAI/rho/internal/engine/safety" + "github.com/GrayCodeAI/rho/internal/engine/cost" ) func TestPermissionMemoryAlwaysAllow(t *testing.T) { - pm := NewPermissionMemory() + pm := safety.NewPermissionMemory() pm.AlwaysAllow("bash") result := pm.Check("bash", "echo hello") @@ -28,7 +30,7 @@ func TestPermissionMemoryAlwaysAllow(t *testing.T) { } func TestPermissionMemoryPattern(t *testing.T) { - pm := NewPermissionMemory() + pm := safety.NewPermissionMemory() pm.AlwaysAllowPattern("bash:go *") result := pm.Check("bash", "go test ./...") @@ -43,7 +45,7 @@ func TestPermissionMemoryPattern(t *testing.T) { } func TestPermissionMemoryArchiveSpecs(t *testing.T) { - pm := NewPermissionMemory() + pm := safety.NewPermissionMemory() pm.AllowSpec("Bash(git:*)") pm.DenySpec("Write(*.env)") @@ -59,7 +61,7 @@ func TestPermissionMemoryArchiveSpecs(t *testing.T) { } func TestPermissionMemoryDenyOverridesAllow(t *testing.T) { - pm := NewPermissionMemory() + pm := safety.NewPermissionMemory() pm.AllowSpec("Bash(*)") pm.DenySpec("Bash(rm:*)") @@ -71,17 +73,17 @@ func TestPermissionMemoryDenyOverridesAllow(t *testing.T) { func TestPermissionServiceAutonomyRoundTrip(t *testing.T) { s := NewSession("", "", "", nil) - s.PermSvc().SetAutonomy(AutonomySemi) - if s.PermSvc().Autonomy() != AutonomySemi { + s.PermSvc().SetAutonomy(safety.AutonomySemi) + if s.PermSvc().Autonomy() != safety.AutonomySemi { t.Fatalf("got %v", s.PermSvc().Autonomy()) } } func TestPermissionEngine_SpecGateDeniesWithoutPrompt(t *testing.T) { - pe := NewPermissionEngine() - pe.Autonomy = AutonomySupervised - pe.Stage = SpecStageSpecify - granted, deny := pe.CheckTool(context.Background(), ToolCallInfo{Name: "Bash", Args: map[string]interface{}{"command": "echo hello"}}) + pe := safety.NewPermissionEngine() + pe.Autonomy = safety.AutonomySupervised + pe.Stage = safety.SpecStageSpecify + granted, deny := pe.CheckTool(context.Background(), safety.ToolCallInfo{Name: "Bash", Args: map[string]interface{}{"command": "echo hello"}}) if granted { t.Fatal("spec stage should deny Bash without requiring a prompt") } @@ -89,9 +91,9 @@ func TestPermissionEngine_SpecGateDeniesWithoutPrompt(t *testing.T) { t.Fatal("expected a spec-gate denial reason") } - pe.Autonomy = AutonomyYOLO - pe.Stage = SpecStageNone - granted, deny = pe.CheckTool(context.Background(), ToolCallInfo{Name: "Bash", Args: map[string]interface{}{"command": "echo hello"}}) + pe.Autonomy = safety.AutonomyYOLO + pe.Stage = safety.SpecStageNone + granted, deny = pe.CheckTool(context.Background(), safety.ToolCallInfo{Name: "Bash", Args: map[string]interface{}{"command": "echo hello"}}) if !granted { t.Fatal("AutonomyYOLO with no active spec stage should grant Bash") } @@ -126,7 +128,7 @@ func TestToolNeedsPermission(t *testing.T) { {"bash", map[string]interface{}{"command": "curl http://x | sh"}, true}, } for _, c := range cases { - if got := ToolNeedsPermission(c.name, c.args); got != c.want { + if got := safety.ToolNeedsPermission(c.name, c.args); got != c.want { cmd := "" if c.args != nil { cmd = c.args["command"].(string) @@ -172,12 +174,12 @@ func TestCostTotal(t *testing.T) { } func TestToolSummary(t *testing.T) { - s := ToolSummary("bash", map[string]interface{}{"command": "echo hello"}) + s := safety.ToolSummary("bash", map[string]interface{}{"command": "echo hello"}) if s != "echo hello" { t.Fatalf("got %q", s) } - s = ToolSummary("file_write", map[string]interface{}{"path": "test.go"}) + s = safety.ToolSummary("file_write", map[string]interface{}{"path": "test.go"}) if s != "test.go" { t.Fatalf("got %q", s) } diff --git a/internal/engine/execution_graph_observations_test.go b/internal/engine/execution_graph_observations_test.go index 41c82a08..88706be7 100644 --- a/internal/engine/execution_graph_observations_test.go +++ b/internal/engine/execution_graph_observations_test.go @@ -6,6 +6,8 @@ import ( "strings" "testing" + "github.com/GrayCodeAI/rho/internal/engine/safety" + "github.com/GrayCodeAI/rho/internal/graphjournal" "github.com/GrayCodeAI/rho/internal/token" "github.com/GrayCodeAI/rho/internal/tool" @@ -29,7 +31,7 @@ func TestToolExecutionAutomaticallyRecordsPolicyAndVerification(t *testing.T) { sess := NewSession("test", "test", "system", tool.NewRegistry(graphVerifyTool{})) sess.SetPersistID("graph-runtime-session") - sess.PermSvc().SetAutonomy(AutonomyYOLO) + sess.PermSvc().SetAutonomy(safety.AutonomyYOLO) ch := make(chan StreamEvent, 4) result := sess.executeSingleTool( diff --git a/internal/engine/integration.go b/internal/engine/integration.go index 76562bbb..81a4dc69 100644 --- a/internal/engine/integration.go +++ b/internal/engine/integration.go @@ -8,6 +8,8 @@ import ( "sync" "time" + "github.com/GrayCodeAI/rho/internal/engine/safety" + "github.com/GrayCodeAI/rho/internal/engine/compression" "github.com/GrayCodeAI/rho/internal/engine/control" "github.com/GrayCodeAI/rho/internal/engine/errs" @@ -59,7 +61,7 @@ type IntegrationPipeline struct { // Security pipeline InjectionScanner *InjectionScanner - OutputRedactor *OutputRedactor + OutputRedactor *safety.OutputRedactor // Learning pipeline ExperienceStore *memory.ExperienceStore @@ -237,7 +239,7 @@ func NewIntegrationPipeline() *IntegrationPipeline { // Security InjectionScanner: NewInjectionScanner(), - OutputRedactor: NewOutputRedactor(), + OutputRedactor: safety.NewOutputRedactor(), // Learning ExperienceStore: memory.NewExperienceStore(filepath.Join(stateRoot, "experience")), diff --git a/internal/engine/integration_test.go b/internal/engine/integration_test.go index 93beed32..bb4dc6f8 100644 --- a/internal/engine/integration_test.go +++ b/internal/engine/integration_test.go @@ -5,6 +5,8 @@ import ( "testing" "time" + "github.com/GrayCodeAI/rho/internal/engine/safety" + "github.com/GrayCodeAI/rho/internal/engine/cost" "github.com/GrayCodeAI/rho/internal/tool" @@ -66,7 +68,7 @@ func TestSessionLifecycle(t *testing.T) { func TestTrustTiersAndSpecStageRoundTrip(t *testing.T) { sess := NewSession("", "", "test", nil) - tiers := []AutonomyLevel{AutonomySupervised, AutonomyBasic, AutonomySemi, AutonomyFull, AutonomyYOLO} + tiers := []safety.AutonomyLevel{safety.AutonomySupervised, safety.AutonomyBasic, safety.AutonomySemi, safety.AutonomyFull, safety.AutonomyYOLO} for _, tier := range tiers { sess.PermSvc().SetAutonomy(tier) if sess.PermSvc().Autonomy() != tier { @@ -74,7 +76,7 @@ func TestTrustTiersAndSpecStageRoundTrip(t *testing.T) { } } - stages := []SpecStage{SpecStageNone, SpecStageSpecify, SpecStagePlan, SpecStageTasks, SpecStageImplementing} + stages := []safety.SpecStage{safety.SpecStageNone, safety.SpecStageSpecify, safety.SpecStagePlan, safety.SpecStageTasks, safety.SpecStageImplementing} for _, stage := range stages { sess.PermSvc().SetSpecStage(stage) if sess.PermSvc().SpecStage() != stage { @@ -189,7 +191,7 @@ func TestToolRegistry(t *testing.T) { // TestPermissionMemory tests permission memory. func TestPermissionMemory(t *testing.T) { - pm := NewPermissionMemory() + pm := safety.NewPermissionMemory() // Test always allow pm.AlwaysAllow("Bash") diff --git a/internal/engine/permission_service.go b/internal/engine/permission_service.go index 0a7244bf..0020950a 100644 --- a/internal/engine/permission_service.go +++ b/internal/engine/permission_service.go @@ -36,11 +36,11 @@ type PermissionService struct { mu sync.RWMutex // perm is the underlying PermissionEngine. Always non-nil after // construction. - perm *PermissionEngine + perm *safety.PermissionEngine // memory/autoMode/classifier/bypassKill are the legacy // PermissionEngine sub-fields, re-exported as top-level fields for // backward compat. - memory *PermissionMemory + memory *safety.PermissionMemory autoMode *permissions.AutoModeState classifier *permissions.Classifier bypassKill *permissions.BypassKillswitch @@ -50,7 +50,7 @@ type PermissionService struct { // allowedDirs is the list of directories the agent may write to. allowedDirs []string // permissionFn is the user-callback that prompts for approval. - permissionFn func(PermissionRequest) + permissionFn func(safety.PermissionRequest) // approval is the human-in-the-loop gate for high-risk tool actions. approval *ApprovalGate // askUserFn is the fallback interactive approval callback. @@ -81,7 +81,7 @@ func NewPermissionService(log *logger.Logger) *PermissionService { if log == nil { log = logger.Default() } - pe := NewPermissionEngine() + pe := safety.NewPermissionEngine() s := &PermissionService{ perm: pe, memory: pe.Memory, @@ -119,12 +119,12 @@ func (s *PermissionService) loadManagedGovernance() { // WithEngine replaces the underlying PermissionEngine. Used by tests // and by callers that want a pre-configured engine. -func (s *PermissionService) WithEngine(pe *PermissionEngine) *PermissionService { +func (s *PermissionService) WithEngine(pe *safety.PermissionEngine) *PermissionService { if s == nil { return s } if pe == nil { - pe = NewPermissionEngine() + pe = safety.NewPermissionEngine() } s.perm = pe s.memory = pe.Memory @@ -155,12 +155,12 @@ func (s *PermissionService) SetLogger(l *logger.Logger) { // Engine returns the underlying PermissionEngine. Used by the legacy // Session fields that read s.Perm directly. -func (s *PermissionService) Engine() *PermissionEngine { return s.perm } +func (s *PermissionService) Engine() *safety.PermissionEngine { return s.perm } // CheckTool is the central permission check. Returns (granted, denyMsg). // 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) { +func (s *PermissionService) CheckTool(ctx context.Context, info safety.ToolCallInfo) (bool, string) { if s == nil || s.perm == nil { return false, "permission service is unavailable" } @@ -175,13 +175,13 @@ 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 { +func (s *PermissionService) CheckToolDecision(ctx context.Context, info safety.ToolCallInfo) safety.Decision { 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 { +func (s *PermissionService) EvaluateTool(ctx context.Context, info safety.ToolCallInfo) safety.Decision { perm := s.engineCopy() return perm.EvaluateTool(ctx, info) } @@ -196,7 +196,7 @@ 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 { +func (s *PermissionService) CheckToolSnapshot(ctx context.Context, info safety.ToolCallInfo, snapshot safety.PolicySnapshot) safety.Decision { perm := s.engineCopy() return perm.CheckToolSnapshot(ctx, info, snapshot) } @@ -204,7 +204,7 @@ func (s *PermissionService) CheckToolSnapshot(ctx context.Context, info ToolCall // engineCopy returns a copy of the engine for cross-goroutine evaluation. The // service lock is held only for the duration of the copy, so evaluation does // not block policy updates or user prompts. -func (s *PermissionService) engineCopy() *PermissionEngine { +func (s *PermissionService) engineCopy() *safety.PermissionEngine { s.mu.RLock() defer s.mu.RUnlock() return s.perm.Copy() @@ -540,7 +540,7 @@ func (s *PermissionService) SetAllowedDirs(dirs []string) { // profile from that level, preserving any user overrides. 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) { +func (s *PermissionService) SetAutonomy(level safety.AutonomyLevel) { if s == nil || s.perm == nil { return } @@ -587,7 +587,7 @@ func (s *PermissionService) AutonomyProfile() *safety.AutonomyProfile { // SetSpecStage sets the independent spec-workflow stage. Also writes // directly to the engine, same reasoning as SetAutonomy. -func (s *PermissionService) SetSpecStage(stage SpecStage) { +func (s *PermissionService) SetSpecStage(stage safety.SpecStage) { if s != nil && s.perm != nil { s.perm.Stage = stage } @@ -649,7 +649,7 @@ func (s *PermissionService) SetSpecSlug(slug string) { } // SetPermissionFn replaces the user-callback. -func (s *PermissionService) SetPermissionFn(fn func(PermissionRequest)) { +func (s *PermissionService) SetPermissionFn(fn func(safety.PermissionRequest)) { if s == nil || s.perm == nil { return } @@ -659,7 +659,7 @@ func (s *PermissionService) SetPermissionFn(fn func(PermissionRequest)) { // PermissionFn returns the configured approval callback for sub-agent // construction and legacy integrations. -func (s *PermissionService) PermissionFn() func(PermissionRequest) { +func (s *PermissionService) PermissionFn() func(safety.PermissionRequest) { if s == nil { return nil } @@ -691,7 +691,7 @@ func (s *PermissionService) AllowedDirs() []string { } // Autonomy returns the autonomy level. -func (s *PermissionService) Autonomy() AutonomyLevel { +func (s *PermissionService) Autonomy() safety.AutonomyLevel { if s == nil || s.perm == nil { return 0 } @@ -699,9 +699,9 @@ func (s *PermissionService) Autonomy() AutonomyLevel { } // SpecStage returns the active spec-workflow stage. -func (s *PermissionService) SpecStage() SpecStage { +func (s *PermissionService) SpecStage() safety.SpecStage { if s == nil || s.perm == nil { - return SpecStageNone + return safety.SpecStageNone } return s.perm.Stage } @@ -758,9 +758,9 @@ func (s *PermissionService) AutonomyExplicit() bool { } // SpecProgress returns the workflow stage and phase counters atomically. -func (s *PermissionService) SpecProgress() (SpecStage, int, int) { +func (s *PermissionService) SpecProgress() (safety.SpecStage, int, int) { if s == nil || s.perm == nil { - return SpecStageNone, 0, 0 + return safety.SpecStageNone, 0, 0 } s.mu.RLock() defer s.mu.RUnlock() @@ -771,7 +771,7 @@ func (s *PermissionService) SpecProgress() (SpecStage, int, int) { // 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 { +func (s *PermissionService) Memory() *safety.PermissionMemory { if s == nil { return nil } @@ -779,7 +779,7 @@ func (s *PermissionService) Memory() *PermissionMemory { } // SetMemory replaces the session's permission-memory policy store. -func (s *PermissionService) SetMemory(m *PermissionMemory) { +func (s *PermissionService) SetMemory(m *safety.PermissionMemory) { if s == nil || s.perm == nil { return } diff --git a/internal/engine/permission_service_spec_journal_test.go b/internal/engine/permission_service_spec_journal_test.go index 48bd841c..58770439 100644 --- a/internal/engine/permission_service_spec_journal_test.go +++ b/internal/engine/permission_service_spec_journal_test.go @@ -4,6 +4,8 @@ import ( "context" "testing" + "github.com/GrayCodeAI/rho/internal/engine/safety" + "github.com/GrayCodeAI/rho/internal/eventlog" ) @@ -12,7 +14,7 @@ func TestPermissionServiceAdvanceSpecStageJournalsFact(t *testing.T) { log := eventlog.New(nil) s.SetJournal(log) - s.SetSpecStage(SpecStageProposal) + s.SetSpecStage(safety.SpecStageProposal) s.AdvanceSpecStage("Specify") events := log.OfType(eventlog.SpecState) diff --git a/internal/engine/permission_service_test.go b/internal/engine/permission_service_test.go index 6c5abe5e..978d9f02 100644 --- a/internal/engine/permission_service_test.go +++ b/internal/engine/permission_service_test.go @@ -5,6 +5,8 @@ import ( "strings" "sync" "testing" + + "github.com/GrayCodeAI/rho/internal/engine/safety" ) func TestPermissionService_CheckTool(t *testing.T) { @@ -17,13 +19,13 @@ func TestPermissionService_CheckTool(t *testing.T) { // forces denial (not implemented in the engine, so use a // permissionFn that returns a specific deny). For now, just verify // the wrapper compiles and returns a (bool, string). - granted, _ := s.CheckTool(context.Background(), ToolCallInfo{Name: "Bash", Args: map[string]interface{}{"command": "ls"}}) + granted, _ := s.CheckTool(context.Background(), safety.ToolCallInfo{Name: "Bash", Args: map[string]interface{}{"command": "ls"}}) _ = granted } func TestPermissionService_SetSpecStage(t *testing.T) { s := NewPermissionService(nil) - stages := []SpecStage{SpecStageNone, SpecStageSpecify, SpecStagePlan, SpecStageTasks, SpecStageImplementing} + stages := []safety.SpecStage{safety.SpecStageNone, safety.SpecStageSpecify, safety.SpecStagePlan, safety.SpecStageTasks, safety.SpecStageImplementing} for _, stage := range stages { s.SetSpecStage(stage) if s.SpecStage() != stage { @@ -46,11 +48,11 @@ func TestPermissionService_BudgetAndTurnCaps(t *testing.T) { func TestPermissionService_AutonomyAndAllowedDirs(t *testing.T) { s := NewPermissionService(nil) - s.SetAutonomy(AutonomySupervised) + s.SetAutonomy(safety.AutonomySupervised) dirs := []string{"/tmp", "/var/folders"} s.SetAllowedDirs(dirs) dirs[0] = "/changed" - if s.Autonomy() != AutonomySupervised { + if s.Autonomy() != safety.AutonomySupervised { t.Errorf("Autonomy = %v, want AutonomySupervised", s.Autonomy()) } if len(s.AllowedDirs()) != 2 { @@ -66,13 +68,13 @@ func TestPermissionService_AutonomyAndAllowedDirs(t *testing.T) { func TestPermissionService_ResetSpecIncrementsRevision(t *testing.T) { s := NewPermissionService(nil) s.SetSpecSlug("demo") - s.SetSpecStage(SpecStageImplementing) + s.SetSpecStage(safety.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 { + if got := s.SpecStage(); got != safety.SpecStageNone { t.Fatalf("SpecStage after reset = %v, want none", got) } if s.Engine().Revision <= before { @@ -88,12 +90,12 @@ func TestPermissionService_ConcurrentPolicyUpdates(t *testing.T) { wg.Add(2) go func(i int) { defer wg.Done() - s.SetAutonomy(AutonomyLevel(i % int(AutonomyYOLO+1))) + s.SetAutonomy(safety.AutonomyLevel(i % int(safety.AutonomyYOLO+1))) s.SetAllowedDirs([]string{"/workspace", "/tmp"}) }(i) go func() { defer wg.Done() - _ = s.EvaluateTool(ctx, ToolCallInfo{Name: "Read"}) + _ = s.EvaluateTool(ctx, safety.ToolCallInfo{Name: "Read"}) _ = s.PolicySnapshot() _, _, _ = s.SpecProgress() }() @@ -112,7 +114,7 @@ func TestPermissionService_ApplyPolicySnapshotCopiesRulesAndScopes(t *testing.T) 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"}) + allowed, reason := child.CheckTool(context.Background(), safety.ToolCallInfo{Name: "Write"}) if allowed || reason != "Permission denied (rule)." { t.Fatalf("child did not inherit deny rule: allowed=%v reason=%q", allowed, reason) } @@ -131,7 +133,7 @@ func TestPermissionService_IsZero(t *testing.T) { if !s.IsZero() { t.Error("freshly-constructed PermissionService should be IsZero()") } - s.SetPermissionFn(func(req PermissionRequest) {}) + s.SetPermissionFn(func(req safety.PermissionRequest) {}) if s.IsZero() { t.Error("after SetPermissionFn, service should not be IsZero()") } @@ -153,14 +155,14 @@ func TestPermissionService_NewReturnsReadyEngine(t *testing.T) { func TestPermissionService_SetPermissionFn(t *testing.T) { s := NewPermissionService(nil) called := false - s.SetPermissionFn(func(req PermissionRequest) { + s.SetPermissionFn(func(req safety.PermissionRequest) { called = true }) if s.Engine().PromptFn == nil { t.Error("SetPermissionFn should have set the engine's PromptFn") } // Call directly to verify. - s.Engine().PromptFn(PermissionRequest{}) + s.Engine().PromptFn(safety.PermissionRequest{}) if !called { t.Error("PromptFn was not called") } diff --git a/internal/engine/permission_turnrecovery_test.go b/internal/engine/permission_turnrecovery_test.go index b727aaf5..d429fb9b 100644 --- a/internal/engine/permission_turnrecovery_test.go +++ b/internal/engine/permission_turnrecovery_test.go @@ -4,6 +4,8 @@ import ( "context" "strings" "testing" + + "github.com/GrayCodeAI/rho/internal/engine/safety" ) // extractedToken pulls the opaque permission_request_id out of a denial @@ -36,7 +38,7 @@ func isHex(s string) bool { func rejectingSession(t *testing.T, promptCount *int) *Session { t.Helper() s := NewSession("recovery", "m", "", nil) - s.PermSvc().SetAutonomy(AutonomyFull) + s.PermSvc().SetAutonomy(safety.AutonomyFull) s.EnableTurnRecovery() s.SetApproval(&ApprovalGate{ Enabled: true, @@ -113,7 +115,7 @@ func TestTurnRecovery_OnlyOpaqueTokenEscalates(t *testing.T) { func TestTurnRecovery_DisabledIsUnchanged(t *testing.T) { prompts := 0 s := NewSession("plain", "m", "", nil) - s.PermSvc().SetAutonomy(AutonomyFull) + s.PermSvc().SetAutonomy(safety.AutonomyFull) s.SetApproval(&ApprovalGate{ Enabled: true, ConfirmFn: func(req ApprovalRequest) ApprovalResponse { diff --git a/internal/engine/safety_reexports.go b/internal/engine/safety_reexports.go deleted file mode 100644 index d89fd1ba..00000000 --- a/internal/engine/safety_reexports.go +++ /dev/null @@ -1,60 +0,0 @@ -package engine - -import "github.com/GrayCodeAI/rho/internal/engine/safety" - -type ( - HallucinationGuard = safety.HallucinationGuard - GroundingResult = safety.GroundingResult - RedactPattern = safety.RedactPattern - RedactStats = safety.RedactStats - OutputRedactor = safety.OutputRedactor - PermissionRequest = safety.PermissionRequest - PermissionMemory = safety.PermissionMemory - PermissionEngine = safety.PermissionEngine - SpecStage = safety.SpecStage - ProtectedPaths = safety.ProtectedPaths - RiskAssessment = safety.RiskAssessment - RiskFactor = safety.RiskFactor - RiskFactorDef = safety.RiskFactorDef - RiskContext = safety.RiskContext - RiskAssessor = safety.RiskAssessor - AutonomyLevel = safety.AutonomyLevel - AutonomyConfig = safety.AutonomyConfig - AutonomyProfile = safety.AutonomyProfile - ToolCallInfo = safety.ToolCallInfo -) - -const ( - AutonomySupervised = safety.AutonomySupervised - AutonomyBasic = safety.AutonomyBasic - AutonomySemi = safety.AutonomySemi - AutonomyFull = safety.AutonomyFull - AutonomyYOLO = safety.AutonomyYOLO - SpecStageNone = safety.SpecStageNone - SpecStageProposal = safety.SpecStageProposal - SpecStageSpecify = safety.SpecStageSpecify - SpecStageDesign = safety.SpecStageDesign - SpecStagePlan = safety.SpecStagePlan - SpecStageTasks = safety.SpecStageTasks - SpecStageImplementing = safety.SpecStageImplementing -) - -var ( - NewHallucinationGuard = safety.NewHallucinationGuard - BuildRejectionMessage = safety.BuildRejectionMessage - FormatGroundingResult = safety.FormatGroundingResult - NewOutputRedactor = safety.NewOutputRedactor - NewPermissionMemory = safety.NewPermissionMemory - NewPermissionEngine = safety.NewPermissionEngine - NewProtectedPaths = safety.NewProtectedPaths - NewRiskAssessor = safety.NewRiskAssessor - GenerateMitigations = safety.GenerateMitigations - FormatAssessment = safety.FormatAssessment - ShouldProceed = safety.ShouldProceed - PresetConfig = safety.PresetConfig - ParseAutonomyLevel = safety.ParseAutonomyLevel - ToolSummary = safety.ToolSummary - ToolNeedsPermission = safety.ToolNeedsPermission - FormatPermissionDisplay = safety.FormatPermissionDisplay - ToolPolicyFor = safety.ToolPolicyFor -) diff --git a/internal/engine/session.go b/internal/engine/session.go index 6c38b242..92a90048 100644 --- a/internal/engine/session.go +++ b/internal/engine/session.go @@ -10,6 +10,8 @@ import ( "sync" "time" + "github.com/GrayCodeAI/rho/internal/engine/safety" + "github.com/GrayCodeAI/rho/internal/engine/cost" "github.com/GrayCodeAI/rho/internal/engine/streaming" @@ -832,7 +834,7 @@ func (s *Session) SetAskUserFn(fn func(question string) (string, error)) { } // SetPermissionFn configures the permission callback on PermissionService. -func (s *Session) SetPermissionFn(fn func(PermissionRequest)) { +func (s *Session) SetPermissionFn(fn func(safety.PermissionRequest)) { if s.perms != nil { s.perms.SetPermissionFn(fn) } diff --git a/internal/engine/session_mock_test.go b/internal/engine/session_mock_test.go index c8326576..ddb05eb6 100644 --- a/internal/engine/session_mock_test.go +++ b/internal/engine/session_mock_test.go @@ -6,6 +6,8 @@ import ( "testing" "time" + "github.com/GrayCodeAI/rho/internal/engine/safety" + "github.com/GrayCodeAI/rho/internal/types" ) @@ -192,8 +194,8 @@ func TestSession_SetAutonomy(t *testing.T) { mc := newMockClient() s := newMockSession(mc) - s.PermSvc().SetAutonomy(AutonomyYOLO) - if s.PermSvc().Autonomy() != AutonomyYOLO { + s.PermSvc().SetAutonomy(safety.AutonomyYOLO) + if s.PermSvc().Autonomy() != safety.AutonomyYOLO { t.Errorf("SetAutonomy did not take effect, got %v", s.PermSvc().Autonomy()) } } diff --git a/internal/engine/spec_mode_test.go b/internal/engine/spec_mode_test.go index 99e8bca5..c4ef599d 100644 --- a/internal/engine/spec_mode_test.go +++ b/internal/engine/spec_mode_test.go @@ -8,6 +8,8 @@ import ( "testing" "time" + "github.com/GrayCodeAI/rho/internal/engine/safety" + "github.com/GrayCodeAI/rho/internal/tool" "github.com/GrayCodeAI/rho/internal/types" ) @@ -31,7 +33,7 @@ func newSpecModeSession(approveImplement bool) (*Session, *int) { ) s := NewSession("", "", "test", registry) prompts := 0 - s.SetPermissionFn(func(req PermissionRequest) { + s.SetPermissionFn(func(req safety.PermissionRequest) { prompts++ allow := true if req.ToolName == "ApproveImplementation" { @@ -71,9 +73,9 @@ func ensureTestConstitution(t *testing.T, s *Session) { func TestSpecMode_SpecifyAdvancesStage(t *testing.T) { s, _ := newSpecModeSession(true) - s.PermSvc().SetSpecStage(SpecStageSpecify) + s.PermSvc().SetSpecStage(safety.SpecStageSpecify) runSpecTool(t, s, "Specify", map[string]interface{}{"title": "test", "spec": "problem statement"}) - if s.PermSvc().SpecStage() != SpecStageSpecify { + if s.PermSvc().SpecStage() != safety.SpecStageSpecify { t.Errorf("expected stage Specify after Specify tool, got %v", s.PermSvc().SpecStage()) } } @@ -85,25 +87,25 @@ func TestSpecMode_PlanTasksAdvanceStage(t *testing.T) { t.Cleanup(func() { os.Chdir(old) }) s, _ := newSpecModeSession(true) - s.PermSvc().SetSpecStage(SpecStageProposal) + s.PermSvc().SetSpecStage(safety.SpecStageProposal) runSpecTool(t, s, "Proposal", map[string]interface{}{"title": "test", "proposal": "proposal"}) ensureTestConstitution(t, s) runSpecTool(t, s, "Specify", map[string]interface{}{"title": "test", "spec": "problem statement"}) runSpecTool(t, s, "Design", map[string]interface{}{"design": "technical design"}) runSpecTool(t, s, "Plan", map[string]interface{}{"plan": "## Summary\n### Simplicity: using <=3 projects\n### Anti-Abstraction: framework directly\n### Integration-First: contract defined\n### Complexity Tracking\n| Gate | Justification |\n|------|---------------|\n"}) - if s.PermSvc().SpecStage() != SpecStagePlan { + if s.PermSvc().SpecStage() != safety.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.PermSvc().SpecStage() != SpecStageTasks { + if s.PermSvc().SpecStage() != safety.SpecStageTasks { t.Errorf("expected stage Tasks after Tasks tool, got %v", s.PermSvc().SpecStage()) } } func TestSpecMode_WriteDeniedMidStage(t *testing.T) { s, _ := newSpecModeSession(true) - s.PermSvc().SetSpecStage(SpecStageSpecify) + s.PermSvc().SetSpecStage(safety.SpecStageSpecify) res := runSpecTool(t, s, "Write", map[string]interface{}{ "file_path": "/tmp/should_not_write.txt", @@ -119,7 +121,7 @@ func TestSpecMode_WriteDeniedMidStage(t *testing.T) { func TestSpecMode_ReadsUnrestrictedMidStage(t *testing.T) { s, _ := newSpecModeSession(true) - s.PermSvc().SetSpecStage(SpecStageSpecify) + s.PermSvc().SetSpecStage(safety.SpecStageSpecify) res := runSpecTool(t, s, "Read", map[string]interface{}{"file_path": "/tmp/does_not_exist_but_permission_should_pass.txt"}) // The read itself may fail (file doesn't exist), but it must not be @@ -136,8 +138,8 @@ func TestSpecMode_ReadsUnrestrictedMidStage(t *testing.T) { // gate must not allow that regardless of tier. func TestSpecMode_WriteDeniedEvenAtYOLO(t *testing.T) { s, _ := newSpecModeSession(true) - s.PermSvc().SetAutonomy(AutonomyYOLO) - s.PermSvc().SetSpecStage(SpecStageSpecify) + s.PermSvc().SetAutonomy(safety.AutonomyYOLO) + s.PermSvc().SetSpecStage(safety.SpecStageSpecify) res := runSpecTool(t, s, "Write", map[string]interface{}{ "file_path": "/tmp/should_not_write_even_at_yolo.txt", @@ -150,8 +152,8 @@ func TestSpecMode_WriteDeniedEvenAtYOLO(t *testing.T) { func TestSpecMode_ApproveImplementationAlwaysPrompts(t *testing.T) { s, prompts := newSpecModeSession(true) - s.PermSvc().SetAutonomy(AutonomyYOLO) - s.PermSvc().SetSpecStage(SpecStageTasks) + s.PermSvc().SetAutonomy(safety.AutonomyYOLO) + s.PermSvc().SetSpecStage(safety.SpecStageTasks) res := runSpecTool(t, s, "ApproveImplementation", map[string]interface{}{}) if res.isErr { @@ -160,7 +162,7 @@ func TestSpecMode_ApproveImplementationAlwaysPrompts(t *testing.T) { if *prompts == 0 { t.Errorf("expected an approval prompt on ApproveImplementation even at AutonomyYOLO") } - if s.PermSvc().SpecStage() != SpecStageImplementing { + if s.PermSvc().SpecStage() != safety.SpecStageImplementing { t.Errorf("expected stage Implementing after approval, got %v", s.PermSvc().SpecStage()) } if !strings.Contains(strings.ToLower(res.output), "implementation") { @@ -170,13 +172,13 @@ func TestSpecMode_ApproveImplementationAlwaysPrompts(t *testing.T) { func TestSpecMode_ApproveImplementationDeniedStaysGated(t *testing.T) { s, _ := newSpecModeSession(false) - s.PermSvc().SetSpecStage(SpecStageTasks) + s.PermSvc().SetSpecStage(safety.SpecStageTasks) res := runSpecTool(t, s, "ApproveImplementation", map[string]interface{}{}) if !res.isErr { t.Errorf("denied ApproveImplementation should report an error result to keep the gate closed") } - if s.PermSvc().SpecStage() != SpecStageTasks { + if s.PermSvc().SpecStage() != safety.SpecStageTasks { t.Errorf("expected to stay at Tasks stage after denial, got %v", s.PermSvc().SpecStage()) } } @@ -198,7 +200,7 @@ func TestSpecMode_ApprovalPromptShowsSpecContent(t *testing.T) { t.Cleanup(func() { _ = os.Chdir(old) }) s, _ := newSpecModeSession(true) - s.PermSvc().SetSpecStage(SpecStageProposal) + s.PermSvc().SetSpecStage(safety.SpecStageProposal) runSpecTool(t, s, "Proposal", map[string]interface{}{"title": "test", "proposal": "proposal"}) ensureTestConstitution(t, s) runSpecTool(t, s, "Specify", map[string]interface{}{"title": "approval preview test", "spec": "unique spec marker xyz123"}) @@ -207,7 +209,7 @@ func TestSpecMode_ApprovalPromptShowsSpecContent(t *testing.T) { runSpecTool(t, s, "Tasks", map[string]interface{}{"tasks": "unique tasks marker def789"}) var lastSummary string - s.SetPermissionFn(func(req PermissionRequest) { + s.SetPermissionFn(func(req safety.PermissionRequest) { if req.ToolName == "ApproveImplementation" { lastSummary = req.Summary } @@ -240,7 +242,7 @@ func TestSpecMode_ImplementingLiftsGate(t *testing.T) { t.Cleanup(func() { _ = os.Chdir(old) }) s, _ := newSpecModeSession(true) - s.PermSvc().SetSpecStage(SpecStageTasks) + s.PermSvc().SetSpecStage(safety.SpecStageTasks) runSpecTool(t, s, "ApproveImplementation", map[string]interface{}{}) res := runSpecTool(t, s, "Write", map[string]interface{}{ @@ -264,7 +266,7 @@ func TestSpecMode_ResetClearsStageAndSlug(t *testing.T) { t.Cleanup(func() { _ = os.Chdir(old) }) s, _ := newSpecModeSession(true) - s.PermSvc().SetSpecStage(SpecStageProposal) + s.PermSvc().SetSpecStage(safety.SpecStageProposal) ensureTestConstitution(t, s) runSpecTool(t, s, "Proposal", map[string]interface{}{"title": "reset-test", "proposal": "proposal"}) ensureTestConstitution(t, s) @@ -273,7 +275,7 @@ func TestSpecMode_ResetClearsStageAndSlug(t *testing.T) { t.Fatal("Specify should set an active slug") } runSpecTool(t, s, "SpecReset", map[string]interface{}{}) - if s.PermSvc().SpecStage() != SpecStageNone || s.PermSvc().SpecSlug() != "" { + if s.PermSvc().SpecStage() != safety.SpecStageNone || s.PermSvc().SpecSlug() != "" { t.Fatalf("reset left stage=%v slug=%q", s.PermSvc().SpecStage(), s.PermSvc().SpecSlug()) } } diff --git a/internal/engine/stream.go b/internal/engine/stream.go index 8a3d865f..2a57821b 100644 --- a/internal/engine/stream.go +++ b/internal/engine/stream.go @@ -8,6 +8,8 @@ import ( "strings" "time" + "github.com/GrayCodeAI/rho/internal/engine/safety" + "github.com/GrayCodeAI/rho/internal/engine/cost" "github.com/GrayCodeAI/rho/internal/engine/token" @@ -104,7 +106,7 @@ func (s *Session) buildTurnOptions(tc turnContext) types.ChatOptions { // explicit approval handoff before any changes. Ephemeral (not // persisted to s.Persistence().System()) so it disappears once the // stage advances to Implementing. - if stage := s.PermSvc().SpecStage(); stage != SpecStageNone && stage != SpecStageImplementing { + if stage := s.PermSvc().SpecStage(); stage != safety.SpecStageNone && stage != safety.SpecStageImplementing { opts.System += specStageSystemPrompt // Inject project constitution as governing principles if constitution := constitutionForPrompt(s.PermSvc().SpecSlug()); constitution != "" { diff --git a/internal/engine/stream_tool_exec_test.go b/internal/engine/stream_tool_exec_test.go index 9c061e91..50ea8247 100644 --- a/internal/engine/stream_tool_exec_test.go +++ b/internal/engine/stream_tool_exec_test.go @@ -10,6 +10,8 @@ import ( "testing" "time" + "github.com/GrayCodeAI/rho/internal/engine/safety" + "github.com/GrayCodeAI/rho/internal/tool" "github.com/GrayCodeAI/rho/internal/types" ) @@ -68,7 +70,7 @@ func (t *countedReadTool) Execute(ctx context.Context, input json.RawMessage) (s func TestExecuteToolCalls_PreservesOriginalOrder(t *testing.T) { sess := NewSession("test", "test", "system", tool.NewRegistry(orderedReadTool{})) - sess.PermSvc().SetAutonomy(AutonomyYOLO) + sess.PermSvc().SetAutonomy(safety.AutonomyYOLO) calls := []types.ToolCall{ {ID: "slow", Name: "Read", Arguments: map[string]interface{}{"id": 1, "delay": 30}}, {ID: "fast", Name: "Read", Arguments: map[string]interface{}{"id": 2, "delay": 1}}, @@ -91,7 +93,7 @@ func TestExecuteToolCalls_PreservesOriginalOrder(t *testing.T) { func TestExecuteToolCalls_BoundsReadOnlyConcurrency(t *testing.T) { read := &countedReadTool{} sess := NewSession("test", "test", "system", tool.NewRegistry(read)) - sess.PermSvc().SetAutonomy(AutonomyYOLO) + sess.PermSvc().SetAutonomy(safety.AutonomyYOLO) calls := make([]types.ToolCall, maxConcurrentReadOnlyToolCalls+6) for i := range calls { calls[i] = types.ToolCall{ID: fmt.Sprintf("r%d", i), Name: "Read", Arguments: map[string]interface{}{"id": i}} @@ -117,7 +119,7 @@ func TestExecuteOne_PreStageShortCircuitsBeforeExecution(t *testing.T) { executed := false read := &countingExecTool{ran: &executed} sess := NewSession("test", "test", "system", tool.NewRegistry(read)) - sess.PermSvc().SetAutonomy(AutonomyYOLO) + sess.PermSvc().SetAutonomy(safety.AutonomyYOLO) // Register a pre-execute interceptor that denies before the raw tool ever // runs, proving the StagePreExecute waterfall is in the ExecuteOne path. @@ -158,7 +160,7 @@ func TestExecuteOne_EmptyPipelineStillExecutes(t *testing.T) { executed := false read := &countingExecTool{ran: &executed} sess := NewSession("test", "test", "system", tool.NewRegistry(read)) - sess.PermSvc().SetAutonomy(AutonomyYOLO) + sess.PermSvc().SetAutonomy(safety.AutonomyYOLO) // No interceptor registered: empty pipeline is a strict pass-through. ch := make(chan StreamEvent, 4) @@ -172,7 +174,7 @@ func TestExecuteOne_PreStageErrorIsDistinct(t *testing.T) { executed := false read := &countingExecTool{ran: &executed} sess := NewSession("test", "test", "system", tool.NewRegistry(read)) - sess.PermSvc().SetAutonomy(AutonomyYOLO) + sess.PermSvc().SetAutonomy(safety.AutonomyYOLO) sess.Tools().Pipeline().Register(tool.StagePreExecute, tool.InterceptFn(func(ctx context.Context, req tool.ToolRequest, res *tool.ToolResult, next func() error) error { return errors.New("pipeline infra failure") })) @@ -202,7 +204,7 @@ func (t *contextCaptureTool) Execute(ctx context.Context, _ json.RawMessage) (st func TestExecuteSingleTool_PropagatesPermissionContext(t *testing.T) { capture := &contextCaptureTool{} sess := NewSession("test", "test", "system", tool.NewRegistry(capture)) - sess.PermSvc().SetAutonomy(AutonomyYOLO) + sess.PermSvc().SetAutonomy(safety.AutonomyYOLO) sess.SetAllowedDirs([]string{"/tmp/extra"}) ch := make(chan StreamEvent, 4) res := sess.executeSingleTool(context.Background(), types.ToolCall{Name: "Read", ID: "ctx"}, ch, 0, "") @@ -238,7 +240,7 @@ func TestGenerateDiffSummary_IncludesUnifiedPreview(t *testing.T) { func TestToolServiceWorkingDirPropagatesContext(t *testing.T) { capture := &contextCaptureTool{} sess := NewSession("test", "test", "system", tool.NewRegistry(capture)) - sess.PermSvc().SetAutonomy(AutonomyYOLO) + sess.PermSvc().SetAutonomy(safety.AutonomyYOLO) sess.Tools().SetWorkingDir("/tmp/rho-working-dir") ch := make(chan StreamEvent, 4) @@ -254,7 +256,7 @@ func TestToolServiceWorkingDirPropagatesContext(t *testing.T) { func TestToolServiceReadOnlyBashPropagatesContext(t *testing.T) { capture := &contextCaptureTool{} sess := NewSession("test", "test", "system", tool.NewRegistry(capture)) - sess.PermSvc().SetAutonomy(AutonomyYOLO) + sess.PermSvc().SetAutonomy(safety.AutonomyYOLO) sess.Tools().SetReadOnlyBash(true) ch := make(chan StreamEvent, 4) diff --git a/internal/engine/tool_service.go b/internal/engine/tool_service.go index 953282d3..81ac0c8d 100644 --- a/internal/engine/tool_service.go +++ b/internal/engine/tool_service.go @@ -10,6 +10,8 @@ import ( "strings" "sync" + "github.com/GrayCodeAI/rho/internal/engine/safety" + "github.com/GrayCodeAI/rho/internal/engine/token" "github.com/GrayCodeAI/rho/internal/engine/diff" @@ -414,7 +416,7 @@ func (s *ToolService) ExecuteOne(ctx context.Context, tc types.ToolCall, overrid return finishDenied("denied", "permission service is unavailable") } result.state = ToolStatePermissionWait - granted, denyMsg := s.deps.permissions.CheckTool(ctx, ToolCallInfo{Name: tc.Name, ID: tc.ID, Args: tc.Arguments}) + granted, denyMsg := s.deps.permissions.CheckTool(ctx, safety.ToolCallInfo{Name: tc.Name, ID: tc.ID, Args: tc.Arguments}) if s.deps.recordPolicy != nil { s.deps.recordPolicy(tc, "permission", granted, denyMsg) } diff --git a/internal/engine/tool_timeout_test.go b/internal/engine/tool_timeout_test.go index e4b2e446..9306b9fb 100644 --- a/internal/engine/tool_timeout_test.go +++ b/internal/engine/tool_timeout_test.go @@ -8,6 +8,8 @@ import ( "testing" "time" + "github.com/GrayCodeAI/rho/internal/engine/safety" + "github.com/GrayCodeAI/rho/internal/tool" "github.com/GrayCodeAI/rho/internal/types" ) @@ -67,7 +69,7 @@ func (selfDeadlineTool) RetryPolicy() tool.RetryPolicy { func newTimeoutTestSession(t *testing.T, tools ...tool.Tool) *Session { t.Helper() sess := NewSession("timeout-test", "test", "system", tool.NewRegistry(tools...)) - sess.PermSvc().SetAutonomy(AutonomyYOLO) + sess.PermSvc().SetAutonomy(safety.AutonomyYOLO) return sess } diff --git a/internal/multiagent/worker.go b/internal/multiagent/worker.go index fae92333..ec04f36f 100644 --- a/internal/multiagent/worker.go +++ b/internal/multiagent/worker.go @@ -8,6 +8,8 @@ import ( "strings" "time" + "github.com/GrayCodeAI/rho/internal/engine/safety" + rhoconfig "github.com/GrayCodeAI/rho/internal/config" "github.com/GrayCodeAI/rho/internal/engine" "github.com/GrayCodeAI/rho/internal/textutil" @@ -61,9 +63,9 @@ func EngineWorker(provider, model, systemPrompt string) WorkerFunc { sess := engine.NewRhoSession(ctx, selection, provider, model, systemPrompt, registry) // Configure for autonomous operation - level := engine.AutonomyLevel(cfg.AutonomyLevel) - if level < engine.AutonomyFull { - level = engine.AutonomyFull + level := safety.AutonomyLevel(cfg.AutonomyLevel) + if level < safety.AutonomyFull { + level = safety.AutonomyFull } sess.PermSvc().SetAutonomy(level) // DSH 2.4: Pin child approval policy to never (deny interactive asks deterministically). @@ -78,7 +80,7 @@ func EngineWorker(provider, model, systemPrompt string) WorkerFunc { // gate is configured: risky calls (network, destructive file ops) // block until the operator responds. The gate's Await uses the worker // ctx, so mission cancellation still unblocks it. - sess.SetPermissionFn(func(req engine.PermissionRequest) { + sess.SetPermissionFn(func(req safety.PermissionRequest) { if cfg.ApprovalGate != nil && req.Response != nil { if err := cfg.ApprovalGate.Check(ctx, req.ToolName, req.Summary); err != nil { req.Response <- false @@ -249,15 +251,15 @@ func ReadOnlyValidationWorker(provider, model, systemPrompt string) WorkerFunc { }) sess := engine.NewRhoSession(ctx, selection, provider, model, systemPrompt, registry) - level := engine.AutonomyLevel(cfg.AutonomyLevel) - if level < engine.AutonomyFull { - level = engine.AutonomyFull + level := safety.AutonomyLevel(cfg.AutonomyLevel) + if level < safety.AutonomyFull { + level = safety.AutonomyFull } sess.PermSvc().SetAutonomy(level) if setErr := sess.SetMaxTurns(30); setErr != nil { return nil, fmt.Errorf("set max turns: %w", setErr) } - sess.SetPermissionFn(func(req engine.PermissionRequest) { + sess.SetPermissionFn(func(req safety.PermissionRequest) { if req.Response != nil { req.Response <- true }