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
53 changes: 53 additions & 0 deletions api/openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -508,6 +508,59 @@ paths:
schema:
$ref: "#/components/schemas/Error"

/v1/cancel:
post:
tags: [agent]
summary: Cancel an in-flight generation
description: |
Aborts the active generation for the given session, if one is running.
The per-session generation is otherwise serialized (one at a time);
this endpoint lets a caller stop a long-running response early.
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [session_id]
properties:
session_id:
type: string
responses:
"200":
description: Generation cancelled (or completed before the request was processed)
content:
application/json:
schema:
type: object
properties:
cancelled:
type: boolean
"400":
description: Invalid or missing session_id
content:
application/json:
schema:
$ref: "#/components/schemas/Error"
"401":
description: Unauthorized
content:
application/json:
schema:
$ref: "#/components/schemas/Error"
"404":
description: No active generation for the session
content:
application/json:
schema:
$ref: "#/components/schemas/Error"
"429":
description: Rate limit exceeded
content:
application/json:
schema:
$ref: "#/components/schemas/Error"

/v1/sessions:
get:
tags: [sessions]
Expand Down
4 changes: 4 additions & 0 deletions cmd/mission.go
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,10 @@ func runMission(_ *cobra.Command, args []string) error {
}

m := mission.New(prompt, cfg)
// Clean up the mission's temp directory when the command finishes,
// whether it succeeded or failed. Without this, /tmp/hawk-missions/
// accumulates one directory per run indefinitely (C6 fix).
defer func() { _ = m.Cleanup() }()

ctx, cancel := context.WithTimeout(context.Background(), missionTimeout)
defer cancel()
Expand Down
10 changes: 8 additions & 2 deletions docs/DAEMON-PORT-THREAT-MODEL.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,8 +58,14 @@ A local attacker can flood the daemon with chat requests, consuming LLM API
credits.

**Mitigation:**
- The daemon processes one generation at a time by default.
- `/v1/cancel` allows killing an in-progress request.
- A global concurrency cap bounds in-flight generations (default **4**, tuned
via `HAWK_DAEMON_MAX_CONCURRENT`). When the cap is hit, new `/v1/chat`
requests are refused with `503` instead of queuing unboundedly.
- Per-IP token-bucket rate limiting: `/v1/chat` is limited to ~30 req/min
(burst 6) and other authenticated endpoints to ~10 req/min (burst 4).
Excess requests get `429`.
- `/v1/cancel` (POST `{ "session_id": ... }`) aborts an in-progress
generation so a runaway response can be stopped early.
- Consider running the daemon behind an OS-level firewall rule if operating
in a shared-machine environment.

Expand Down
45 changes: 44 additions & 1 deletion internal/acp/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,10 @@ type Server struct {

mu sync.Mutex
sessions map[string]*acpSession
seq int
// order tracks session creation order for FIFO eviction when the session
// cap is exceeded (H11).
order []string
seq int

writeMu sync.Mutex
w io.Writer
Expand All @@ -69,6 +72,11 @@ type Server struct {
nextReqID int
}

// maxACPSessions bounds how many sessions are kept alive at once. ACP is a
// long-lived stdio peer that can open many sessions; without a cap (and
// teardown on disconnect) the map grows unboundedly (H11).
const maxACPSessions = 64

type acpSession struct {
sess *engine.Session
cancel context.CancelFunc
Expand All @@ -94,6 +102,7 @@ func (s *Server) ServeStdio(ctx context.Context) error {
// while a prompt is streaming.
func (s *Server) Serve(ctx context.Context, r io.Reader, w io.Writer) error {
s.w = w
defer s.teardown() // release every session on disconnect (H11)
scanner := bufio.NewScanner(r)
scanner.Buffer(make([]byte, 0, 1024*1024), 8*1024*1024)

Expand Down Expand Up @@ -179,9 +188,13 @@ func (s *Server) handleSessionNew(msg rpcMessage) {
}

s.mu.Lock()
if len(s.sessions) >= maxACPSessions {
s.evictOldestLocked()
}
s.seq++
id := fmt.Sprintf("sess_%d", s.seq)
s.sessions[id] = &acpSession{sess: sess}
s.order = append(s.order, id)
s.mu.Unlock()

// Route tool-permission prompts to the client for this session.
Expand All @@ -190,6 +203,36 @@ func (s *Server) handleSessionNew(msg rpcMessage) {
s.reply(msg.ID, map[string]any{"sessionId": id})
}

// evictOldestLocked removes the oldest session to keep memory bounded; the
// caller must hold s.mu. Any in-flight prompt is cancelled first.
func (s *Server) evictOldestLocked() {
for len(s.order) > 0 {
oldest := s.order[0]
s.order = s.order[1:]
if as, ok := s.sessions[oldest]; ok {
delete(s.sessions, oldest)
if as != nil && as.cancel != nil {
as.cancel()
}
return
}
}
}

// teardown cancels and releases every session. It is called when Serve exits
// (disconnect or context cancellation).
func (s *Server) teardown() {
s.mu.Lock()
defer s.mu.Unlock()
for id, as := range s.sessions {
if as != nil && as.cancel != nil {
as.cancel()
}
delete(s.sessions, id)
}
s.order = nil
}

type promptParams struct {
SessionID string `json:"sessionId"`
Prompt []struct {
Expand Down
18 changes: 17 additions & 1 deletion internal/config/settings.go
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,15 @@ func readSettingsFileCached(path string) ([]byte, error) {
return data, err
}

// invalidateSettingsCache forces the next readSettingsFileCached to re-read
// from disk. SaveGlobal calls it after writing so a same-second, same-size
// write cannot be served from stale mtime/size cache (Phase 3 fix).
func invalidateSettingsCache() {
settingsCache.Lock()
settingsCache.valid = false
settingsCache.Unlock()
}

// LoadGlobalSettings loads only Hawk's user config settings.json.
func LoadGlobalSettings() Settings {
var s Settings
Expand Down Expand Up @@ -388,7 +397,14 @@ func SaveGlobal(s Settings) error {
return err
}
// 0600: per-user config; keep it unreadable to other local users.
return os.WriteFile(globalSettingsPath(), data, 0o600)
if err := os.WriteFile(globalSettingsPath(), data, 0o600); err != nil {
return err
}
// Invalidate the in-process byte cache so subsequent loads within the
// same second see the new file (the cache is also keyed on mtime/size,
// which can be identical for a same-size write).
invalidateSettingsCache()
return nil
}

// SettingValue returns a display-safe value for a supported setting key.
Expand Down
Loading
Loading