claude-dev-env 8.48.6 → 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
 
@@ -27,7 +27,7 @@ The broker stores cached meters, spent marks, and Claude session bindings in one
27
27
  | `0` | `choose` or `check` found an account, or the job exited successfully. |
28
28
  | `3` | No account clears its floor. The decision includes `resets_at`. |
29
29
  | `4` | A Claude job stopped with `advisor_blocked`. |
30
- | `127` | A Codex job timed out, or its command could not start while no Codex roster is configured. |
30
+ | `127` | A Codex job timed out, its command could not start while no Codex roster is configured, or the command could not start on any account in the roster. |
31
31
  | Other command code | `run` returns the command's exit code after a served response. |
32
32
 
33
33
  `run` writes a JSON report with `product`, `command`, `events`, and `final_decision`. A wait report has `final_decision.action` equal to `wait` and a UTC `final_decision.resets_at`.
@@ -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
  )
@@ -31,6 +31,7 @@ def test_should_move_the_spawn_to_opus(requested_model: object) -> None:
31
31
  if requested_model is not None:
32
32
  tool_input["model"] = requested_model
33
33
  decision = json.loads(_run_hook(tool_input))["hookSpecificOutput"]
34
+ assert decision["hookEventName"] == "PreToolUse"
34
35
  assert "permissionDecision" not in decision
35
36
  assert decision["updatedInput"] == {**SPAWN_INPUT, "model": "opus"}
36
37
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-dev-env",
3
- "version": "8.48.6",
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": {
@@ -234,6 +234,9 @@ def _prepare_run(
234
234
 
235
235
  def _wait_outcome(context: _RunContext, decision: Decision) -> JobOutcome:
236
236
  context.report.final_decision = decision
237
+ all_attempt_statuses = {each_status for _, each_status in context.all_attempts}
238
+ if all_attempt_statuses == {"start_failed"}:
239
+ return JobOutcome(COMMAND_MISSING_EXIT_CODE, "", "", None, tuple(context.all_attempts), "start_failed", None, None)
237
240
  status = "exhausted" if context.all_attempts else "wait"
238
241
  return JobOutcome(WAIT_EXIT_CODE, "", "", None, tuple(context.all_attempts), status, None, decision.resets_at)
239
242
 
@@ -418,6 +418,50 @@ def test_should_leave_later_choices_open_after_start_failure(
418
418
  assert json.loads(capsys.readouterr().out)["decision"]["account"] == "first"
419
419
 
420
420
 
421
+ @pytest.mark.parametrize("product", (Product.CLAUDE, Product.CODEX))
422
+ def test_should_exit_127_when_every_account_fails_to_start(
423
+ monkeypatch: pytest.MonkeyPatch, tmp_path: Path, product: Product
424
+ ) -> None:
425
+ accounts = (_account("first", product), _account("second", product))
426
+ monkeypatch.setitem(account_broker.all_product_adapters, product, _adapter(accounts, {
427
+ "first": _meters(80, 80), "second": _meters(70, 70)
428
+ }))
429
+ monkeypatch.setattr(account_broker.sys, "stdin", io.TextIOWrapper(io.BytesIO(b""), encoding="utf-8"))
430
+
431
+ def runner(argv: object, **options: object) -> subprocess.CompletedProcess[str]:
432
+ raise OSError("missing command")
433
+
434
+ with account_broker.override_subprocess_runner(runner):
435
+ outcome, _ = account_broker._execute(product, ("job",), now=datetime.now(timezone.utc))
436
+ code = account_broker.main(("run", "--product", product.value, "--report", str(tmp_path / "report.json"), "--", "job"))
437
+
438
+ assert outcome.status == "start_failed"
439
+ assert outcome.returncode == 127
440
+ assert outcome.wait_reset_at is None
441
+ assert code == 127
442
+
443
+
444
+ def test_should_report_exhausted_when_start_failure_meets_usage_limit(
445
+ monkeypatch: pytest.MonkeyPatch,
446
+ ) -> None:
447
+ accounts = (_account("first"), _account("second"))
448
+ monkeypatch.setitem(account_broker.all_product_adapters, Product.CODEX, _adapter(accounts, {
449
+ "first": _meters(80, 80), "second": _meters(70, 70)
450
+ }))
451
+
452
+ def runner(argv: object, **options: object) -> subprocess.CompletedProcess[str]:
453
+ if Path(options["env"]["CODEX_HOME"]).name == "first":
454
+ raise OSError("missing command")
455
+ return subprocess.CompletedProcess(argv, 1, "", "rate limit")
456
+
457
+ with account_broker.override_subprocess_runner(runner):
458
+ outcome, _ = account_broker._execute(Product.CODEX, ("job",), now=datetime.now(timezone.utc))
459
+
460
+ assert outcome.attempts == (("first", "start_failed"), ("second", "usage_limited"))
461
+ assert outcome.status == "exhausted"
462
+ assert outcome.returncode == 3
463
+
464
+
421
465
  def test_should_stop_after_timeout_without_running_job_again(
422
466
  monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
423
467
  ) -> None:
@@ -707,8 +751,8 @@ def test_should_record_start_failure_without_launching_a_command_missing_from_pa
707
751
  outcome, _ = account_broker._execute(Product.CLAUDE, ("claude", "-p"), now=NOW)
708
752
 
709
753
  assert outcome.attempts == (("extra", "start_failed"),)
710
- assert outcome.status == "exhausted"
711
- assert outcome.returncode == WAIT_EXIT_CODE
754
+ assert outcome.status == "start_failed"
755
+ assert outcome.returncode == 127
712
756
 
713
757
 
714
758
  @pytest.mark.parametrize("marked_name", ["retired", "first"])