P3.2: deny file tools against credential/config paths

ober

fd15b5f96126894657fe4dd5facb383f0349a235

diff --git a/src/jcode/tool/file.ss b/src/jcode/tool/file.ss
index e0c3483..a8ac8eb 100644
--- a/src/jcode/tool/file.ss
+++ b/src/jcode/tool/file.ss
@@ -98,15 +98,70 @@
                 (string-join (take lines 100) "\n")
                 (format "\n... (~a more entries, use glob to search)" (- total 100)))))))))))
 
+;;; P3.2: sensitive-path denylist.
+;;;
+;;; Limits prompt-injection blast radius: the LLM cannot use file tools
+;;; to exfiltrate credentials or rewrite jcode's own keystore even if
+;;; the conversation is hijacked. This is NOT a strict workspace
+;;; boundary — most paths are still allowed. We only block well-known
+;;; credential/config locations.
+;;;
+;;; Tightly mirrors external-llm.ss sensitive-deny-paths so the deny
+;;; surface stays consistent across the two tools.
+(def (jcode-home) (or (getenv "HOME") "/"))
+
+(def (sensitive-deny-fragments)
+  ;; Substring matches: if any of these appears in the absolute path,
+  ;; the file op is refused. Substring (not equality) catches files
+  ;; under these directories as well as the dirs themselves.
+  (list (path-join (jcode-home) ".ssh")
+        (path-join (jcode-home) ".aws")
+        (path-join (jcode-home) ".gnupg")
+        (path-join (jcode-home) ".netrc")
+        (path-join (jcode-home) ".config/gh")
+        (path-join (jcode-home) ".docker")
+        ;; jcode's own encrypted keystore
+        (path-join (jcode-home) ".jcode/keys.enc")
+        ;; project-root keys file (NEVER COMMIT — see CLAUDE.md)
+        "/jcode.json"
+        ;; system credentials
+        "/etc/shadow" "/etc/sudoers" "/etc/sudoers.d"))
+
+(def (normalize-path p)
+  ;; Expand leading ~/ to $HOME so denylist matches uniformly.
+  ;; Does NOT resolve symlinks (that would need an FFI to realpath(3));
+  ;; the denylist is substring-based, so a symlink directly into a
+  ;; sensitive dir would bypass this — acceptable for the prompt-injection
+  ;; threat model since the LLM rarely constructs symlink attacks.
+  (cond
+    ((not (string? p)) p)
+    ((string=? p "~") (jcode-home))
+    ((string-prefix? "~/" p)
+     (path-join (jcode-home) (substring p 2 (string-length p))))
+    (else p)))
+
+(def (sensitive-path? path)
+  (let ((norm (normalize-path path)))
+    (let loop ((frags (sensitive-deny-fragments)))
+      (cond
+        ((null? frags) #f)
+        ((and (string-contains norm (car frags)) #t) #t)
+        (else (loop (cdr frags)))))))
+
+(def (sensitive-path-error tool path)
+  (format "Error: ~a refused — ~a matches the credential/config denylist (P3.2 prompt-injection guard). If intentional, run the operation outside the agent."
+          tool path))
+
 ;;; READ ;;;
 
 (def (handle-read args)
   (let ((path (hash-ref args "path" #f)))
     (unless path (error 'read "Missing required parameter: path"))
     (log-debug logger "read" `((path . ,path)))
-    (if (file-exists? path)
-      (read-file-string path)
-      (format "Error: File not found: ~a" path))))
+    (cond
+      ((sensitive-path? path) (sensitive-path-error "read" path))
+      ((file-exists? path) (read-file-string path))
+      (else (format "Error: File not found: ~a" path)))))
 
 ;;; WRITE ;;;
 
@@ -133,6 +188,7 @@
     (unless content (error 'write "Missing required parameter: content"))
     (log-debug logger "write" `((path . ,path) (length . ,(string-length content))))
     (cond
+      ((sensitive-path? path) (sensitive-path-error "write" path))
       ((generated-artifact-error "write" path) => (lambda (e) e))
       (else
         (let ((dir (path-directory path)))
@@ -151,6 +207,7 @@
     (unless old-str (error 'edit "Missing required parameter: old_str"))
     (log-debug logger "edit" `((path . ,path)))
     (cond
+      ((sensitive-path? path) (sensitive-path-error "edit" path))
       ((generated-artifact-error "edit" path) => (lambda (e) e))
       ((not (file-exists? path))
        (format "Error: File not found: ~a" path))
@@ -190,6 +247,7 @@
     (unless blocks (error 'edit-block "Missing required parameter: blocks"))
     (log-debug logger "edit-block" `((path . ,path)))
     (cond
+      ((sensitive-path? path) (sensitive-path-error "edit_block" path))
       ((generated-artifact-error "edit_block" path) => (lambda (e) e))
       ((not (file-exists? path))
        (format "Error: File not found: ~a" path))
@@ -339,6 +397,7 @@
     (unless edits (error 'multi-edit "Missing required parameter: edits"))
     (log-debug logger "multi-edit" `((path . ,path)))
     (cond
+      ((sensitive-path? path) (sensitive-path-error "multi-edit" path))
       ((generated-artifact-error "multi-edit" path) => (lambda (e) e))
       ((not (file-exists? path))
        (format "Error: File not found: ~a" path))