From 6755c56e2b5dcbc10fa93a498c0f1d430cc8016d Mon Sep 17 00:00:00 2001 From: Shi Qi Date: Wed, 4 Nov 2026 09:56:35 -0800 Subject: [PATCH] fix: confine editor workspace files to the selected worktree --- README.md | 2 + docs/configuration.md | 18 +++- lib/adapters.sh | 52 +++++++-- lib/commands/help.sh | 4 +- tests/workspace.bats | 237 ++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 304 insertions(+), 9 deletions(-) create mode 100644 tests/workspace.bats diff --git a/README.md b/README.md index 90a7351..49ee8ac 100644 --- a/README.md +++ b/README.md @@ -477,6 +477,8 @@ git gtr config set gtr.ui.color never **Adapter safety:** Generic `gtr.editor.default` and `gtr.ai.default` values must resolve to safe PATH commands. Filesystem paths such as `./tool` and shell wrapper forms such as `sh -c ...` are rejected. Override-backed adapters like `claude`, `cursor`, and `nano` may include additional flags, for example `claude --continue` or `nano -w`. +**Workspace files:** `editor.workspace` in `.gtrconfig` does not require command approval. Workspace paths from all config sources must resolve inside the selected worktree, including symlink targets; invalid paths open the folder instead. See [workspace settings](docs/configuration.md#editor-settings) for supported paths and editor trust boundaries. + **Configuration precedence** (highest to lowest): 1. `git config --local` (`.git/config`) - personal overrides diff --git a/docs/configuration.md b/docs/configuration.md index 3073533..e0b0aef 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -194,10 +194,24 @@ gtr.editor.workspace = project.code-workspace VS Code, Cursor, and Antigravity support `.code-workspace` files for multi-root workspaces, custom settings, and recommended extensions. When opening a worktree: -1. If `gtr.editor.workspace` is set to a path, opens that file (relative to worktree root) +1. If `gtr.editor.workspace` is set to a path, opens that file (relative to and contained within the worktree root) 2. If set to `none`, disables workspace lookup (always opens folder) 3. Otherwise, auto-detects any `*.code-workspace` file in the worktree root -4. Falls back to opening the folder if no workspace file is found +4. Falls back to opening the folder if no valid workspace file is found + +Workspace paths from every config source must resolve to a file inside the selected +worktree. Nested paths, spaces, and symlinks that resolve inside the worktree are +supported. Absolute configured paths and paths or symlinks that resolve outside +the worktree are ignored. An invalid or missing explicit path opens the folder; +it does not trigger auto-detection. Previously accepted outside-worktree paths +now open the folder instead. + +The shared `.gtrconfig` `editor.workspace` setting is workspace data and does not +require command approval. Selecting an editor through `.gtrconfig` +`defaults.editor` still requires `git gtr trust`; a local/global editor default +or an explicit `--editor` can also select the editor. Containment applies to the +workspace file itself, not the folders or settings described inside it. The +editor remains responsible for its own workspace trust checks. --- diff --git a/lib/adapters.sh b/lib/adapters.sh index b7dfedc..a972e1e 100644 --- a/lib/adapters.sh +++ b/lib/adapters.sh @@ -406,7 +406,50 @@ _editor_define_terminal() { } } -# Resolve workspace file for VS Code/Cursor/Antigravity editors +# Validate a relative workspace file against the physical worktree root. +# Follow internal symlinks without requiring readlink -f (unavailable on macOS). +# Return the original path so workspace-relative editor settings keep their meaning. +# Returns 1 for missing files, link cycles, absolute config paths or escapes. +_workspace_file_within_worktree() { + local worktree_path="$1" relative_path="$2" root path parent target os + local links=0 + os=$(detect_os) + # Windows accepts backslashes as separators, including in traversal paths. + if [ "$os" = windows ]; then relative_path="${relative_path//\\//}"; fi + case "$relative_path" in + /*|[a-zA-Z]:*|\\*) return 1 ;; + esac + # Sentinels preserve trailing newlines in physical directory/link names. + root=$(canonicalize_path "$worktree_path" && printf '.') || return 1 + root="${root%$'\n.'}" + path="${root%/}/$relative_path" + + while :; do + parent=$(canonicalize_path "${path%/*}" && printf '.') || return 1 + parent="${parent%$'\n.'}" + path="${parent%/}/${path##*/}" + [ -L "$path" ] || break + [ "$links" -lt 40 ] || return 1 + links=$((links + 1)) + target=$(readlink "$path" && printf '.') || return 1 + target="${target%$'\n.'}" + if [ "$os" = windows ]; then target="${target//\\//}"; fi + path="${parent%/}/$target" + case "$target" in + /*) path="$target" ;; + [a-zA-Z]:/*) if [ "$os" = windows ]; then path="$target"; fi ;; + esac + done + + [ -f "$path" ] || return 1 + case "$path" in + "${root%/}/"*) printf '%s\n' "$worktree_path/$relative_path" ;; + *) return 1 ;; + esac +} + +# Resolve workspace file for VS Code/Cursor/Antigravity editors. +# Workspace data is not command-trust-gated; confine it for every config source. # Returns the workspace file path if found, empty otherwise resolve_workspace_file() { local worktree_path="$1" @@ -421,10 +464,7 @@ resolve_workspace_file() { fi if [ -n "$configured" ]; then - local full_path="$worktree_path/$configured" - if [ -f "$full_path" ]; then - echo "$full_path" - fi + _workspace_file_within_worktree "$worktree_path" "$configured" || true # Explicit config set - don't fall through to auto-detect return 0 fi @@ -433,7 +473,7 @@ resolve_workspace_file() { local ws_file ws_file=$(find "$worktree_path" -maxdepth 1 -name "*.code-workspace" -type f 2>/dev/null | head -1) if [ -n "$ws_file" ]; then - echo "$ws_file" + _workspace_file_within_worktree "$worktree_path" "${ws_file##*/}" || true fi } diff --git a/lib/commands/help.sh b/lib/commands/help.sh index 3cf5fd0..8589800 100644 --- a/lib/commands/help.sh +++ b/lib/commands/help.sh @@ -88,6 +88,8 @@ Usage: git gtr editor [--editor ] Opens the specified worktree in your configured editor. Uses the default editor from gtr.editor.default config, or the --editor flag to override. +Configured workspace files must resolve inside the worktree, including symlinks. +Invalid workspace paths fall back to opening the worktree folder. Options: --editor Override the default editor for this invocation @@ -756,7 +758,7 @@ CONFIGURATION OPTIONS: nvim, pycharm, sublime, vim, vscode, webstorm, zed, none (legacy: atom) gtr.editor.workspace Workspace file for VS Code/Cursor/Antigravity - (relative path, auto-detects, or "none") + (path inside worktree, auto-detects, or "none") gtr.ai.default Default AI tool Options: antigravity, aider, auggie, claude, codex, copilot, cursor, gemini, opencode, none diff --git a/tests/workspace.bats b/tests/workspace.bats new file mode 100644 index 0000000..1d7f23b --- /dev/null +++ b/tests/workspace.bats @@ -0,0 +1,237 @@ +#!/usr/bin/env bats +# Workspace selection through the real CLI with a benign editor that only logs argv. + +load test_helper + +setup() { + export GIT_CONFIG_NOSYSTEM=1 + export GIT_CONFIG_GLOBAL="$BATS_TEST_TMPDIR/gitconfig" + export XDG_CONFIG_HOME="$BATS_TEST_TMPDIR/config" + unset GTR_EDITOR_CMD GTR_EDITOR_DEFAULT GTR_WORKTREES_DIR + TEST_REPO="$BATS_TEST_TMPDIR/repo" + mkdir -p "$TEST_REPO" "$BATS_TEST_TMPDIR/bin" + TEST_REPO=$(cd -P "$TEST_REPO" && pwd -P) + git -C "$TEST_REPO" init --quiet + git -C "$TEST_REPO" config user.name "Test User" + git -C "$TEST_REPO" config user.email "test@example.com" + git -C "$TEST_REPO" commit --allow-empty -m init --quiet + TEST_WT_PATH="$TEST_REPO/worktrees/feature" + git -C "$TEST_REPO" worktree add --quiet -b feature "$TEST_WT_PATH" + git -C "$TEST_REPO" config gtr.worktrees.dir worktrees + export WORKSPACE_EDITOR_LOG="$BATS_TEST_TMPDIR/editor-argv" + cat > "$BATS_TEST_TMPDIR/bin/code" <<'EOF' +#!/usr/bin/env bash +printf '%s\n' "$#" "$@" > "$WORKSPACE_EDITOR_LOG" +EOF + chmod +x "$BATS_TEST_TMPDIR/bin/code" + export PATH="$BATS_TEST_TMPDIR/bin:$PATH" + cd "$TEST_REPO" + source_gtr_commands +} + +assert_editor_target() { + [ "$status" -eq 0 ] + local expected actual + expected=$(printf '1\n%s' "$1") + actual=$(cat "$WORKSPACE_EDITOR_LOG") + if [ "$actual" != "$expected" ]; then + printf 'Expected editor argv: %s\nActual editor argv: %s\n' "$expected" "$actual" >&2 + return 1 + fi +} + +open_workspace() { + run /bin/bash "$PROJECT_ROOT/bin/git-gtr" editor feature --editor vscode +} + +@test "workspace traversal in untrusted shared config falls back to the worktree" { + touch "$TEST_REPO/outside.code-workspace" "$TEST_WT_PATH/auto.code-workspace" + git config -f .gtrconfig editor.workspace ../../outside.code-workspace + git config gtr.editor.default vscode + + run /bin/bash "$PROJECT_ROOT/bin/git-gtr" editor feature + + assert_editor_target "$TEST_WT_PATH" + [ ! -d "$XDG_CONFIG_HOME/gtr/trusted" ] +} + +@test "workspace containment also applies to explicit local and global config" { + touch "$TEST_REPO/outside.code-workspace" + git config --global gtr.editor.workspace ../../outside.code-workspace + open_workspace + assert_editor_target "$TEST_WT_PATH" + + git config --local gtr.editor.workspace ../../outside.code-workspace + open_workspace + assert_editor_target "$TEST_WT_PATH" +} + +@test "workspace file symlink cannot select an external file" { + touch "$TEST_REPO/outside.code-workspace" + ln -s ../../outside.code-workspace "$TEST_WT_PATH/project.code-workspace" + git config -f .gtrconfig editor.workspace project.code-workspace + open_workspace + assert_editor_target "$TEST_WT_PATH" +} + +@test "workspace directory symlink cannot select a sibling with the same path prefix" { + mkdir "$TEST_WT_PATH-other" + touch "$TEST_WT_PATH-other/project.code-workspace" + ln -s ../feature-other "$TEST_WT_PATH/linked" + git config -f .gtrconfig editor.workspace linked/project.code-workspace + open_workspace + assert_editor_target "$TEST_WT_PATH" +} + +@test "workspace supports nested paths with spaces and internal parent components" { + mkdir -p "$TEST_WT_PATH/configs/subdir" + touch "$TEST_WT_PATH/configs/my project.code-workspace" + git config -f .gtrconfig editor.workspace './configs/subdir/../my project.code-workspace' + open_workspace + assert_editor_target "$TEST_WT_PATH/./configs/subdir/../my project.code-workspace" +} + +@test "workspace preserves internal directory and chained file symlinks" { + mkdir "$TEST_WT_PATH/configs" + touch "$TEST_WT_PATH/configs/project.code-workspace" + ln -s configs "$TEST_WT_PATH/linked" + ln -s linked/project.code-workspace "$TEST_WT_PATH/relative.code-workspace" + ln -s "$TEST_WT_PATH/relative.code-workspace" "$TEST_WT_PATH/absolute.code-workspace" + git config -f .gtrconfig editor.workspace absolute.code-workspace + open_workspace + assert_editor_target "$TEST_WT_PATH/absolute.code-workspace" +} + +@test "workspace resolves a symlinked worktree root without changing the caller directory" { + touch "$TEST_WT_PATH/project.code-workspace" + ln -s "$TEST_WT_PATH" "$TEST_REPO/linked-worktree" + git config -f .gtrconfig editor.workspace project.code-workspace + local before="$PWD" result + result=$(resolve_workspace_file "$TEST_REPO/linked-worktree") + [ "$result" = "$TEST_REPO/linked-worktree/project.code-workspace" ] + [ "$PWD" = "$before" ] +} + +@test "workspace none and missing explicit files disable auto-detection" { + touch "$TEST_WT_PATH/auto.code-workspace" + local configured + for configured in none missing.code-workspace; do + git config -f .gtrconfig editor.workspace "$configured" + open_workspace + assert_editor_target "$TEST_WT_PATH" + done +} + +@test "workspace auto-detects a root file and otherwise opens the folder" { + open_workspace + assert_editor_target "$TEST_WT_PATH" + touch "$TEST_WT_PATH/project.code-workspace" + open_workspace + assert_editor_target "$TEST_WT_PATH/project.code-workspace" +} + +@test "workspace auto-detection does not follow external file symlinks" { + touch "$TEST_REPO/outside.code-workspace" + ln -s ../../outside.code-workspace "$TEST_WT_PATH/project.code-workspace" + open_workspace + assert_editor_target "$TEST_WT_PATH" +} + +@test "workspace selection retains local over shared over global precedence" { + touch "$TEST_WT_PATH/local.code-workspace" "$TEST_WT_PATH/shared.code-workspace" + touch "$TEST_WT_PATH/global.code-workspace" + git config --global gtr.editor.workspace global.code-workspace + git config -f .gtrconfig editor.workspace shared.code-workspace + git config --local gtr.editor.workspace local.code-workspace + open_workspace + assert_editor_target "$TEST_WT_PATH/local.code-workspace" + git config --unset gtr.editor.workspace + open_workspace + assert_editor_target "$TEST_WT_PATH/shared.code-workspace" + git config -f .gtrconfig --unset editor.workspace + open_workspace + assert_editor_target "$TEST_WT_PATH/global.code-workspace" +} + +@test "workspace data does not approve a shared editor command" { + git config -f .gtrconfig defaults.editor vscode + git config -f .gtrconfig editor.workspace project.code-workspace + touch "$TEST_WT_PATH/project.code-workspace" + [ "$(_cfg_editor_default)" = none ] + open_workspace + assert_editor_target "$TEST_WT_PATH/project.code-workspace" + [ ! -d "$XDG_CONFIG_HOME/gtr/trusted" ] +} + +@test "workspace broken links and link cycles fall back under set -e" { + ln -s missing.code-workspace "$TEST_WT_PATH/broken.code-workspace" + ln -s cycle.code-workspace "$TEST_WT_PATH/cycle.code-workspace" + local configured + for configured in broken.code-workspace cycle.code-workspace; do + git config -f .gtrconfig editor.workspace "$configured" + open_workspace + assert_editor_target "$TEST_WT_PATH" + done +} + +@test "workspace cannot confuse a newline-suffixed sibling with the worktree" { + local outside="$TEST_WT_PATH"$'\n' + mkdir "$outside" + touch "$outside/project.code-workspace" + ln -s "$outside" "$TEST_WT_PATH/linked" + git config -f .gtrconfig editor.workspace linked/project.code-workspace + open_workspace + assert_editor_target "$TEST_WT_PATH" +} + +@test "workspace treats shell metacharacters as a single literal filename" { + local name='$(touch should-not-run); project.code-workspace' + touch "$TEST_WT_PATH/$name" + git config -f .gtrconfig editor.workspace "$name" + open_workspace + assert_editor_target "$TEST_WT_PATH/$name" + [ ! -e "$TEST_REPO/should-not-run" ] +} + +@test "workspace rejects absolute configuration even when the joined path exists" { + mkdir -p "$TEST_WT_PATH/absolute" + touch "$TEST_WT_PATH/absolute/project.code-workspace" + git config -f .gtrconfig editor.workspace /absolute/project.code-workspace + open_workspace + assert_editor_target "$TEST_WT_PATH" +} + +@test "workspace normalizes Windows separators before checking containment" { + # Exercise Windows path handling without claiming a native Windows test. + detect_os() { printf windows; } + touch "$TEST_REPO/outside.code-workspace" + git config -f .gtrconfig editor.workspace '..\..\outside.code-workspace' + run resolve_workspace_file "$TEST_WT_PATH" + [ "$status" -eq 0 ] + [ -z "$output" ] + + mkdir "$TEST_WT_PATH/configs" + touch "$TEST_WT_PATH/configs/project.code-workspace" + git config -f .gtrconfig editor.workspace 'configs\project.code-workspace' + run resolve_workspace_file "$TEST_WT_PATH" + [ "$status" -eq 0 ] + [ "$output" = "$TEST_WT_PATH/configs/project.code-workspace" ] +} + +@test "workspace resolves drive-like symlink targets relative to their parent on POSIX" { + [ "$(detect_os)" != windows ] || skip "POSIX path semantics" + mkdir "$TEST_REPO/outside" "$TEST_WT_PATH/inside" + touch "$TEST_REPO/outside/project.code-workspace" + touch "$TEST_WT_PATH/inside/project.code-workspace" + ln -s "$TEST_REPO/outside" "$TEST_WT_PATH/a:" + ln -s "$TEST_WT_PATH/inside" "$TEST_REPO/a:" + ln -s a:/project.code-workspace "$TEST_WT_PATH/project.code-workspace" + git config -f .gtrconfig editor.workspace project.code-workspace + open_workspace + assert_editor_target "$TEST_WT_PATH" + + rm "$TEST_WT_PATH/a:" + ln -s inside "$TEST_WT_PATH/a:" + open_workspace + assert_editor_target "$TEST_WT_PATH/project.code-workspace" +}