Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .claude/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
]
}
Expand Down
105 changes: 105 additions & 0 deletions scripts/hooks/cloud-setup.sh
Original file line number Diff line number Diff line change
@@ -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" </dev/null
fi

say "cloud session. No seat registry, mail or ADR allocation reaches the local fleet from here; open the PR with gh api -X POST repos/MEFORORG/MessageFoundry/pulls, because gh pr is refused (GraphQL)."
exit 0
46 changes: 41 additions & 5 deletions tests/test_claude_settings_contract.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@

import json
import re
import shlex
from pathlib import Path
from typing import Any

Expand Down Expand Up @@ -86,9 +87,23 @@ def _hook_handlers(settings: dict[str, Any]) -> 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]:
Expand Down Expand Up @@ -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]:
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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.
Expand Down
177 changes: 177 additions & 0 deletions tests/test_cloud_setup_hook.py
Original file line number Diff line number Diff line change
@@ -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
1 change: 1 addition & 0 deletions tests/tooling_manifest.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading