-
-
Notifications
You must be signed in to change notification settings - Fork 78
Expand file tree
/
Copy pathvalid_paths.clj
More file actions
287 lines (243 loc) · 12.1 KB
/
valid_paths.clj
File metadata and controls
287 lines (243 loc) · 12.1 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
(ns clojure-mcp.utils.valid-paths
(:require [clojure.string :as str]
[clojure.java.io :as io]
[clojure-mcp.config :as config]))
(defn validate-path
"Validates that a path is within allowed directories.
Parameters:
- path: The path to validate (can be relative or absolute)
- current-working-directory: The absolute path of the current working directory
- allowed-directories: A sequence of absolute paths representing allowed directories
Returns:
- The normalized absolute path if valid
- Throws an exception if the path is not within any allowed directory"
[path current-working-directory allowed-directories]
(let [cwd-file (io/file current-working-directory)
path-file (io/file path)]
;; Validate current working directory is absolute
(when-not (.isAbsolute cwd-file)
(throw (ex-info (str "Current working directory must be absolute \nCWD:" current-working-directory)
{:cwd current-working-directory})))
;; Validate all allowed directories are absolute
(let [non-absolute (filter #(not (.isAbsolute (io/file %))) allowed-directories)]
(when (seq non-absolute)
(throw (ex-info "All allowed directories must be absolute paths"
{:non-absolute-dirs non-absolute}))))
;; Normalize the allowed directories upfront
(let [canonical-allowed-dirs (map #(.getCanonicalPath (io/file %))
(filter identity allowed-directories))
;; Normalize the path (resolve relative paths against CWD)
abs-path-file (if (.isAbsolute path-file)
path-file
(io/file cwd-file path))
normalized-path (.getCanonicalPath abs-path-file)]
;; Check if path is within any allowed directory
(if (or (empty? canonical-allowed-dirs) ;; If no allowed directories, accept all paths
(some (fn [allowed-canonical]
;; Check if normalized path starts with allowed dir plus separator
;; or if it equals the allowed dir exactly
(or (= normalized-path allowed-canonical)
(str/starts-with? normalized-path
(str allowed-canonical
(System/getProperty "file.separator")))))
canonical-allowed-dirs))
normalized-path
(throw (ex-info (str "Your path:\n" normalized-path
" is outside the allowed directories:\n" (str/join "\n" allowed-directories))
{:path normalized-path
:allowed-dirs allowed-directories}))))))
(defn path-exists? [path]
(.exists (io/file path)))
(defn- clojure-source-ext?
"Returns true if the file path has a Clojure source extension (.clj, .cljs, .cljc, .cljd)
that follows the dash-to-underscore filename convention."
[file-path]
(when file-path
(let [lower-path (str/lower-case file-path)]
(or (str/ends-with? lower-path ".clj")
(str/ends-with? lower-path ".cljs")
(str/ends-with? lower-path ".cljc")
(str/ends-with? lower-path ".cljd")))))
(defn- try-dash-to-underscore-correction
"When a validated path doesn't exist and has a Clojure source extension,
tries replacing dashes with underscores in the filename part only
(not directory components). Returns the re-validated corrected path
if the file exists, nil otherwise."
[validated-path current-dir allowed-dirs]
(when (clojure-source-ext? validated-path)
(let [file (io/file validated-path)
parent (.getParentFile file)
filename (.getName file)
corrected-filename (str/replace filename "-" "_")]
(when (not= filename corrected-filename)
(let [corrected-file (if parent
(io/file parent corrected-filename)
(io/file corrected-filename))
corrected-path (.getPath corrected-file)]
(when (path-exists? corrected-path)
;; Re-validate for defense-in-depth (symlink protection)
(validate-path corrected-path current-dir allowed-dirs)))))))
(defn validate-path-with-client
"Validates a path using settings from the nrepl-client.
Parameters:
- path: The path to validate (can be relative or absolute)
- nrepl-client-map: The nREPL client map (dereferenced atom)
Returns:
- The normalized absolute path if valid
- Throws an exception if the path is invalid or if required settings are missing
When the validated path doesn't exist and has a Clojure source extension
(.clj, .cljs, .cljc, .cljd), tries replacing dashes with underscores in the
filename (not directory components) and returns the corrected path if it exists."
[path nrepl-client]
(let [current-dir (config/get-nrepl-user-dir nrepl-client)
allowed-dirs (config/get-allowed-directories nrepl-client)]
(when-not current-dir
(throw (ex-info "Missing nrepl-user-dir in config"
{:client-keys (keys nrepl-client)})))
(when-not allowed-dirs
(throw (ex-info "Missing allowed-directories in config"
{:client-keys (keys nrepl-client)})))
(let [validated (validate-path path current-dir allowed-dirs)]
(if (path-exists? validated)
validated
(or (try-dash-to-underscore-correction validated current-dir allowed-dirs)
validated)))))
(defn- babashka-shebang?
[file-path]
(when (path-exists? file-path)
(try
(with-open [r (io/reader file-path)]
(let [line (-> r line-seq first)]
(and line
(re-matches #"^#!/[^\s]+/(bb|env\s{1,3}bb)(\s.*)?$" line))))
(catch Exception _ false))))
(defn clojure-file?
"Checks if a file path has a Clojure-related extension or Babashka shebang.
Supported extensions:
- .clj (Clojure)
- .cljs (ClojureScript)
- .cljc (Clojure/ClojureScript shared)
- .cljd (ClojureDart)
- .bb (Babashka)
- .edn (Extensible Data Notation)
- .lpy (Librepl)
Also detects files starting with a Babashka shebang (`bb`)."
[file-path]
(when file-path
(let [lower-path (str/lower-case file-path)]
(or (str/ends-with? lower-path ".clj")
(str/ends-with? lower-path ".cljs")
(str/ends-with? lower-path ".cljc")
(str/ends-with? lower-path ".cljd")
(str/ends-with? lower-path ".bb")
(str/ends-with? lower-path ".lpy")
(str/ends-with? lower-path ".edn")
(babashka-shebang? file-path)))))
(defn extract-paths-from-bash-command
"Extract file/directory paths from a bash command string.
Returns a set of path strings that the command might access.
Only extracts paths that look like filesystem paths:
- Absolute paths: /path/to/file
- Relative paths: ./file, ../dir/file
- Home directory: ~/file
- Current/parent directory: . or ..
- Quoted paths with spaces: '/path with spaces'
Avoids false positives like search patterns in quotes,
regex patterns, URLs, etc.
Examples:
(extract-paths-from-bash-command \"ls /usr/bin\")
=> #{\"usr/bin\"}
(extract-paths-from-bash-command \"find . -name '*.clj'\")
=> #{\".\"}
(extract-paths-from-bash-command \"echo 'not/a/path'\")
=> #{}"
[command]
(when-not (str/blank? command)
(let [;; Regex patterns for different path types
absolute-pattern #"(?<=\s|^)/[^\s\"'`;<>|&]+(?=\s|$)" ; /path/to/file
relative-pattern #"(?<=\s|^)\.{1,2}/[^\s\"'`;<>|&]*(?=\s|$)" ; ./file ../dir
home-pattern #"(?<=\s|^)~/[^\s\"'`;<>|&]*(?=\s|$)" ; ~/file
current-parent #"(?<=\s|^)\.{1,2}(?=\s|$)" ; . or ..
quoted-pattern #"[\"']([^\"']+)[\"']" ; "quoted content"
;; Extract all matches
absolute-paths (re-seq absolute-pattern command)
relative-paths (re-seq relative-pattern command)
home-paths (re-seq home-pattern command)
dot-paths (re-seq current-parent command)
;; For quoted strings, only include if they look like filesystem paths
quoted-paths (->> (re-seq quoted-pattern command)
(map second) ; Get capture group content
(filter #(and (str/includes? % "/")
(or (str/starts-with? % "/")
(str/starts-with? % "./")
(str/starts-with? % "../")
(str/starts-with? % "~/")))))]
(->> (concat absolute-paths relative-paths home-paths dot-paths quoted-paths)
(remove str/blank?)
set))))
(defn preprocess-path
"Preprocess a path extracted from a bash command for validation.
Handles:
- Home directory expansion: ~/file -> /home/user/file
- Leaves other paths unchanged for validate-path to handle
Examples:
(preprocess-path \"~/config\") => \"/home/user/config\"
(preprocess-path \"./file\") => \"./file\""
[path]
(cond
;; Expand home directory paths
(str/starts-with? path "~/")
(str (System/getProperty "user.home") (subs path 1))
;; Handle bare ~ (home directory itself)
(= path "~")
(System/getProperty "user.home")
;; All other paths pass through unchanged
:else path))
(defn validate-bash-command-paths
"Extract and validate all filesystem paths from a bash command.
This function combines path extraction, preprocessing, and validation
to ensure a bash command only accesses allowed directories.
Parameters:
- command: The bash command string to analyze
- current-working-directory: The current working directory (absolute path)
- allowed-directories: Sequence of allowed directory paths
Returns:
- Set of normalized absolute paths if all paths are valid
- Empty set if no paths found in command (command is safe)
Throws:
- Exception if any path is invalid, with details about failed paths
Examples:
(validate-bash-command-paths \"ls ./src /tmp\" \"/home/user\" [\"/home/user\" \"/tmp\"])
=> #{absolute-path-to-src absolute-path-to-tmp}"
[command current-working-directory allowed-directories]
(if-let [extracted-paths (extract-paths-from-bash-command command)]
(let [;; Preprocess all paths (expand ~ etc.)
preprocessed-paths (map preprocess-path extracted-paths)
;; Validate each path and collect results
validation-results (for [path preprocessed-paths]
(try
{:path path
:original (first (filter #(= (preprocess-path %) path) extracted-paths))
:status :valid
:normalized (validate-path path current-working-directory allowed-directories)}
(catch Exception e
{:path path
:original (first (filter #(= (preprocess-path %) path) extracted-paths))
:status :invalid
:error (.getMessage e)})))
;; Separate valid and invalid results
valid-results (filter #(= (:status %) :valid) validation-results)
invalid-results (filter #(= (:status %) :invalid) validation-results)]
(if (empty? invalid-results)
;; All paths valid - return normalized paths
(set (map :normalized valid-results))
;; Some paths invalid - throw with details
(let [error-details (->> invalid-results
(map #(str "'" (:original %) "' -> " (:error %)))
(str/join "\n"))]
(throw (ex-info (str "Invalid paths in bash command:\n" error-details)
{:command command
:invalid-paths invalid-results
:valid-paths valid-results})))))
;; No paths found - return empty set (command is safe)
#{}))