The 4 panel padme-anakin sketch with anakin saying 'every dev in your team must now spend 2000$ more per month'. Padme asking, in disbelief: 'To invest in DevSecOps with everything going on, right?'. The last panel shows our parrot mascot.

Original: Gunshow - On Fire, source for this render: medium.com/@CWSkelly

Notes on safer development - Part 1.5: VSCode and Claude

Prompted (lol) by the most recent backdoor in Claude's VSCode extensions, we figured an interlude focusing exclusively on VSCode and Claude is in order.

Both tools are very similar in their neglect for basic supply chain security: Both happily auto-update themselves or third-party extensions with blisful ignorance of any lesson to be learned from the ecosystem they both originate from, NodeJS. VSCode goes out it’s way to make it hard for you to pin and revision extensions, while Claude will --force install its VSCode extension without asking you when you launch it from Code.

Claude takes the lead though, as it installes through our personal favorite nightmare, the curl-bash one-liner.

With a few config switches, the security stance of using either of these tools can be improved significantly. By using our install-script of Claude, you may also pin the exact version and drastically reduce the chance of a supply-chain attack through installing Claude.

Hardening VSCode

Our general advise is to run VSCode from within devcontainers on dedicated VMs without any credentials to production environments. Use the following user settings to disable extensions and self-updates, switch off automatic tasks, and to disable passing credentials to a devcontainers.

~/.config/Code/User/settings.json

{
    "extensions.autoUpdate": false,
    "extensions.autoCheckUpdates": false,
    "extensions.ignoreRecommendations": true,
    "update.mode": "none",

    "task.allowAutomaticTasks": "off",
    "task.autoDetect": "off",
    "npm.autoDetect": "off",
    "gulp.autoDetect": "off",
    "grunt.autoDetect": "off",
    "jake.autoDetect": "off",
    "dev.containers.dockerCredentialHelper": false,
    "dev.containers.copyGitConfig": false,
    "dev.containers.gitCredentialHelperConfigLocation": "none"
}

Enumerating allowed extensions

To go even a step further, extensions.allowed should be used as a group-, user- and workspace-policy to only selectively allow exactly pinned versions of extensions.

~/.config/Code/User/settings.json

{
  "extensions.allowed": {
    "*": false,
    "dbaeumer.vscode-eslint": ["3.0.0"],
    "figma.figma-vscode-extension": ["3.0.0", "4.2.3", "4.1.2"],
  }
}

Disabling SSH and GPG agent in devcontainers

When using flatpak, you can only avoid SSH and GPG sockets being passed through to the devcontainer by disabling them being passed to to the flatpak bubblewrap. Outside of flatpak, one should run VSCode only from within an unprivileged VM.

$ flatpak override \
  --nosocket=ssh-auth \
  --nosocket=gpg-agent \
  --unset-env=SSH_AUTH_SOCK \
  com.visualstudio.code

Unfortunately, VSCode does not offer a native way to disable passing SSH- and GPG agents through to the container (see #9466 for a recent, well-reasoned comment on the issue).

Hardening Claude

Our installation script adds a layer of security by expecting you to pin both the version to install and the checksum of the manifest used to install the right binary for your platform.

Anthropic actually signs these manifests with a GPG key, which their official script never bothers to validate. We can’t help but wonder if this either is a box-ticking exercise they don’t care about or the result of a sycophantic “gotcha, I’ve added GPG verification to the installer” reply to a prompt no one ever bothered to verify.

Anyways, we actually use the GPG signature they provide to make sure that the release is actually signed, however thrustworty you might deem that signature.

For a reasonably recent, pre-compromise version, the invocation would be:

# To install a fixed version
$ ./install-claude.sh 2.1.312 f9bc2d099665e498cbc4123f81f2220852c3a75ee118c0081ef83f42f165aec9 

# To find out which SHA for a newer version to pin
$ ./install-claude.sh 2.1.314 - 

install-claude.bash

#!/bin/bash
# Usage: install-claude.sh <version> <expected-manifest-sha256>
#
# Installs an exact Claude Code version, pinned to the SHA-256 of that
# release's manifest.json. 
#
# Dry-run a version bump by passing any placeholder as the sum, e.g.
#   install-claude.sh 2.1.120 -
#
# The latest version is available at https://downloads.claude.ai/claude-code-releases/latest
# but we recommend to always work with a 14 day cooldown period.
#
# Anthropic publishes the versions on npm, so
# `npm show @anthropic-ai/claude-code --json | jq .time` 
# can be used to identify a reasonably recent version.
set -exuo pipefail

readonly REPO="https://downloads.claude.ai/claude-code-releases"
readonly KEY_URL="https://downloads.claude.ai/keys/claude-code.asc"
readonly EXPECTED_FP="31DDDE24DDFAB679F42D7BD2BAA929FF1A7ECACE"
readonly DOWNLOAD_DIR="$HOME/.claude/downloads"

fail() {
  echo "$*" >&2
  exit 1
}

sha256_of() {
  if command -v sha256sum >/dev/null 2>&1; then
    sha256sum "$1" | cut -d' ' -f1
  else
    shasum -a 256 "$1" | cut -d' ' -f1
  fi
}

create_throwaway_gpg_home() {
  local dir
  dir="$(mktemp -d)"
  chmod 700 "$dir"
  echo "$dir"
}

clean_up() {
  local gnupg_home="$1" manifest="$2" binary="$3"
  GNUPGHOME="$gnupg_home" gpgconf --kill gpg-agent 2>/dev/null || true
  rm -rf "$gnupg_home"
  rm -f "$manifest" "$manifest.sig" "$binary"
}

running_under_rosetta() {
  [ "$(sysctl -n sysctl.proc_translated 2>/dev/null)" = "1" ]
}

uses_musl_libc() {
  [ -f /lib/libc.musl-x86_64.so.1 ] || [ -f /lib/libc.musl-aarch64.so.1 ] || ldd /bin/ls 2>&1 | grep -q musl
}

detect_platform() {
  local os arch
  case "$(uname -s)" in
    Darwin) os="darwin" ;;
    Linux) os="linux" ;;
    *) fail "Unsupported operating system: $(uname -s)" ;;
  esac

  case "$(uname -m)" in
    x86_64|amd64) arch="x64" ;;
    arm64|aarch64) arch="arm64" ;;
    *) fail "Unsupported architecture: $(uname -m)" ;;
  esac

  if [ "$os" = "darwin" ] && [ "$arch" = "x64" ] && running_under_rosetta; then
    arch="arm64"
  fi

  if [ "$os" = "linux" ] && uses_musl_libc; then
    echo "linux-${arch}-musl"
  else
    echo "${os}-${arch}"
  fi
}

import_anthropic_signing_key() {
  local actual_fp
  curl -fsSL "$KEY_URL" | gpg --import 2>&1
  actual_fp="$(gpg --with-colons --fingerprint [email protected] \
    | awk -F: '/^fpr:/{print $10; exit}')"
  [[ "$actual_fp" == "$EXPECTED_FP" ]] || fail "GPG fingerprint mismatch: got $actual_fp"
}

download_manifest_and_verify_signature() {
  local version="$1" manifest="$2"
  curl -fsSL -o "$manifest"     "$REPO/$version/manifest.json"
  curl -fsSL -o "$manifest.sig" "$REPO/$version/manifest.json.sig"
  gpg --verify "$manifest.sig" "$manifest"
}

check_manifest_matches_pinned_sum() {
  local version="$1" manifest="$2" expected_sha="$3" manifest_sha
  manifest_sha="$(sha256_of "$manifest")"
  [[ "$manifest_sha" == "$expected_sha" ]] \
    || fail "Manifest checksum mismatch for $version: expected sum $expected_sha, but got $manifest_sha"
}

download_binary_and_verify_checksum() {
  local version="$1" platform="$2" manifest="$3" binary="$4" checksum actual
  checksum="$(jq -r ".platforms[\"$platform\"].checksum // empty" "$manifest")"
  [[ "$checksum" =~ ^[a-f0-9]{64}$ ]] || fail "No checksum for $platform in manifest"

  curl -fsSL -o "$binary" "$REPO/$version/$platform/claude"
  actual="$(sha256_of "$binary")"
  [[ "$actual" == "$checksum" ]] \
    || fail "Binary checksum mismatch for $version ($platform): expected sum $checksum, but got $actual"
}

install_claude_code() {
  local version="$1" binary="$2"
  chmod +x "$binary"
  export DISABLE_AUTOUPDATER=1
  export CLAUDE_CODE_IDE_SKIP_AUTO_INSTALL=1

  "$binary" install "$version"
}

main() {
  [[ $# -eq 2 ]] || fail "Usage: $0 <version> <expected-manifest-sha256>"

  local version="$1" expected_sha platform manifest binary GNUPGHOME
  expected_sha="$(printf '%s' "$2" | tr '[:upper:]' '[:lower:]')"
  platform="$(detect_platform)"
  manifest="$DOWNLOAD_DIR/manifest-$version.json"
  binary="$DOWNLOAD_DIR/claude-$version-$platform"
  mkdir -p "$DOWNLOAD_DIR"

  GNUPGHOME="$(create_throwaway_gpg_home)"
  export GNUPGHOME
  # Expand the paths now: main's locals are gone by the time the EXIT trap runs.
  # shellcheck disable=SC2064
  trap "clean_up $(printf '%q ' "$GNUPGHOME" "$manifest" "$binary")" EXIT

  import_anthropic_signing_key
  download_manifest_and_verify_signature "$version" "$manifest"
  check_manifest_matches_pinned_sum "$version" "$manifest" "$expected_sha"
  download_binary_and_verify_checksum "$version" "$platform" "$manifest" "$binary"
  install_claude_code "$version" "$binary"
}

main "$@"

After installing, you should persist the following configuration so that claude will not attempt to auto update itself or its VSCode extension. Claude will --force-install its extension, so all VSCode hardening from above is mood unless you explicitly set the following settings:

~/.claude/settings.json

{
  "env": {
    "DISABLE_UPDATES": "1",
    "DISABLE_AUTOUPDATER": "1",
    "CLAUDE_CODE_IDE_SKIP_AUTO_INSTALL": "1"
  }
} 

DISABLE_UPDATES will block installing any newer version of claude, so comment it out temporarily for when you really want to update it.