Skip to content

Commit 42375d0

Browse files
committed
perf+sec: package-by-package optimization sprint
- render: single rune scan in truncate (was double scan) - repl: errors.Is(err, io.EOF) instead of fragile string comparison - odek: LoadProjectFile rejects symlinks (O_NOFOLLOW) - file_tool: single-pass readFile (binary check + count + read in one open) - file_tool: searchFiles single open (was open-close-open) - file_tool: atomic write via temp+rename (TOCTOU fix) - resource: O_NOFOLLOW on FileResolver.Load (TOCTOU fix) - loop: pre-allocated slice for skill injection (no nested append) - session: index.json for O(1) List/Latest (no per-file reads) - mcpclient: single reader goroutine per conn (no goroutine-per-read leak) - memory: AddFact caches entries, single embed per mutation (was O(n²)) All 14 packages pass tests.
1 parent a3974bb commit 42375d0

12 files changed

Lines changed: 494 additions & 82 deletions

File tree

cmd/odek/file_tool.go

Lines changed: 50 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -106,7 +106,7 @@ func (t *readFileTool) Call(argsJSON string) (string, error) {
106106
return jsonError(fmt.Sprintf("%q is a directory, not a file", args.Path))
107107
}
108108

109-
// Check for binary content — only examine first 8KB
109+
// Single pass: open → binary check from sample → seek → read+count
110110
f, err := os.Open(args.Path)
111111
if err != nil {
112112
return jsonError(fmt.Sprintf("cannot open %q: %v", args.Path, err))
@@ -115,17 +115,16 @@ func (t *readFileTool) Call(argsJSON string) (string, error) {
115115

116116
sample := make([]byte, 8192)
117117
n, _ := f.Read(sample)
118-
sample = sample[:n]
119-
if isBinary(sample) {
118+
if isBinary(sample[:n]) {
120119
return jsonError(fmt.Sprintf("%q appears to be a binary file — use shell to read it", args.Path))
121120
}
122-
f.Close() // close to re-read from start
123121

124-
// Count total lines first
125-
totalLines := countLines(args.Path)
122+
// Seek back to start for the full scan
123+
if _, err := f.Seek(0, 0); err != nil {
124+
return jsonError(fmt.Sprintf("cannot seek %q: %v", args.Path, err))
125+
}
126126

127-
// Read the requested range
128-
content, err := readLines(args.Path, args.Offset, args.Limit)
127+
content, totalLines, err := readLinesWithCount(f, args.Offset, args.Limit)
129128
if err != nil {
130129
return jsonError(fmt.Sprintf("cannot read %q: %v", args.Path, err))
131130
}
@@ -205,9 +204,31 @@ func (t *writeFileTool) Call(argsJSON string) (string, error) {
205204
}
206205
}
207206

208-
if err := os.WriteFile(args.Path, []byte(args.Content), 0644); err != nil {
207+
// Atomic write via temp file + rename to prevent TOCTOU symlink races.
208+
// os.CreateTemp creates the file in the same directory (same filesystem),
209+
// and os.Rename atomically replaces the directory entry without following
210+
// symlinks — closing the window between check and write.
211+
tmpFile, err := os.CreateTemp(dir, ".tmp_write_*")
212+
if err != nil {
213+
return jsonError(fmt.Sprintf("cannot create temp file: %v", err))
214+
}
215+
tmpPath := tmpFile.Name()
216+
217+
if _, err := tmpFile.Write([]byte(args.Content)); err != nil {
218+
tmpFile.Close()
219+
os.Remove(tmpPath)
209220
return jsonError(fmt.Sprintf("cannot write %q: %v", args.Path, err))
210221
}
222+
if err := tmpFile.Close(); err != nil {
223+
os.Remove(tmpPath)
224+
return jsonError(fmt.Sprintf("cannot close temp file: %v", err))
225+
}
226+
227+
// Atomic rename — replaces target directory entry without following symlinks
228+
if err := os.Rename(tmpPath, args.Path); err != nil {
229+
os.Remove(tmpPath)
230+
return jsonError(fmt.Sprintf("cannot rename %q: %v", args.Path, err))
231+
}
211232

212233
return jsonResult(writeFileResult{
213234
Success: true,
@@ -346,24 +367,25 @@ func (t *searchFilesTool) searchContent(args searchFilesArgs) (string, error) {
346367
}
347368
}
348369

349-
// Skip binary files
350-
sample := make([]byte, 512)
370+
// Skip binary files — single open for check then search
351371
f, err := os.Open(path)
352372
if err != nil {
353373
return nil
354374
}
375+
defer f.Close()
376+
377+
sample := make([]byte, 512)
355378
n, _ := f.Read(sample)
356-
f.Close()
357379
if isBinary(sample[:n]) {
358380
return nil
359381
}
360382

361-
// Search line by line
362-
f, err = os.Open(path)
363-
if err != nil {
383+
// Seek back to start for content search
384+
if _, err := f.Seek(0, 0); err != nil {
364385
return nil
365386
}
366-
defer f.Close()
387+
388+
// Search line by line
367389

368390
scanner := bufio.NewScanner(f)
369391
scanner.Buffer(make([]byte, 1024*1024), 1024*1024)
@@ -601,28 +623,9 @@ func isBinary(data []byte) bool {
601623
return float64(nonPrintable)/float64(len(data)) > 0.30
602624
}
603625

604-
func countLines(path string) int {
605-
f, err := os.Open(path)
606-
if err != nil {
607-
return 0
608-
}
609-
defer f.Close()
610-
count := 0
611-
scanner := bufio.NewScanner(f)
612-
scanner.Buffer(make([]byte, 1024*1024), 1024*1024)
613-
for scanner.Scan() {
614-
count++
615-
}
616-
return count
617-
}
618-
619-
func readLines(path string, offset, limit int) (string, error) {
620-
f, err := os.Open(path)
621-
if err != nil {
622-
return "", err
623-
}
624-
defer f.Close()
625-
626+
// readLinesWithCount reads lines from an open file, returning content
627+
// and total line count in a single pass. offset is 1-based, limit caps lines.
628+
func readLinesWithCount(f *os.File, offset, limit int) (string, int, error) {
626629
var out strings.Builder
627630
scanner := bufio.NewScanner(f)
628631
scanner.Buffer(make([]byte, 1024*1024), 1024*1024)
@@ -636,12 +639,19 @@ func readLines(path string, offset, limit int) (string, error) {
636639
continue
637640
}
638641
if lineNum > end {
639-
break
642+
continue // count total even beyond limit
640643
}
641644
out.WriteString(fmt.Sprintf("%d|%s\n", lineNum, scanner.Text()))
642645
}
643646

644-
return strings.TrimSuffix(out.String(), "\n"), scanner.Err()
647+
// If no limit was set (limit=0), continue counting past start
648+
if limit > 0 {
649+
for scanner.Scan() {
650+
lineNum++
651+
}
652+
}
653+
654+
return strings.TrimSuffix(out.String(), "\n"), lineNum, scanner.Err()
645655
}
646656

647657
func truncateDiff(s string, maxLen int) string {

cmd/odek/file_tool_test.go

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -617,3 +617,104 @@ func containsStr(slice []string, item string) bool {
617617
}
618618
return false
619619
}
620+
621+
// TestWriteFile_TOCTOU_SymlinkRejected verifies that write_file does NOT
622+
// follow symlinks (security: TOCTOU race prevention).
623+
// This test writes to a temp path, then replaces it with a symlink
624+
// to a protected file, and verifies the tool refuses or replaces the symlink.
625+
func TestWriteFile_TOCTOU_SymlinkRejected(t *testing.T) {
626+
dir := t.TempDir()
627+
targetPath := filepath.Join(dir, "target.txt")
628+
protectedPath := filepath.Join(dir, "protected.txt")
629+
630+
// Create the protected file (what attacker wants us to overwrite)
631+
if err := os.WriteFile(protectedPath, []byte("sensitive data"), 0644); err != nil {
632+
t.Fatal(err)
633+
}
634+
635+
// Create target then replace with symlink to protected file
636+
if err := os.WriteFile(targetPath, []byte("original"), 0644); err != nil {
637+
t.Fatal(err)
638+
}
639+
os.Remove(targetPath)
640+
if err := os.Symlink(protectedPath, targetPath); err != nil {
641+
t.Fatal(err)
642+
}
643+
644+
// Try to write to targetPath — if the tool follows the symlink,
645+
// protected.txt gets overwritten. Our fix should prevent this.
646+
tool := &writeFileTool{}
647+
result := callJSON(t, tool, `{"path":"`+targetPath+`","content":"overwritten!"}`)
648+
649+
// After the write, protected.txt should still be intact
650+
data, _ := os.ReadFile(protectedPath)
651+
if string(data) != "sensitive data" {
652+
t.Errorf("protected file was overwritten! Got: %q", string(data))
653+
}
654+
655+
// The call should have succeeded (it writes to a temp file + renames,
656+
// which replaces the symlink entry not the target)
657+
_ = result
658+
}
659+
660+
// TestReadFile_CountAndContentSinglePass verifies that readLinesWithCount
661+
// returns both content and total line count in one pass.
662+
func TestReadFile_CountAndContentSinglePass(t *testing.T) {
663+
dir := t.TempDir()
664+
path := filepath.Join(dir, "count.txt")
665+
content := "line1\nline2\nline3\nline4\nline5\n"
666+
if err := os.WriteFile(path, []byte(content), 0644); err != nil {
667+
t.Fatal(err)
668+
}
669+
670+
f, err := os.Open(path)
671+
if err != nil {
672+
t.Fatal(err)
673+
}
674+
defer f.Close()
675+
676+
gotContent, totalLines, err := readLinesWithCount(f, 1, 3)
677+
if err != nil {
678+
t.Fatalf("readLinesWithCount error: %v", err)
679+
}
680+
if totalLines != 5 {
681+
t.Errorf("totalLines = %d, want 5", totalLines)
682+
}
683+
if !strings.Contains(gotContent, "1|line1") || !strings.Contains(gotContent, "3|line3") {
684+
t.Errorf("gotContent missing expected lines: %s", gotContent)
685+
}
686+
}
687+
688+
// defaultTestDangerousConfig returns a permissive DangerousConfig for tests.
689+
func defaultTestDangerousConfig() DangerConfig {
690+
return DangerConfig{
691+
Action: "allow",
692+
NonInteractive: "allow",
693+
Classes: map[RiskClass]string{
694+
RiskClassDestructive: "allow",
695+
RiskClassNetworkEgress: "allow",
696+
RiskClassCodeExecution: "allow",
697+
RiskClassInstall: "allow",
698+
RiskClassSystemWrite: "allow",
699+
},
700+
}
701+
}
702+
703+
// DangerConfig matches the danger.DangerousConfig structure for test use.
704+
type DangerConfig struct {
705+
Action string `json:"action"`
706+
NonInteractive string `json:"non_interactive"`
707+
Classes map[RiskClass]string `json:"classes"`
708+
Allowlist []string `json:"allowlist,omitempty"`
709+
Denylist []string `json:"denylist,omitempty"`
710+
}
711+
712+
type RiskClass string
713+
714+
const (
715+
RiskClassDestructive RiskClass = "destructive"
716+
RiskClassNetworkEgress RiskClass = "network_egress"
717+
RiskClassCodeExecution RiskClass = "code_execution"
718+
RiskClassInstall RiskClass = "install"
719+
RiskClassSystemWrite RiskClass = "system_write"
720+
)

cmd/odek/repl.go

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,9 @@ package main
22

33
import (
44
"context"
5+
"errors"
56
"fmt"
7+
"io"
68
"os"
79
"os/signal"
810
"path/filepath"
@@ -186,7 +188,7 @@ func replCmd(args []string) error {
186188

187189
input, err := editor.ReadLine()
188190
if err != nil {
189-
if err.Error() == "EOF" || err.Error() == "interrupt" {
191+
if errors.Is(err, io.EOF) || err.Error() == "interrupt" {
190192
fmt.Fprintf(os.Stderr, "\n")
191193
break
192194
}

internal/loop/loop.go

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -234,7 +234,12 @@ func (e *Engine) runLoop(ctx context.Context, messages []llm.Message) (string, [
234234
}
235235
}
236236
skillMsg := llm.Message{Role: "system", Content: "# Relevant Skill\n\n" + skillContext}
237-
messages = append(messages[:insertIdx], append([]llm.Message{skillMsg}, messages[insertIdx:]...)...)
237+
// Pre-allocate and copy to avoid nested append allocations
238+
newMsgs := make([]llm.Message, 0, len(messages)+1)
239+
newMsgs = append(newMsgs, messages[:insertIdx]...)
240+
newMsgs = append(newMsgs, skillMsg)
241+
newMsgs = append(newMsgs, messages[insertIdx:]...)
242+
messages = newMsgs
238243
}
239244
}
240245
}

internal/mcpclient/client.go

Lines changed: 40 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,12 @@ type ServerConfig struct {
134134
Env map[string]string `json:"env,omitempty"`
135135
}
136136

137+
// lineResult carries the result of a single readLine from the reader goroutine.
138+
type lineResult struct {
139+
line string
140+
err error
141+
}
142+
137143
// ── Client ─────────────────────────────────────────────────────────────
138144

139145
// Client manages a connection to an external MCP server over stdio.
@@ -142,7 +148,8 @@ type Client struct {
142148
cmd *exec.Cmd
143149
stdin io.WriteCloser
144150
stdout *bufio.Reader
145-
done chan struct{} // closed when process exits
151+
lineCh chan lineResult // single-reader goroutine sends lines here
152+
done chan struct{} // closed when process exits
146153
mu sync.Mutex
147154
nextID int
148155
}
@@ -181,9 +188,13 @@ func New(name string, cfg ServerConfig) (*Client, error) {
181188
cmd: cmd,
182189
stdin: stdin,
183190
stdout: bufio.NewReader(stdout),
191+
lineCh: make(chan lineResult, 10),
184192
done: make(chan struct{}),
185193
}
186194

195+
// Start single-reader goroutine
196+
go c.readLoop()
197+
187198
// Monitor process exit in background
188199
go func() {
189200
cmd.Wait()
@@ -381,23 +392,39 @@ func (c *Client) call(ctx context.Context, method string, params json.RawMessage
381392
}
382393
}
383394

384-
// readLine reads a single line with context-based timeout.
385-
func (c *Client) readLine(ctx context.Context) (string, error) {
386-
type lineResult struct {
387-
line string
388-
err error
395+
// readLoop is a single reader goroutine that reads lines from stdout and
396+
// sends them on lineCh. It exits when stdout returns an error (EOF on pipe close).
397+
func (c *Client) readLoop() {
398+
defer close(c.lineCh)
399+
for {
400+
line, err := c.stdout.ReadString('\n')
401+
select {
402+
case c.lineCh <- lineResult{strings.TrimSpace(line), err}:
403+
default:
404+
// Channel full — drain the queue by consuming one stale entry,
405+
// then retry. This prevents a stuck server from deadlocking the
406+
// reader goroutine.
407+
<-c.lineCh
408+
c.lineCh <- lineResult{strings.TrimSpace(line), err}
409+
}
410+
if err != nil {
411+
return
412+
}
389413
}
414+
}
390415

391-
ch := make(chan lineResult, 1)
392-
go func() {
393-
l, err := c.stdout.ReadString('\n')
394-
ch <- lineResult{strings.TrimSpace(l), err}
395-
}()
396-
416+
// readLine reads a single line with context-based timeout.
417+
// Uses the single-reader goroutine (readLoop) so no goroutine leaks on context
418+
// cancellation — the goroutine is owned by the connection, not the RPC call.
419+
func (c *Client) readLine(ctx context.Context) (string, error) {
397420
select {
398421
case <-ctx.Done():
399422
return "", ctx.Err()
400-
case r := <-ch:
423+
case r, ok := <-c.lineCh:
424+
if !ok {
425+
// Channel closed — reader goroutine exited (process gone)
426+
return "", io.EOF
427+
}
401428
return r.line, r.err
402429
}
403430
}

0 commit comments

Comments
 (0)