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
2 changes: 1 addition & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ hawk_bin
.grok/
.hermes/
.qoder/
.pi/

# Cache / local data
.gocache/
Expand Down Expand Up @@ -48,4 +49,3 @@ hawk_bin
# Logs
*.log
hawk-sec105b.log

6 changes: 6 additions & 0 deletions cmd/chat.go
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,9 @@ func genID() string {
func prepareSession(sess *engine.Session) (string, *session.Session, error) {
id := genID()
if sessionIDFlag != "" && resumeID == "" && !continueFlag {
if err := session.ValidateID(sessionIDFlag); err != nil {
return "", nil, fmt.Errorf("invalid --session-id: %w", err)
}
id = sessionIDFlag
}
if sessionIDFlag != "" && (resumeID != "" || continueFlag) {
Expand Down Expand Up @@ -88,6 +91,9 @@ func prepareSession(sess *engine.Session) (string, *session.Session, error) {
sess.LoadMessages(session.ToRuntimeMessages(saved.Messages))
if forkSessionFlag {
if sessionIDFlag != "" {
if err := session.ValidateID(sessionIDFlag); err != nil {
return "", nil, fmt.Errorf("invalid --session-id: %w", err)
}
id = sessionIDFlag
}
return id, saved, nil
Expand Down
7 changes: 3 additions & 4 deletions cmd/chat_commands_session.go
Original file line number Diff line number Diff line change
Expand Up @@ -300,10 +300,9 @@ func (m *chatModel) handleSessionCommand(cmd string, parts []string, text string
m.messages = append(m.messages, displayMsg{role: "system", content: "Usage: /rename <new-session-name>"})
return m, nil
}
// Sanitize: strip directory components to prevent path traversal.
newName := filepath.Base(parts[1])
if newName == "" || newName == "." || newName == "/" {
m.messages = append(m.messages, displayMsg{role: "error", content: "Invalid session name."})
newName := parts[1]
if err := session.ValidateID(newName); err != nil {
m.messages = append(m.messages, displayMsg{role: "error", content: fmt.Sprintf("Invalid session name: %v", err)})
return m, nil
}
sessDir := storage.SessionsDir()
Expand Down
23 changes: 23 additions & 0 deletions cmd/cli_contracts_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,29 @@ func TestPrepareSession_ResumeUsesRecoveryPath(t *testing.T) {
}
}

func TestPrepareSessionRejectsInvalidSessionID(t *testing.T) {
oldResumeID := resumeID
oldContinueFlag := continueFlag
oldSessionIDFlag := sessionIDFlag
oldForkSessionFlag := forkSessionFlag
t.Cleanup(func() {
resumeID = oldResumeID
continueFlag = oldContinueFlag
sessionIDFlag = oldSessionIDFlag
forkSessionFlag = oldForkSessionFlag
})

resumeID = ""
continueFlag = false
sessionIDFlag = "../../escape"
forkSessionFlag = false

sess := engine.NewSession("test-provider", "test-model", "system", nil)
if _, _, err := prepareSession(sess); err == nil {
t.Fatal("prepareSession accepted an unsafe --session-id")
}
}

func TestReplBuiltinResponse_ToolsAndSession(t *testing.T) {
sess := engine.NewSession("demo-provider", "demo-model", "system", nil)
sess.AddUser("hello")
Expand Down
2 changes: 1 addition & 1 deletion external/eyrie
Submodule eyrie updated 0 files
2 changes: 1 addition & 1 deletion external/hawk-core-contracts
2 changes: 1 addition & 1 deletion external/inspect
Submodule inspect updated 0 files
2 changes: 1 addition & 1 deletion external/sight
Submodule sight updated 0 files
2 changes: 1 addition & 1 deletion external/trace
Submodule trace updated 0 files
2 changes: 1 addition & 1 deletion external/yaad
14 changes: 7 additions & 7 deletions go.mod

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

28 changes: 14 additions & 14 deletions go.sum

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

13 changes: 2 additions & 11 deletions internal/daemon/daemon.go
Original file line number Diff line number Diff line change
Expand Up @@ -453,7 +453,7 @@ func (s *Server) handleChat(w http.ResponseWriter, r *http.Request) {
requestedID := strings.TrimSpace(req.SessionID)
if requestedID != "" && !validSessionID(requestedID) {
writeJSON(w, http.StatusBadRequest, ErrorResponse{
Error: "invalid session id: use 1-128 alphanumeric, dash, underscore, or dot characters",
Error: "invalid session id: must be 1-128 characters using letters, digits, dash, underscore, or dot (reserved: . and ..)",
Code: "invalid_session_id",
})
return
Expand Down Expand Up @@ -699,16 +699,7 @@ func writeJSONResponse(s *Server, w http.ResponseWriter, events <-chan engine.St
}

func validSessionID(id string) bool {
if len(id) == 0 || len(id) > 128 {
return false
}
for _, c := range id {
if !((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') ||
(c >= '0' && c <= '9') || c == '-' || c == '_' || c == '.') {
return false
}
}
return true
return hawksession.ValidID(id)
}

func newSessionID() (string, error) {
Expand Down
28 changes: 24 additions & 4 deletions internal/mcp/http.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,11 @@ import (
"time"
)

const (
maxMCPResponseSize = 8 << 20
maxMCPErrorBody = 64 << 10
)

// HTTPServer represents an MCP server connected via HTTP or SSE transport.
type HTTPServer struct {
Name string
Expand Down Expand Up @@ -94,7 +99,7 @@ func (s *HTTPServer) Call(ctx context.Context, method string, params interface{}
defer func() { _ = resp.Body.Close() }()

if resp.StatusCode != http.StatusOK {
data, _ := io.ReadAll(resp.Body)
data, _ := io.ReadAll(io.LimitReader(resp.Body, maxMCPErrorBody))
return nil, fmt.Errorf("mcp %s: HTTP %d: %s", s.Type, resp.StatusCode, string(data))
}

Expand All @@ -103,9 +108,16 @@ func (s *HTTPServer) Call(ctx context.Context, method string, params interface{}
return s.parseSSEResponse(resp.Body, id)
}

// HTTP: parse JSON-RPC response directly
// HTTP: parse JSON-RPC response directly with an explicit memory bound.
data, err := io.ReadAll(io.LimitReader(resp.Body, maxMCPResponseSize+1))
if err != nil {
return nil, fmt.Errorf("mcp %s read response: %w", s.Type, err)
}
if len(data) > maxMCPResponseSize {
return nil, fmt.Errorf("mcp %s response exceeds %d byte limit", s.Type, maxMCPResponseSize)
}
var rpcResp jsonrpcResponse
if err := json.NewDecoder(resp.Body).Decode(&rpcResp); err != nil {
if err := json.Unmarshal(data, &rpcResp); err != nil {
return nil, fmt.Errorf("mcp %s decode: %w", s.Type, err)
}
if rpcResp.Error != nil {
Expand All @@ -115,7 +127,9 @@ func (s *HTTPServer) Call(ctx context.Context, method string, params interface{}
}

func (s *HTTPServer) parseSSEResponse(body io.Reader, id int) (json.RawMessage, error) {
scanner := bufio.NewScanner(body)
limited := &io.LimitedReader{R: body, N: maxMCPResponseSize + 1}
scanner := bufio.NewScanner(limited)
scanner.Buffer(make([]byte, 64<<10), maxMCPResponseSize)
for scanner.Scan() {
line := scanner.Text()
if !strings.HasPrefix(line, "data: ") {
Expand All @@ -133,6 +147,12 @@ func (s *HTTPServer) parseSSEResponse(body io.Reader, id int) (json.RawMessage,
return rpcResp.Result, nil
}
}
if err := scanner.Err(); err != nil {
return nil, fmt.Errorf("mcp sse scan response: %w", err)
}
if limited.N == 0 {
return nil, fmt.Errorf("mcp sse response exceeds %d byte limit", maxMCPResponseSize)
}
return nil, fmt.Errorf("mcp sse: no response for request %d", id)
}

Expand Down
56 changes: 56 additions & 0 deletions internal/mcp/http_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
package mcp

import (
"context"
"io"
"net/http"
"strings"
"testing"
)

type roundTripFunc func(*http.Request) (*http.Response, error)

func (fn roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
return fn(req)
}

func TestParseSSEResponseAllowsLargeEvent(t *testing.T) {
payload := strings.Repeat("x", 128<<10)
body := strings.NewReader(`data: {"jsonrpc":"2.0","id":7,"result":"` + payload + `"}` + "\n\n")
server := &HTTPServer{}

result, err := server.parseSSEResponse(body, 7)
if err != nil {
t.Fatalf("parseSSEResponse returned error: %v", err)
}
if !strings.Contains(string(result), payload[:100]) {
t.Fatal("parseSSEResponse did not return the large result")
}
}

func TestParseSSEResponseReportsScannerError(t *testing.T) {
body := strings.NewReader("data: " + strings.Repeat("x", maxMCPResponseSize+1))
server := &HTTPServer{}

if _, err := server.parseSSEResponse(body, 1); err == nil {
t.Fatal("parseSSEResponse accepted an oversized event")
}
}

func TestHTTPCallRejectsOversizedResponse(t *testing.T) {
server := &HTTPServer{
URL: "http://mcp.invalid",
Type: "http",
client: &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) {
return &http.Response{
StatusCode: http.StatusOK,
Body: io.NopCloser(strings.NewReader(strings.Repeat("x", maxMCPResponseSize+1))),
Header: make(http.Header),
}, nil
})},
}

if _, err := server.Call(context.Background(), "test", nil); err == nil {
t.Fatal("Call accepted an oversized response")
}
}
3 changes: 3 additions & 0 deletions internal/session/autosave.go
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,9 @@ type LockFile struct {

// AcquireLock creates a lock file for the session. Returns error if already locked.
func AcquireLock(sessionID string) (*LockFile, error) {
if err := ValidateID(sessionID); err != nil {
return nil, err
}
dir := sessionsDir()
path := filepath.Join(dir, sessionID+".lock")

Expand Down
3 changes: 3 additions & 0 deletions internal/session/compress.go
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,9 @@ func CompressOldSessions(maxAge time.Duration) (int, error) {

// DecompressSession decompresses a .jsonl.gz file for loading.
func DecompressSession(id string) (*Session, error) {
if err := ValidateID(id); err != nil {
return nil, err
}
dir := sessionsDir()

// Try .jsonl.gz first, then .json.gz
Expand Down
33 changes: 33 additions & 0 deletions internal/session/id.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
package session

import "fmt"

const maxSessionIDLength = 128

// ValidateID rejects session identifiers that are unsafe to use in file paths.
func ValidateID(id string) error {
if id == "" {
return fmt.Errorf("session ID is required")
}
if len(id) > maxSessionIDLength {
return fmt.Errorf("session ID exceeds %d characters", maxSessionIDLength)
}
if id == "." || id == ".." {
return fmt.Errorf("session ID %q is reserved", id)
}
for _, r := range id {
if (r >= 'a' && r <= 'z') ||
(r >= 'A' && r <= 'Z') ||
(r >= '0' && r <= '9') ||
r == '-' || r == '_' || r == '.' {
continue
}
return fmt.Errorf("session ID contains invalid character %q", r)
}
return nil
}

// ValidID reports whether id is safe to use as a session identifier.
func ValidID(id string) bool {
return ValidateID(id) == nil
}
Loading
Loading