diff --git a/plugins/magpie-setup/skills/setup/setup_preflight/isolated_fingerprint.py b/plugins/magpie-setup/skills/setup/setup_preflight/isolated_fingerprint.py index 4aaec5b47..6db453c5f 100644 --- a/plugins/magpie-setup/skills/setup/setup_preflight/isolated_fingerprint.py +++ b/plugins/magpie-setup/skills/setup/setup_preflight/isolated_fingerprint.py @@ -21,4 +21,4 @@ for installs that do not carry the framework source (see `isolated.py`). """ -FRAMEWORK_FINGERPRINT = "sha256:b45b2c81a7f1bd64" +FRAMEWORK_FINGERPRINT = "sha256:60bec0b8ff160f1e" diff --git a/tools/agent-isolation/gpg-touch-overlay.sh b/tools/agent-isolation/gpg-touch-overlay.sh index a1dafcb3f..943444bc3 100755 --- a/tools/agent-isolation/gpg-touch-overlay.sh +++ b/tools/agent-isolation/gpg-touch-overlay.sh @@ -304,8 +304,13 @@ agent_socket_rows() { # a false negative costs a silent block with no window. The subcommand # may be followed by whitespace, the end of the command, or whatever # ends a shell word — `git commit;`, `git commit)` and `git commit | tee` -# sign just as much as `git commit -m`. -readonly KEY_SUBCOMMANDS='commit|tag|merge|rebase|revert|cherry-pick|am|push|pull|fetch|clone|ls-remote|remote|submodule' +# sign just as much as `git commit -m`. The plumbing and porcelain that +# reach a remote without being `fetch` or `push` count too: `archive +# --remote`, `fetch-pack` / `send-pack`, `lfs` (git-lfs-authenticate runs +# over ssh), `subtree push`, `svn` (svn+ssh), `maintenance run` (its +# prefetch task fetches) and `request-pull` (it asks the remote for the +# commit). +readonly KEY_SUBCOMMANDS='commit|tag|merge|rebase|revert|cherry-pick|am|push|pull|fetch-pack|fetch|clone|ls-remote|remote|submodule|archive|send-pack|lfs|subtree|svn|maintenance|request-pull' # The same reach, without git in front of it. An ssh transport asks the # key for its authentication touch before anything moves, and gpg asks @@ -318,6 +323,32 @@ readonly KEY_SUBCOMMANDS='commit|tag|merge|rebase|revert|cherry-pick|am|push|pul # lead the alternation so the match cannot stop short at `ssh`. readonly KEY_COMMANDS='ssh-keygen|ssh-add|ssh|scp|sftp|sshfs|rsync|svn|gpg2|gpg' +# gh runs git for these, over ssh when its git_protocol is ssh: `create` +# pushes a branch or repository that is not on GitHub yet. Options +# may stand before the command group or between it and the command +# (`gh -R owner/repo pr checkout 1`, `gh pr -R owner/repo checkout 1`), and +# `co` is gh's own alias for `pr checkout`, both on its own (`gh co 1`) +# and under `pr`. +readonly GH_OPTIONS='([[:space:]]+-[^[:space:];&|]*([[:space:]]+[^-[:space:];&|][^[:space:];&|]*)?)*' +readonly GH_GIT_COMMANDS="gh[\"']?${GH_OPTIONS}[[:space:]]+(repo${GH_OPTIONS}[[:space:]]+(clone|create|fork|sync)|pr${GH_OPTIONS}[[:space:]]+(checkout|co|create)|co)" + +# What may stand right before a command word, and right after one. Besides +# the shell separators: a `/`, for a command named by its path +# (`/usr/bin/git push`, `/opt/homebrew/bin/ssh host`), and quotes or a +# backtick, for one handed to a shell as a string (`bash -c "git push"`, +# `sh -c 'ssh host'`). A dot is not among them, which keeps `~/.ssh` +# quiet. +readonly WORD_START="(^|[;&|(\`\"'/]|[[:space:]])" +readonly WORD_END="([[:space:];&|)\`\"']|$)" + +# One shell word of a git global option or its value: unquoted characters, +# or a quoted span, which may hold spaces. Without the quoted spans +# `git -c user.name="Jane Doe" commit` and `git --git-dir=/x commit` sign +# with no window, because the option no longer looks like one word. A +# backslash takes the character after it, so `O\'Brien` opens no quote, +# and so it does inside bash's `$'...'`, where `$'O\'Brien'` is one word. +readonly GIT_OPTION_WORD="(\\\\.|\\\$'([^'\\\\]|\\\\.)*'|[^[:space:]\"'\\\\]|\"([^\"\\\\]|\\\\.)*\"|'[^']*')" + # A screen to draw on, and something to draw the window with. # # On macOS both questions collapse into one: a logged-in user always has @@ -557,7 +588,7 @@ arm() { cwd="$(printf '%s' "$payload" | jq -r '.cwd // empty' 2>/dev/null)" printf '%s' "$command_text" | - grep -Eq "(^|[;&|(]|[[:space:]])(git([[:space:]]+-[A-Za-z-]+([[:space:]]+[^[:space:]]+)?)*[[:space:]]+($KEY_SUBCOMMANDS)|($KEY_COMMANDS))([[:space:];&|)]|$)" || + grep -Eq "${WORD_START}(git[\"']?([[:space:]]+-${GIT_OPTION_WORD}*([[:space:]]+${GIT_OPTION_WORD}+)?)*[[:space:]]+($KEY_SUBCOMMANDS)|$GH_GIT_COMMANDS|($KEY_COMMANDS))${WORD_END}" || return 0 # A card that never asks for a touch needs no watcher. diff --git a/tools/agent-isolation/tests/test_gpg_touch_overlay.py b/tools/agent-isolation/tests/test_gpg_touch_overlay.py index ff841d248..fcb32c4c5 100644 --- a/tools/agent-isolation/tests/test_gpg_touch_overlay.py +++ b/tools/agent-isolation/tests/test_gpg_touch_overlay.py @@ -74,6 +74,18 @@ def _arm(command: str) -> str: "git commit -m 'x'", "git commit --amend --no-edit", "git -C /some/repo commit -m 'x'", + # Global options whose value is quoted and holds spaces, or is + # joined with `=`, still put the subcommand in front of the key. + 'git -c user.name="Jane Doe" -c user.email="jane@example.com" commit -q -am x', + "git -c user.name='Jane Doe' commit -m x", + "git -c core.hooksPath=/dev/null rebase --continue", + "git --git-dir=/some/repo/.git commit -m x", + 'GIT_EDITOR=true git -c user.name="Jane Doe" commit --amend --no-edit', + # A backslash-escaped quote opens no quoted span. + "git -c user.name=O\\'Brien commit -m x", + 'git -c user.name="Jane \\"JD\\" Doe" commit -m x', + # bash's ANSI-C quoting, where a backslash escapes the quote too. + "git -c user.name=$'O\\'Brien' commit -m x", "cd /tmp/repo && git commit -m 'x'", "git tag -s v1.0.0 -m 'release'", "git rebase --continue", @@ -106,6 +118,8 @@ def test_arms_for_commands_that_can_reach_the_key(command: str) -> None: "git status --short", "git log --oneline -5", "git diff HEAD", + 'git -c user.name="Jane Doe" log --oneline -1', + "git -c color.ui=always status", "ls -la", "echo 'committing to the plan'", "grep -r commit .", @@ -117,6 +131,17 @@ def test_arms_for_commands_that_can_reach_the_key(command: str) -> None: "sshuttle --dns -r host 0/0", # Starting an agent is not a request to the key. "ssh-agent -s", + # A path into an ssh directory, not the command. + "cat /etc/ssh/sshd_config", + # gh talking to the API only, never through git. + "gh pr view 1234", + "gh repo view apache/magpie", + "gh -R apache/magpie pr view 1234", + "gh pr -R apache/magpie list", + "gh pr comment 1234 --body done", + "gh codespace list", + # A script whose name merely starts with git-, not git itself. + "./scripts/git-push-helper.sh origin main", ], ) def test_stays_quiet_for_commands_that_cannot_sign(command: str) -> None: @@ -147,6 +172,42 @@ def test_stays_quiet_for_commands_that_cannot_sign(command: str) -> None: "ssh host; echo done", "(ssh host)", "git fetch && ssh host", + # Named by path rather than found on PATH. + "/usr/bin/git push origin main", + "/opt/homebrew/bin/ssh -T git@github.com", + # A quoted path or name: the quote closes before the arguments. + '"/usr/bin/git" push origin main', + '"git" commit -m x', + "'ssh' host", + '"gh" pr checkout 1234', + '"/opt/homebrew/bin/gh" repo clone apache/magpie', + # Handed to a shell as a string. + 'bash -c "git push origin main"', + "sh -c 'ssh host uptime'", + "echo `git ls-remote origin`", + # Remote-talking git that is neither fetch nor push. + "git archive --remote=git@github.com:apache/magpie.git HEAD", + "git fetch-pack git@github.com:apache/magpie.git", + "git send-pack git@github.com:apache/magpie.git main", + "git lfs pull", + "git subtree push --prefix=docs origin gh-pages", + "git svn dcommit", + "git maintenance run --task=prefetch", + "git request-pull v1.0 origin", + # gh runs git for these, over ssh under git_protocol=ssh. + "gh repo clone apache/magpie", + "gh repo fork apache/magpie --clone", + "gh repo sync", + "gh pr checkout 1234", + # Options before or inside the command group, and gh's alias. + "gh -R apache/magpie pr checkout 1234", + "gh pr -R apache/magpie checkout 1234", + "gh pr co 1234", + "gh co 1234", + # Pushes the branch first when it is not on GitHub yet. + "gh pr create --fill", + "gh repo create apache/x --source=. --push", + "gh repo clone apache/magpie -- --depth=1", ], ) def test_arms_for_non_git_commands_that_can_reach_the_key(command: str) -> None: