From debb7d9d7446552b937bf2b71d2fa997212cd53f Mon Sep 17 00:00:00 2001 From: Scott Hall Date: Mon, 19 Oct 2026 14:23:56 -0500 Subject: [PATCH] Cloud sessions: a cloud-only SessionStart hook installs pwsh and Python 3.14 and sets the git identity A Claude Code cloud VM ships no pwsh, so every repo hook fails to start and the shell guards are off. Its Python is 3.13, and its commits use an identity the required cla check refuses. Cloud environments are set per account in the claude.ai UI, but this repo's settings.json reaches every account. scripts/hooks/cloud-setup.sh is a no-op unless CLAUDE_CODE_REMOTE=true, gated inline as well. It installs pwsh, then Python 3.14 through uv. It commits as the pushing GitHub account's noreply (gh api user), never a fixed name. It re-runs the role card when pwsh is new, guesses no seat, and builds no venv. Every failure prints what is off; it exits 0. The settings contract now splits shell-form commands, scans .sh, and classifies .sh hooks, with a planted control. Probe: MEFORORG/MessageFoundry PR 2432. --- .claude/settings.json | 7 + scripts/hooks/cloud-setup.sh | 105 +++++++++++++++ tests/test_claude_settings_contract.py | 46 ++++++- tests/test_cloud_setup_hook.py | 177 +++++++++++++++++++++++++ tests/tooling_manifest.txt | 1 + 5 files changed, 331 insertions(+), 5 deletions(-) create mode 100755 scripts/hooks/cloud-setup.sh create mode 100644 tests/test_cloud_setup_hook.py diff --git a/.claude/settings.json b/.claude/settings.json index f930950ffc..8db7d10756 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -82,6 +82,13 @@ ], "timeout": 20, "statusMessage": "Injecting this worktree's role card" + }, + { + "type": "command", + "shell": "bash", + "command": "[ \"${CLAUDE_CODE_REMOTE:-}\" = true ] || exit 0; bash \"${CLAUDE_PROJECT_DIR}/scripts/hooks/cloud-setup.sh\"", + "timeout": 300, + "statusMessage": "Cloud session only: installing pwsh and Python 3.14, setting the git identity" } ] } diff --git a/scripts/hooks/cloud-setup.sh b/scripts/hooks/cloud-setup.sh new file mode 100755 index 0000000000..57e8e17bbd --- /dev/null +++ b/scripts/hooks/cloud-setup.sh @@ -0,0 +1,105 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: AGPL-3.0-or-later +# Copyright (C) 2026 MessageFoundry Foundation, LLC and contributors +# +# SessionStart setup for a Claude Code CLOUD session. A no-op everywhere else. +# +# WHY THIS IS IN THE REPOSITORY AND NOT IN A CLOUD ENVIRONMENT'S SETUP SCRIPT. A setup script lives +# in one claude.ai account's environment settings, entered by hand in the UI, and this project's +# sessions run under several separate accounts. The repository's .claude/settings.json is the one +# thing every cloud session clones, so a hook wired there covers every account with no per-account +# step. The cost is that it runs on every session start, not once per cached image. Measured on a +# probe session on 2026-10-19 (MEFORORG/MessageFoundry PR 2432): the pwsh install took 17 s and the +# Python 3.14 install 3 s, and both are skipped once present. +# +# WHAT A CLOUD SESSION LACKS, AND WHAT THAT COSTS. The image ships no pwsh, so every other hook in +# .claude/settings.json fails to start. A hook that cannot start is a non-blocking error, so the +# blanket-stage, API-burn and filesystem-scan guards are OFF while the tool calls run. The image's +# default Python is 3.13 and the project requires 3.14. Its commits are made as an identity the +# required `cla` check refuses (wiki note 20261002T125230275Z-0glfkb, PR 1926). +# +# LOUD, NEVER BLOCKING. Each failure prints a line naming what is now off, and the hook still exits +# 0: a SessionStart hook cannot stop a session, and a non-zero exit would only hide this output. +# +# THE GATE IS ALSO INLINE IN settings.json, so a local session never reads this file. The check +# here is the second copy, for anyone who runs the file by hand. + +[ "${CLAUDE_CODE_REMOTE:-}" = "true" ] || exit 0 + +say() { printf '[cloud-setup] %s\n' "$*"; } + +root="${CLAUDE_PROJECT_DIR:-$(git rev-parse --show-toplevel 2>/dev/null)}" +if [ -z "$root" ] || [ ! -f "$root/pyproject.toml" ]; then + say "NOT RUN: no checkout found (CLAUDE_PROJECT_DIR='${CLAUDE_PROJECT_DIR:-}'). Every repo guard is OFF in this session." + exit 0 +fi +cd "$root" || exit 0 + +# Run a step with its output kept out of the context unless it fails. +quiet() { + local log + log="$(mktemp)" + if "$@" >"$log" 2>&1; then rm -f "$log"; return 0; fi + tail -n 5 "$log" | sed 's/^/[cloud-setup] /' + rm -f "$log" + return 1 +} + +install_pwsh() { + local deb + deb="$(mktemp --suffix=.deb)" + # packages.microsoft.com is on the cloud environment's default Trusted allowlist. + curl -fsSL https://packages.microsoft.com/config/ubuntu/24.04/packages-microsoft-prod.deb -o "$deb" && + dpkg -i "$deb" && + apt-get update -qq && + DEBIAN_FRONTEND=noninteractive apt-get install -y -qq powershell + local rc=$? + rm -f "$deb" + return $rc +} + +pwsh_new=0 +if command -v pwsh >/dev/null 2>&1; then + : +elif quiet install_pwsh && command -v pwsh >/dev/null 2>&1; then + pwsh_new=1 + say "installed pwsh $(pwsh -NoProfile -Command '$PSVersionTable.PSVersion.ToString()' 2>/dev/null)" +else + say "PWSH INSTALL FAILED. Every hook in .claude/settings.json fails to start in this session, so the git-stage, API-burn and fs-scan guards are OFF. Do not run git add -A or a gh polling loop." +fi + +py="" +if quiet uv python install 3.14; then + py="$(uv python find 3.14 2>/dev/null)" +fi +if [ -z "$py" ]; then + say "PYTHON 3.14 INSTALL FAILED. The project requires 3.14; pytest and mypy will not run." +fi + +# The default cloud identity is not a GitHub account, so the required `cla` check refuses it. Commit +# as the GitHub account this session pushes as, through its noreply address: never a fixed name, so +# whoever runs the session is who the commits and the cla check name. Set for this clone only. +gh_user="$(gh api user --jq '"\(.id)\t\(.login)\t\(.name // .login)"' 2>/dev/null)" +if [ -n "$gh_user" ]; then + IFS=$'\t' read -r gh_id gh_login gh_name <<<"$gh_user" + git config user.name "$gh_name" + git config user.email "${gh_id}+${gh_login}@users.noreply.github.com" +else + say "git identity left as the session set it: gh api user failed. The cla check will refuse these commits." +fi + +# The venv is NOT built here. A full install runs for minutes, and a hook that reaches its timeout +# loses all of its output, including the warnings above. Build it as the first step of the work. +if [ -n "$py" ]; then + say "no venv yet. Before running checks: uv venv --python 3.14 .venv, then uv pip install --python .venv/bin/python with the install line in scripts/worktree/ensure-venv.ps1 (that script itself does not run on Linux yet)." +fi + +# Hooks for one event start together, so on a fresh VM the role-card hook ran before pwsh existed +# and injected nothing. Run it again only then; on a resumed VM it already ran and would repeat. +# It resolves the seat itself (KORUS_SEAT, set on the cloud environment); nothing here supplies one. +if [ "$pwsh_new" = 1 ]; then + pwsh -NoProfile -NonInteractive -File scripts/hooks/role-card-inject.ps1 -WorktreeRoot "$root" list[tuple[str, dict[str, Any]]] def _repo_script_refs(handler: dict[str, Any]) -> list[str]: - """Every token in a handler that names a file under the repo's script trees.""" - tokens = [handler.get("command", ""), *handler.get("args", [])] - return [t for t in tokens if isinstance(t, str) and (".ps1" in t or ".py" in t)] + """Every token in a handler that names a file under the repo's script trees. + + A shell-form handler (no `args`) carries its script inside one command string, so that string + is split as the shell would split it. Unsplit, the token starts with whatever precedes the + path, and the script would be invisible to the anchoring and existence checks below. + """ + command = handler.get("command", "") + if "args" in handler or not isinstance(command, str): + tokens = [command, *handler.get("args", [])] + else: + try: + tokens = shlex.split(command) + except ValueError: + # Unbalanced quoting: keep the whole string, so the anchoring check names this + # handler instead of every contract test dying in a traceback. + tokens = [command] + return [t for t in tokens if isinstance(t, str) and any(x in t for x in (".ps1", ".py", ".sh"))] def _unanchored_refs(settings: dict[str, Any]) -> list[str]: @@ -239,7 +254,8 @@ def _spawn_tools_the_matcher_misses(settings: dict[str, Any]) -> list[str]: def _hook_scripts() -> list[str]: - return sorted(p.name for p in (_ROOT / "scripts" / "hooks").glob("*.ps1")) + hooks = _ROOT / "scripts" / "hooks" + return sorted(p.name for pattern in ("*.ps1", "*.sh") for p in hooks.glob(pattern)) def _installer_wired(root: Path | None = None) -> set[str]: @@ -450,6 +466,20 @@ def _unclassified_for_planted(settings: dict[str, Any]) -> list[str]: _unanchored_refs, "bare relative hook path", ), + ( + # Shell form carries the path inside one command string. Unsplit, the token starts with + # `bash`, and before `.sh` was scanned at all this reference was invisible. + { + "permissions": {"deny": []}, + "hooks": { + "SessionStart": [ + {"hooks": [{"shell": "bash", "command": "bash scripts/hooks/x.sh"}]} + ] + }, + }, + _unanchored_refs, + "bare relative path in a shell-form hook", + ), ( {"permissions": {"deny": ["Read(./.env)"]}, "hooks": {}}, _dot_anchored_denies, @@ -496,7 +526,13 @@ def _unclassified_for_planted(settings: dict[str, Any]) -> list[str]: "matcher that is an exact-name list rather than the regex it resembles", ), ], - ids=["unanchored-hook", "dot-anchored-deny", "unwired-hook-script", "exact-list-matcher"], + ids=[ + "unanchored-hook", + "unanchored-shell-form-hook", + "dot-anchored-deny", + "unwired-hook-script", + "exact-list-matcher", + ], ) def test_the_checks_can_actually_fail(planted: dict[str, Any], checker: Any, label: str) -> None: """A guard that cannot be shown to fail is not a guard. diff --git a/tests/test_cloud_setup_hook.py b/tests/test_cloud_setup_hook.py new file mode 100644 index 0000000000..58fbe42d0e --- /dev/null +++ b/tests/test_cloud_setup_hook.py @@ -0,0 +1,177 @@ +# SPDX-License-Identifier: AGPL-3.0-or-later +# Copyright (C) 2026 MessageFoundry Foundation, LLC and contributors +"""The cloud-only SessionStart hook: wired once, gated on CLAUDE_CODE_REMOTE, and inert locally. + +The hook installs pwsh and Python 3.14 in a Claude Code cloud VM. The property that matters most +is the one nobody would notice breaking: on a LOCAL session it must do nothing at all. It is wired +in the tracked settings file, so every session on every account runs its command, and a broken +gate would run apt-get on a developer box. So the gate is tested as wired, with a control proving +the same command DOES run the script once the variable is set. +""" + +from __future__ import annotations + +import json +import os +import shutil +import subprocess +from pathlib import Path +from typing import Any + +import pytest + +_ROOT = Path(__file__).resolve().parents[1] +_SETTINGS = _ROOT / ".claude" / "settings.json" +_SCRIPT = _ROOT / "scripts" / "hooks" / "cloud-setup.sh" +_BASH = shutil.which("bash") + +needs_bash = pytest.mark.skipif(_BASH is None, reason="bash is not on PATH") + + +def _cloud_handlers() -> list[dict[str, Any]]: + settings = json.loads(_SETTINGS.read_text(encoding="utf-8")) + return [ + handler + for group in settings["hooks"]["SessionStart"] + for handler in group.get("hooks", []) + if "cloud-setup.sh" in handler.get("command", "") + ] + + +def _run( + command: list[str], env_extra: dict[str, str], cwd: Path +) -> subprocess.CompletedProcess[str]: + env = {k: v for k, v in os.environ.items() if k != "CLAUDE_CODE_REMOTE"} + env.update(env_extra) + return subprocess.run( + command, cwd=cwd, env=env, capture_output=True, text=True, timeout=60, check=False + ) + + +def test_the_hook_is_wired_exactly_once_in_shell_form_with_its_gate() -> None: + handlers = _cloud_handlers() + assert len(handlers) == 1, ( + f"expected one SessionStart handler for cloud-setup.sh, got {handlers}" + ) + handler = handlers[0] + # Exec form would need `bash` on PATH, and on the Windows dev box it is not: the hook would then + # fail to start in EVERY local session. `shell: bash` is resolved by Claude Code itself. + assert handler.get("shell") == "bash" + assert "args" not in handler + assert handler["command"].startswith('[ "${CLAUDE_CODE_REMOTE:-}" = true ] || exit 0;') + # Installs on a fresh VM take tens of seconds. A stated budget keeps a slow mirror from being + # mistaken for a hang; the script's header has the measured times. + assert handler.get("timeout", 0) >= 120 + + +def _planted_project(tmp_path: Path) -> Path: + """A project dir whose cloud-setup.sh only reports that it ran.""" + hooks = tmp_path / "scripts" / "hooks" + hooks.mkdir(parents=True) + (hooks / "cloud-setup.sh").write_text("echo PLANTED-RAN\n", encoding="utf-8") + return tmp_path + + +@needs_bash +@pytest.mark.parametrize(("remote", "runs"), [(None, False), ("false", False), ("true", True)]) +def test_the_wired_command_runs_the_script_only_in_the_cloud( + tmp_path: Path, remote: str | None, runs: bool +) -> None: + assert _BASH is not None + project = _planted_project(tmp_path) + env = {"CLAUDE_PROJECT_DIR": project.as_posix()} + if remote is not None: + env["CLAUDE_CODE_REMOTE"] = remote + result = _run([_BASH, "-c", _cloud_handlers()[0]["command"]], env, project) + assert result.returncode == 0, result.stderr + # The "true" row is the control: without it, a command that never ran anything would pass the + # two local rows. + assert ("PLANTED-RAN" in result.stdout) is runs, result.stdout + + +@needs_bash +def test_the_script_is_a_silent_no_op_outside_the_cloud(tmp_path: Path) -> None: + assert _BASH is not None + result = _run([_BASH, str(_SCRIPT)], {"CLAUDE_PROJECT_DIR": tmp_path.as_posix()}, tmp_path) + assert result.returncode == 0, result.stderr + assert result.stdout == "" + + +@needs_bash +def test_the_script_refuses_loudly_without_a_checkout_and_installs_nothing(tmp_path: Path) -> None: + assert _BASH is not None + result = _run( + [_BASH, str(_SCRIPT)], + {"CLAUDE_CODE_REMOTE": "true", "CLAUDE_PROJECT_DIR": tmp_path.as_posix()}, + tmp_path, + ) + assert result.returncode == 0, result.stderr + assert "NOT RUN: no checkout found" in result.stdout + assert "guard" in result.stdout + + +def _stub_tools(tmp_path: Path, gh_output: str | None) -> Path: + """`pwsh`, `uv` and `gh` stand-ins, so the cloud path runs with no install and no network.""" + stubs = tmp_path / "stubs" + stubs.mkdir() + gh_body = f"printf '%s\\n' '{gh_output}'" if gh_output is not None else "exit 1" + bodies = { + "pwsh": "exit 0", + "uv": '[ "$1 $2" = "python find" ] && echo /stub/python3.14; exit 0', + "gh": gh_body, + } + for name, body in bodies.items(): + f = stubs / name + f.write_text(f"#!/usr/bin/env bash\n{body}\n", encoding="utf-8", newline="\n") + f.chmod(0o755) + return stubs + + +def _checkout(tmp_path: Path) -> Path: + repo = tmp_path / "repo" + repo.mkdir() + (repo / "pyproject.toml").write_text("", encoding="utf-8") + subprocess.run(["git", "init", "-q", str(repo)], check=True) + return repo + + +def _local_git_config(repo: Path, key: str) -> str | None: + out = subprocess.run( + ["git", "-C", str(repo), "config", "--local", "--get", key], + capture_output=True, + text=True, + check=False, + ) + return out.stdout.strip() if out.returncode == 0 else None + + +@needs_bash +def test_the_identity_is_the_pushing_accounts_own_noreply(tmp_path: Path) -> None: + assert _BASH is not None + repo = _checkout(tmp_path) + stubs = _stub_tools(tmp_path, "12345\tjdoe\tJane Doe") + env = { + "CLAUDE_CODE_REMOTE": "true", + "CLAUDE_PROJECT_DIR": repo.as_posix(), + "PATH": str(stubs) + os.pathsep + os.environ.get("PATH", ""), + } + result = _run([_BASH, str(_SCRIPT)], env, repo) + assert result.returncode == 0, result.stderr + assert _local_git_config(repo, "user.name") == "Jane Doe", result.stdout + assert _local_git_config(repo, "user.email") == "12345+jdoe@users.noreply.github.com" + + +@needs_bash +def test_a_failed_account_lookup_sets_no_identity_and_says_so(tmp_path: Path) -> None: + assert _BASH is not None + repo = _checkout(tmp_path) + stubs = _stub_tools(tmp_path, None) + env = { + "CLAUDE_CODE_REMOTE": "true", + "CLAUDE_PROJECT_DIR": repo.as_posix(), + "PATH": str(stubs) + os.pathsep + os.environ.get("PATH", ""), + } + result = _run([_BASH, str(_SCRIPT)], env, repo) + assert result.returncode == 0, result.stderr + assert "gh api user failed" in result.stdout + assert _local_git_config(repo, "user.email") is None diff --git a/tests/tooling_manifest.txt b/tests/tooling_manifest.txt index 04ac31cf49..d7a1b877c5 100644 --- a/tests/tooling_manifest.txt +++ b/tests/tooling_manifest.txt @@ -86,6 +86,7 @@ tests/test_hook_prose_folding.py tests/test_hook_prose_folding_push_ledger.py tests/test_claude_section_citations.py tests/test_claude_settings_contract.py +tests/test_cloud_setup_hook.py tests/test_collision_gate.py tests/test_coord_box_activity.py tests/test_coord_claim_adjudicate.py