Skip to content

Commit 89fbcc9

Browse files
committed
v0.54.1 — SessionResolver path traversal guard + tool panic recovery
1 parent e87559e commit 89fbcc9

5 files changed

Lines changed: 109 additions & 5 deletions

File tree

cmd/odek/odek.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
{"skills": {"verbose": true, "auto_save": {"enabled": true, "require_llm": false}, "llm_learn": false}}

internal/loop/loop.go

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -779,11 +779,24 @@ if e.approver != nil && len(result.ToolCalls) > 1 {
779779
if ctxTool, ok := t.(interface{ SetContext(context.Context) }); ok {
780780
ctxTool.SetContext(ctx)
781781
}
782-
res, err := t.Call(tcRef.Function.Arguments)
783-
if err != nil {
784-
output = fmt.Sprintf("error: %s", err.Error())
785-
} else {
786-
output = redact.RedactSecrets(res)
782+
// Capture any panic from the tool so it does not kill the agent.
783+
var toolPanicked bool
784+
func() {
785+
defer func() {
786+
if r := recover(); r != nil {
787+
output = fmt.Sprintf("error: tool %q panicked: %v", tcRef.Function.Name, r)
788+
toolPanicked = true
789+
}
790+
}()
791+
res, err := t.Call(tcRef.Function.Arguments)
792+
if err != nil {
793+
output = fmt.Sprintf("error: %s", err.Error())
794+
} else {
795+
output = redact.RedactSecrets(res)
796+
}
797+
}()
798+
if toolPanicked {
799+
return
787800
}
788801
}
789802
results[idx] = execResult{output: output}

internal/loop/loop_test.go

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1908,3 +1908,48 @@ func TestEngine_InteractionModeDefault_ProducesRenderOutput(t *testing.T) {
19081908
}
19091909
}
19101910

1911+
1912+
// ── Bug #8: Tool goroutines lack panic recovery ─────────────────────────
1913+
// If a tool.Call() panics, the agent should not crash.
1914+
1915+
type panicTool struct {
1916+
name string
1917+
}
1918+
1919+
func (p *panicTool) Name() string { return p.name }
1920+
func (p *panicTool) Description() string { return "panics on call" }
1921+
func (p *panicTool) Schema() any { return map[string]any{"type": "object"} }
1922+
func (p *panicTool) Call(args string) (string, error) {
1923+
panic("deliberate panic from tool")
1924+
}
1925+
1926+
// TestToolPanic_DoesNotKillAgent verifies that a panicking tool call
1927+
// does not crash the agent. The agent should recover and continue.
1928+
func TestToolPanic_DoesNotKillAgent(t *testing.T) {
1929+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
1930+
var body struct {
1931+
Messages []llm.Message `json:"messages"`
1932+
}
1933+
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
1934+
t.Fatal(err)
1935+
}
1936+
// First call: return tool calls with panic tool
1937+
if len(body.Messages) == 2 {
1938+
fmt.Fprint(w, `{"choices":[{"index":0,"message":{"role":"assistant","content":"","tool_calls":[{"id":"call_1","type":"function","function":{"name":"panic_tool","arguments":"{}"}}]},"finish_reason":"tool_calls"}]}`)
1939+
return
1940+
}
1941+
// Second call (after tool panic): return content
1942+
fmt.Fprint(w, `{"choices":[{"index":0,"message":{"role":"assistant","content":"Agent survived the panic!"},"finish_reason":"stop"}]}`)
1943+
}))
1944+
defer server.Close()
1945+
1946+
client := llm.New(server.URL, "sk-test", "test-model", "", 0)
1947+
engine := New(client, tool.NewRegistry([]tool.Tool{&panicTool{name: "panic_tool"}}), 10, "", nil, 0)
1948+
result, err := engine.Run(context.Background(), "test task")
1949+
if err != nil {
1950+
t.Fatalf("engine.Run should not crash on tool panic: %v", err)
1951+
}
1952+
if !strings.Contains(result, "Agent survived") {
1953+
t.Errorf("agent result = %q, want 'Agent survived'", result)
1954+
}
1955+
}

internal/resource/resource.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@ import (
1717
"strings"
1818
"syscall"
1919
"time"
20+
21+
"github.com/BackendStack21/odek/internal/session"
2022
)
2123

2224
// Resource is a discovered resource returned by a Resolver.
@@ -384,6 +386,10 @@ func (s *SessionResolver) Search(ctx context.Context, query string, limit int) (
384386

385387
func (s *SessionResolver) Load(ctx context.Context, id string) (string, error) {
386388
// id is the session ID (without "sess:" prefix)
389+
// Security: validate session ID to prevent path traversal.
390+
if err := session.ValidateSessionID(id); err != nil {
391+
return "", fmt.Errorf("resource: invalid session ID: %w", err)
392+
}
387393
data, err := os.ReadFile(filepath.Join(s.dir, id+".json"))
388394
if err != nil {
389395
return "", err

internal/resource/resource_test.go

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -610,3 +610,42 @@ func (e *emptyResolver) Search(ctx context.Context, query string, limit int) ([]
610610
func (e *emptyResolver) Load(ctx context.Context, id string) (string, error) {
611611
return "", os.ErrNotExist
612612
}
613+
614+
// ── Bug #7: SessionResolver.Load path traversal ─────────────────────────
615+
616+
func TestSessionResolverLoad_PathTraversal(t *testing.T) {
617+
// Create a temp dir for sessions
618+
dir := t.TempDir()
619+
620+
// Create a real session file
621+
sessionID := "my-valid-session"
622+
if err := os.WriteFile(filepath.Join(dir, sessionID+".json"), []byte(`{"id":"valid"}`), 0644); err != nil {
623+
t.Fatal(err)
624+
}
625+
626+
resolver := &SessionResolver{dir: dir}
627+
628+
// Valid session ID should succeed
629+
content, err := resolver.Load(context.Background(), sessionID)
630+
if err != nil {
631+
t.Fatalf("Load with valid session failed: %v", err)
632+
}
633+
if !strings.Contains(content, "valid") {
634+
t.Errorf("Load with valid session returned wrong content: %s", content)
635+
}
636+
637+
// Path traversal attempts should FAIL
638+
traversalAttempts := []string{
639+
"../../etc/passwd",
640+
"..%2f..%2fetc/passwd",
641+
"/etc/passwd",
642+
"../other-session",
643+
"foo/../../etc/passwd",
644+
}
645+
for _, attempt := range traversalAttempts {
646+
_, err := resolver.Load(context.Background(), attempt)
647+
if err == nil {
648+
t.Errorf("Load with path traversal %q should have failed but succeeded (security bug)", attempt)
649+
}
650+
}
651+
}

0 commit comments

Comments
 (0)