From 45ea3ea70ccfdb23c785529b9541eb5455d87500 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 11:46:04 +0000 Subject: [PATCH 1/3] Add a SessionStart hook for Claude Code cloud sessions The cloud image has no .NET SDK, and Ubuntu's own dotnet-sdk-10.0 is the 1xx feature band, whose compiler is too old for ktsu.Sdk.Analyzers (CS9057). The hook installs the SDK global.json asks for into ~/.dotnet, puts it on the session's PATH and restores the solution. dotnet-install.sh is tried first. The default cloud network policy blocks builds.dotnet.microsoft.com, so the SDK falls back to the layers of the mcr.microsoft.com/dotnet/sdk image, which the policy allows. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01FGcrXbknJ2JWPDzNoNhPCw --- .claude/hooks/install-dotnet.sh | 111 ++++++++++++++++++++++++++++++++ .claude/hooks/session-start.sh | 36 +++++++++++ .claude/settings.json | 14 ++++ 3 files changed, 161 insertions(+) create mode 100755 .claude/hooks/install-dotnet.sh create mode 100755 .claude/hooks/session-start.sh create mode 100644 .claude/settings.json diff --git a/.claude/hooks/install-dotnet.sh b/.claude/hooks/install-dotnet.sh new file mode 100755 index 0000000..11aaa01 --- /dev/null +++ b/.claude/hooks/install-dotnet.sh @@ -0,0 +1,111 @@ +#!/bin/bash +# Installs the .NET SDK into ~/.dotnet for Claude Code cloud sessions. +# +# The official route is dotnet-install.sh, which downloads from +# builds.dotnet.microsoft.com. The default cloud network policy does not allow +# that host, but it does allow mcr.microsoft.com, so when the script cannot be +# fetched the SDK is unpacked from the layers of the official +# mcr.microsoft.com/dotnet/sdk container image instead. Both give the same +# Microsoft-built SDK; Ubuntu's own dotnet-sdk-10.0 package is the 1xx feature +# band, whose compiler is too old for ktsu.Sdk.Analyzers (CS9057). +# +# Usage: install-dotnet.sh [runtime channel...] +# e.g. install-dotnet.sh 10.0 10.0.400 8.0 9.0 +# The extra runtime channels are for test projects that multi-target older +# frameworks: building them needs only the SDK, running them needs the runtime. +set -euo pipefail + +channel="${1:-10.0}" +minimum="${2:-${channel}.100}" +shift 2 || shift $# +runtimes=("$@") +root="${DOTNET_INSTALL_DIR:-$HOME/.dotnet}" + +version_at_least() { + [ "$(printf '%s\n%s\n' "$2" "$1" | sort -V | head -n1)" = "$2" ] +} + +has_runtime() { + [ -x "$root/dotnet" ] && "$root/dotnet" --list-runtimes 2>/dev/null | grep -q "^Microsoft.NETCore.App $1\." +} + +has_sdk() { + [ -x "$root/dotnet" ] || return 1 + local sdk + for sdk in $("$root/dotnet" --list-sdks 2>/dev/null | awk '{print $1}'); do + case "$sdk" in + "$channel".*) version_at_least "$sdk" "$minimum" && return 0 ;; + esac + done + return 1 +} + +# install_with_script [dotnet-install.sh options...] +install_with_script() { + local script + script="$(mktemp)" + curl -fsSL --max-time 30 -o "$script" https://builds.dotnet.microsoft.com/dotnet/scripts/v1/dotnet-install.sh 2>/dev/null || { + rm -f "$script" + return 1 + } + bash "$script" --channel "$1" --install-dir "$root" --no-path "${@:2}" + rm -f "$script" +} + +# install_from_container_image , e.g. sdk 10.0 or runtime 8.0 +install_from_container_image() { + local registry="https://mcr.microsoft.com/v2/dotnet/$1" + local arch + case "$(uname -m)" in + x86_64) arch=amd64 ;; + aarch64 | arm64) arch=arm64 ;; + *) echo "install-dotnet: unsupported architecture $(uname -m)" >&2; return 1 ;; + esac + + local index manifest digest layer work + index="$(curl -fsSL -H 'Accept: application/vnd.oci.image.index.v1+json,application/vnd.docker.distribution.manifest.list.v2+json' \ + "$registry/manifests/$2-noble")" + digest="$(jq -r --arg arch "$arch" \ + '.manifests[] | select(.platform.os == "linux" and .platform.architecture == $arch and (.platform.variant // "") == "") | .digest' \ + <<<"$index" | head -n1)" + manifest="$(curl -fsSL -H 'Accept: application/vnd.oci.image.manifest.v1+json,application/vnd.docker.distribution.manifest.v2+json' \ + "$registry/manifests/$digest")" + + # A runtime image contributes its shared framework and nothing else, so its + # older dotnet host never replaces the one the SDK brought. + local wanted='usr/share/dotnet/*' + [ "$1" = sdk ] || wanted='usr/share/dotnet/shared/*' + + work="$(mktemp -d)" + mkdir -p "$root" + # Only the files under usr/share/dotnet are taken: the runtime, ASP.NET Core + # and SDK layers. The rest of the image is its operating system. Layers are + # extracted over what is there, so an SDK and older runtimes share one root. + for layer in $(jq -r '.layers[].digest' <<<"$manifest"); do + curl -fsSL -o "$work/layer.tgz" "$registry/blobs/$layer" + tar -xzf "$work/layer.tgz" -C "$root" --strip-components=3 --wildcards "$wanted" 2>/dev/null || true + done + rm -rf "$work" +} + +if ! has_sdk; then + echo "install-dotnet: installing the .NET $channel SDK (at least $minimum) into $root" >&2 + install_with_script "$channel" || { + echo "install-dotnet: builds.dotnet.microsoft.com is not reachable; using the mcr.microsoft.com/dotnet/sdk image" >&2 + install_from_container_image sdk "$channel" + } + if ! has_sdk; then + echo "install-dotnet: no .NET $channel SDK at or above $minimum after installing" >&2 + exit 1 + fi +fi + +for runtime in "${runtimes[@]}"; do + has_runtime "$runtime" && continue + echo "install-dotnet: installing the .NET $runtime runtime into $root" >&2 + install_with_script "$runtime" --runtime dotnet || install_from_container_image runtime "$runtime" + if ! has_runtime "$runtime"; then + echo "install-dotnet: no .NET $runtime runtime after installing" >&2 + exit 1 + fi +done diff --git a/.claude/hooks/session-start.sh b/.claude/hooks/session-start.sh new file mode 100755 index 0000000..481d236 --- /dev/null +++ b/.claude/hooks/session-start.sh @@ -0,0 +1,36 @@ +#!/bin/bash +# SessionStart hook for Claude Code cloud sessions: installs the .NET SDK this +# repository's global.json asks for and restores its packages, so a session +# can build and test straight away. +set -euo pipefail + +if [ "${CLAUDE_CODE_REMOTE:-}" != "true" ]; then + exit 0 +fi + +cd "$CLAUDE_PROJECT_DIR" + +minimum=10.0.100 +if [ -f global.json ]; then + minimum="$(jq -r '.sdk.version // "10.0.100"' global.json)" +fi +# The test projects multi-target, so the older runtimes are needed to run them. +bash .claude/hooks/install-dotnet.sh "${minimum%.*}" "$minimum" 8.0 9.0 + +export DOTNET_ROOT="$HOME/.dotnet" +export PATH="$DOTNET_ROOT:$DOTNET_ROOT/tools:$PATH" +export DOTNET_CLI_TELEMETRY_OPTOUT=1 +export DOTNET_NOLOGO=1 +if [ -n "${CLAUDE_ENV_FILE:-}" ]; then + { + echo "export DOTNET_ROOT=\"$DOTNET_ROOT\"" + echo "export PATH=\"$DOTNET_ROOT:$DOTNET_ROOT/tools:\$PATH\"" + echo 'export DOTNET_CLI_TELEMETRY_OPTOUT=1' + echo 'export DOTNET_NOLOGO=1' + } >>"$CLAUDE_ENV_FILE" +fi + +if [ -f .config/dotnet-tools.json ]; then + dotnet tool restore +fi +dotnet restore diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000..e06b033 --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,14 @@ +{ + "hooks": { + "SessionStart": [ + { + "hooks": [ + { + "type": "command", + "command": "$CLAUDE_PROJECT_DIR/.claude/hooks/session-start.sh" + } + ] + } + ] + } +} From 71921a87b4fa65bd483b3cb9fe1ba24dca326158 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 12:05:40 +0000 Subject: [PATCH 2/3] Verify container layer digests before unpacking the .NET SDK Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01FGcrXbknJ2JWPDzNoNhPCw --- .claude/hooks/install-dotnet.sh | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/.claude/hooks/install-dotnet.sh b/.claude/hooks/install-dotnet.sh index 11aaa01..d1cd5fe 100755 --- a/.claude/hooks/install-dotnet.sh +++ b/.claude/hooks/install-dotnet.sh @@ -81,10 +81,21 @@ install_from_container_image() { # Only the files under usr/share/dotnet are taken: the runtime, ASP.NET Core # and SDK layers. The rest of the image is its operating system. Layers are # extracted over what is there, so an SDK and older runtimes share one root. - for layer in $(jq -r '.layers[].digest' <<<"$manifest"); do + # Each layer is checked against the sha256 digest its manifest names before + # anything in it is unpacked, since what it contains is run as dotnet. + while IFS= read -r layer; do + case "$layer" in + sha256:*) ;; + *) echo "install-dotnet: unsupported layer digest $layer" >&2; rm -rf "$work"; return 1 ;; + esac curl -fsSL -o "$work/layer.tgz" "$registry/blobs/$layer" + if [ "sha256:$(sha256sum "$work/layer.tgz" | awk '{print $1}')" != "$layer" ]; then + echo "install-dotnet: layer $layer does not match its digest" >&2 + rm -rf "$work" + return 1 + fi tar -xzf "$work/layer.tgz" -C "$root" --strip-components=3 --wildcards "$wanted" 2>/dev/null || true - done + done < <(jq -r '.layers[].digest' <<<"$manifest") rm -rf "$work" } From 95d7c6b7104302413486ad9d0bd423e90862a40d Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 12:08:56 +0000 Subject: [PATCH 3/3] Install .NET only from the checked container image layers dotnet-install.sh is fetched and run with nothing to verify it against, and its host is blocked in cloud sessions anyway, so the installer now always takes the MCR route, whose layers are checked against their digests. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01FGcrXbknJ2JWPDzNoNhPCw --- .claude/hooks/install-dotnet.sh | 33 +++++++++------------------------ 1 file changed, 9 insertions(+), 24 deletions(-) diff --git a/.claude/hooks/install-dotnet.sh b/.claude/hooks/install-dotnet.sh index d1cd5fe..851c09b 100755 --- a/.claude/hooks/install-dotnet.sh +++ b/.claude/hooks/install-dotnet.sh @@ -1,13 +1,13 @@ #!/bin/bash # Installs the .NET SDK into ~/.dotnet for Claude Code cloud sessions. # -# The official route is dotnet-install.sh, which downloads from -# builds.dotnet.microsoft.com. The default cloud network policy does not allow -# that host, but it does allow mcr.microsoft.com, so when the script cannot be -# fetched the SDK is unpacked from the layers of the official -# mcr.microsoft.com/dotnet/sdk container image instead. Both give the same -# Microsoft-built SDK; Ubuntu's own dotnet-sdk-10.0 package is the 1xx feature -# band, whose compiler is too old for ktsu.Sdk.Analyzers (CS9057). +# The SDK is unpacked from the layers of the official +# mcr.microsoft.com/dotnet/sdk container image, each checked against the +# sha256 digest its manifest names. dotnet-install.sh is not used: it comes from +# builds.dotnet.microsoft.com, which the default cloud network policy does not +# allow, and it is a script fetched and run with nothing to check it against. +# Ubuntu's own dotnet-sdk-10.0 package is the 1xx feature band, whose compiler +# is too old for ktsu.Sdk.Analyzers (CS9057). # # Usage: install-dotnet.sh [runtime channel...] # e.g. install-dotnet.sh 10.0 10.0.400 8.0 9.0 @@ -40,18 +40,6 @@ has_sdk() { return 1 } -# install_with_script [dotnet-install.sh options...] -install_with_script() { - local script - script="$(mktemp)" - curl -fsSL --max-time 30 -o "$script" https://builds.dotnet.microsoft.com/dotnet/scripts/v1/dotnet-install.sh 2>/dev/null || { - rm -f "$script" - return 1 - } - bash "$script" --channel "$1" --install-dir "$root" --no-path "${@:2}" - rm -f "$script" -} - # install_from_container_image , e.g. sdk 10.0 or runtime 8.0 install_from_container_image() { local registry="https://mcr.microsoft.com/v2/dotnet/$1" @@ -101,10 +89,7 @@ install_from_container_image() { if ! has_sdk; then echo "install-dotnet: installing the .NET $channel SDK (at least $minimum) into $root" >&2 - install_with_script "$channel" || { - echo "install-dotnet: builds.dotnet.microsoft.com is not reachable; using the mcr.microsoft.com/dotnet/sdk image" >&2 - install_from_container_image sdk "$channel" - } + install_from_container_image sdk "$channel" if ! has_sdk; then echo "install-dotnet: no .NET $channel SDK at or above $minimum after installing" >&2 exit 1 @@ -114,7 +99,7 @@ fi for runtime in "${runtimes[@]}"; do has_runtime "$runtime" && continue echo "install-dotnet: installing the .NET $runtime runtime into $root" >&2 - install_with_script "$runtime" --runtime dotnet || install_from_container_image runtime "$runtime" + install_from_container_image runtime "$runtime" if ! has_runtime "$runtime"; then echo "install-dotnet: no .NET $runtime runtime after installing" >&2 exit 1