From abf9ddf2fc4ef89bd9be99505b2ddd64a370d8fc Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Wed, 4 Mar 2026 10:59:57 +0100 Subject: [PATCH 1/9] feat: show repository slug in approval dialog Display the full owner/repo (e.g. lklimek/ghsudo) instead of just the organization name in GUI and terminal approval prompts when the repo can be detected from -R/--repo args or the git origin remote. Refactors org detection to build on new repo-slug helpers, adds 27 tests for slug/org detection and approval message formatting. Co-Authored-By: Claude Opus 4.6 --- README.md | 4 + src/ghsudo/__main__.py | 191 ++++++++++++++++++++++++++-------------- tests/test_detection.py | 183 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 312 insertions(+), 66 deletions(-) create mode 100644 tests/test_detection.py diff --git a/README.md b/README.md index 728b031..927c533 100644 --- a/README.md +++ b/README.md @@ -44,6 +44,8 @@ pip install . **Requirement:** Python 3.10+ +> **Note:** Only **Linux** is actively tested. macOS and Windows have basic support (GUI dialogs, path handling) but are **not tested** — contributions welcome. + ## Quick Start ```bash @@ -213,6 +215,8 @@ On **Windows**, it uses PowerShell's `MessageBox`. If no GUI is available (e.g. headless server), it falls back to a terminal prompt. Use `--no-gui` to force terminal-only mode. +> **Tip:** If you run your agent on a remote machine via SSH, use `ssh -X` (X11 forwarding) so that `ghsudo` GUI dialogs appear on your local display. + The dialog auto-denies after **60 seconds** of no response to prevent the agent from hanging indefinitely. ## Token management diff --git a/src/ghsudo/__main__.py b/src/ghsudo/__main__.py index 2334b92..1017fd1 100644 --- a/src/ghsudo/__main__.py +++ b/src/ghsudo/__main__.py @@ -146,9 +146,7 @@ def _derive_machine_key() -> bytes: raise RuntimeError("Cannot derive machine key: no stable identifiers") raw = "|".join(components).encode("utf-8") - key = hashlib.pbkdf2_hmac( - "sha256", raw, _PBKDF2_SALT, _PBKDF2_ITERATIONS - ) + key = hashlib.pbkdf2_hmac("sha256", raw, _PBKDF2_SALT, _PBKDF2_ITERATIONS) _debug("machine key derived") return key @@ -240,9 +238,7 @@ def _load_token(org: str) -> str: _err(f"Available orgs: {', '.join(orgs)}\n") _err("To set up a token, run:") _err(f" ghsudo --setup {org}\n") - _err( - "This will prompt you for a GitHub Personal Access Token with" - ) + _err("This will prompt you for a GitHub Personal Access Token with") _err("write permissions and store it encrypted on this machine.\n") _err(f"See: {_README_URL}") sys.exit(EXIT_NO_TOKEN) @@ -253,9 +249,7 @@ def _load_token(org: str) -> str: return _decrypt_token(data, key) except Exception: # noqa: BLE001 _err(f"Failed to decrypt token for org '{org}'.") - _err( - "Was it stored on a different machine, or did the hostname change?" - ) + _err("Was it stored on a different machine, or did the hostname change?") _err(f"Re-run: ghsudo --setup {org}") sys.exit(EXIT_ERROR) @@ -265,27 +259,27 @@ def _load_token(org: str) -> str: # --------------------------------------------------------------------------- -def _detect_org_from_args(cmd: list[str]) -> str | None: - """Extract org from -R/--repo owner/repo in gh command args.""" +def _detect_repo_slug_from_args(cmd: list[str]) -> str | None: + """Extract owner/repo slug from -R/--repo in gh command args.""" for i, arg in enumerate(cmd): if arg in ("-R", "--repo") and i + 1 < len(cmd): repo_arg = cmd[i + 1] if "/" in repo_arg: - return repo_arg.split("/")[0].lower() + return repo_arg.strip().lower() # Handle --repo=owner/repo if arg.startswith("--repo="): repo_arg = arg.split("=", 1)[1] if "/" in repo_arg: - return repo_arg.split("/")[0].lower() + return repo_arg.strip().lower() if arg.startswith("-R") and len(arg) > 2: repo_arg = arg[2:] if "/" in repo_arg: - return repo_arg.split("/")[0].lower() + return repo_arg.strip().lower() return None -def _detect_org_from_git_remote() -> str | None: - """Extract org from the current repo's origin remote.""" +def _detect_repo_slug_from_git_remote() -> str | None: + """Extract owner/repo slug from the current repo's origin remote.""" try: result = subprocess.run( ["git", "remote", "get-url", "origin"], @@ -300,15 +294,39 @@ def _detect_org_from_git_remote() -> str | None: return None # SSH: git@github.com:owner/repo.git - m = re.match(r"git@github\.com:([^/]+)/", url) + m = re.match(r"git@github\.com:([^/]+)/([^/]+?)(?:\.git)?$", url) if m: - return m.group(1).lower() + return f"{m.group(1).lower()}/{m.group(2).lower()}" # HTTPS: https://github.com/owner/repo.git - m = re.match(r"https?://github\.com/([^/]+)/", url) + m = re.match(r"https?://github\.com/([^/]+)/([^/]+?)(?:\.git)?$", url) if m: - return m.group(1).lower() + return f"{m.group(1).lower()}/{m.group(2).lower()}" + + return None + + +def _detect_repo_slug(cmd: list[str]) -> str | None: + """Auto-detect owner/repo slug from command args, then git remote.""" + slug = _detect_repo_slug_from_args(cmd) + if slug: + return slug + return _detect_repo_slug_from_git_remote() + + +def _detect_org_from_args(cmd: list[str]) -> str | None: + """Extract org from -R/--repo owner/repo in gh command args.""" + slug = _detect_repo_slug_from_args(cmd) + if slug and "/" in slug: + return slug.split("/")[0] + return None + +def _detect_org_from_git_remote() -> str | None: + """Extract org from the current repo's origin remote.""" + slug = _detect_repo_slug_from_git_remote() + if slug and "/" in slug: + return slug.split("/")[0] return None @@ -360,62 +378,85 @@ def _run_gui(cmd: list[str]) -> int | None: return None -def _format_approval_msg(cmd_str: str, org: str) -> str: +def _format_approval_msg(cmd_str: str, org: str, repo: str | None = None) -> str: + target = f"Repository: {repo}" if repo else f"Organization: {org}" return ( "A GitHub command requires elevated (write) permissions.\n\n" - f"Organization: {org}\n" + f"{target}\n" f"Command to execute:\n {cmd_str}\n\n" "Allow this command to run with elevated GitHub permissions?" ) -def _ask_xmessage(cmd_str: str, org: str) -> bool | None: +def _ask_xmessage(cmd_str: str, org: str, repo: str | None = None) -> bool | None: """Lightweight X11 dialog. Returns True=approved, False=denied, None=unavailable.""" - msg = _format_approval_msg(cmd_str, org) - rc = _run_gui([ - "xmessage", "-center", - "-xrm", "*international:true", - "-xrm", "*form.message.Scroll:WhenNeeded", - "-xrm", "*form.minimumWidth:500", - "-buttons", "Allow:0,Deny:1", - "-default", "Deny", msg, - ]) + msg = _format_approval_msg(cmd_str, org, repo) + rc = _run_gui( + [ + "xmessage", + "-center", + "-xrm", + "*international:true", + "-xrm", + "*form.message.Scroll:WhenNeeded", + "-xrm", + "*form.minimumWidth:500", + "-buttons", + "Allow:0,Deny:1", + "-default", + "Deny", + msg, + ] + ) if rc is None: return None return rc == 0 -def _ask_zenity(cmd_str: str, org: str) -> bool | None: +def _ask_zenity(cmd_str: str, org: str, repo: str | None = None) -> bool | None: """Returns True=approved, False=denied, None=unavailable.""" - msg = _format_approval_msg(cmd_str, org) - rc = _run_gui([ - "zenity", "--question", - "--title=GitHub Elevated Access (ghsudo)", - f"--text={msg}", "--width=500", - "--ok-label=Allow", "--cancel-label=Deny", - ]) + msg = _format_approval_msg(cmd_str, org, repo) + rc = _run_gui( + [ + "zenity", + "--question", + "--title=GitHub Elevated Access (ghsudo)", + f"--text={msg}", + "--width=500", + "--ok-label=Allow", + "--cancel-label=Deny", + ] + ) if rc is None: return None # not installed or timed out return rc == 0 -def _ask_kdialog(cmd_str: str, org: str) -> bool | None: +def _ask_kdialog(cmd_str: str, org: str, repo: str | None = None) -> bool | None: """Returns True=approved, False=denied, None=unavailable.""" - msg = _format_approval_msg(cmd_str, org) - rc = _run_gui([ - "kdialog", "--title", "GitHub Elevated Access (ghsudo)", - "--yesno", msg, - "--yes-label", "Allow", "--no-label", "Deny", - ]) + msg = _format_approval_msg(cmd_str, org, repo) + rc = _run_gui( + [ + "kdialog", + "--title", + "GitHub Elevated Access (ghsudo)", + "--yesno", + msg, + "--yes-label", + "Allow", + "--no-label", + "Deny", + ] + ) if rc is None: return None return rc == 0 -def _ask_osascript(cmd_str: str, org: str) -> bool | None: +def _ask_osascript(cmd_str: str, org: str, repo: str | None = None) -> bool | None: """Returns True=approved, False=denied, None=unavailable.""" escaped = _escape_for_applescript( - _format_approval_msg(cmd_str, org).replace("\n", "\\n") + _format_approval_msg(cmd_str, org, repo).replace("\n", "\\n") ) # "cancel button" makes Deny return exit code 1. script = ( @@ -430,17 +471,18 @@ def _ask_osascript(cmd_str: str, org: str) -> bool | None: return rc == 0 -def _ask_powershell(cmd_str: str, org: str) -> bool | None: +def _ask_powershell(cmd_str: str, org: str, repo: str | None = None) -> bool | None: """Returns True=approved, False=denied, None=unavailable.""" escaped = _escape_for_powershell(cmd_str) + target = f"Repository: {repo}" if repo else f"Organization: {org}" ps = ( "Add-Type -AssemblyName System.Windows.Forms; " "$r = [System.Windows.Forms.MessageBox]::Show(" - f"\"A GitHub command requires elevated (write) permissions." - f"`n`nOrganization: {org}" + f'"A GitHub command requires elevated (write) permissions.' + f"`n`n{target}" f"`nCommand to execute:`n {escaped}`n`n" - f"Allow this command to run with elevated GitHub permissions?\"," - f"\"GitHub Elevated Access (ghsudo)\"," + f'Allow this command to run with elevated GitHub permissions?",' + f'"GitHub Elevated Access (ghsudo)",' "[System.Windows.Forms.MessageBoxButtons]::YesNo," "[System.Windows.Forms.MessageBoxIcon]::Warning); " 'if ($r -eq "Yes") { exit 0 } else { exit 1 }' @@ -451,11 +493,14 @@ def _ask_powershell(cmd_str: str, org: str) -> bool | None: return rc == 0 -def _ask_terminal(cmd_str: str, org: str) -> bool: +def _ask_terminal(cmd_str: str, org: str, repo: str | None = None) -> bool: if not sys.stdin.isatty(): return False _info("GitHub elevated access required.") - _info(f"Organization: {org}") + if repo: + _info(f"Repository: {repo}") + else: + _info(f"Organization: {org}") _info(f"Command: {cmd_str}") try: answer = input(f"{_PREFIX} Allow? (yes/no): ").strip().lower() @@ -475,7 +520,9 @@ def _has_display() -> bool: return bool(os.environ.get("DISPLAY") or os.environ.get("WAYLAND_DISPLAY")) -def _ask_approval(cmd_str: str, org: str, *, no_gui: bool = False) -> bool: +def _ask_approval( + cmd_str: str, org: str, *, no_gui: bool = False, repo: str | None = None +) -> bool: """Ask the user to approve the command. Returns True if approved.""" system = platform.system() _debug(f"approval: system={system}, no_gui={no_gui}, has_display={_has_display()}") @@ -484,22 +531,22 @@ def _ask_approval(cmd_str: str, org: str, *, no_gui: bool = False) -> bool: gui_result = None if system == "Linux": # Try lightest first: xmessage → zenity → kdialog - gui_result = _ask_xmessage(cmd_str, org) + gui_result = _ask_xmessage(cmd_str, org, repo) if gui_result is None: - gui_result = _ask_zenity(cmd_str, org) + gui_result = _ask_zenity(cmd_str, org, repo) if gui_result is None: - gui_result = _ask_kdialog(cmd_str, org) + gui_result = _ask_kdialog(cmd_str, org, repo) elif system == "Darwin": - gui_result = _ask_osascript(cmd_str, org) + gui_result = _ask_osascript(cmd_str, org, repo) elif system == "Windows": - gui_result = _ask_powershell(cmd_str, org) + gui_result = _ask_powershell(cmd_str, org, repo) # If a GUI tool gave a definitive answer, use it (no terminal re-ask) if gui_result is not None: return gui_result # Terminal fallback (only reached if GUI unavailable/timed out) - if _ask_terminal(cmd_str, org): + if _ask_terminal(cmd_str, org, repo): return True if not sys.stdin.isatty(): @@ -650,8 +697,12 @@ def cmd_run(cmd: list[str], *, org: str | None = None, no_gui: bool = False) -> cmd_str = shlex.join(cmd) + # Detect full repo slug (owner/repo) for display in approval dialog + repo_slug = _detect_repo_slug(cmd) + _debug(f"repo_slug={repo_slug}") + _debug("requesting approval") - if not _ask_approval(cmd_str, org, no_gui=no_gui): + if not _ask_approval(cmd_str, org, no_gui=no_gui, repo=repo_slug): _info("Permission denied by user.") return EXIT_DENIED _debug("approved, executing command") @@ -822,10 +873,18 @@ def main() -> int: elif arg == "--list": return cmd_list() elif arg == "--verify": - verify_org = argv[i + 1] if i + 1 < len(argv) and not argv[i + 1].startswith("--") else None + verify_org = ( + argv[i + 1] + if i + 1 < len(argv) and not argv[i + 1].startswith("--") + else None + ) return cmd_verify(verify_org) elif arg == "--revoke": - revoke_org = argv[i + 1] if i + 1 < len(argv) and not argv[i + 1].startswith("--") else None + revoke_org = ( + argv[i + 1] + if i + 1 < len(argv) and not argv[i + 1].startswith("--") + else None + ) return cmd_revoke(revoke_org) elif arg == "--org": if i + 1 >= len(argv): diff --git a/tests/test_detection.py b/tests/test_detection.py new file mode 100644 index 0000000..647d003 --- /dev/null +++ b/tests/test_detection.py @@ -0,0 +1,183 @@ +"""Tests for org and repo slug detection functions.""" + +from __future__ import annotations + +from unittest.mock import patch, MagicMock +import subprocess + +import pytest + +from ghsudo.__main__ import ( + _detect_org, + _detect_org_from_args, + _detect_org_from_git_remote, + _detect_repo_slug, + _detect_repo_slug_from_args, + _detect_repo_slug_from_git_remote, + _format_approval_msg, +) + + +# --------------------------------------------------------------------------- +# _detect_repo_slug_from_args +# --------------------------------------------------------------------------- + + +class TestDetectRepoSlugFromArgs: + """Tests for extracting owner/repo from gh CLI arguments.""" + + def test_dash_R_separate(self): + assert _detect_repo_slug_from_args(["gh", "pr", "-R", "acme/widget", "list"]) == "acme/widget" + + def test_dash_R_attached(self): + assert _detect_repo_slug_from_args(["gh", "pr", "-Racme/widget", "list"]) == "acme/widget" + + def test_long_repo_separate(self): + assert _detect_repo_slug_from_args(["gh", "issue", "--repo", "Org/Repo"]) == "org/repo" + + def test_long_repo_equals(self): + assert _detect_repo_slug_from_args(["gh", "--repo=Org/Repo", "pr", "list"]) == "org/repo" + + def test_no_repo_flag(self): + assert _detect_repo_slug_from_args(["gh", "pr", "list"]) is None + + def test_dash_R_at_end_no_value(self): + assert _detect_repo_slug_from_args(["gh", "pr", "-R"]) is None + + def test_repo_without_slash(self): + """A bare name without '/' should not match.""" + assert _detect_repo_slug_from_args(["gh", "-R", "onlyorg"]) is None + + def test_whitespace_trimmed(self): + assert _detect_repo_slug_from_args(["gh", "-R", " Org/Repo "]) == "org/repo" + + +# --------------------------------------------------------------------------- +# _detect_repo_slug_from_git_remote +# --------------------------------------------------------------------------- + + +def _mock_git_remote(url: str): + """Return a patch that makes `git remote get-url origin` return *url*.""" + result = MagicMock(spec=subprocess.CompletedProcess) + result.returncode = 0 + result.stdout = url + return patch("ghsudo.__main__.subprocess.run", return_value=result) + + +class TestDetectRepoSlugFromGitRemote: + """Tests for extracting owner/repo from git origin remote.""" + + def test_ssh_url(self): + with _mock_git_remote("git@github.com:lklimek/ghsudo.git\n"): + assert _detect_repo_slug_from_git_remote() == "lklimek/ghsudo" + + def test_ssh_url_no_dot_git(self): + with _mock_git_remote("git@github.com:lklimek/ghsudo\n"): + assert _detect_repo_slug_from_git_remote() == "lklimek/ghsudo" + + def test_https_url(self): + with _mock_git_remote("https://github.com/Acme/Widget.git\n"): + assert _detect_repo_slug_from_git_remote() == "acme/widget" + + def test_https_url_no_dot_git(self): + with _mock_git_remote("https://github.com/Acme/Widget\n"): + assert _detect_repo_slug_from_git_remote() == "acme/widget" + + def test_http_url(self): + with _mock_git_remote("http://github.com/foo/bar.git\n"): + assert _detect_repo_slug_from_git_remote() == "foo/bar" + + def test_non_github_url(self): + with _mock_git_remote("git@gitlab.com:org/repo.git\n"): + assert _detect_repo_slug_from_git_remote() is None + + def test_git_failure(self): + result = MagicMock(spec=subprocess.CompletedProcess) + result.returncode = 1 + result.stdout = "" + with patch("ghsudo.__main__.subprocess.run", return_value=result): + assert _detect_repo_slug_from_git_remote() is None + + def test_git_not_found(self): + with patch( + "ghsudo.__main__.subprocess.run", + side_effect=FileNotFoundError, + ): + assert _detect_repo_slug_from_git_remote() is None + + +# --------------------------------------------------------------------------- +# _detect_repo_slug (combined) +# --------------------------------------------------------------------------- + + +class TestDetectRepoSlug: + """Args take priority over git remote.""" + + def test_args_preferred_over_remote(self): + with _mock_git_remote("git@github.com:remote/repo.git"): + slug = _detect_repo_slug(["gh", "-R", "arg/repo", "pr", "list"]) + assert slug == "arg/repo" + + def test_falls_back_to_remote(self): + with _mock_git_remote("git@github.com:remote/repo.git"): + slug = _detect_repo_slug(["gh", "pr", "list"]) + assert slug == "remote/repo" + + def test_none_when_nothing(self): + result = MagicMock(spec=subprocess.CompletedProcess) + result.returncode = 1 + result.stdout = "" + with patch("ghsudo.__main__.subprocess.run", return_value=result): + assert _detect_repo_slug(["gh", "pr", "list"]) is None + + +# --------------------------------------------------------------------------- +# _detect_org_from_args / _detect_org_from_git_remote / _detect_org +# --------------------------------------------------------------------------- + + +class TestDetectOrg: + """Org helpers should delegate to slug functions and return the owner.""" + + def test_org_from_args(self): + assert _detect_org_from_args(["gh", "-R", "myorg/myrepo"]) == "myorg" + + def test_org_from_args_none(self): + assert _detect_org_from_args(["gh", "pr", "list"]) is None + + def test_org_from_git_remote(self): + with _mock_git_remote("git@github.com:SomeOrg/SomeRepo.git"): + assert _detect_org_from_git_remote() == "someorg" + + def test_detect_org_args_first(self): + with _mock_git_remote("git@github.com:remote-org/repo.git"): + assert _detect_org(["gh", "-R", "arg-org/repo"]) == "arg-org" + + def test_detect_org_falls_back(self): + with _mock_git_remote("git@github.com:remote-org/repo.git"): + assert _detect_org(["gh", "pr", "list"]) == "remote-org" + + +# --------------------------------------------------------------------------- +# _format_approval_msg +# --------------------------------------------------------------------------- + + +class TestFormatApprovalMsg: + """Approval message should show repo when available, org otherwise.""" + + def test_with_repo(self): + msg = _format_approval_msg("gh pr merge 1", "acme", repo="acme/widget") + assert "Repository: acme/widget" in msg + assert "Organization:" not in msg + + def test_without_repo(self): + msg = _format_approval_msg("gh pr merge 1", "acme") + assert "Organization: acme" in msg + assert "Repository:" not in msg + + def test_command_present(self): + msg = _format_approval_msg("gh pr merge 42 --merge", "x", repo="x/y") + assert "gh pr merge 42 --merge" in msg From a60a879b5e958fdd0790b9d16bda43954dcb2332 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Wed, 4 Mar 2026 11:03:38 +0100 Subject: [PATCH 2/9] feat!: require GUI display for approval, show repo slug in dialog MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BREAKING: Remove --no-gui / terminal fallback — a terminal prompt is trivially auto-approvable by an AI agent, defeating ghsudo's purpose. A graphical display (DISPLAY/WAYLAND_DISPLAY) is now required. Also display the full owner/repo slug (e.g. lklimek/ghsudo) instead of just the organization name when the repo can be detected from -R/--repo args or the git origin remote. Bump version to 0.2.0. Co-Authored-By: Claude Opus 4.6 --- README.md | 5 ++--- pyproject.toml | 2 +- src/ghsudo/__init__.py | 2 +- src/ghsudo/__main__.py | 49 +++++++++--------------------------------- 4 files changed, 14 insertions(+), 44 deletions(-) diff --git a/README.md b/README.md index 927c533..35f6d7d 100644 --- a/README.md +++ b/README.md @@ -183,7 +183,6 @@ GitHub Sudo — re-execute commands with per-org elevated tokens. Options: --org ORG Target org (auto-detected from -R flag or git remote) - --no-gui Skip GUI dialog, use terminal prompt only --setup ORG Store encrypted GitHub PAT for an org --verify [ORG] Verify stored token(s) --revoke [ORG] Revoke stored token(s) @@ -213,7 +212,7 @@ On **Linux**, `ghsudo` tries (in order): `xmessage`, `zenity`, `kdialog`. On **macOS**, it uses `osascript` (the built-in AppleScript runner). On **Windows**, it uses PowerShell's `MessageBox`. -If no GUI is available (e.g. headless server), it falls back to a terminal prompt. Use `--no-gui` to force terminal-only mode. +A graphical display is **required** — `ghsudo` will refuse to run without one, because a terminal prompt can be trivially auto-approved by an AI agent, defeating the purpose. If no GUI toolkit is found, `ghsudo` exits with code 3. > **Tip:** If you run your agent on a remote machine via SSH, use `ssh -X` (X11 forwarding) so that `ghsudo` GUI dialogs appear on your local display. @@ -242,7 +241,7 @@ The dialog auto-denies after **60 seconds** of no response to prevent the agent | 0 | Success | | 1 | Error | | 2 | User denied the request | -| 3 | No interactive session available to ask for approval | +| 3 | No graphical display available to show approval dialog | | 4 | No token stored for the target org | ## Debugging diff --git a/pyproject.toml b/pyproject.toml index 40368b6..a0c9907 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "ghsudo" -version = "0.1.0" +version = "0.2.0" description = "GitHub Sudo — re-execute commands with an elevated GitHub token after user approval" readme = "README.md" license = { file = "LICENSE" } diff --git a/src/ghsudo/__init__.py b/src/ghsudo/__init__.py index c9fd61a..f548879 100644 --- a/src/ghsudo/__init__.py +++ b/src/ghsudo/__init__.py @@ -1,3 +1,3 @@ """ghsudo — GitHub Sudo: re-execute commands with an elevated GitHub token.""" -__version__ = "1.0.0" +__version__ = "0.2.0" diff --git a/src/ghsudo/__main__.py b/src/ghsudo/__main__.py index 1017fd1..84ae155 100644 --- a/src/ghsudo/__main__.py +++ b/src/ghsudo/__main__.py @@ -493,22 +493,6 @@ def _ask_powershell(cmd_str: str, org: str, repo: str | None = None) -> bool | N return rc == 0 -def _ask_terminal(cmd_str: str, org: str, repo: str | None = None) -> bool: - if not sys.stdin.isatty(): - return False - _info("GitHub elevated access required.") - if repo: - _info(f"Repository: {repo}") - else: - _info(f"Organization: {org}") - _info(f"Command: {cmd_str}") - try: - answer = input(f"{_PREFIX} Allow? (yes/no): ").strip().lower() - except (EOFError, KeyboardInterrupt): - return False - return answer in ("yes", "y") - - def _has_display() -> bool: """Check if a graphical display is available.""" system = platform.system() @@ -520,14 +504,12 @@ def _has_display() -> bool: return bool(os.environ.get("DISPLAY") or os.environ.get("WAYLAND_DISPLAY")) -def _ask_approval( - cmd_str: str, org: str, *, no_gui: bool = False, repo: str | None = None -) -> bool: +def _ask_approval(cmd_str: str, org: str, *, repo: str | None = None) -> bool: """Ask the user to approve the command. Returns True if approved.""" system = platform.system() - _debug(f"approval: system={system}, no_gui={no_gui}, has_display={_has_display()}") + _debug(f"approval: system={system}, has_display={_has_display()}") - if not no_gui and _has_display(): + if _has_display(): gui_result = None if system == "Linux": # Try lightest first: xmessage → zenity → kdialog @@ -545,15 +527,10 @@ def _ask_approval( if gui_result is not None: return gui_result - # Terminal fallback (only reached if GUI unavailable/timed out) - if _ask_terminal(cmd_str, org, repo): - return True - - if not sys.stdin.isatty(): - _err("Cannot request approval: no display and no terminal available.") - sys.exit(EXIT_NO_INTERACTIVE) - - return False + # No GUI available — cannot safely prompt (terminal is trivially auto-approvable) + _err("Cannot request approval: no graphical display available.") + _err("Ensure DISPLAY or WAYLAND_DISPLAY is set (e.g. ssh -X).") + sys.exit(EXIT_NO_INTERACTIVE) # --------------------------------------------------------------------------- @@ -658,7 +635,7 @@ def cmd_setup(org: str) -> int: return EXIT_OK -def cmd_run(cmd: list[str], *, org: str | None = None, no_gui: bool = False) -> int: +def cmd_run(cmd: list[str], *, org: str | None = None) -> int: """Show approval dialog, then re-execute command with elevated token.""" if not cmd: _err("No command specified.") @@ -702,7 +679,7 @@ def cmd_run(cmd: list[str], *, org: str | None = None, no_gui: bool = False) -> _debug(f"repo_slug={repo_slug}") _debug("requesting approval") - if not _ask_approval(cmd_str, org, no_gui=no_gui, repo=repo_slug): + if not _ask_approval(cmd_str, org, repo=repo_slug): _info("Permission denied by user.") return EXIT_DENIED _debug("approved, executing command") @@ -842,7 +819,6 @@ def cmd_list() -> int: Options: --org ORG Target org (auto-detected from -R flag or git remote) - --no-gui Skip GUI dialog, use terminal prompt only --setup ORG Store encrypted GitHub PAT for an org --verify [ORG] Verify stored token(s) --revoke [ORG] Revoke stored token(s) @@ -860,7 +836,6 @@ def main() -> int: # Parse -- flags, collect the rest as the command org: str | None = None - no_gui = False cmd: list[str] = [] i = 0 while i < len(argv): @@ -893,17 +868,13 @@ def main() -> int: org = argv[i + 1] i += 2 continue - elif arg == "--no-gui": - no_gui = True - i += 1 - continue else: # Everything from here on is the command cmd = argv[i:] break i += 1 - return cmd_run(cmd, org=org, no_gui=no_gui) + return cmd_run(cmd, org=org) if __name__ == "__main__": From 7b977dae74bf249b83c14ab4320270dda1036765 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Wed, 4 Mar 2026 11:04:28 +0100 Subject: [PATCH 3/9] build: read version from package metadata instead of duplicating Use importlib.metadata.version() in __init__.py so the version is defined only in pyproject.toml. Co-Authored-By: Claude Opus 4.6 --- src/ghsudo/__init__.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/ghsudo/__init__.py b/src/ghsudo/__init__.py index f548879..154ed65 100644 --- a/src/ghsudo/__init__.py +++ b/src/ghsudo/__init__.py @@ -1,3 +1,5 @@ """ghsudo — GitHub Sudo: re-execute commands with an elevated GitHub token.""" -__version__ = "0.2.0" +from importlib.metadata import version + +__version__ = version("ghsudo") From 8f6897d5ef1f63268621c18407d05c9948209d9e Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Wed, 4 Mar 2026 11:04:59 +0100 Subject: [PATCH 4/9] docs: add CLAUDE.md with release process and dev guide Co-Authored-By: Claude Opus 4.6 --- CLAUDE.md | 50 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) create mode 100644 CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..7366ecd --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,50 @@ +# ghsudo — Developer Guide + +## Project structure + +- `src/ghsudo/__main__.py` — CLI entry point and all logic (single-file tool) +- `src/ghsudo/__init__.py` — package init, reads version from package metadata +- `pyproject.toml` — build config and **single source of truth for version** +- `tests/` — pytest tests + +## Version management + +Version is defined **only** in `pyproject.toml`. The `__init__.py` reads it at +runtime via `importlib.metadata.version("ghsudo")`. + +When bumping the version, edit **only** `pyproject.toml`. + +## Testing + +```bash +python -m pytest tests/ -v +``` + +Only run formatting/linting right before committing: + +```bash +python -m ruff check src/ tests/ && python -m ruff format src/ tests/ +``` + +## Release process + +1. **Bump version** in `pyproject.toml` (semver: major.minor.patch) +2. **Commit**: `git commit -m "build: bump version to X.Y.Z"` +3. **Push**: `git push` +4. **Create GitHub release** (triggers PyPI publish via CI): + ```bash + ghsudo gh release create vX.Y.Z --generate-notes + ``` +5. **Verify** the publish workflow completes on GitHub Actions. + +The `publish.yml` workflow builds and publishes to PyPI automatically when a +GitHub release is created. + +## Platform support + +Only Linux is actively tested. macOS/Windows have basic support but are untested. + +## Security design + +GUI-only approval is intentional — terminal prompts are trivially auto-approvable +by AI agents. A graphical display (`DISPLAY`/`WAYLAND_DISPLAY`) is required. From a21274f04aee4965f2385afa5fb142349f799099 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Wed, 4 Mar 2026 11:17:17 +0100 Subject: [PATCH 5/9] docs: require HTTPS remotes, add gh auth setup-git hint Agent instruction files tell agents to remind the user (not run it themselves) since gh auth setup-git modifies global git config. Co-Authored-By: Claude Opus 4.6 --- AGENTS-example.md | 1 + CLAUDE-example.md | 1 + CLAUDE.md | 19 +++++++++++++++++++ README.md | 6 ++++++ 4 files changed, 27 insertions(+) diff --git a/AGENTS-example.md b/AGENTS-example.md index bd323ec..7f4d20e 100644 --- a/AGENTS-example.md +++ b/AGENTS-example.md @@ -11,6 +11,7 @@ ghsudo gh issue comment 42 --body "Done!" ## Rules +- **Always** use `https://` URLs for git remotes (required for `ghsudo` token injection). If git push/pull fails over HTTPS, remind the user to run `gh auth setup-git` to configure the credential helper. - **Never** bypass `ghsudo` or ask the user for the write token directly. - Exit code 2 (denied): stop and report to the user. - Exit code 3 (no interactive session): inform the user approval is not possible. diff --git a/CLAUDE-example.md b/CLAUDE-example.md index bd323ec..7f4d20e 100644 --- a/CLAUDE-example.md +++ b/CLAUDE-example.md @@ -11,6 +11,7 @@ ghsudo gh issue comment 42 --body "Done!" ## Rules +- **Always** use `https://` URLs for git remotes (required for `ghsudo` token injection). If git push/pull fails over HTTPS, remind the user to run `gh auth setup-git` to configure the credential helper. - **Never** bypass `ghsudo` or ask the user for the write token directly. - Exit code 2 (denied): stop and report to the user. - Exit code 3 (no interactive session): inform the user approval is not possible. diff --git a/CLAUDE.md b/CLAUDE.md index 7366ecd..67028ff 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,3 +1,22 @@ +# GitHub access — two-token model + +You have a **read-only** `GH_TOKEN`. For write operations, use `ghsudo`: + +``` +ghsudo gh pr merge 123 --merge +ghsudo gh issue comment 42 --body "Done!" +``` + +`ghsudo` shows a dialog and runs the command with elevated permissions only after user approval. + +## Rules + +- **Always** use `https://` URLs for git remotes (required for `ghsudo` token injection). If git push/pull fails over HTTPS, remind the user to run `gh auth setup-git` to configure the credential helper. +- **Never** bypass `ghsudo` or ask the user for the write token directly. +- Exit code 2 (denied): stop and report to the user. +- Exit code 3 (no interactive session): inform the user approval is not possible. +- Exit code 4 (not set up): tell the user to follow . + # ghsudo — Developer Guide ## Project structure diff --git a/README.md b/README.md index 35f6d7d..fb8c3c9 100644 --- a/README.md +++ b/README.md @@ -42,6 +42,12 @@ cd ghsudo pip install . ``` +> **Note:** Git remotes **must** use `https://` URLs (not SSH). `ghsudo` injects the elevated token via `GH_TOKEN`/`GITHUB_TOKEN` environment variables, which only work with HTTPS remotes. +> To make `git` use `gh` as its credential helper (so `git push`/`pull` work over HTTPS), run: +> ```bash +> gh auth setup-git +> ``` + **Requirement:** Python 3.10+ > **Note:** Only **Linux** is actively tested. macOS and Windows have basic support (GUI dialogs, path handling) but are **not tested** — contributions welcome. From 653b7b875b97acca4ce3fdeac88115123214b4bc Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Wed, 4 Mar 2026 11:21:48 +0100 Subject: [PATCH 6/9] fix: address Copilot review comments - Escape repo/org values in PowerShell dialog string to prevent injection via malformed repo names - Differentiate error messages: "no display" vs "no GUI toolkit found" with actionable remediation (install xmessage/zenity/kdialog) - Handle PackageNotFoundError gracefully in __init__.py for uninstalled source checkouts Co-Authored-By: Claude Opus 4.6 --- src/ghsudo/__init__.py | 7 +++++-- src/ghsudo/__main__.py | 15 +++++++++++---- tests/test_detection.py | 21 ++++++++++++++++----- 3 files changed, 32 insertions(+), 11 deletions(-) diff --git a/src/ghsudo/__init__.py b/src/ghsudo/__init__.py index 154ed65..5a8b87b 100644 --- a/src/ghsudo/__init__.py +++ b/src/ghsudo/__init__.py @@ -1,5 +1,8 @@ """ghsudo — GitHub Sudo: re-execute commands with an elevated GitHub token.""" -from importlib.metadata import version +from importlib.metadata import PackageNotFoundError, version -__version__ = version("ghsudo") +try: + __version__ = version("ghsudo") +except PackageNotFoundError: + __version__ = "0.0.0+unknown" diff --git a/src/ghsudo/__main__.py b/src/ghsudo/__main__.py index 84ae155..9dd913d 100644 --- a/src/ghsudo/__main__.py +++ b/src/ghsudo/__main__.py @@ -474,7 +474,8 @@ def _ask_osascript(cmd_str: str, org: str, repo: str | None = None) -> bool | No def _ask_powershell(cmd_str: str, org: str, repo: str | None = None) -> bool | None: """Returns True=approved, False=denied, None=unavailable.""" escaped = _escape_for_powershell(cmd_str) - target = f"Repository: {repo}" if repo else f"Organization: {org}" + raw_target = f"Repository: {repo}" if repo else f"Organization: {org}" + target = _escape_for_powershell(raw_target) ps = ( "Add-Type -AssemblyName System.Windows.Forms; " "$r = [System.Windows.Forms.MessageBox]::Show(" @@ -527,9 +528,15 @@ def _ask_approval(cmd_str: str, org: str, *, repo: str | None = None) -> bool: if gui_result is not None: return gui_result - # No GUI available — cannot safely prompt (terminal is trivially auto-approvable) - _err("Cannot request approval: no graphical display available.") - _err("Ensure DISPLAY or WAYLAND_DISPLAY is set (e.g. ssh -X).") + # Cannot get user approval — either no display or no GUI toolkit found + if not _has_display(): + _err("Cannot request approval: no graphical display available.") + _err("Ensure DISPLAY or WAYLAND_DISPLAY is set (e.g. ssh -X).") + else: + _err("Cannot request approval: no supported GUI dialog tool found.") + if system == "Linux": + _err("Install one of: xmessage, zenity, kdialog.") + _err("Display is available but no toolkit could show the dialog.") sys.exit(EXIT_NO_INTERACTIVE) diff --git a/tests/test_detection.py b/tests/test_detection.py index 647d003..215ae2e 100644 --- a/tests/test_detection.py +++ b/tests/test_detection.py @@ -5,7 +5,6 @@ from unittest.mock import patch, MagicMock import subprocess -import pytest from ghsudo.__main__ import ( _detect_org, @@ -27,16 +26,28 @@ class TestDetectRepoSlugFromArgs: """Tests for extracting owner/repo from gh CLI arguments.""" def test_dash_R_separate(self): - assert _detect_repo_slug_from_args(["gh", "pr", "-R", "acme/widget", "list"]) == "acme/widget" + assert ( + _detect_repo_slug_from_args(["gh", "pr", "-R", "acme/widget", "list"]) + == "acme/widget" + ) def test_dash_R_attached(self): - assert _detect_repo_slug_from_args(["gh", "pr", "-Racme/widget", "list"]) == "acme/widget" + assert ( + _detect_repo_slug_from_args(["gh", "pr", "-Racme/widget", "list"]) + == "acme/widget" + ) def test_long_repo_separate(self): - assert _detect_repo_slug_from_args(["gh", "issue", "--repo", "Org/Repo"]) == "org/repo" + assert ( + _detect_repo_slug_from_args(["gh", "issue", "--repo", "Org/Repo"]) + == "org/repo" + ) def test_long_repo_equals(self): - assert _detect_repo_slug_from_args(["gh", "--repo=Org/Repo", "pr", "list"]) == "org/repo" + assert ( + _detect_repo_slug_from_args(["gh", "--repo=Org/Repo", "pr", "list"]) + == "org/repo" + ) def test_no_repo_flag(self): assert _detect_repo_slug_from_args(["gh", "pr", "list"]) is None From 8f45c7c8691dfc33c4447cf9f8ca0156b3006c4f Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Wed, 4 Mar 2026 11:24:29 +0100 Subject: [PATCH 7/9] =?UTF-8?q?test:=20remove=206=20duplicate=20tests=20(2?= =?UTF-8?q?8=20=E2=86=92=2022)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drop TestDetectOrg (5 tests) — thin wrappers retesting slug functions through a trivial split. Drop test_http_url — same regex path as test_https_url. Co-Authored-By: Claude Opus 4.6 --- tests/test_detection.py | 34 ---------------------------------- 1 file changed, 34 deletions(-) diff --git a/tests/test_detection.py b/tests/test_detection.py index 215ae2e..27d17bf 100644 --- a/tests/test_detection.py +++ b/tests/test_detection.py @@ -7,9 +7,6 @@ from ghsudo.__main__ import ( - _detect_org, - _detect_org_from_args, - _detect_org_from_git_remote, _detect_repo_slug, _detect_repo_slug_from_args, _detect_repo_slug_from_git_remote, @@ -95,10 +92,6 @@ def test_https_url_no_dot_git(self): with _mock_git_remote("https://github.com/Acme/Widget\n"): assert _detect_repo_slug_from_git_remote() == "acme/widget" - def test_http_url(self): - with _mock_git_remote("http://github.com/foo/bar.git\n"): - assert _detect_repo_slug_from_git_remote() == "foo/bar" - def test_non_github_url(self): with _mock_git_remote("git@gitlab.com:org/repo.git\n"): assert _detect_repo_slug_from_git_remote() is None @@ -144,33 +137,6 @@ def test_none_when_nothing(self): assert _detect_repo_slug(["gh", "pr", "list"]) is None -# --------------------------------------------------------------------------- -# _detect_org_from_args / _detect_org_from_git_remote / _detect_org -# --------------------------------------------------------------------------- - - -class TestDetectOrg: - """Org helpers should delegate to slug functions and return the owner.""" - - def test_org_from_args(self): - assert _detect_org_from_args(["gh", "-R", "myorg/myrepo"]) == "myorg" - - def test_org_from_args_none(self): - assert _detect_org_from_args(["gh", "pr", "list"]) is None - - def test_org_from_git_remote(self): - with _mock_git_remote("git@github.com:SomeOrg/SomeRepo.git"): - assert _detect_org_from_git_remote() == "someorg" - - def test_detect_org_args_first(self): - with _mock_git_remote("git@github.com:remote-org/repo.git"): - assert _detect_org(["gh", "-R", "arg-org/repo"]) == "arg-org" - - def test_detect_org_falls_back(self): - with _mock_git_remote("git@github.com:remote-org/repo.git"): - assert _detect_org(["gh", "pr", "list"]) == "remote-org" - - # --------------------------------------------------------------------------- # _format_approval_msg # --------------------------------------------------------------------------- From 65577950772351e785d34e680df788ece54b5c05 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Wed, 4 Mar 2026 12:08:30 +0100 Subject: [PATCH 8/9] fix: address remaining Copilot review comments - Timeout now auto-denies (exit 2) instead of returning "no GUI" (exit 3). _run_gui returns 1 on timeout, distinguishing it from tool-not-found (None). - Clarify HTTPS requirement: needed for git push/pull via gh credential helper, not for ghsudo gh commands themselves. - Update exit code 3 descriptions to cover both no-display and no-toolkit. - Remove stale "terminal prompt" mention from README. Co-Authored-By: Claude Opus 4.6 --- AGENTS-example.md | 6 +++--- CLAUDE-example.md | 6 +++--- CLAUDE.md | 6 +++--- README.md | 8 ++++---- src/ghsudo/__main__.py | 10 ++++++---- 5 files changed, 19 insertions(+), 17 deletions(-) diff --git a/AGENTS-example.md b/AGENTS-example.md index 7f4d20e..f5fea9c 100644 --- a/AGENTS-example.md +++ b/AGENTS-example.md @@ -11,8 +11,8 @@ ghsudo gh issue comment 42 --body "Done!" ## Rules -- **Always** use `https://` URLs for git remotes (required for `ghsudo` token injection). If git push/pull fails over HTTPS, remind the user to run `gh auth setup-git` to configure the credential helper. +- **Always** use `https://` URLs for git remotes so that `git push`/`pull` can use the `gh` credential helper. `ghsudo` itself injects `GH_TOKEN`/`GITHUB_TOKEN` for the `gh` CLI and does not depend on the remote URL scheme. If git push/pull fails over HTTPS, remind the user to run `gh auth setup-git`. - **Never** bypass `ghsudo` or ask the user for the write token directly. -- Exit code 2 (denied): stop and report to the user. -- Exit code 3 (no interactive session): inform the user approval is not possible. +- Exit code 2 (denied or timed out): stop and report to the user. +- Exit code 3 (no GUI display or no supported dialog tool): inform the user approval is not possible. - Exit code 4 (not set up): tell the user to follow . diff --git a/CLAUDE-example.md b/CLAUDE-example.md index 7f4d20e..f5fea9c 100644 --- a/CLAUDE-example.md +++ b/CLAUDE-example.md @@ -11,8 +11,8 @@ ghsudo gh issue comment 42 --body "Done!" ## Rules -- **Always** use `https://` URLs for git remotes (required for `ghsudo` token injection). If git push/pull fails over HTTPS, remind the user to run `gh auth setup-git` to configure the credential helper. +- **Always** use `https://` URLs for git remotes so that `git push`/`pull` can use the `gh` credential helper. `ghsudo` itself injects `GH_TOKEN`/`GITHUB_TOKEN` for the `gh` CLI and does not depend on the remote URL scheme. If git push/pull fails over HTTPS, remind the user to run `gh auth setup-git`. - **Never** bypass `ghsudo` or ask the user for the write token directly. -- Exit code 2 (denied): stop and report to the user. -- Exit code 3 (no interactive session): inform the user approval is not possible. +- Exit code 2 (denied or timed out): stop and report to the user. +- Exit code 3 (no GUI display or no supported dialog tool): inform the user approval is not possible. - Exit code 4 (not set up): tell the user to follow . diff --git a/CLAUDE.md b/CLAUDE.md index 67028ff..494b52d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -11,10 +11,10 @@ ghsudo gh issue comment 42 --body "Done!" ## Rules -- **Always** use `https://` URLs for git remotes (required for `ghsudo` token injection). If git push/pull fails over HTTPS, remind the user to run `gh auth setup-git` to configure the credential helper. +- **Always** use `https://` URLs for git remotes so that `git push`/`pull` can use the `gh` credential helper. `ghsudo` itself injects `GH_TOKEN`/`GITHUB_TOKEN` for the `gh` CLI and does not depend on the remote URL scheme. If git push/pull fails over HTTPS, remind the user to run `gh auth setup-git`. - **Never** bypass `ghsudo` or ask the user for the write token directly. -- Exit code 2 (denied): stop and report to the user. -- Exit code 3 (no interactive session): inform the user approval is not possible. +- Exit code 2 (denied or timed out): stop and report to the user. +- Exit code 3 (no GUI display or no supported dialog tool): inform the user approval is not possible. - Exit code 4 (not set up): tell the user to follow . # ghsudo — Developer Guide diff --git a/README.md b/README.md index fb8c3c9..374cd3e 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,7 @@ The naive solutions both have drawbacks: 2. **Write token** — stored encrypted on your machine. When the agent needs to perform a write operation (a `gh` command that would otherwise fail with HTTP 403), it calls `ghsudo` instead. `ghsudo` then: -- Shows you a **GUI popup** (or terminal prompt) listing the exact command to be executed. +- Shows you a **GUI popup** listing the exact command to be executed. - **Waits for your explicit approval** before proceeding. - If approved, re-runs the command with the elevated write token injected into the environment. - If denied (or timed out after 60 s), exits with a non-zero code so the agent knows it was blocked. @@ -42,8 +42,8 @@ cd ghsudo pip install . ``` -> **Note:** Git remotes **must** use `https://` URLs (not SSH). `ghsudo` injects the elevated token via `GH_TOKEN`/`GITHUB_TOKEN` environment variables, which only work with HTTPS remotes. -> To make `git` use `gh` as its credential helper (so `git push`/`pull` work over HTTPS), run: +> **Note:** For `git push`/`pull` to work with `ghsudo`'s elevated token, your remotes need `https://` URLs (not SSH). `ghsudo` injects `GH_TOKEN`/`GITHUB_TOKEN` which the `gh` credential helper uses for HTTPS Git operations. (`ghsudo gh ...` commands work regardless of remote URL scheme.) +> To configure `gh` as the Git credential helper, run: > ```bash > gh auth setup-git > ``` @@ -247,7 +247,7 @@ The dialog auto-denies after **60 seconds** of no response to prevent the agent | 0 | Success | | 1 | Error | | 2 | User denied the request | -| 3 | No graphical display available to show approval dialog | +| 3 | No graphical display available, or no supported GUI dialog tool found | | 4 | No token stored for the target org | ## Debugging diff --git a/src/ghsudo/__main__.py b/src/ghsudo/__main__.py index 9dd913d..8e6c7d2 100644 --- a/src/ghsudo/__main__.py +++ b/src/ghsudo/__main__.py @@ -352,9 +352,10 @@ def _escape_for_powershell(s: str) -> str: def _run_gui(cmd: list[str]) -> int | None: - """Run a GUI command with timeout. Returns exit code, or None on failure. + """Run a GUI command with timeout. Returns exit code, or None if not found. - Properly kills the child process on timeout (subprocess.run doesn't). + Returns 1 (denial) on timeout — the user didn't respond in time. + Returns None only when the tool is not installed (FileNotFoundError). """ _debug(f"gui: launching {cmd[0]}") try: @@ -372,10 +373,11 @@ def _run_gui(cmd: list[str]) -> int | None: _debug(f"gui: {cmd[0]} exited with {proc.returncode}") return proc.returncode except subprocess.TimeoutExpired: - _debug(f"gui: {cmd[0]} timed out after {_GUI_TIMEOUT}s, killing") + _debug(f"gui: {cmd[0]} timed out after {_GUI_TIMEOUT}s, auto-denying") proc.kill() proc.wait() - return None + _info(f"Dialog timed out after {_GUI_TIMEOUT}s — auto-denied.") + return 1 # treat as denial, not as tool-unavailable def _format_approval_msg(cmd_str: str, org: str, repo: str | None = None) -> str: From ff4bb259bb0ab8932fcc1fd7df472cc0f0d529c9 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Wed, 4 Mar 2026 12:33:29 +0100 Subject: [PATCH 9/9] fix: validate repo slug has exactly two non-empty segments Reject malformed slugs like "org/", "/repo", "a/b/c" in _detect_repo_slug_from_args via new _parse_repo_slug helper. Co-Authored-By: Claude Opus 4.6 --- src/ghsudo/__main__.py | 27 ++++++++++++++++++--------- tests/test_detection.py | 9 +++++++++ 2 files changed, 27 insertions(+), 9 deletions(-) diff --git a/src/ghsudo/__main__.py b/src/ghsudo/__main__.py index 8e6c7d2..338985d 100644 --- a/src/ghsudo/__main__.py +++ b/src/ghsudo/__main__.py @@ -259,22 +259,31 @@ def _load_token(org: str) -> str: # --------------------------------------------------------------------------- +def _parse_repo_slug(value: str) -> str | None: + """Validate and normalize an owner/repo slug. Returns None if invalid.""" + value = value.strip().lower() + parts = value.split("/") + if len(parts) == 2 and parts[0] and parts[1]: + return value + return None + + def _detect_repo_slug_from_args(cmd: list[str]) -> str | None: """Extract owner/repo slug from -R/--repo in gh command args.""" for i, arg in enumerate(cmd): if arg in ("-R", "--repo") and i + 1 < len(cmd): - repo_arg = cmd[i + 1] - if "/" in repo_arg: - return repo_arg.strip().lower() + slug = _parse_repo_slug(cmd[i + 1]) + if slug: + return slug # Handle --repo=owner/repo if arg.startswith("--repo="): - repo_arg = arg.split("=", 1)[1] - if "/" in repo_arg: - return repo_arg.strip().lower() + slug = _parse_repo_slug(arg.split("=", 1)[1]) + if slug: + return slug if arg.startswith("-R") and len(arg) > 2: - repo_arg = arg[2:] - if "/" in repo_arg: - return repo_arg.strip().lower() + slug = _parse_repo_slug(arg[2:]) + if slug: + return slug return None diff --git a/tests/test_detection.py b/tests/test_detection.py index 27d17bf..fbb0123 100644 --- a/tests/test_detection.py +++ b/tests/test_detection.py @@ -59,6 +59,15 @@ def test_repo_without_slash(self): def test_whitespace_trimmed(self): assert _detect_repo_slug_from_args(["gh", "-R", " Org/Repo "]) == "org/repo" + def test_trailing_slash_rejected(self): + assert _detect_repo_slug_from_args(["gh", "-R", "org/"]) is None + + def test_leading_slash_rejected(self): + assert _detect_repo_slug_from_args(["gh", "-R", "/repo"]) is None + + def test_extra_segments_rejected(self): + assert _detect_repo_slug_from_args(["gh", "-R", "a/b/c"]) is None + # --------------------------------------------------------------------------- # _detect_repo_slug_from_git_remote