agent/tool: harden against off-target edits by local models

ober

d5c064f2817ead6a78c66250ea1e2cb47b890b5b

diff --git a/src/jcode/core/agent.ss b/src/jcode/core/agent.ss
index 5aeff0e..c988582 100644
--- a/src/jcode/core/agent.ss
+++ b/src/jcode/core/agent.ss
@@ -37,17 +37,20 @@ You have access to these tools:
 
 ~a
 
-IMPORTANT: Do NOT call the same tool repeatedly with the same or similar arguments.
-If you already retrieved information, use what you have. Use batch to parallelize.
-Use glob to find files by pattern instead of exploring directory by directory with ls.
+IMPORTANT RULES:
+- Source code lives in src/ with extension .ss. NEVER edit files in lib/ — those are generated build artifacts (.sls/.so).
+- ALWAYS read a file (with the read tool) before editing it. Do not invent file contents.
+- The edit tool requires old_str to match the file BYTE-FOR-BYTE. If edit returns 'old_str not found', do NOT retry with similar text — read the file to see real content, then edit.
+- Do NOT call the same tool repeatedly with the same or similar arguments. Use grep/glob to locate code instead of guessing paths.
+- Use batch to parallelize independent reads.
 
 When the user asks you to do something:
-1. Think about what tools you need
-2. Use tools to gather information or make changes
-3. Report back with results
+1. Use grep or glob to FIND the relevant source files in src/
+2. READ those files to see what's actually there
+3. Make minimal targeted edits with the edit tool
+4. Report back with results
 
-Be concise and helpful. When editing files, make minimal targeted changes.
-Prefer using the edit tool over write for modifying existing files."
+Be concise. Prefer edit over write for modifying existing files."
     (current-directory)
     (mode-label (current-mode))
     (format-tool-list)
diff --git a/src/jcode/tool/file.ss b/src/jcode/tool/file.ss
index 5a8ddc2..1025f4a 100644
--- a/src/jcode/tool/file.ss
+++ b/src/jcode/tool/file.ss
@@ -105,17 +105,36 @@
 
 ;;; WRITE ;;;
 
+(def (generated-artifact-error tool path)
+  ;; Block writes to generated build artifacts so the model can't "fix" the
+  ;; bug in the compiled output. Returns an error string or #f.
+  (let ((p (or path "")))
+    (cond
+      ((string-suffix? ".sls" p)
+       (format "Error: ~a refused — ~a is a generated .sls build artifact. Edit the corresponding .ss file under src/ instead." tool p))
+      ((string-suffix? ".so" p)
+       (format "Error: ~a refused — ~a is a compiled binary. Edit the corresponding .ss source under src/ instead." tool p))
+      ((or (string-contains p "/lib/jcode/")
+           (string-contains p "/lib/std/")
+           (string-prefix? "lib/" p)
+           (string-prefix? "./lib/" p))
+       (format "Error: ~a refused — ~a is under lib/ (generated). Edit the corresponding source under src/ instead." tool p))
+      (else #f))))
+
 (def (handle-write args)
   (let ((path    (hash-ref args "path" #f))
         (content (hash-ref args "content" #f)))
     (unless path    (error 'write "Missing required parameter: path"))
     (unless content (error 'write "Missing required parameter: content"))
     (log-debug logger "write" `((path . ,path) (length . ,(string-length content))))
-    (let ((dir (path-directory path)))
-      (when (and dir (not (equal? dir "")) (not (file-exists? dir)))
-        (mkdir-p dir)))
-    (write-file-string path content)
-    (format "Successfully wrote ~a bytes to ~a" (string-length content) path)))
+    (cond
+      ((generated-artifact-error "write" path) => (lambda (e) e))
+      (else
+        (let ((dir (path-directory path)))
+          (when (and dir (not (equal? dir "")) (not (file-exists? dir)))
+            (mkdir-p dir)))
+        (write-file-string path content)
+        (format "Successfully wrote ~a bytes to ~a" (string-length content) path)))))
 
 ;;; EDIT ;;;
 
@@ -126,15 +145,18 @@
     (unless path    (error 'edit "Missing required parameter: path"))
     (unless old-str (error 'edit "Missing required parameter: old_str"))
     (log-debug logger "edit" `((path . ,path)))
-    (if (file-exists? path)
-      (let* ((content     (read-file-string path))
-             (new-content (string-replace-first content old-str new-str)))
-        (if (equal? content new-content)
-          (format "Error: old_str not found in ~a" path)
-          (begin
-            (write-file-string path new-content)
-            (format "Successfully edited ~a" path))))
-      (format "Error: File not found: ~a" path))))
+    (cond
+      ((generated-artifact-error "edit" path) => (lambda (e) e))
+      ((not (file-exists? path))
+       (format "Error: File not found: ~a" path))
+      (else
+       (let* ((content     (read-file-string path))
+              (new-content (string-replace-first content old-str new-str)))
+         (if (equal? content new-content)
+           (format "Error: old_str not found in ~a" path)
+           (begin
+             (write-file-string path new-content)
+             (format "Successfully edited ~a" path))))))))
 
 (def (string-replace-first str old new)
   (let ((idx (find-substring str old)))
@@ -222,25 +244,28 @@
     (unless path  (error 'multi-edit "Missing required parameter: path"))
     (unless edits (error 'multi-edit "Missing required parameter: edits"))
     (log-debug logger "multi-edit" `((path . ,path)))
-    (if (not (file-exists? path))
-      (format "Error: File not found: ~a" path)
-      (let loop ((content (read-file-string path))
-                 (remaining (if (list? edits) edits (list edits)))
-                 (applied 0))
-        (if (null? remaining)
-          (begin
-            (write-file-string path content)
-            (format "Successfully applied ~a edit~a to ~a"
-              applied (if (= applied 1) "" "s") path))
-          (let* ((edit    (car remaining))
-                 (old-str (and (hash-table? edit) (hash-get edit "old_str")))
-                 (new-str (or (and (hash-table? edit) (hash-get edit "new_str")) "")))
-            (if (not old-str)
-              (loop content (cdr remaining) applied)
-              (let ((new-content (string-replace-first content old-str new-str)))
-                (if (equal? content new-content)
-                  (format "Error: old_str not found in ~a: ~s" path old-str)
-                  (loop new-content (cdr remaining) (+ applied 1)))))))))))
+    (cond
+      ((generated-artifact-error "multi-edit" path) => (lambda (e) e))
+      ((not (file-exists? path))
+       (format "Error: File not found: ~a" path))
+      (#t
+       (let loop ((content (read-file-string path))
+                  (remaining (if (list? edits) edits (list edits)))
+                  (applied 0))
+         (if (null? remaining)
+           (begin
+             (write-file-string path content)
+             (format "Successfully applied ~a edit~a to ~a"
+               applied (if (= applied 1) "" "s") path))
+           (let* ((edit    (car remaining))
+                  (old-str (and (hash-table? edit) (hash-get edit "old_str")))
+                  (new-str (or (and (hash-table? edit) (hash-get edit "new_str")) "")))
+             (if (not old-str)
+               (loop content (cdr remaining) applied)
+               (let ((new-content (string-replace-first content old-str new-str)))
+                 (if (equal? content new-content)
+                   (format "Error: old_str not found in ~a: ~s" path old-str)
+                   (loop new-content (cdr remaining) (+ applied 1))))))))))))
 
 (def (make-multi-edit-schema)
   (let ((schema     (make-hash-table))
@@ -283,11 +308,12 @@
     (let* ((path    (or path-override (extract-patch-target patch-str)))
            (content (if (file-exists? path) (read-file-string path) ""))
            (result  (apply-unified-patch content patch-str)))
-      (if (string? result)
-        (begin
-          (write-file-string path result)
-          (format "Successfully applied patch to ~a" path))
-        (format "Error applying patch: ~a" (cdr result))))))
+      (cond
+        ((generated-artifact-error "patch" path) => (lambda (e) e))
+        ((string? result)
+         (write-file-string path result)
+         (format "Successfully applied patch to ~a" path))
+        (#t (format "Error applying patch: ~a" (cdr result)))))))
 
 (def (extract-patch-target patch-str)
   ;; Find "+++ b/path" or "+++ path" line