@@ -180,17 +180,161 @@ ai_start() {
180180 (cd " $path " && _run_configured_command " $GTR_AI_CMD " " $@ " )
181181}
182182
183+ # Split a config-supplied command string without shell evaluation.
184+ # Populates the global _GTR_PARSED_CMD_ARGS array.
185+ _parse_configured_command () {
186+ local command_string=" $1 "
187+ local length=" ${# command_string} "
188+ local i=0 char=" " token=" " state=" normal" escaped=0 token_started=0
189+
190+ _GTR_PARSED_CMD_ARGS=()
191+
192+ while [ " $i " -lt " $length " ]; do
193+ char=" ${command_string: $i : 1} "
194+
195+ case " $state " in
196+ normal)
197+ if [ " $escaped " -eq 1 ]; then
198+ token=" ${token}${char} "
199+ token_started=1
200+ escaped=0
201+ else
202+ case " $char " in
203+ " \\ " )
204+ escaped=1
205+ token_started=1
206+ ;;
207+ " " | $' \t ' | $' \n ' )
208+ if [ " $token_started " -eq 1 ]; then
209+ _GTR_PARSED_CMD_ARGS+=(" $token " )
210+ token=" "
211+ token_started=0
212+ fi
213+ ;;
214+ " '" )
215+ state=" single"
216+ token_started=1
217+ ;;
218+ ' "' )
219+ state=" double"
220+ token_started=1
221+ ;;
222+ * )
223+ token=" ${token}${char} "
224+ token_started=1
225+ ;;
226+ esac
227+ fi
228+ ;;
229+ single)
230+ if [ " $char " = " '" ]; then
231+ state=" normal"
232+ else
233+ token=" ${token}${char} "
234+ fi
235+ ;;
236+ double)
237+ if [ " $escaped " -eq 1 ]; then
238+ token=" ${token}${char} "
239+ token_started=1
240+ escaped=0
241+ else
242+ case " $char " in
243+ " \\ " )
244+ escaped=1
245+ token_started=1
246+ ;;
247+ ' "' )
248+ state=" normal"
249+ ;;
250+ * )
251+ token=" ${token}${char} "
252+ token_started=1
253+ ;;
254+ esac
255+ fi
256+ ;;
257+ esac
258+
259+ i=$(( i + 1 ))
260+ done
261+
262+ [ " $escaped " -eq 0 ] || return 1
263+ [ " $state " = " normal" ] || return 1
264+
265+ if [ " $token_started " -eq 1 ]; then
266+ _GTR_PARSED_CMD_ARGS+=(" $token " )
267+ fi
268+
269+ [ " ${# _GTR_PARSED_CMD_ARGS[@]} " -gt 0 ]
270+ }
271+
272+ _configured_command_uses_path_arg () {
273+ local arg=" $1 "
274+ case " $arg " in
275+ /* | ./* | ../* | ~ * | * \\ * )
276+ return 0
277+ ;;
278+ esac
279+ return 1
280+ }
281+
282+ _configured_command_is_wrapper () {
283+ local cmd_name=" $1 "
284+ case " $cmd_name " in
285+ sh | bash | zsh | dash | ksh | fish | env | eval | source | . | python | python3 | node | ruby | perl | php | lua | pwsh | powershell)
286+ return 0
287+ ;;
288+ esac
289+ return 1
290+ }
291+
292+ _validate_configured_command () {
293+ local command_string=" $1 "
294+
295+ # Reject shell metacharacters in config-supplied command names to prevent injection.
296+ # shellcheck disable=SC2016 # Literal '$(' pattern match is intentional
297+ case " $command_string " in
298+ * \; * | * \` * | * ' $(' * | * \| * | * \& * | * ' >' * | * ' <' * )
299+ return 1
300+ ;;
301+ esac
302+
303+ _parse_configured_command " $command_string " || return 1
304+ [ " ${# _GTR_PARSED_CMD_ARGS[@]} " -gt 0 ] || return 1
305+
306+ local cmd_name=" ${_GTR_PARSED_CMD_ARGS[0]} "
307+
308+ case " $cmd_name " in
309+ * /* | * \\ * )
310+ return 1
311+ ;;
312+ esac
313+
314+ if _configured_command_is_wrapper " $cmd_name " && [ " ${# _GTR_PARSED_CMD_ARGS[@]} " -gt 1 ]; then
315+ return 1
316+ fi
317+
318+ local arg
319+ for arg in " ${_GTR_PARSED_CMD_ARGS[@]: 1} " ; do
320+ if _configured_command_uses_path_arg " $arg " ; then
321+ return 1
322+ fi
323+ done
324+
325+ type -P " $cmd_name " > /dev/null 2>&1
326+ }
327+
183328# Parse and run a config-supplied command string while preserving quoted args.
184329_run_configured_command () {
185330 local command_string=" $1 "
186331 shift
187332 local extra_args=(" $@ " )
188333
189- (
190- eval " set -- $command_string " || exit 1
191- [ " $# " -gt 0 ] || exit 1
192- " $@ " " ${extra_args[@]} "
193- )
334+ _parse_configured_command " $command_string " || return 1
335+ [ " ${# _GTR_PARSED_CMD_ARGS[@]} " -gt 0 ] || return 1
336+
337+ " ${_GTR_PARSED_CMD_ARGS[@]} " " ${extra_args[@]} "
194338}
195339
196340# Standard AI adapter builder — used by adapter files that follow the common pattern
@@ -304,7 +448,14 @@ resolve_workspace_file() {
304448# Usage: _load_adapter <type> <name> <label> <builtin_list> <path_hint>
305449_load_adapter () {
306450 local type=" $1 " name=" $2 " label=" $3 " builtin_list=" $4 " path_hint=" $5 "
307- local adapter_selector=" ${name%% * } "
451+ if ! _validate_configured_command " $name " ; then
452+ log_error " $label '$name ' is not a safe executable command"
453+ log_info " Use a PATH command name, optionally with flags (e.g., 'code --wait')"
454+ return 1
455+ fi
456+
457+ local adapter_selector=" ${_GTR_PARSED_CMD_ARGS[0]} "
458+ local cmd_args=(" ${_GTR_PARSED_CMD_ARGS[@]: 1} " )
308459
309460 local adapter_file=" $GTR_DIR /adapters/${type} /${adapter_selector} .sh"
310461
@@ -313,6 +464,17 @@ _load_adapter() {
313464 * /* | * ..* | * \\ * ) ;;
314465 * )
315466 if [ -f " $adapter_file " ]; then
467+ if [ " $type " = " editor" ]; then
468+ # shellcheck disable=SC2034 # Used by sourced override adapters.
469+ GTR_EDITOR_CMD=" $name "
470+ GTR_EDITOR_CMD_NAME=" $adapter_selector "
471+ GTR_EDITOR_CMD_ARGS=(" ${cmd_args[@]} " )
472+ else
473+ # shellcheck disable=SC2034 # Used by sourced override adapters.
474+ GTR_AI_CMD=" $name "
475+ GTR_AI_CMD_NAME=" $adapter_selector "
476+ GTR_AI_CMD_ARGS=(" ${cmd_args[@]} " )
477+ fi
316478 # shellcheck disable=SC1090
317479 . " $adapter_file "
318480 return 0
@@ -337,44 +499,27 @@ _load_adapter() {
337499 return 0
338500 fi
339501
340- # 3. Generic fallback: check if command exists in PATH
341- # Extract first word (command name) from potentially multi-word string
342- local cmd_name=" ${name%% * } "
343-
344- case " $cmd_name " in
345- * /* | * \\ * )
346- log_error " $label '$name ' must use a PATH command name, not a filesystem path"
347- log_info " Use a simple command name, optionally with flags (e.g., 'code --wait')"
348- return 1
349- ;;
350- esac
351-
352- if ! command -v " $cmd_name " > /dev/null 2>&1 ; then
502+ # 3. Generic fallback: command already validated and resolved in PATH
503+ local cmd_name=" $adapter_selector "
504+ if ! type -P " $cmd_name " > /dev/null 2>&1 ; then
353505 log_error " $label '$name ' not found"
354506 log_info " Built-in adapters: $builtin_list "
355507 log_info " Or use any $label command available in your PATH (e.g., $path_hint )"
356508 return 1
357509 fi
358510
359- # Reject shell metacharacters in config-supplied command names to prevent injection
360- # Allows multi-word commands (e.g., "code --wait") but blocks shell operators
361- # shellcheck disable=SC2016 # Literal '$(' pattern match is intentional
362- case " $name " in
363- * \; * | * \` * | * ' $(' * | * \| * | * \& * | * ' >' * | * ' <' * )
364- log_error " $label '$name ' contains shell metacharacters — refusing to execute"
365- log_info " Use a simple command name, optionally with flags (e.g., 'code --wait')"
366- return 1
367- ;;
368- esac
369-
370511 # Set globals for generic adapter functions
371512 # Note: $name may contain arguments (e.g., "code --wait", "bunx @github/copilot@latest")
372513 if [ " $type " = " editor" ]; then
373514 GTR_EDITOR_CMD=" $name "
374515 GTR_EDITOR_CMD_NAME=" $cmd_name "
516+ # shellcheck disable=SC2034 # Used by sourced override adapters.
517+ GTR_EDITOR_CMD_ARGS=(" ${cmd_args[@]} " )
375518 else
376519 GTR_AI_CMD=" $name "
377520 GTR_AI_CMD_NAME=" $cmd_name "
521+ # shellcheck disable=SC2034 # Used by sourced override adapters.
522+ GTR_AI_CMD_ARGS=(" ${cmd_args[@]} " )
378523 fi
379524}
380525
0 commit comments