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.
- package/.agents/skills/pull-request/SKILL.md +3 -0
- package/hooks/blocking/gh_global_account_switch_gate.py +116 -0
- package/hooks/blocking/test_bash_dispatcher_interpreter_starts.py +8 -3
- package/hooks/blocking/test_gh_global_account_switch_gate.py +100 -0
- package/hooks/hooks_constants/bash_pre_tool_use_dispatcher_constants.py +4 -0
- package/hooks/hooks_constants/gh_global_account_switch_gate_constants.py +33 -0
- package/hooks/hooks_constants/test_bash_pre_tool_use_dispatcher_constants.py +5 -1
- package/package.json +1 -1
|
@@ -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
|
|
6
|
-
the headless Claude broker gate
|
|
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
|
|
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
|
|
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
|
)
|