Merge security/p3.2-sensitive-path-deny
ober
bd141a5bb2f5df56d355542d907e581dbc2d0eea
--- 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))