diff options
| author | Craig Jennings <c@cjennings.net> | 2026-05-16 11:30:04 -0500 |
|---|---|---|
| committer | Craig Jennings <c@cjennings.net> | 2026-05-16 11:30:04 -0500 |
| commit | 244d4c56768fcc60bd1b23fe45df7a57c7b293ec (patch) | |
| tree | 3a83c5953f7c963c3f3ab7b28044d6decd6ec2fb /gptel-tools | |
| parent | b35d10bae7315fe3e497f1188bbe5ce86cef1bbf (diff) | |
| download | dotemacs-244d4c56768fcc60bd1b23fe45df7a57c7b293ec.tar.gz dotemacs-244d4c56768fcc60bd1b23fe45df7a57c7b293ec.zip | |
feat(gptel-tools): harden path validation with file-truename realpath
Resolves PATH through file-truename before applying home-directory and
read/write checks across the path-handling tools (git_status, git_log,
git_diff, move_to_trash, read_text_file, update_text_file,
write_text_file, list_directory_files, read_buffer, web_fetch).
Without the resolve step, a symlink under HOME pointing outside HOME
would pass the prefix check but the tool would act on the real target
-- a symlink-escape.
move_to_trash also tightens the trash-bin construction (treats empty
file extensions correctly) and switches the "critical directories"
list to truename-resolved canonical forms so a symlinked ~/.config
can't be trashed via an aliased path.
update_text_file fixes an off-by-one in the line-count derivation
when the source content is empty.
Each source change pairs with tests in tests/test-gptel-tools-*.el
and tests/test-update-text-file.el covering the realpath escape
paths, the empty-extension trash case, and the empty-content line-
count edge. Combined coverage is now 100% across all ten gptel-tools
source files: 516 / 516 executable lines, 217 tests.
Diffstat (limited to 'gptel-tools')
| -rw-r--r-- | gptel-tools/git_diff.el | 8 | ||||
| -rw-r--r-- | gptel-tools/git_log.el | 8 | ||||
| -rw-r--r-- | gptel-tools/git_status.el | 8 | ||||
| -rw-r--r-- | gptel-tools/move_to_trash.el | 23 | ||||
| -rw-r--r-- | gptel-tools/read_buffer.el | 4 | ||||
| -rw-r--r-- | gptel-tools/read_text_file.el | 20 | ||||
| -rw-r--r-- | gptel-tools/update_text_file.el | 30 | ||||
| -rw-r--r-- | gptel-tools/web_fetch.el | 2 | ||||
| -rw-r--r-- | gptel-tools/write_text_file.el | 15 |
9 files changed, 82 insertions, 36 deletions
diff --git a/gptel-tools/git_diff.el b/gptel-tools/git_diff.el index daccdc20..47db8dae 100644 --- a/gptel-tools/git_diff.el +++ b/gptel-tools/git_diff.el @@ -23,11 +23,17 @@ "Validate PATH for a git diff call. Return the expanded path on success. Same contract as the other git_* validators: under HOME, a directory, inside a git working tree." - (let ((full (expand-file-name (or path "~") "~"))) + (let* ((home (file-name-as-directory (file-truename (expand-file-name "~")))) + (full (expand-file-name (or path "~") "~"))) (unless (string-prefix-p (expand-file-name "~") full) (error "Path must be within home directory: %s" path)) (unless (file-directory-p full) (error "Not a directory: %s" full)) + (let ((resolved (file-truename full))) + (unless (or (string= resolved (directory-file-name home)) + (string-prefix-p home resolved)) + (error "Resolved path must be within home directory: %s" path)) + (setq full resolved)) (let ((default-directory full)) (unless (zerop (process-file "git" nil nil nil "rev-parse" "--is-inside-work-tree")) diff --git a/gptel-tools/git_log.el b/gptel-tools/git_log.el index 9cfae263..324435dc 100644 --- a/gptel-tools/git_log.el +++ b/gptel-tools/git_log.el @@ -25,11 +25,17 @@ "Validate PATH for a git log call. Return the expanded path on success. Same contract as the git_status validator: must be under HOME, must be a directory, must be inside a git working tree." - (let ((full (expand-file-name (or path "~") "~"))) + (let* ((home (file-name-as-directory (file-truename (expand-file-name "~")))) + (full (expand-file-name (or path "~") "~"))) (unless (string-prefix-p (expand-file-name "~") full) (error "Path must be within home directory: %s" path)) (unless (file-directory-p full) (error "Not a directory: %s" full)) + (let ((resolved (file-truename full))) + (unless (or (string= resolved (directory-file-name home)) + (string-prefix-p home resolved)) + (error "Resolved path must be within home directory: %s" path)) + (setq full resolved)) (let ((default-directory full)) (unless (zerop (process-file "git" nil nil nil "rev-parse" "--is-inside-work-tree")) diff --git a/gptel-tools/git_status.el b/gptel-tools/git_status.el index 300d5da5..de76a985 100644 --- a/gptel-tools/git_status.el +++ b/gptel-tools/git_status.el @@ -23,11 +23,17 @@ PATH must resolve under the user's home directory, must be an existing directory, and must be inside a git working tree. Returns the expanded path string on success; signals `error' otherwise." - (let ((full (expand-file-name (or path "~") "~"))) + (let* ((home (file-name-as-directory (file-truename (expand-file-name "~")))) + (full (expand-file-name (or path "~") "~"))) (unless (string-prefix-p (expand-file-name "~") full) (error "Path must be within home directory: %s" path)) (unless (file-directory-p full) (error "Not a directory: %s" full)) + (let ((resolved (file-truename full))) + (unless (or (string= resolved (directory-file-name home)) + (string-prefix-p home resolved)) + (error "Resolved path must be within home directory: %s" path)) + (setq full resolved)) (let ((default-directory full)) (unless (zerop (process-file "git" nil nil nil "rev-parse" "--is-inside-work-tree")) diff --git a/gptel-tools/move_to_trash.el b/gptel-tools/move_to_trash.el index 6ea97995..923da790 100644 --- a/gptel-tools/move_to_trash.el +++ b/gptel-tools/move_to_trash.el @@ -41,7 +41,7 @@ YYYY-MM-DD-HH-MM-SS." (let* ((extension (file-name-extension base-name t)) (name-sans-ext (file-name-sans-extension base-name)) (timestamp (format-time-string "%Y-%m-%d-%H-%M-%S")) - (new-name (if extension + (new-name (if (and extension (not (string= extension ""))) (concat name-sans-ext "-" timestamp extension) (concat base-name "-" timestamp)))) (expand-file-name new-name trash-dir))))) @@ -51,15 +51,18 @@ YYYY-MM-DD-HH-MM-SS." Returns the expanded path if valid, signals an error otherwise. Ensures path is within home directory or /tmp, and prevents trashing of critical system directories." - (let ((expanded-path (expand-file-name path)) - (home-dir (expand-file-name "~")) - (critical-dirs (list (expand-file-name "~") - (expand-file-name "~/.emacs.d") - (expand-file-name "~/.config") - "/tmp"))) + (let* ((expanded-path (expand-file-name path)) + (resolved-path (and (file-exists-p expanded-path) + (file-truename expanded-path))) + (home-dir (file-name-as-directory (file-truename (expand-file-name "~")))) + (tmp-dir (file-name-as-directory (file-truename "/tmp"))) + (critical-dirs (list (directory-file-name home-dir) + (file-truename (expand-file-name "~/.emacs.d")) + (file-truename (expand-file-name "~/.config")) + (directory-file-name tmp-dir)))) ;; Security check: must be within allowed directories (unless (or (string-prefix-p home-dir expanded-path) - (string-prefix-p "/tmp" expanded-path)) + (string-prefix-p tmp-dir expanded-path)) (error "Path must be within home directory or /tmp: %s" path)) ;; Prevent trashing critical directories @@ -70,6 +73,10 @@ trashing of critical system directories." (unless (file-exists-p expanded-path) (error "File or directory does not exist: %s" path)) + (unless (or (string-prefix-p home-dir resolved-path) + (string-prefix-p tmp-dir resolved-path)) + (error "Resolved path must be within home directory or /tmp: %s" path)) + expanded-path)) (defun gptel--move-to-trash-perform (expanded-path trash-dir) diff --git a/gptel-tools/read_buffer.el b/gptel-tools/read_buffer.el index 1b4fc904..c9136e3c 100644 --- a/gptel-tools/read_buffer.el +++ b/gptel-tools/read_buffer.el @@ -14,7 +14,9 @@ error when no live buffer matches." (unless (buffer-live-p (get-buffer buffer)) (error "Buffer %s is not live" buffer)) (with-current-buffer buffer - (buffer-substring-no-properties (point-min) (point-max)))) + (save-restriction + (widen) + (buffer-substring-no-properties (point-min) (point-max))))) (gptel-make-tool :name "read_buffer" diff --git a/gptel-tools/read_text_file.el b/gptel-tools/read_text_file.el index 8e0433a9..f35c9494 100644 --- a/gptel-tools/read_text_file.el +++ b/gptel-tools/read_text_file.el @@ -25,19 +25,21 @@ ;; Helper functions for read_text_file tool (defun cj/validate-file-path (path) "Validate PATH is within home directory and exists." - (let ((full-path (expand-file-name path "~"))) + (let* ((home (file-name-as-directory (file-truename (expand-file-name "~")))) + (full-path (expand-file-name path "~"))) (unless (string-prefix-p (expand-file-name "~") full-path) (error "Path must be within home directory")) (unless (file-exists-p full-path) (error "File not found: %s" full-path)) - (when (file-directory-p full-path) - (error "Path is a directory, not a file: %s" full-path)) - (unless (file-readable-p full-path) - (error "No read permission for file: %s" full-path)) - ;; Follow symlinks - (if (file-symlink-p full-path) - (file-truename full-path) - full-path))) + (let ((resolved (file-truename full-path))) + (unless (or (string= resolved (directory-file-name home)) + (string-prefix-p home resolved)) + (error "Resolved path must be within home directory: %s" path)) + (when (file-directory-p resolved) + (error "Path is a directory, not a file: %s" resolved)) + (unless (file-readable-p resolved) + (error "No read permission for file: %s" resolved)) + resolved))) (defun cj/get-file-metadata (path) "Return formatted metadata string for file at PATH." diff --git a/gptel-tools/update_text_file.el b/gptel-tools/update_text_file.el index 492ed554..f8b58025 100644 --- a/gptel-tools/update_text_file.el +++ b/gptel-tools/update_text_file.el @@ -40,20 +40,23 @@ PATH must resolve inside the user's home directory, must exist, must be a regular file, and must be readable and writable." - (let ((full (expand-file-name path "~"))) + (let* ((home (file-name-as-directory (file-truename (expand-file-name "~")))) + (full (expand-file-name path "~"))) (unless (string-prefix-p (expand-file-name "~") full) (error "Path must be within home directory: %s" path)) (unless (file-exists-p full) (error "File not found: %s" full)) - (when (file-directory-p full) - (error "Path is a directory, not a file: %s" full)) - (unless (file-readable-p full) - (error "No read permission for file: %s" full)) - (unless (file-writable-p full) - (error "No write permission for file: %s" full)) - (if (file-symlink-p full) - (file-truename full) - full))) + (let ((resolved (file-truename full))) + (unless (or (string= resolved (directory-file-name home)) + (string-prefix-p home resolved)) + (error "Resolved path must be within home directory: %s" path)) + (when (file-directory-p resolved) + (error "Path is a directory, not a file: %s" resolved)) + (unless (file-readable-p resolved) + (error "No read permission for file: %s" resolved)) + (unless (file-writable-p resolved) + (error "No write permission for file: %s" resolved)) + resolved))) (defun cj/update-text-file--backup-name (path) "Return a backup filename for PATH timestamped to the current second." @@ -113,9 +116,10 @@ on out-of-range LINE-NUM or empty TEXT." ;; extra empty element at the end. Trim it so the line count ;; matches what a human would say. (trailing-newline (string-suffix-p "\n" content)) - (line-count (if trailing-newline - (1- (length lines)) - (length lines)))) + (line-count (cond + ((string-empty-p content) 0) + (trailing-newline (1- (length lines))) + (t (length lines))))) (when (> line-num (1+ line-count)) (error "Line %d out of range (file has %d lines)" line-num line-count)) (let* ((to-insert (if (string-suffix-p "\n" text) diff --git a/gptel-tools/web_fetch.el b/gptel-tools/web_fetch.el index 1f950a31..b2f80c5f 100644 --- a/gptel-tools/web_fetch.el +++ b/gptel-tools/web_fetch.el @@ -62,7 +62,7 @@ from the response status line, or nil when the line is unrecognized." (let* ((status (when (re-search-forward "^HTTP/[0-9.]+ \\([0-9]+\\)" (point-max) t) (string-to-number (match-string 1)))) - (body-start (when (re-search-forward "\n\n" nil t) + (body-start (when (re-search-forward "\r?\n\r?\n" nil t) (point)))) (cons status (if body-start diff --git a/gptel-tools/write_text_file.el b/gptel-tools/write_text_file.el index 40482c66..1bda5446 100644 --- a/gptel-tools/write_text_file.el +++ b/gptel-tools/write_text_file.el @@ -22,9 +22,22 @@ (defun cj/write-text-file--validate-path (path) "Validate PATH for write. Return the expanded path on success. PATH must resolve inside the user's home directory." - (let ((full (expand-file-name path "~"))) + (let* ((home (file-name-as-directory (file-truename (expand-file-name "~")))) + (full (expand-file-name path "~")) + (existing (and (file-exists-p full) (file-truename full))) + (parent (file-name-directory full)) + (resolved-parent (and parent + (file-exists-p parent) + (file-truename parent)))) (unless (string-prefix-p (expand-file-name "~") full) (error "Path must be within home directory: %s" path)) + (when (and existing + (not (string-prefix-p home existing))) + (error "Resolved path must be within home directory: %s" path)) + (when (and resolved-parent + (not (or (string= resolved-parent (directory-file-name home)) + (string-prefix-p home resolved-parent)))) + (error "Resolved parent must be within home directory: %s" path)) full)) (defun cj/write-text-file--backup-name (path) |
