@@ -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
141149type 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
146155func (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+
657736func truncateDiff (s string , maxLen int ) string {
658737 // Take first line for diff display
659738 firstLine := strings .SplitN (s , "\n " , 2 )[0 ]
0 commit comments