claude-dev-env 8.48.7 → 8.49.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -205,6 +205,9 @@ The recovery command restores the named account and deletes only that record.
205
205
  `--confirm-inactive` before recovery.
206
206
  - Normal pull request actions never call `gh auth switch`. If that command
207
207
  appears outside explicit recovery, stop.
208
+ - `gh auth switch`, `login`, and `logout` change the account for every session
209
+ on the machine, and a PreToolUse hook denies them. Scope another account to
210
+ one command: `GH_TOKEN=$(gh auth token -u <account>) gh <command>`.
208
211
  - GitHub CLI output can contain account data. The command captures account
209
212
  lookup and recovery output and prints only generic errors.
210
213
 
@@ -0,0 +1,116 @@
1
+ #!/usr/bin/env python3
2
+ """PreToolUse gate: keep agents from changing the global GitHub CLI account.
3
+
4
+ ``gh auth switch``, ``gh auth login``, and ``gh auth logout`` write the shared
5
+ gh configuration, so they change the active account for every session on the
6
+ machine. A command segment whose program is ``gh`` and whose arguments start
7
+ with one of those subcommands is denied, and the deny reason names the
8
+ per-command ``GH_TOKEN`` form to run instead. The program is read past wrappers
9
+ and chained commands, so ``cd x && gh auth switch`` and ``bash -c "gh auth
10
+ logout"`` are denied too. ``gh auth status``, ``gh auth token``, and quoted text
11
+ that only mentions the words pass.
12
+
13
+ Hosted by ``blocking/bash_pre_tool_use_dispatcher.py`` for the Bash and
14
+ PowerShell tools.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import json
20
+ import sys
21
+ from pathlib import Path
22
+
23
+ try:
24
+ _hooks_root_directory = str(Path(__file__).resolve().parent.parent)
25
+ if _hooks_root_directory not in sys.path:
26
+ sys.path.insert(0, _hooks_root_directory)
27
+ from hooks_constants.bash_pre_tool_use_dispatcher_constants import (
28
+ ALL_BASH_AND_POWERSHELL_TOOL_NAMES,
29
+ DENY_DECISION,
30
+ HOOK_EVENT_NAME,
31
+ )
32
+ from hooks_constants.gh_global_account_switch_gate_constants import (
33
+ ALL_GLOBAL_ACCOUNT_CHANGING_COMMAND_PATHS,
34
+ GATE_HOOK_NAME,
35
+ GH_COMMAND_PATH_LENGTH,
36
+ GH_PROGRAM_NAME,
37
+ GLOBAL_ACCOUNT_CHANGE_DENY_REASON,
38
+ )
39
+ from hooks_constants.hook_block_logger import log_hook_block
40
+ from hooks_constants.pre_tool_use_stdin import read_hook_input_dictionary_from_stdin
41
+ from hooks_constants.shell_command_pipeline import pipeline_segments_for_command
42
+ from hooks_constants.shell_command_wrappers import (
43
+ all_wrapped_command_texts,
44
+ segment_program_and_arguments,
45
+ )
46
+ except ImportError as import_error:
47
+ raise ImportError(
48
+ "The gh global account switch gate cannot import its dependencies; "
49
+ "ensure the hooks directory is importable."
50
+ ) from import_error
51
+
52
+
53
+ def _changes_global_gh_account(all_segment_tokens: list[str]) -> bool:
54
+ """Return True when a segment runs gh auth switch, login, or logout.
55
+
56
+ Args:
57
+ all_segment_tokens: One command segment's shell tokens.
58
+ """
59
+ program_name, all_arguments = segment_program_and_arguments(all_segment_tokens)
60
+ command_path = tuple(all_arguments[:GH_COMMAND_PATH_LENGTH])
61
+ return program_name.lower() == GH_PROGRAM_NAME and command_path in ALL_GLOBAL_ACCOUNT_CHANGING_COMMAND_PATHS
62
+
63
+
64
+ def changes_global_gh_account(command: str) -> bool:
65
+ """Return True when any command segment changes the machine-wide gh account.
66
+
67
+ ::
68
+
69
+ gh auth switch -u <account> -> True
70
+ git fetch && gh auth login -> True
71
+ GH_TOKEN=$(gh auth token -u <account>) gh repo list -> False
72
+ git commit -m "never run gh auth switch" -> False
73
+
74
+ Args:
75
+ command: The shell command text the agent is about to run.
76
+ """
77
+ return any(
78
+ _changes_global_gh_account(each_segment)
79
+ for each_text in all_wrapped_command_texts(command)
80
+ for each_segment, _each_following_operator in pipeline_segments_for_command(each_text)
81
+ )
82
+
83
+
84
+ def main() -> None:
85
+ """Deny a command that changes the global gh account, or stay quiet."""
86
+ hook_payload = read_hook_input_dictionary_from_stdin()
87
+ if hook_payload is None:
88
+ return
89
+ tool_name = hook_payload.get("tool_name")
90
+ if tool_name not in ALL_BASH_AND_POWERSHELL_TOOL_NAMES:
91
+ return
92
+ tool_input = hook_payload.get("tool_input")
93
+ if not isinstance(tool_input, dict):
94
+ return
95
+ command = tool_input.get("command", "")
96
+ if not isinstance(command, str) or not changes_global_gh_account(command):
97
+ return
98
+ log_hook_block(
99
+ GATE_HOOK_NAME, HOOK_EVENT_NAME, GLOBAL_ACCOUNT_CHANGE_DENY_REASON, str(tool_name), command
100
+ )
101
+ sys.stdout.write(
102
+ json.dumps(
103
+ {
104
+ "hookSpecificOutput": {
105
+ "hookEventName": HOOK_EVENT_NAME,
106
+ "permissionDecision": DENY_DECISION,
107
+ "permissionDecisionReason": GLOBAL_ACCOUNT_CHANGE_DENY_REASON,
108
+ }
109
+ }
110
+ )
111
+ )
112
+ sys.stdout.flush()
113
+
114
+
115
+ if __name__ == "__main__":
116
+ main()
@@ -2,8 +2,9 @@
2
2
 
3
3
  A Bash tool call starts one interpreter, the dispatcher's own. Each roster
4
4
  entry then runs in that process through runpy, so the roster's exact content is
5
- the chain's cost after that single start. The roster holds the MSYS rewriter and
6
- the headless Claude broker gate; any other hook added here fails this test.
5
+ the chain's cost after that single start. The roster holds the MSYS rewriter,
6
+ the headless Claude broker gate, and the gh global account gate; any other hook
7
+ added here fails this test.
7
8
  """
8
9
 
9
10
  import sys
@@ -21,7 +22,7 @@ from hooks_constants.bash_pre_tool_use_dispatcher_constants import (
21
22
  )
22
23
 
23
24
 
24
- def test_bash_roster_starts_only_the_msys_rewriter_and_the_broker_gate() -> None:
25
+ def test_bash_roster_starts_only_the_msys_rewriter_and_the_two_gates() -> None:
25
26
  assert ALL_BASH_HOSTED_HOOK_ENTRIES == (
26
27
  BashHostedHookEntry(
27
28
  script_relative_path="blocking/msys_rev_path_rewriter.py",
@@ -31,4 +32,8 @@ def test_bash_roster_starts_only_the_msys_rewriter_and_the_broker_gate() -> None
31
32
  script_relative_path="blocking/headless_claude_broker_gate.py",
32
33
  applicable_tool_names=ALL_BASH_AND_POWERSHELL_TOOL_NAMES,
33
34
  ),
35
+ BashHostedHookEntry(
36
+ script_relative_path="blocking/gh_global_account_switch_gate.py",
37
+ applicable_tool_names=ALL_BASH_AND_POWERSHELL_TOOL_NAMES,
38
+ ),
34
39
  )
@@ -0,0 +1,100 @@
1
+ """Tests for gh_global_account_switch_gate, which keeps the global gh account fixed."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from io import StringIO
7
+ from unittest.mock import patch
8
+
9
+ import pytest
10
+
11
+ import gh_global_account_switch_gate as gate
12
+
13
+
14
+ def _stdout_from_main(payload: dict[str, object]) -> str:
15
+ """Return what main() wrote to stdout for one hook payload."""
16
+ captured_stdout = StringIO()
17
+ with (
18
+ patch("sys.stdin", StringIO(json.dumps(payload))),
19
+ patch("sys.stdout", captured_stdout),
20
+ patch.object(gate, "log_hook_block"),
21
+ ):
22
+ gate.main()
23
+ return captured_stdout.getvalue()
24
+
25
+
26
+ @pytest.mark.parametrize(
27
+ "command",
28
+ [
29
+ "gh auth switch",
30
+ "gh auth switch -u account-a",
31
+ "gh auth login",
32
+ "gh auth login --with-token",
33
+ "gh auth logout -u account-b",
34
+ "gh.exe auth switch -u account-b",
35
+ "cd repo && gh auth switch -u account-a",
36
+ "gh auth status; gh auth switch -u account-a",
37
+ "gh auth status || gh auth login",
38
+ 'bash -c "gh auth switch -u account-a"',
39
+ 'pwsh -Command "gh auth logout"',
40
+ "& gh auth switch",
41
+ "sudo gh auth switch --user account-b",
42
+ ],
43
+ )
44
+ def test_global_account_change_is_detected(command: str) -> None:
45
+ assert gate.changes_global_gh_account(command) is True
46
+
47
+
48
+ @pytest.mark.parametrize(
49
+ "command",
50
+ [
51
+ "gh auth status",
52
+ "gh auth status --active",
53
+ "gh auth token -u account-a",
54
+ "GH_TOKEN=$(gh auth token -u account-a) gh repo list",
55
+ "$env:GH_TOKEN = gh auth token -u account-a; gh repo list; Remove-Item Env:GH_TOKEN",
56
+ "gh pr view 12",
57
+ "gh api user",
58
+ "gh",
59
+ 'git commit -m "never call gh auth switch"',
60
+ 'echo "gh auth switch"',
61
+ 'grep -rn "gh auth login" docs',
62
+ "git switch main",
63
+ ],
64
+ )
65
+ def test_read_only_and_unrelated_commands_pass(command: str) -> None:
66
+ assert gate.changes_global_gh_account(command) is False
67
+
68
+
69
+ def test_bash_payload_with_switch_is_denied_with_the_scoped_token_forms() -> None:
70
+ decision = json.loads(
71
+ _stdout_from_main(
72
+ {"tool_name": "Bash", "tool_input": {"command": "gh auth switch -u account-a"}}
73
+ )
74
+ )
75
+ specific_output = decision["hookSpecificOutput"]
76
+ assert specific_output["permissionDecision"] == "deny"
77
+ reason = specific_output["permissionDecisionReason"]
78
+ assert "GH_TOKEN=$(gh auth token -u <account>) gh <command>" in reason
79
+ assert (
80
+ "$env:GH_TOKEN = gh auth token -u <account>; gh <command>; Remove-Item Env:GH_TOKEN"
81
+ in reason
82
+ )
83
+
84
+
85
+ def test_powershell_payload_with_logout_is_denied() -> None:
86
+ decision = json.loads(
87
+ _stdout_from_main({"tool_name": "PowerShell", "tool_input": {"command": "gh auth logout"}})
88
+ )
89
+ assert decision["hookSpecificOutput"]["permissionDecision"] == "deny"
90
+
91
+
92
+ def test_scoped_token_payload_emits_nothing() -> None:
93
+ scoped_command = "GH_TOKEN=$(gh auth token -u account-b) gh repo list"
94
+ assert _stdout_from_main({"tool_name": "Bash", "tool_input": {"command": scoped_command}}) == ""
95
+
96
+
97
+ def test_other_tool_payload_emits_nothing() -> None:
98
+ assert (
99
+ _stdout_from_main({"tool_name": "Write", "tool_input": {"command": "gh auth switch"}}) == ""
100
+ )
@@ -63,4 +63,8 @@ ALL_BASH_HOSTED_HOOK_ENTRIES: tuple[BashHostedHookEntry, ...] = (
63
63
  script_relative_path="blocking/headless_claude_broker_gate.py",
64
64
  applicable_tool_names=ALL_BASH_AND_POWERSHELL_TOOL_NAMES,
65
65
  ),
66
+ BashHostedHookEntry(
67
+ script_relative_path="blocking/gh_global_account_switch_gate.py",
68
+ applicable_tool_names=ALL_BASH_AND_POWERSHELL_TOOL_NAMES,
69
+ ),
66
70
  )
@@ -0,0 +1,33 @@
1
+ """Constants for the gate that keeps agents from changing the global gh account.
2
+
3
+ Holds the GitHub CLI program name, the ``gh auth`` command paths that change the
4
+ account every session on the machine uses and their word count, the hook name
5
+ the block log records, and the deny message that names the per-command token
6
+ form to run instead.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ __all__ = [
12
+ "GH_PROGRAM_NAME",
13
+ "GH_COMMAND_PATH_LENGTH",
14
+ "ALL_GLOBAL_ACCOUNT_CHANGING_COMMAND_PATHS",
15
+ "GATE_HOOK_NAME",
16
+ "GLOBAL_ACCOUNT_CHANGE_DENY_REASON",
17
+ ]
18
+
19
+ GH_PROGRAM_NAME: str = "gh"
20
+ GH_COMMAND_PATH_LENGTH: int = 2
21
+ ALL_GLOBAL_ACCOUNT_CHANGING_COMMAND_PATHS: frozenset[tuple[str, ...]] = frozenset(
22
+ {("auth", "switch"), ("auth", "login"), ("auth", "logout")}
23
+ )
24
+ GATE_HOOK_NAME: str = "gh_global_account_switch_gate.py"
25
+ GLOBAL_ACCOUNT_CHANGE_DENY_REASON: str = (
26
+ "BLOCKED: gh auth switch, login, and logout change the active GitHub CLI account "
27
+ "for every session on this machine, so other running agents lose access. "
28
+ "Scope the other signed-in account to one command instead. "
29
+ "Bash: GH_TOKEN=$(gh auth token -u <account>) gh <command>. "
30
+ "PowerShell: $env:GH_TOKEN = gh auth token -u <account>; gh <command>; Remove-Item Env:GH_TOKEN. "
31
+ "Run gh auth status to list the signed-in accounts. "
32
+ "Signing an account in or out is a step for the user to run."
33
+ )
@@ -12,7 +12,7 @@ ALL_BASH_AND_POWERSHELL_TOOL_NAMES = ALL_CONSTANT_BINDINGS["ALL_BASH_AND_POWERSH
12
12
  BashHostedHookEntry = ALL_CONSTANT_BINDINGS["BashHostedHookEntry"]
13
13
 
14
14
 
15
- def test_roster_hosts_only_the_msys_rewriter_and_the_broker_gate() -> None:
15
+ def test_roster_hosts_the_msys_rewriter_the_broker_gate_and_the_gh_account_gate() -> None:
16
16
  assert ALL_BASH_HOSTED_HOOK_ENTRIES == (
17
17
  BashHostedHookEntry(
18
18
  script_relative_path="blocking/msys_rev_path_rewriter.py",
@@ -22,4 +22,8 @@ def test_roster_hosts_only_the_msys_rewriter_and_the_broker_gate() -> None:
22
22
  script_relative_path="blocking/headless_claude_broker_gate.py",
23
23
  applicable_tool_names=ALL_BASH_AND_POWERSHELL_TOOL_NAMES,
24
24
  ),
25
+ BashHostedHookEntry(
26
+ script_relative_path="blocking/gh_global_account_switch_gate.py",
27
+ applicable_tool_names=ALL_BASH_AND_POWERSHELL_TOOL_NAMES,
28
+ ),
25
29
  )
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-dev-env",
3
- "version": "8.48.7",
3
+ "version": "8.49.0",
4
4
  "description": "Claude Code development standards — rules, hooks, agents, commands, and skills",
5
5
  "type": "module",
6
6
  "bin": {