Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
80 changes: 80 additions & 0 deletions cmd/chat_permission_keys_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
package cmd

import (
"strings"
"testing"
"time"

tea "charm.land/bubbletea/v2"
contracts "github.com/GrayCodeAI/hawk-core-contracts/policy"
"github.com/GrayCodeAI/hawk/internal/engine"
)

func TestPermissionAlwaysAllowDoesNotNilDeref(t *testing.T) {
m := newTestChatModel()
req := engine.PermissionRequest{
PermissionRequest: contracts.PermissionRequest{
ToolName: "Bash",
Summary: "git -C /tmp status",
},
Response: make(chan bool, 1),
}

next, _ := m.Update(permissionAskMsg{req: req})
cm := requireChatModel(t, next)
if cm.permReq == nil {
t.Fatal("expected active permission request")
}

next, _ = cm.Update(tea.KeyPressMsg{Code: 'a', Text: "a"})
cm = requireChatModel(t, next)
if cm.permReq != nil {
t.Fatal("expected permission request cleared after always-allow")
}
select {
case allowed := <-req.Response:
if !allowed {
t.Fatal("expected always-allow to approve the request")
}
case <-time.After(time.Second):
t.Fatal("timed out waiting for permission response")
}
if got := lastSystemMessage(cm.messages); !strings.Contains(got, "Always allowed: Bash") {
t.Fatalf("unexpected always-allow message: %q", got)
}
decision := cm.session.Perm.Memory.Check("Bash", "anything")
if decision == nil || !*decision {
t.Fatal("expected Bash:* always-allow rule to be recorded")
}
}

func TestPermissionAlwaysDenyDoesNotNilDeref(t *testing.T) {
m := newTestChatModel()
req := engine.PermissionRequest{
PermissionRequest: contracts.PermissionRequest{
ToolName: "Bash",
Summary: "rm -rf /",
},
Response: make(chan bool, 1),
}

next, _ := m.Update(permissionAskMsg{req: req})
cm := requireChatModel(t, next)

next, _ = cm.Update(tea.KeyPressMsg{Code: 'd', Text: "d"})
cm = requireChatModel(t, next)
if cm.permReq != nil {
t.Fatal("expected permission request cleared after always-deny")
}
select {
case allowed := <-req.Response:
if allowed {
t.Fatal("expected always-deny to reject the request")
}
case <-time.After(time.Second):
t.Fatal("timed out waiting for permission response")
}
if got := lastSystemMessage(cm.messages); !strings.Contains(got, "Always denied: Bash") {
t.Fatalf("unexpected always-deny message: %q", got)
}
}
42 changes: 33 additions & 9 deletions cmd/chat_update.go
Original file line number Diff line number Diff line change
Expand Up @@ -610,31 +610,55 @@ func (m chatModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, tea.Batch(append(cmds, cmd)...)
}

// Permission prompt active — handle y/n
// Permission prompt active — handle y/n/a/d
if m.permReq != nil {
switch msg.String() {
case "y", "Y":
m.permReq.Response <- true
req := m.permReq
req.Response <- true
m.permReq = nil
m.permTimeoutAt = time.Time{}
if m.session != nil && m.session.Perm != nil && m.session.Perm.AutoMode != nil {
m.session.Perm.AutoMode.Record(req.ToolName, req.Summary, true)
}
m.messages = append(m.messages, displayMsg{role: "system", content: icons.CheckBold() + " Allowed"})
case "n", "N":
m.permReq.Response <- false
req := m.permReq
req.Response <- false
m.permReq = nil
m.permTimeoutAt = time.Time{}
if m.session != nil && m.session.Perm != nil && m.session.Perm.AutoMode != nil {
m.session.Perm.AutoMode.Record(req.ToolName, req.Summary, false)
}
m.messages = append(m.messages, displayMsg{role: "system", content: icons.CloseThick() + " Denied"})
case "a", "A":
m.permReq.Response <- true
req := m.permReq
toolName := req.ToolName
summary := req.Summary
req.Response <- true
m.permReq = nil
m.permTimeoutAt = time.Time{}
m.session.Perm.Memory.AlwaysAllowPattern(m.permReq.ToolName + ":*")
m.messages = append(m.messages, displayMsg{role: "system", content: icons.CheckBold() + " Always allowed: " + m.permReq.ToolName + " (all)"})
if m.session != nil && m.session.Perm != nil {
m.session.Perm.Memory.AlwaysAllowPattern(toolName + ":*")
if m.session.Perm.AutoMode != nil {
m.session.Perm.AutoMode.Record(toolName, summary, true)
}
}
m.messages = append(m.messages, displayMsg{role: "system", content: icons.CheckBold() + " Always allowed: " + toolName + " (all)"})
case "d", "D":
m.permReq.Response <- false
req := m.permReq
toolName := req.ToolName
summary := req.Summary
req.Response <- false
m.permReq = nil
m.permTimeoutAt = time.Time{}
m.session.Perm.Memory.AlwaysDeny(m.permReq.ToolName)
m.messages = append(m.messages, displayMsg{role: "system", content: icons.CloseThick() + " Always denied: " + m.permReq.ToolName})
if m.session != nil && m.session.Perm != nil {
m.session.Perm.Memory.AlwaysDeny(toolName)
if m.session.Perm.AutoMode != nil {
m.session.Perm.AutoMode.Record(toolName, summary, false)
}
}
m.messages = append(m.messages, displayMsg{role: "system", content: icons.CloseThick() + " Always denied: " + toolName})
}
m.viewDirty = true
m.updateViewportContent()
Expand Down
1 change: 1 addition & 0 deletions cmd/options.go
Original file line number Diff line number Diff line change
Expand Up @@ -307,6 +307,7 @@ func configureSessionStartup(sess *engine.Session, settings hawkconfig.Settings,
if err := sess.SetMaxBudgetUSD(budget); err != nil {
return err
}
sess.ApplyTokUsageSettings(settings.HourlyTokenLimit, settings.DailyTokenLimit, settings.SessionTokenLimit)

// Teach mode: augment system prompt with explanation instructions
if teachMode {
Expand Down
2 changes: 1 addition & 1 deletion external/tok
Submodule tok updated 1 files
+11 −0 ratelimit.go
61 changes: 54 additions & 7 deletions internal/config/settings.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,13 +28,18 @@ func fetchModelsViaRuntime(ctx context.Context, provider string) ([]EngineModel,
type Settings struct {
// Model and Provider are legacy fields read only for one-time migration into eyrie provider.json.
// Hawk does not persist model/provider here; use SetActiveModel / SetActiveProvider.
Model string `json:"model,omitempty"`
Provider string `json:"provider,omitempty"`
Theme string `json:"theme,omitempty"`
AutoAllow []string `json:"auto_allow,omitempty"` // tools to always allow
AllowedTools []string `json:"allowedTools,omitempty"` // archive-compatible allow rules
DisallowedTools []string `json:"disallowedTools,omitempty"` // archive-compatible deny rules
MaxBudgetUSD float64 `json:"max_budget_usd,omitempty"` // cost cap per session
Model string `json:"model,omitempty"`
Provider string `json:"provider,omitempty"`
Theme string `json:"theme,omitempty"`
AutoAllow []string `json:"auto_allow,omitempty"` // tools to always allow
AllowedTools []string `json:"allowedTools,omitempty"` // archive-compatible allow rules
DisallowedTools []string `json:"disallowedTools,omitempty"` // archive-compatible deny rules
MaxBudgetUSD float64 `json:"max_budget_usd,omitempty"` // cost cap per session
// Optional local token ceilings. Disabled by default (provider rate limits
// own throughput). Set a positive value to opt in; -1 also means disabled.
HourlyTokenLimit int `json:"hourly_token_limit,omitempty"`
DailyTokenLimit int `json:"daily_token_limit,omitempty"`
SessionTokenLimit int `json:"session_token_limit,omitempty"`
MCPServers []MCPServerConfig `json:"mcp_servers,omitempty"`
CustomProviders []CustomProviderConfig `json:"custom_providers,omitempty"`
RepoMap *bool `json:"repo_map,omitempty"`
Expand Down Expand Up @@ -277,6 +282,15 @@ func MergeSettings(base, override Settings) Settings {
if override.MaxBudgetUSD > 0 {
base.MaxBudgetUSD = override.MaxBudgetUSD
}
if override.HourlyTokenLimit != 0 {
base.HourlyTokenLimit = override.HourlyTokenLimit
}
if override.DailyTokenLimit != 0 {
base.DailyTokenLimit = override.DailyTokenLimit
}
if override.SessionTokenLimit != 0 {
base.SessionTokenLimit = override.SessionTokenLimit
}
if len(override.AutoAllow) > 0 {
base.AutoAllow = override.AutoAllow
}
Expand Down Expand Up @@ -387,6 +401,21 @@ func SettingValue(s Settings, key string) (string, bool) {
return "", true
}
return strconv.FormatFloat(s.MaxBudgetUSD, 'f', -1, 64), true
case "hourlytokenlimit":
if s.HourlyTokenLimit == 0 {
return "", true
}
return strconv.Itoa(s.HourlyTokenLimit), true
case "dailytokenlimit":
if s.DailyTokenLimit == 0 {
return "", true
}
return strconv.Itoa(s.DailyTokenLimit), true
case "sessiontokenlimit":
if s.SessionTokenLimit == 0 {
return "", true
}
return strconv.Itoa(s.SessionTokenLimit), true
case "mcpservers":
data, _ := json.Marshal(s.MCPServers)
return string(data), true
Expand Down Expand Up @@ -444,6 +473,24 @@ func SetGlobalSetting(key, value string) error {
return fmt.Errorf("invalid max budget: %w", err)
}
s.MaxBudgetUSD = amount
case "hourlytokenlimit":
n, err := strconv.Atoi(strings.TrimSpace(value))
if err != nil {
return fmt.Errorf("invalid hourly_token_limit: %w", err)
}
s.HourlyTokenLimit = n
case "dailytokenlimit":
n, err := strconv.Atoi(strings.TrimSpace(value))
if err != nil {
return fmt.Errorf("invalid daily_token_limit: %w", err)
}
s.DailyTokenLimit = n
case "sessiontokenlimit":
n, err := strconv.Atoi(strings.TrimSpace(value))
if err != nil {
return fmt.Errorf("invalid session_token_limit: %w", err)
}
s.SessionTokenLimit = n
case "deploymentrouting":
switch strings.ToLower(strings.TrimSpace(value)) {
case "1", "true", "yes", "on":
Expand Down
16 changes: 16 additions & 0 deletions internal/config/validator.go
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,22 @@ func ValidateSettings(s Settings) ValidationResult {
Value: fmt.Sprintf("%f", s.MaxBudgetUSD),
})
}
for _, lim := range []struct {
field string
value int
}{
{"hourlyTokenLimit", s.HourlyTokenLimit},
{"dailyTokenLimit", s.DailyTokenLimit},
{"sessionTokenLimit", s.SessionTokenLimit},
} {
if lim.value < -1 {
errors = append(errors, ValidationError{
Field: lim.field,
Message: "must be -1 (unlimited), 0 (default), or a positive token count",
Value: fmt.Sprintf("%d", lim.value),
})
}
}

return ValidationResult{
Errors: errors,
Expand Down
39 changes: 39 additions & 0 deletions internal/engine/execution_graph_observations_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,45 @@ func TestTokUsageBudgetStopsAtConfiguredLimit(t *testing.T) {
}
}

func TestApplyTokUsageSettingsOverridesAndDisables(t *testing.T) {
sess := NewSession("test", "test", "system", tool.NewRegistry())
// Defaults: token ceilings off (provider rate limits own throughput).
defaults := sess.ensureTokUsageTracker().GetLimits()
if defaults.HourlyTokens != 0 || defaults.DailyTokens != 0 || defaults.SessionTokens != 0 {
t.Fatalf("expected disabled token ceilings by default, got %#v", defaults)
}

sess.ApplyTokUsageSettings(250_000, -1, 0)
limits := sess.ensureTokUsageTracker().GetLimits()
if limits.HourlyTokens != 250_000 {
t.Fatalf("HourlyTokens = %d, want 250000", limits.HourlyTokens)
}
if limits.DailyTokens != 0 {
t.Fatalf("DailyTokens = %d, want 0 (disabled)", limits.DailyTokens)
}
if limits.SessionTokens != 0 {
t.Fatalf("SessionTokens = %d, want 0 (still disabled)", limits.SessionTokens)
}
}

func TestDrainAlertsSurfacesHourlyWarning(t *testing.T) {
tracker := tok.NewUsageTracker()
tracker.SetLimits(tok.UsageLimits{
HourlyTokens: 100,
DailyTokens: 10_000,
SessionTokens: 10_000,
CostUSD: 10,
})
tracker.Record(55, 0, "provider", "model")
alerts := tracker.DrainAlerts()
if len(alerts) == 0 {
t.Fatal("expected threshold alert after crossing 50%")
}
if len(tracker.DrainAlerts()) != 0 {
t.Fatal("expected DrainAlerts to clear pending alerts")
}
}

func TestEyrieOperationObservationIsPrivacySafe(t *testing.T) {
t.Setenv("HAWK_STATE_DIR", t.TempDir())
sess := NewSession("test", "test", "system", tool.NewRegistry())
Expand Down
30 changes: 30 additions & 0 deletions internal/engine/permission_session_methods.go
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,36 @@ func (s *Session) SetMaxBudgetUSD(amount float64) error {
return nil
}

// ApplyTokUsageSettings optionally enables tok.UsageTracker token ceilings.
// Values: 0 or -1 leave/disable the ceiling (provider owns rate limits);
// >0 opts into a local cap.
func (s *Session) ApplyTokUsageSettings(hourly, daily, session int) {
if s == nil {
return
}
if hourly == 0 && daily == 0 && session == 0 {
return
}
tracker := s.ensureTokUsageTracker()
limits := tracker.GetLimits()
if hourly == -1 {
limits.HourlyTokens = 0
} else if hourly > 0 {
limits.HourlyTokens = hourly
}
if daily == -1 {
limits.DailyTokens = 0
} else if daily > 0 {
limits.DailyTokens = daily
}
if session == -1 {
limits.SessionTokens = 0
} else if session > 0 {
limits.SessionTokens = session
}
tracker.SetLimits(limits)
}

func (s *Session) exceededBudget() bool {
return s.LifecycleSvc().Limits().MaxBudgetUSD() > 0 && s.Cost.Total() > s.LifecycleSvc().Limits().MaxBudgetUSD()
}
Expand Down
9 changes: 9 additions & 0 deletions internal/engine/stream_usage.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import (
"encoding/json"
"fmt"
"strings"
"time"

Expand Down Expand Up @@ -105,6 +106,14 @@
Model: model,
},
}
if tracker := s.currentTokUsageTracker(); tracker != nil {
for _, alert := range tracker.DrainAlerts() {

Check failure on line 110 in internal/engine/stream_usage.go

View workflow job for this annotation

GitHub Actions / public module graph

tracker.DrainAlerts undefined (type *tok.UsageTracker has no field or method DrainAlerts)
ch <- StreamEvent{
Type: "content",
Content: fmt.Sprintf("\n⚠ Usage %s: %s\n", alert.Level, alert.Message),
}
}
}
}
}

Expand Down
Loading
Loading