Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
51 commits
Select commit Hold shift + click to select a range
68e90ba
fix: enforce permission autonomy and spec gates
Patel230 Aug 1, 2026
07c0560
fix: close permission edge cases
Patel230 Aug 1, 2026
b34119d
fix: persist explicit supervised autonomy
Patel230 Aug 1, 2026
dc605a6
feat: add structured permission decisions
Patel230 Aug 1, 2026
e7c6b45
feat: add capability-aware permission metadata
Patel230 Aug 1, 2026
bdb1bbc
feat: snapshot permission rules per request
Patel230 Aug 1, 2026
f4ee21f
refactor: validate spec workflow transitions
Patel230 Aug 1, 2026
54549a5
feat: bound child policies and version settings
Patel230 Aug 1, 2026
2928a45
feat: expose nonblocking policy evaluation
Patel230 Aug 1, 2026
40c37fb
fix: centralize permission scope and spec state
Patel230 Aug 1, 2026
f2ad8e5
refactor: synchronize permission service state
Patel230 Aug 1, 2026
610b90b
fix: make persistence messages snapshot-safe
Patel230 Aug 1, 2026
3ebe482
fix: track asynchronous hook lifecycle
Patel230 Aug 1, 2026
3bf0032
fix: evaluate each tool call from one policy snapshot
Patel230 Aug 1, 2026
650a133
fix: harden plugin command execution
Patel230 Aug 1, 2026
fbfa373
fix: confine sandbox snapshot restores
Patel230 Aug 1, 2026
f33ff37
fix: enforce private network proxy boundaries
Patel230 Aug 1, 2026
5567084
test: isolate package storage and clipboard checks
Patel230 Aug 1, 2026
4a982e2
test: isolate sandbox and prompt storage
Patel230 Aug 1, 2026
589c99c
docs: reconcile architecture extraction status
Patel230 Aug 1, 2026
6ddfa1d
test: clean up hermetic test roots
Patel230 Aug 1, 2026
4f476e6
fix: harden permission and spec filesystem boundaries
Patel230 Aug 1, 2026
1407616
chore: close remaining security scan findings
Patel230 Aug 1, 2026
b9cc950
refactor: establish canonical session services
Patel230 Aug 1, 2026
eb4062b
refactor: initialize standalone session service graph
Patel230 Aug 1, 2026
e9b8a40
refactor: move tool and approval orchestration into services
Patel230 Aug 1, 2026
7c40a0a
refactor: harden session service boundaries
Patel230 Aug 1, 2026
c9d0d3d
refactor: route compaction through persistence service
Patel230 Aug 1, 2026
6c52e44
refactor: route structured chat through chat service
Patel230 Aug 1, 2026
9db134a
refactor: encapsulate spec stage transitions
Patel230 Aug 1, 2026
97b0068
refactor: route runtime telemetry through facades
Patel230 Aug 1, 2026
37b4d6e
refactor: move raw tool execution into service
Patel230 Aug 1, 2026
3871983
refactor: centralize tool output normalization
Patel230 Aug 1, 2026
2f5cc99
refactor: centralize tool result completion
Patel230 Aug 1, 2026
3c3fee3
refactor: move tool post-processing into service
Patel230 Aug 1, 2026
8d1c765
refactor: remove legacy tool execution fallback
Patel230 Aug 1, 2026
2680b4e
refactor: route permissions through service boundary
Patel230 Aug 1, 2026
09ab869
fix: harden permission service boundaries
Patel230 Aug 1, 2026
1f55f75
refactor: remove obsolete session services bridge
Patel230 Aug 1, 2026
8b703ab
refactor: remove session permission and state aliases
Patel230 Aug 1, 2026
587574c
refactor: move compaction state into persistence service
Patel230 Aug 1, 2026
e176e63
refactor: remove lifecycle aliases from session
Patel230 Aug 1, 2026
a715df1
refactor: move cost tracking into lifecycle service
Patel230 Aug 1, 2026
5f3e50d
refactor: keep rate limiting inside chat service
Patel230 Aug 1, 2026
b7ba3a3
refactor: centralize tracing and chat options
Patel230 Aug 1, 2026
69af8ec
refactor: complete session facade ownership cleanup
Patel230 Aug 1, 2026
5a77de9
feat: harden cli commands and scrollbar UX
Patel230 Aug 1, 2026
70341a2
chore: combine session and cli improvements
Patel230 Aug 1, 2026
83fa9e3
chore: combine permission and spec hardening
Patel230 Aug 1, 2026
4d8e2d3
fix: reconcile permission service boundaries
Patel230 Aug 1, 2026
425e16d
fix(engine): lock allowedDirs in SetAllowedDirs to fix data race
Patel230 Aug 1, 2026
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
6 changes: 4 additions & 2 deletions cmd/ai_comments.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import (
"regexp"
"sort"
"strings"

"github.com/GrayCodeAI/hawk/internal/fsutil"
)

// AIDirective represents a found AI comment directive in a source file.
Expand Down Expand Up @@ -54,7 +56,7 @@ func scanForAIComments(dir string, ignore []string) []AIDirective {
if !aiSupportedExts[ext] {
return nil
}
data, err := os.ReadFile(path) // #nosec G304 -- path comes from filepath.Walk over the target directory
data, err := fsutil.ReadPinnedFile(path)
if err != nil {
return nil
}
Expand Down Expand Up @@ -207,5 +209,5 @@ func removeAIComment(path string, line int) error {
}

// #nosec G306 -- rewrites an existing project source file in place, matching typical source file permissions
return os.WriteFile(path, []byte(strings.Join(lines, "\n")), 0o644)
return fsutil.WritePinnedFile(path, []byte(strings.Join(lines, "\n")), 0o644)
}
26 changes: 22 additions & 4 deletions cmd/chat_commands.go
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,7 @@ func slashAliases() map[string]string {
}
}

// #nosec G101 -- command descriptions are static UI strings, not credentials.
var slashDescriptions = map[string]string{
"/add": "Add files to conversation context",
"/add-dir": "Add a directory to context",
Expand Down Expand Up @@ -306,9 +307,11 @@ func slashSuggestions(input string) []string {
if !strings.HasPrefix(v, "/") || strings.Contains(v, " ") {
return nil
}
v = strings.ToLower(v)
var out []string
seen := map[string]bool{}
for _, c := range allSlashCommands {
for _, c := range slashCommands() {
c = strings.ToLower(c)
if strings.HasPrefix(c, v) {
seen[c] = true
desc := slashDescriptions[c]
Expand All @@ -319,7 +322,15 @@ func slashSuggestions(input string) []string {
}
}
}
for alias, target := range slashAliases() {
aliases := slashAliases()
aliasNames := make([]string, 0, len(aliases))
for alias := range aliases {
aliasNames = append(aliasNames, alias)
}
sort.Strings(aliasNames)
for _, alias := range aliasNames {
target := aliases[alias]
alias = strings.ToLower(alias)
if strings.HasPrefix(alias, v) && !seen[target] {
seen[alias] = true
out = append(out, alias+" → "+target)
Expand Down Expand Up @@ -348,7 +359,14 @@ func applySlashSuggestion(input string) string {

func (m *chatModel) handleCommand(text string) (tea.Model, tea.Cmd) {
parts := strings.Fields(text)
cmd := parts[0]
if len(parts) == 0 {
return m, nil
}
rawCmd := parts[0]
cmd := rawCmd
if strings.HasPrefix(cmd, "/") {
cmd = strings.ToLower(cmd)
}

// Track the last command for context-aware tips and recent-command history.
if strings.HasPrefix(cmd, "/") {
Expand Down Expand Up @@ -377,7 +395,7 @@ func (m *chatModel) handleCommand(text string) (tea.Model, tea.Cmd) {
}

// Fallback: plugin commands and unknown-command error.
if m.pluginRuntime != nil && m.pluginRuntime.IsCommand(cmd[1:]) {
if strings.HasPrefix(cmd, "/") && m.pluginRuntime != nil && m.pluginRuntime.IsCommand(cmd[1:]) {
out, err := m.pluginRuntime.ExecuteCommand(cmd[1:], parts[1:])
if err != nil {
m.messages = append(m.messages, displayMsg{role: "error", content: err.Error()})
Expand Down
11 changes: 11 additions & 0 deletions cmd/chat_commands_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,17 @@ func TestHandleCommandAddDir(t *testing.T) {
}
}

func TestHandleCommandEmptyInputIsNoOp(t *testing.T) {
m := &chatModel{}
model, cmd := m.handleCommand(" \n\t")
if cmd != nil {
t.Fatal("empty command should not schedule a tea command")
}
if model != m || len(m.messages) != 0 {
t.Fatal("empty command should leave the model unchanged")
}
}

func TestLocalSlashCommands(t *testing.T) {
preserveCLICompilerVersionState(t)
version = "test-version"
Expand Down
2 changes: 1 addition & 1 deletion cmd/chat_commands_util.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ import (
func gitOutput(args ...string) (string, error) {
// Output (not CombinedOutput): git writes warnings to stderr, which must
// not be folded into values like the branch name shown in the status bar.
out, err := exec.CommandContext(context.Background(), "git", args...).Output()
out, err := exec.CommandContext(context.Background(), "git", args...).Output() // #nosec G204 -- fixed git executable
return strings.TrimSpace(string(out)), err
}

Expand Down
24 changes: 12 additions & 12 deletions cmd/chat_copy_e2e_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -58,19 +58,15 @@ func runCopySelectionE2EPass(t *testing.T, pass int) {
if !ok {
t.Fatalf("pass %d: /copy all returned %T", pass, result)
}
if !strings.Contains(lastSystemMessage(cm.messages), "Copied chat transcript") {
t.Fatalf("pass %d: /copy all: %s", pass, lastSystemMessage(cm.messages))
}
assertCopySucceeded(t, pass, "/copy all", lastSystemMessage(cm.messages), "Copied chat transcript")
m = cm

result, _ = m.handleCommand("/copy")
cm, ok = result.(*chatModel)
if !ok {
t.Fatalf("pass %d: /copy returned %T", pass, result)
}
if last := lastSystemMessage(cm.messages); !strings.Contains(last, "Copied") {
t.Fatalf("pass %d: /copy smart: %s", pass, last)
}
assertCopySucceeded(t, pass, "/copy smart", lastSystemMessage(cm.messages), "Copied")
m = cm

// Keyboard shortcut path
Expand All @@ -79,9 +75,7 @@ func runCopySelectionE2EPass(t *testing.T, pass int) {
if !ok {
t.Fatalf("pass %d: handleCopyShortcut returned %T", pass, result)
}
if !strings.Contains(lastSystemMessage(cm.messages), "Copied input") {
t.Fatalf("pass %d: Ctrl+Shift+C shortcut: %s", pass, lastSystemMessage(cm.messages))
}
assertCopySucceeded(t, pass, "Ctrl+Shift+C shortcut", lastSystemMessage(cm.messages), "Copied input")
m = cm

if !isCopyToClipboardKey(tea.KeyPressMsg{Code: 'c', Mod: tea.ModAlt}) {
Expand Down Expand Up @@ -130,9 +124,7 @@ func runCopySelectionE2EPass(t *testing.T, pass int) {
if !ok {
t.Fatalf("pass %d: /copy assistant returned %T", pass, result)
}
if !strings.Contains(lastSystemMessage(cm.messages), "Copied assistant reply") {
t.Fatalf("pass %d: /copy assistant: %s", pass, lastSystemMessage(cm.messages))
}
assertCopySucceeded(t, pass, "/copy assistant", lastSystemMessage(cm.messages), "Copied assistant reply")

// Settings-backed mouse default
disabled := false
Expand All @@ -142,6 +134,14 @@ func runCopySelectionE2EPass(t *testing.T, pass int) {
}
}

func assertCopySucceeded(t *testing.T, pass int, operation, message, copiedText string) {
t.Helper()
if strings.Contains(message, copiedText) || strings.Contains(message, "Clipboard unavailable — saved") {
return
}
t.Fatalf("pass %d: %s: %s", pass, operation, message)
}

func lastSystemMessage(msgs []displayMsg) string {
for i := len(msgs) - 1; i >= 0; i-- {
if msgs[i].role == "system" || msgs[i].role == "error" {
Expand Down
6 changes: 3 additions & 3 deletions cmd/chat_export.go
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,7 @@ func exportSessionMarkdown(m *chatModel) (string, error) {
if err := os.MkdirAll(exportDir, 0o700); err != nil {
return "", err
}
_ = os.Chmod(exportDir, 0o700)
_ = os.Chmod(exportDir, 0o700) // #nosec G302 -- exports are private session data
exportPath := filepath.Join(exportDir, m.sessionID+".md")
if err := os.WriteFile(exportPath, data, 0o600); err != nil {
return "", err
Expand Down Expand Up @@ -146,7 +146,7 @@ func exportSessionJSON(m *chatModel) (string, error) {
if err := os.MkdirAll(exportDir, 0o700); err != nil {
return "", err
}
_ = os.Chmod(exportDir, 0o700)
_ = os.Chmod(exportDir, 0o700) // #nosec G302 -- exports are private session data
exportPath := filepath.Join(exportDir, m.sessionID+".json")
if err := os.WriteFile(exportPath, data, 0o600); err != nil {
return "", err
Expand Down Expand Up @@ -190,7 +190,7 @@ func exportSessionTxt(m *chatModel) (string, error) {
if err := os.MkdirAll(exportDir, 0o700); err != nil {
return "", err
}
_ = os.Chmod(exportDir, 0o700)
_ = os.Chmod(exportDir, 0o700) // #nosec G302 -- exports are private session data
exportPath := filepath.Join(exportDir, m.sessionID+".txt")
if err := os.WriteFile(exportPath, []byte(b.String()), 0o600); err != nil {
return "", err
Expand Down
2 changes: 1 addition & 1 deletion cmd/chat_permission_keys_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ func TestPermissionAlwaysAllowDoesNotNilDeref(t *testing.T) {
if got := lastSystemMessage(cm.messages); !strings.Contains(got, "Always allowed: Bash") {
t.Fatalf("unexpected always-allow message: %q", got)
}
decision := cm.session.Perm.Memory.Check("Bash", "anything")
decision := cm.session.PermSvc().Memory().Check("Bash", "anything")
if decision == nil || !*decision {
t.Fatal("expected Bash:* always-allow rule to be recorded")
}
Expand Down
26 changes: 18 additions & 8 deletions cmd/chat_scrollbar.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,14 +11,17 @@ const scrollbarWidth = 1

// Scrollbar glyph palette — tuned to look premium in dark terminals.
const (
scrollbarTrackGlyph = " " // blank track the gutter itself provides the visual rhythm
scrollbarTrackGlyph = "" // dim track keeps the scroll position legible at a glance
scrollbarThumbGlyph = "┃" // heavy vertical line for the thumb (visible without reading as a solid block)
scrollbarTopGlyph = "╷" // cap at the very top of the track
scrollbarBottomGlyph = "╵" // cap at the very bottom of the track
)

// scrollbarThumbStyle — Talon Gold thumb so it reads as a brand control.
var scrollbarThumbStyle = lipgloss.NewStyle().Foreground(hawkColor)
var (
scrollbarThumbStyle = lipgloss.NewStyle().Foreground(hawkColor)
scrollbarTrackStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("238"))
)

// chatHasOverflow reports whether chat content exceeds the viewport height.
func (m chatModel) chatHasOverflow() bool {
Expand All @@ -41,10 +44,10 @@ func (m chatModel) chatScrollbarVisible() bool {
// When chat content exceeds the viewport height, one column is reserved for the scrollbar slider
// so text wrapping remains completely stable and smooth as the user scrolls up and down.
func (m chatModel) chatViewportWidth(totalWidth int) int {
if totalWidth < 20 {
return 80
if totalWidth <= 0 {
return 0
}
if m.chatHasOverflow() {
if m.chatHasOverflow() && totalWidth > scrollbarWidth {
return totalWidth - scrollbarWidth
}
return totalWidth
Expand All @@ -60,11 +63,14 @@ func (m chatModel) chatViewportWidth(totalWidth int) int {
//
// Returns an empty string when there is no overflow.
func (m chatModel) renderScrollbar() string {
return m.renderScrollbarHeight(m.viewport.Height())
}

func (m chatModel) renderScrollbarHeight(vpH int) string {
if !m.chatHasOverflow() {
return ""
}

vpH := m.viewport.Height()
totalLines := m.contentLines
if vpH <= 0 || totalLines <= 0 {
return ""
Expand Down Expand Up @@ -108,8 +114,12 @@ func (m chatModel) renderScrollbar() string {
for row := 0; row < vpH; row++ {
if row >= thumbTop && row <= thumbBottom {
sb.WriteString(scrollbarThumbStyle.Render(scrollbarThumbGlyph))
} else if row == 0 {
sb.WriteString(scrollbarTrackStyle.Render(scrollbarTopGlyph))
} else if row == vpH-1 {
sb.WriteString(scrollbarTrackStyle.Render(scrollbarBottomGlyph))
} else {
sb.WriteString(" ")
sb.WriteString(scrollbarTrackStyle.Render(scrollbarTrackGlyph))
}
if row < vpH-1 {
sb.WriteByte('\n')
Expand Down Expand Up @@ -157,7 +167,7 @@ func (m chatModel) renderChatPane() string {
return padToHeight(chatView, vpH)
}

scrollbar := m.renderScrollbar()
scrollbar := m.renderScrollbarHeight(vpH)
if scrollbar == "" {
return padToHeight(chatView, vpH)
}
Expand Down
18 changes: 18 additions & 0 deletions cmd/chat_scrollbar_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,16 @@ func TestChatViewportWidth_NoScrollbar(t *testing.T) {
}
}

func TestChatViewportWidth_NarrowTerminalClampsWithoutJump(t *testing.T) {
m := chatModel{viewport: viewportWithSize(8, 4), contentLines: 100}
if got := m.chatViewportWidth(8); got != 7 {
t.Fatalf("narrow overflowing viewport width = %d, want 7", got)
}
if got := m.chatViewportWidth(1); got != 1 {
t.Fatalf("single-column viewport width = %d, want 1", got)
}
}

func TestRenderScrollbar_TopAndBottom(t *testing.T) {
m := chatModel{
viewport: viewportWithSize(80, 10),
Expand Down Expand Up @@ -92,6 +102,14 @@ func TestRenderScrollbar_SlightOverflowUsesLargeThumb(t *testing.T) {
}
}

func TestRenderScrollbar_UsesTrackOutsideThumb(t *testing.T) {
m := chatModel{viewport: viewportWithSize(80, 10), contentLines: 100}
lines := strings.Split(m.renderScrollbar(), "\n")
if !strings.Contains(lines[5], scrollbarTrackGlyph) {
t.Fatalf("expected track glyph outside thumb, got %q", lines[5])
}
}

func TestRenderChatPane_PaddedWidth(t *testing.T) {
m := chatModel{
viewport: viewportWithSize(19, 4),
Expand Down
2 changes: 1 addition & 1 deletion cmd/chat_subcommand_spec.go
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ func (s *specSubcommand) Handle(m *chatModel, args []string, text string) (tea.M
return m, nil

case strings.EqualFold(arg, "reset"):
m.session.PermSvc().SetSpecStage(engine.SpecStageNone)
m.session.PermSvc().ResetSpec()
m.messages = append(m.messages, displayMsg{role: "system", content: "Spec workflow reset — Write/Edit/Bash follow the normal autonomy tier again."})
return m, nil

Expand Down
Loading
Loading