Skip to content

Commit f48c99f

Browse files
committed
security: comprehensive code review fixes
- Fix NotebookEditTool path validation bypass (sandbox escape) - Add MCP server command validation/sandboxing - Fix sandbox fail-open to fail-closed behavior - Clean up daemon API key file on shutdown - Add safeguard for --dangerously-skip-permissions - Fix SSRF bypass in redirect chains - Make Session thread-safe with sync.RWMutex - Add preview for fuzzy edit replacements - Improve Guardian prompt sanitization patterns - Fix overly broad credential detection regex - Fix sandbox mode defaulting to ModeOff on typo - Fix global state mutation in exec.go - Fix os.Exit bypassing deferred cleanup - Add nil check for notebook cell type assertion - Cache injection scanner and egress regex compilation - Remove dead code in guardian.go - Remove duplicate flag registrations - Add validation for tool alias collisions - Fix settings merge replacing arrays - Fix TruncateOutput splitting multi-byte runes - Fix predictable exec IDs and sandbox instance race - Add WAL checkpoint cleanup after successful save
1 parent 4076672 commit f48c99f

25 files changed

Lines changed: 375 additions & 107 deletions

cmd/chat.go

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,10 +107,24 @@ func defaultRegistry(settings hawkconfig.Settings) (*tool.Registry, error) {
107107
if tool.IsPowerShellAvailable() {
108108
tools = append(tools, tool.PowerShellTool{})
109109
}
110+
// Detect project-level MCP servers (supply chain attack vector).
111+
// Project .hawk/settings.json can be committed to a repo and define
112+
// arbitrary commands that execute on clone. Gate behind --allow-project-mcp.
113+
projectMCPServers := hawkconfig.ProjectMCPServers()
114+
projectMCPNames := make(map[string]bool, len(projectMCPServers))
115+
for _, cfg := range projectMCPServers {
116+
if cfg.Name != "" {
117+
projectMCPNames[cfg.Name] = true
118+
}
119+
}
110120
for _, cfg := range settings.MCPServers {
111121
if cfg.Name == "" || cfg.Command == "" {
112122
continue
113123
}
124+
if projectMCPNames[cfg.Name] && !allowProjectMCP {
125+
fmt.Fprintf(os.Stderr, "hawk: skipping project-level MCP server %q (defined in .hawk/settings.json); use --allow-project-mcp to enable\n", cfg.Name)
126+
continue
127+
}
114128
mcpTools, err := tool.LoadMCPTools(context.Background(), cfg.Name, cfg.Command, cfg.Args...)
115129
if err != nil {
116130
continue

cmd/chat_commands.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -690,7 +690,7 @@ func (m *chatModel) handleCommand(text string) (tea.Model, tea.Cmd) {
690690
return m, nil
691691
}
692692
// List branches
693-
history, err := m.session.ConvoDAG.History(headID)
693+
history, err := m.session.ConvoDAG.History(context.Background(), headID)
694694
if err != nil || len(history) < 2 {
695695
m.messages = append(m.messages, displayMsg{role: "system", content: "No branches available (linear conversation).\nUse /fork to create a branch."})
696696
return m, nil

cmd/daemon.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,8 @@ func runDaemonStart(_ *cobra.Command, _ []string) error {
142142
<-sig
143143

144144
fmt.Println("\nShutting down...")
145+
// Clean up the API key file to avoid leaving secrets on disk.
146+
_ = os.Remove(keyFile)
145147
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
146148
defer cancel()
147149
return srv.Stop(ctx)

cmd/exec.go

Lines changed: 43 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ package cmd
22

33
import (
44
"context"
5+
"crypto/rand"
6+
"encoding/hex"
57
"encoding/json"
68
"fmt"
79
"io"
@@ -33,6 +35,23 @@ var (
3335
execJSON bool
3436
)
3537

38+
// ExitCodeError wraps a non-zero exit code so it can be returned from RunE
39+
// instead of calling os.Exit directly. This allows deferred cleanup (worktree
40+
// removal, global state restoration) to run before the process exits.
41+
type ExitCodeError struct {
42+
Code int
43+
Err error
44+
}
45+
46+
func (e *ExitCodeError) Error() string {
47+
if e.Err != nil {
48+
return e.Err.Error()
49+
}
50+
return fmt.Sprintf("exit code %d", e.Code)
51+
}
52+
53+
func (e *ExitCodeError) Unwrap() error { return e.Err }
54+
3655
// ExecResult is the structured output for --output-format json.
3756
type ExecResult struct {
3857
SessionID string `json:"session_id"`
@@ -116,7 +135,7 @@ func runExec(_ *cobra.Command, args []string) error {
116135
base := getCurrentBranch(cwd)
117136
branch := execWorktreeName
118137
if branch == "" {
119-
branch = fmt.Sprintf("hawk-exec/%d", start.UnixMilli())
138+
branch = fmt.Sprintf("hawk-exec/%d-%s", start.UnixMilli(), randomHex(4))
120139
}
121140
var wtErr error
122141
wtPath, wtErr = createExecWorktree(cwd, base, branch)
@@ -130,14 +149,6 @@ func runExec(_ *cobra.Command, args []string) error {
130149
}
131150
}
132151

133-
// Override global flags so shared helpers pick up exec-specific values
134-
if execModel != "" {
135-
model = execModel
136-
}
137-
if execMaxTurns > 0 {
138-
maxTurns = execMaxTurns
139-
}
140-
141152
// Load settings
142153
settings := hawkconfig.LoadSettings()
143154

@@ -148,15 +159,14 @@ func runExec(_ *cobra.Command, args []string) error {
148159
}
149160

150161
// If --agent is specified, prepend the agent persona
162+
var agentModel string
151163
if execAgent != "" {
152164
agentDef, err := agents.Get(execAgent)
153165
if err != nil {
154166
return fmt.Errorf("agent %q: %w", execAgent, err)
155167
}
156168
systemPrompt = agentDef.Prompt + "\n\n" + systemPrompt
157-
if agentDef.Model != "" {
158-
model = agentDef.Model
159-
}
169+
agentModel = agentDef.Model
160170
}
161171

162172
// Create tool registry
@@ -165,12 +175,21 @@ func runExec(_ *cobra.Command, args []string) error {
165175
return err
166176
}
167177

168-
// Create engine session
178+
// Resolve effective model/provider without mutating globals.
179+
// Priority: agent model > exec model > settings > global > auto-detect.
169180
effectiveModel, effectiveProvider := effectiveModelAndProvider(settings)
181+
if execModel != "" {
182+
effectiveModel = execModel
183+
}
184+
if agentModel != "" {
185+
effectiveModel = agentModel
186+
}
187+
188+
// Create engine session
170189
sess := newHawkSession(settings, effectiveProvider, effectiveModel, systemPrompt, registry)
171190
sess.SetLogger(logger.New(io.Discard, logger.Error))
172191

173-
if err := configureSession(sess, settings); err != nil {
192+
if err := configureSession(sess, settings, execMaxTurns); err != nil {
174193
return err
175194
}
176195

@@ -279,7 +298,7 @@ func runExec(_ *cobra.Command, args []string) error {
279298
exitCode = 1
280299
}
281300
if !execEphemeral {
282-
sessionID := fmt.Sprintf("exec-%d", start.UnixMilli())
301+
sessionID := fmt.Sprintf("exec-%d-%s", start.UnixMilli(), randomHex(4))
283302
persistExecSession(sessionID, effectiveModel, effectiveProvider, prompt, response.String())
284303
}
285304

@@ -295,7 +314,7 @@ func runExec(_ *cobra.Command, args []string) error {
295314

296315
if execOutputFormat == "json" {
297316
result := ExecResult{
298-
SessionID: fmt.Sprintf("exec-%d", start.UnixMilli()),
317+
SessionID: fmt.Sprintf("exec-%d-%s", start.UnixMilli(), randomHex(4)),
299318
Response: response.String(),
300319
ExitCode: exitCode,
301320
TokensIn: totalIn,
@@ -320,7 +339,7 @@ func runExec(_ *cobra.Command, args []string) error {
320339
}
321340

322341
if exitCode != 0 {
323-
os.Exit(exitCode)
342+
return &ExitCodeError{Code: exitCode}
324343
}
325344
return nil
326345
}
@@ -376,3 +395,10 @@ func cleanupExecWorktree(repoDir, wtPath string) {
376395
cmd.Dir = repoDir
377396
_ = cmd.Run()
378397
}
398+
399+
// randomHex returns a hex-encoded string of n random bytes.
400+
func randomHex(n int) string {
401+
b := make([]byte, n)
402+
_, _ = rand.Read(b)
403+
return hex.EncodeToString(b)
404+
}

cmd/options.go

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -189,7 +189,7 @@ func newHawkSession(settings hawkconfig.Settings, effectiveProvider, effectiveMo
189189
return engine.NewHawkSession(context.Background(), hawkconfig.DeploymentRoutingEnabled(settings), effectiveProvider, effectiveModel, systemPrompt, registry)
190190
}
191191

192-
func configureSession(sess *engine.Session, settings hawkconfig.Settings) error {
192+
func configureSession(sess *engine.Session, settings hawkconfig.Settings, maxTurnsOverride ...int) error {
193193
sess.WireAgentTool()
194194
sess.SetAllowedDirs(addDirs)
195195

@@ -240,7 +240,11 @@ func configureSession(sess *engine.Session, settings hawkconfig.Settings) error
240240
if err := sess.SetPermissionMode(mode); err != nil {
241241
return err
242242
}
243-
if err := sess.SetMaxTurns(maxTurns); err != nil {
243+
effectiveMaxTurns := maxTurns
244+
if len(maxTurnsOverride) > 0 && maxTurnsOverride[0] > 0 {
245+
effectiveMaxTurns = maxTurnsOverride[0]
246+
}
247+
if err := sess.SetMaxTurns(effectiveMaxTurns); err != nil {
244248
return err
245249
}
246250

cmd/review_store.go

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -211,8 +211,14 @@ func (s *ReviewStore) Summary() (map[ReviewStatus]int, error) {
211211
return m, rows.Err()
212212
}
213213

214-
// Close closes the database.
214+
// Close checkpoints the WAL and closes the database.
215+
// Running PRAGMA wal_checkpoint(TRUNCATE) before close ensures that
216+
// .db-wal and .db-shm files are cleaned up after all data is safely
217+
// flushed to the main database file.
215218
func (s *ReviewStore) Close() error {
219+
// Checkpoint WAL to flush all data into the main db and truncate
220+
// the WAL file, so no .db-wal / .db-shm files linger on disk.
221+
_, _ = s.db.Exec("PRAGMA wal_checkpoint(TRUNCATE)")
216222
return s.db.Close()
217223
}
218224

cmd/root.go

Lines changed: 40 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package cmd
22

33
import (
4+
"bufio"
45
"context"
56
"fmt"
67
"os"
@@ -58,6 +59,7 @@ var (
5859
containerMode bool
5960
noContainer bool
6061
recoverFlag bool
62+
allowProjectMCP bool
6163
)
6264

6365
// SetVersion sets the version string from main.
@@ -95,6 +97,11 @@ var rootCmd = &cobra.Command{
9597
if err := validateRootFlags(); err != nil {
9698
return err
9799
}
100+
if dangerouslySkipPermissions {
101+
if err := confirmDangerousSkipPermissions(); err != nil {
102+
return err
103+
}
104+
}
98105
if printMode || promptFlag != "" || inputFormat == "stream-json" {
99106
if promptFlag == "" {
100107
stdinPrompt, err := readPromptFromStdin(inputFormat)
@@ -162,9 +169,7 @@ func init() {
162169
rootCmd.Flags().StringArrayVar(&addDirs, "add-dir", nil, "additional directories to include in session context")
163170
rootCmd.Flags().StringArrayVar(&mcpServers, "mcp", nil, "MCP server command")
164171
rootCmd.Flags().StringArrayVar(&toolsFlag, "tools", nil, `available tools: "" disables all tools, "default" enables all, or names like "Bash,Edit,Read"`)
165-
rootCmd.Flags().StringArrayVar(&allowedToolsFlag, "allowedTools", nil, `comma or space-separated tool permission rules to allow (e.g. "Bash(git:*) Edit")`)
166172
rootCmd.Flags().StringArrayVar(&allowedToolsFlag, "allowed-tools", nil, `comma or space-separated tool permission rules to allow (e.g. "Bash(git:*) Edit")`)
167-
rootCmd.Flags().StringArrayVar(&disallowedToolsFlag, "disallowedTools", nil, `comma or space-separated tool permission rules to deny (e.g. "Bash(git:*) Edit")`)
168173
rootCmd.Flags().StringArrayVar(&disallowedToolsFlag, "disallowed-tools", nil, `comma or space-separated tool permission rules to deny (e.g. "Bash(git:*) Edit")`)
169174
rootCmd.Flags().StringVar(&permissionMode, "permission-mode", "", "permission mode: default, acceptEdits, bypassPermissions, dontAsk, or plan")
170175
rootCmd.Flags().BoolVar(&dangerouslySkipPermissions, "dangerously-skip-permissions", false, "bypass all permission checks")
@@ -190,6 +195,7 @@ func init() {
190195
rootCmd.Flags().BoolVar(&refreshCatalogFlag, "refresh-catalog", false, "refresh the eyrie model catalog before starting")
191196
rootCmd.Flags().BoolVar(&skipCatalogRefreshFlag, "no-auto-catalog-refresh", false, "disable automatic catalog refresh when cache is missing, empty, or stale")
192197
rootCmd.Flags().BoolVar(&recoverFlag, "recover", false, "scan for interrupted sessions and offer to resume")
198+
rootCmd.Flags().BoolVar(&allowProjectMCP, "allow-project-mcp", false, "allow MCP servers defined in project-level .hawk/settings.json (security risk)")
193199
rootCmd.AddCommand(versionCmd)
194200
rootCmd.AddCommand(setupCmd)
195201
rootCmd.AddCommand(doctorCmd)
@@ -222,6 +228,38 @@ func init() {
222228
rootCmd.AddCommand(recoverCmd)
223229
}
224230

231+
// confirmDangerousSkipPermissions enforces a safety guard when --dangerously-skip-permissions is set.
232+
// In a terminal, it prompts for interactive confirmation. In non-interactive mode (CI, scripts),
233+
// it requires the HAWK_DANGEROUSLY_SKIP_PERMISSIONS=1 environment variable.
234+
func confirmDangerousSkipPermissions() error {
235+
if isStdinTerminal() {
236+
fmt.Fprint(os.Stderr, "Are you sure? This disables all safety checks [y/N]: ")
237+
scanner := bufio.NewScanner(os.Stdin)
238+
if !scanner.Scan() {
239+
return fmt.Errorf("--dangerously-skip-permissions requires confirmation")
240+
}
241+
answer := strings.TrimSpace(strings.ToLower(scanner.Text()))
242+
if answer != "y" && answer != "yes" {
243+
return fmt.Errorf("--dangerously-skip-permissions declined; aborting")
244+
}
245+
return nil
246+
}
247+
// Non-interactive: require explicit env var override.
248+
if os.Getenv("HAWK_DANGEROUSLY_SKIP_PERMISSIONS") != "1" {
249+
return fmt.Errorf("--dangerously-skip-permissions requires HAWK_DANGEROUSLY_SKIP_PERMISSIONS=1 in non-interactive mode")
250+
}
251+
return nil
252+
}
253+
254+
// isStdinTerminal reports whether stdin is connected to a terminal.
255+
func isStdinTerminal() bool {
256+
fi, err := os.Stdin.Stat()
257+
if err != nil {
258+
return false
259+
}
260+
return fi.Mode()&os.ModeCharDevice != 0
261+
}
262+
225263
var completionCmd = &cobra.Command{
226264
Use: "completion [bash|zsh|fish|powershell]",
227265
Short: "Generate shell completion script",

cmd/sandbox.go

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package cmd
22

33
import (
44
"fmt"
5+
"sync"
56

67
"github.com/GrayCodeAI/hawk/internal/diffsandbox"
78
"github.com/spf13/cobra"
@@ -10,12 +11,15 @@ import (
1011
// sandboxInstance is a package-level sandbox for the CLI session.
1112
// In a real integration this would be loaded/shared from the hawk engine;
1213
// for now we create a fresh sandbox rooted at the current directory.
13-
var sandboxInstance *diffsandbox.Sandbox
14+
var (
15+
sandboxInstance *diffsandbox.Sandbox
16+
sandboxOnce sync.Once
17+
)
1418

1519
func getSandbox() *diffsandbox.Sandbox {
16-
if sandboxInstance == nil {
20+
sandboxOnce.Do(func() {
1721
sandboxInstance = diffsandbox.New(".")
18-
}
22+
})
1923
return sandboxInstance
2024
}
2125

go.mod

Lines changed: 5 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)