Skip to content

Commit 82521ab

Browse files
committed
v0.17.0: security fixes — O_NOFOLLOW, atomic writes, mutex protection, path confinement
- Fix #1+10: readFileTool uses os.OpenFile with O_NOFOLLOW (eliminates stat-then-open TOCTOU race and symlink following) - Fix #3: patchTool uses atomic temp-file + rename for writes - Fix #6: writeFileTool has optional restrictToCWD flag (rejects ../ escape) - Fix #8: sync.Mutex on shellTool.trustedClasses and TTYApprover.TrustedClasses - Fix: context trimmer drops complete assistant+tool groups (prevents orphaned tool messages that DeepSeek rejects) - Fix: config loader tests isolate HOME to avoid leaking real config - Fix: truncate() handles negative/zero limits without panic - Tests: odek auto-generated 15 buildSubagentPrompt contract tests
1 parent 090d1ba commit 82521ab

8 files changed

Lines changed: 745 additions & 34 deletions

File tree

cmd/odek/file_tool.go

Lines changed: 94 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,13 @@ import (
44
"bufio"
55
"encoding/json"
66
"fmt"
7+
"io"
78
"os"
89
"path/filepath"
910
"regexp"
1011
"sort"
1112
"strings"
13+
"syscall"
1214

1315
"github.com/BackendStack21/kode/internal/danger"
1416
)
@@ -94,25 +96,31 @@ func (t *readFileTool) Call(argsJSON string) (string, error) {
9496
return jsonError(err.Error())
9597
}
9698

97-
// Check file exists and is not a directory
98-
info, err := os.Stat(args.Path)
99+
// Security: open the file without following symlinks (O_NOFOLLOW).
100+
// This atomically handles the existence check, type check, and open
101+
// in a single syscall — eliminating the TOCTOU window between
102+
// os.Stat (check) and os.Open (use). If the path is a symlink, the
103+
// open fails with ELOOP.
104+
f, err := os.OpenFile(args.Path, os.O_RDONLY|syscall.O_NOFOLLOW, 0)
99105
if err != nil {
100106
if os.IsNotExist(err) {
101107
return jsonError(fmt.Sprintf("file not found: %s", args.Path))
102108
}
103-
return jsonError(fmt.Sprintf("cannot access %q: %v", args.Path, err))
104-
}
105-
if info.IsDir() {
106-
return jsonError(fmt.Sprintf("%q is a directory, not a file", args.Path))
109+
// ELOOP means the path is a symlink — refuse to follow it
110+
return jsonError(fmt.Sprintf("cannot open %q: %v", args.Path, err))
107111
}
112+
defer f.Close()
108113

109-
// Single pass: open → binary check from sample → seek → read+count
110-
f, err := os.Open(args.Path)
114+
// Check it's a regular file, not a directory
115+
info, err := f.Stat()
111116
if err != nil {
112-
return jsonError(fmt.Sprintf("cannot open %q: %v", args.Path, err))
117+
return jsonError(fmt.Sprintf("cannot stat %q: %v", args.Path, err))
118+
}
119+
if info.IsDir() {
120+
return jsonError(fmt.Sprintf("%q is a directory, not a file", args.Path))
113121
}
114-
defer f.Close()
115122

123+
// Single pass: binary check from sample → seek → read+count
116124
sample := make([]byte, 8192)
117125
n, _ := f.Read(sample)
118126
if isBinary(sample[:n]) {
@@ -140,7 +148,8 @@ func (t *readFileTool) Call(argsJSON string) (string, error) {
140148

141149
type writeFileTool struct {
142150
dangerousConfig danger.DangerousConfig
143-
trustedClasses map[danger.RiskClass]bool
151+
trustedClasses map[danger.RiskClass]bool
152+
restrictToCWD bool // when true, reject paths escaping the working directory
144153
}
145154

146155
func (t *writeFileTool) Name() string { return "write_file" }
@@ -188,6 +197,16 @@ func (t *writeFileTool) Call(argsJSON string) (string, error) {
188197
return jsonError("path is required")
189198
}
190199

200+
// Path confinement: when restrictToCWD is enabled, reject paths that
201+
// escape the working directory via ".." traversal or absolute paths.
202+
if t.restrictToCWD {
203+
resolved, err := confineToCWD(args.Path)
204+
if err != nil {
205+
return jsonError(err.Error())
206+
}
207+
args.Path = resolved
208+
}
209+
191210
// Security: classify and check write operation
192211
risk := danger.ClassifyPath(args.Path)
193212
if err := t.dangerousConfig.CheckOperation(danger.ToolOperation{
@@ -549,16 +568,23 @@ func (t *patchTool) Call(argsJSON string) (string, error) {
549568
return jsonError(err.Error())
550569
}
551570

552-
// Read the file
553-
data, err := os.ReadFile(args.Path)
571+
// Read the file without following symlinks
572+
f, err := os.OpenFile(args.Path, os.O_RDONLY|syscall.O_NOFOLLOW, 0)
554573
if err != nil {
555574
if os.IsNotExist(err) {
556575
return jsonError(fmt.Sprintf("file not found: %s", args.Path))
557576
}
558577
return jsonError(fmt.Sprintf("cannot read %q: %v", args.Path, err))
559578
}
579+
defer f.Close()
560580

561-
original := string(data)
581+
// Read content through the opened fd (not re-opening the path)
582+
var sb strings.Builder
583+
_, err = io.Copy(&sb, f)
584+
if err != nil {
585+
return jsonError(fmt.Sprintf("cannot read %q: %v", args.Path, err))
586+
}
587+
original := sb.String()
562588

563589
// Check that old_string exists
564590
if !strings.Contains(original, args.OldString) {
@@ -579,7 +605,35 @@ func (t *patchTool) Call(argsJSON string) (string, error) {
579605
truncateDiff(modified, 100),
580606
)
581607

582-
if err := os.WriteFile(args.Path, []byte(modified), 0644); err != nil {
608+
// Atomic write via temp file + rename to prevent TOCTOU symlink races.
609+
// The temp file is created in the same directory (same filesystem),
610+
// and os.Rename atomically replaces the directory entry without
611+
// following symlinks.
612+
dir := filepath.Dir(args.Path)
613+
tmpFile, err := os.CreateTemp(dir, ".tmp_patch_*")
614+
if err != nil {
615+
return jsonError(fmt.Sprintf("cannot create temp file: %v", err))
616+
}
617+
tmpPath := tmpFile.Name()
618+
619+
if _, err := tmpFile.Write([]byte(modified)); err != nil {
620+
tmpFile.Close()
621+
os.Remove(tmpPath)
622+
return jsonError(fmt.Sprintf("cannot write %q: %v", args.Path, err))
623+
}
624+
if err := tmpFile.Chmod(0644); err != nil {
625+
tmpFile.Close()
626+
os.Remove(tmpPath)
627+
return jsonError(fmt.Sprintf("cannot set permissions %q: %v", args.Path, err))
628+
}
629+
if err := tmpFile.Close(); err != nil {
630+
os.Remove(tmpPath)
631+
return jsonError(fmt.Sprintf("cannot close temp file: %v", err))
632+
}
633+
634+
// Atomic rename — replaces target directory entry without following symlinks
635+
if err := os.Rename(tmpPath, args.Path); err != nil {
636+
os.Remove(tmpPath)
583637
return jsonError(fmt.Sprintf("cannot write %q: %v", args.Path, err))
584638
}
585639

@@ -654,6 +708,31 @@ func readLinesWithCount(f *os.File, offset, limit int) (string, int, error) {
654708
return strings.TrimSuffix(out.String(), "\n"), lineNum, scanner.Err()
655709
}
656710

711+
// confineToCWD resolves path relative to the current working directory and
712+
// rejects paths that escape the working directory via ".." traversal or are
713+
// absolute paths outside the CWD. Returns the cleaned absolute path on success.
714+
func confineToCWD(path string) (string, error) {
715+
cwd, err := os.Getwd()
716+
if err != nil {
717+
return "", fmt.Errorf("cannot determine working directory: %v", err)
718+
}
719+
720+
// Resolve to absolute path
721+
var abs string
722+
if filepath.IsAbs(path) {
723+
abs = filepath.Clean(path)
724+
} else {
725+
abs = filepath.Join(cwd, path)
726+
}
727+
728+
// Check that the resolved path is within CWD
729+
if !strings.HasPrefix(abs, cwd+string(filepath.Separator)) && abs != cwd {
730+
return "", fmt.Errorf("path %q escapes the working directory", path)
731+
}
732+
733+
return abs, nil
734+
}
735+
657736
func truncateDiff(s string, maxLen int) string {
658737
// Take first line for diff display
659738
firstLine := strings.SplitN(s, "\n", 2)[0]

cmd/odek/main.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1080,7 +1080,7 @@ func builtinTools(dc danger.DangerousConfig, sm *skills.SkillManager, approver d
10801080
timeout: 120 * time.Second,
10811081
},
10821082
&readFileTool{dangerousConfig: dc},
1083-
&writeFileTool{dangerousConfig: dc},
1083+
&writeFileTool{dangerousConfig: dc, restrictToCWD: true},
10841084
&searchFilesTool{dangerousConfig: dc},
10851085
&patchTool{dangerousConfig: dc},
10861086
newBrowserTool(dc),

cmd/odek/shell.go

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import (
66
"fmt"
77
"os/exec"
88
"strings"
9+
"sync"
910

1011
"github.com/BackendStack21/kode/internal/danger"
1112
)
@@ -55,6 +56,7 @@ type shellTool struct {
5556
// trustedClasses caches user-approved risk classes for this process.
5657
// Set when user presses T (trust this session) at the prompt.
5758
trustedClasses map[danger.RiskClass]bool
59+
trustedMu sync.Mutex
5860

5961
// ttyPath is the path to the terminal device for approval prompts.
6062
// Overridden in tests to mock user input. Only used when approver is nil.
@@ -159,9 +161,11 @@ func (t *shellTool) promptUser(cmd, description string) error {
159161
approver := t.approver
160162
if approver == nil {
161163
ttyApprover := danger.NewTTYApprover(&t.dangerousConfig)
164+
t.trustedMu.Lock()
162165
if t.trustedClasses != nil {
163-
ttyApprover.TrustedClasses = t.trustedClasses
166+
ttyApprover.SetTrustedClasses(t.trustedClasses)
164167
}
168+
t.trustedMu.Unlock()
165169
if t.ttyPath != "" {
166170
ttyApprover.TTYPath = t.ttyPath
167171
}
@@ -172,7 +176,9 @@ func (t *shellTool) promptUser(cmd, description string) error {
172176
if err == nil {
173177
// Sync trusted classes back if using TTYApprover
174178
if tty, ok := approver.(*danger.TTYApprover); ok {
179+
t.trustedMu.Lock()
175180
t.trustedClasses = tty.TrustedClasses
181+
t.trustedMu.Unlock()
176182
}
177183
}
178184
return err

cmd/odek/subagent.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -466,6 +466,9 @@ func extractFilesChanged(messages []llm.Message) []string {
466466
}
467467

468468
func truncate(s string, n int) string {
469+
if n <= 0 {
470+
return "…"
471+
}
469472
runes := []rune(s)
470473
if len(runes) <= n {
471474
return s

0 commit comments

Comments
 (0)