The repository's facts, for whoever is editing it — human, Claude Code, Codex, Gemini or
anything else. CLAUDE.md and AGENTS.md are short pointers to this
file plus whatever is specific to their reader; see
ADR-004 for why there is one copy rather than two.
Read BUSINESS-CASE.md first if you want to know why any of this exists.
This file is how.
| Section | Read it when |
|---|---|
main IS the release |
before merging anything |
| What the scripts are | orienting; ten scripts, five pairs |
| Script versioning | you changed a script — the format is YYYY.MMDDHHMM |
| The target runtime is Windows PowerShell 5.1 | touching any .ps1. The Invoke-Native* tables are here |
| Conventions | writing new code in either language |
| Duplicated state: the six-script lists | adding a marketplace, plugin or MCP server |
| The guard layer | adding a check, or wondering what check-all.sh proves |
| Script execution flow, and the plan | adding a step to main(). The to-do ledger is here |
| Debug mode | diagnosing a user's run, or adding output |
| The CSA contracts | anything touching plugins or MCP servers |
| Periodic source sweep | weekly, by hand |
| Bootstrap commands | copying a one-liner — keep the no-cache header |
| Three habits | always |
Every documented install path fetches HEAD:
raw.githubusercontent.com/CloudSecurityAlliance/DesktopSetup/HEAD/scripts/...
and the internal-setup fetch (gh api repos/.../contents/internal-setup/$name) pins no ref
either. A merge is live to the next person who runs the one-liner. There is no staging
branch and deliberately so (ADR-001).
Consequences you have to work with:
- CI on the pull request is the only gate.
mainonce carried a red test suite for six commits (#132). - Every script carries a version so a person can say what they ran (below), and CI refuses a changed script that did not bump it.
- Bootstrap one-liners all send
Cache-Control: no-cache, because theraw.githubusercontent.comedge cache otherwise serves a stale copy for a few minutes after a fix ships. Keep that header in every documented command, README included.
Ten scripts, five macOS/Windows pairs. Each is self-contained and idempotent.
| macOS | Windows | What |
|---|---|---|
macos-work-tools.sh |
windows-work-tools.ps1 |
Core work apps (1Password, Slack, Zoom, Chrome, Office, Git, GitHub CLI) plus an optional dev profile (VS Code, AWS CLI, Wrangler) |
macos-ai-tools.sh |
windows-ai-tools.ps1 |
The AI layer: Claude, ChatGPT, Gemini and Copilot desktop apps (why each source); Claude Code, Codex and Gemini CLIs; uv, Python, Node; the doc toolchain; plugin marketplaces; the CSA MCP server; and every internal CSA MCP server |
macos-update.sh |
windows-update.ps1 |
Update everything — Homebrew/winget, npm globals, pip, claude update — then the plugin and MCP sync. Snapshots versions first, for rollback |
macos-plugins.sh |
windows-plugins.ps1 |
The CSA layer only: plugins and every CSA MCP server, with no Homebrew/winget/npm/pip. The fast path |
clone-and-claude.sh |
clone-and-claude.ps1 |
Clone a CSA repo into ~/GitHub/Org/Repo and print how to start Claude Code |
Plus scripts/csa-claude-connectors.py — disables chosen claude.ai connectors (Gmail, Google
Calendar, Google Drive by default) in Claude Code everywhere, so the CSA Google MCP servers are
used instead. Report-only unless --apply; backs up and writes atomically
(#129).
*-plugins is named badly on purpose. It installs and upgrades every CSA MCP server, not
just plugins. The filename stays because it is in documented one-liners that fetch HEAD; the
strings were all corrected to say "CSA plugins and MCP servers"
(ADR-003, from
#89).
AI skills, MCP server catalogues and per-project tooling live in other repositories.
scripts/
csa-plugins.txt # public plugin list, fetched from HEAD at runtime
csa-plugins-internal.txt # CSA-internal plugin list, same
tools/
check-all.sh # everything CI runs, locally, in one command
check-*.py # eleven repo-specific checks; all eleven also run in CI
sweep-csa-sources.sh # weekly drift sweep (network + gh; NOT in check-all.sh)
tests/
test_*.py # ten standalone executables — run one directly with python3
NativeWrappers.Tests.ps1 # Pester; the only suite that needs real 5.1 to mean anything
DesktopApps.Tests.ps1 # Pester; windows-update.ps1's wait before replacing a running app
docs/
desktop-ai-apps.md # where each desktop AI app comes from, and how it stays current
periodic-sweep.md # the weekly sweep runbook
mcp-servers.md # historical third-party MCP config reference
archives/ # previous script versions, for reference
Project documents — BUSINESS-CASE.md, GOALS.md, RACI.md, DECISIONS-ADR.md,
FRICTION.md, WAITING-FOR.md, TODO.md, OPERATIONAL-RESOURCES.md,
BACKUP-RESOURCES.md — are indexed in README.md.
Every script declares a version near the top: SCRIPT_VERSION="YYYY.MMDDHHMM" in bash,
$ScriptVersion = "YYYY.MMDDHHMM" in PowerShell. Minutes, not seconds — UTC.
python3 -c "import datetime;print(datetime.datetime.now(datetime.timezone.utc).strftime('%Y.%m%d%H%M'))"tools/check-script-versions.py refuses a changed script that did not bump, comparing the merge
base against the working tree — so it catches the omission locally, before a commit, rather
than in CI. A version-only change is legitimate on its own.
Windows 11 ships 5.1. PowerShell 7 is an optional install and this installer does not perform
it (ADR-002). Everything in scripts/*.ps1 must parse and run under 5.1.
7-only syntax fails at parse time, so the whole file is dead rather than one branch of it:
the ternary ? :, ??, ??=, ?., and the pipeline chain operators && / ||.
tools/check-powershell-native.py rejects all five.
Other 5.1 facts that have each cost a defect:
Sort-Objectis not stable.-Stableis 7-only. Three sign-in items once rendered in reverse order on the runtime that ships; stability is now built from an explicit read-position key.Set-Content -Encoding utf8writes a BOM (ef bb bf). Credential files are written with[System.IO.File]::WriteAllTextand aUTF8Encoding $falseinstead. Note the asymmetry:[System.IO.File]::ReadAllTextstrips a BOM, so a round-trip hides the problem.$nullis dropped from a native command's argv, so a$nullargument shifts every argument after it.
In 5.1, a native command writing to stderr becomes a terminating NativeCommandError under
$ErrorActionPreference = 'Stop' whatever its exit code. npm writes deprecation warnings to
stderr routinely, so a script would die on a successful run.
Measured on 5.1.26100, in the shape these scripts actually run — & ([ScriptBlock]::Create(…))
invoked from a 'Stop' session, which is how one script here runs another:
| form | result |
|---|---|
| bare call | kills the script and its caller |
| Out-Null |
kills the script and its caller |
$x = cmd (assignment) |
kills the script and its caller |
cmd 2>$null |
kills the script and its caller |
cmd *> $null |
kills the script and its caller |
$x = cmd 2>&1 | Out-String |
kills the script and its caller |
any of the above, after EAP = 'Continue' |
all six survive |
So no form of redirection helps — 2>$null hides the text, not the termination. The trap
that makes this hard to believe: standalone, the same error is only statement-terminating, so
every form above looks survivable in isolation. Inside & ([ScriptBlock]::Create(…)) the whole
invocation is one statement in the caller, so an error that merely ends a statement in the
callee ends the entire call. Measure it the way it is deployed or not at all. (This table
exists because the note it replaced had the conclusion backwards — twice, once in each
direction.)
check-powershell-native.py enforces that every native command (winget, npm, gh,
claude, git, icacls, …) goes through a wrapper or a try/catch. Its first run found 14
unguarded calls, 13 of them in windows-work-tools.ps1.
brew install powershell gives PowerShell 7, which differs on precisely the behaviour the
wrappers exist for:
| stderr on a successful command | non-zero exit | |
|---|---|---|
| Windows PowerShell 5.1 | terminates ← the bug | terminates |
pwsh 7, $PSNativeCommandUseErrorActionPreference=$false (default) |
survives | survives |
pwsh 7, same flag $true |
survives | terminates |
In pwsh 7 stderr output is not an error condition under any setting — the flag only makes a non-zero exit code respect the preference.
So local pwsh will tell you the wrappers are unnecessary, and it will be wrong. Do not remove them on the strength of a green run on macOS. Local pwsh is genuinely useful for parse checking, PSScriptAnalyzer and Pester tests of pure logic. It is not a substitute for running the installers on Windows.
On 2026-09-15, on Windows 11 26200 under real 5.1 (5.1.26100.9444), against the live machine:
windows-update.ps1's Test-Preconditions, Save-Snapshot, Show-Preflight (including the
live network plugin comparison), Show-Summary, and the pip list --outdated --format=json
parse; plus windows-work-tools.ps1's Setup-GitIdentity. The technique was to strip the
trailing Main invocation and dot-source the script, so the real shipping code ran without the
mutating half.
Still never run: any installer end to end, and any actual upgrade — winget upgrade --all,
npm update -g, claude update. The parts that decide what to do are tested on the target
runtime; the parts that do it are not.
The same lesson from the other direction. This repo was authored on a Mac; the suite first ran on Windows on 2026-09-15 and ten of eleven checks failed — none because the scripts were wrong:
| macOS / Linux | Windows | |
|---|---|---|
Path.read_text() default encoding |
UTF-8 | cp1252 — dies on bytes 0x81 0x8D 0x8F 0x90 0x9D |
Path.chmod(0o755) |
mode 0o755 |
mode 0o666 — Python cannot set the exec bit |
test -x stub under Git Bash |
true | false — yet the stub still runs |
bare "bash" via subprocess |
/bin/bash |
System32\bash.exe, the WSL launcher |
shutil.which("bash") |
/bin/bash |
…\Git\usr\bin\bash.EXE — disagrees with the line above |
pty.fork() |
works | absent |
Four consequences worth keeping:
- Pin every
read_text/write_texttoencoding="utf-8". Noterrors="replace": that decodes cp1252 without raising and substitutes U+FFFD, socheck-duplication.pywould compare two functions after corrupting both identically — they differ in a non-ASCII character and still compare equal. A check that cannot fail, again. cp1252 chokes on only five byte values, so a file passes or fails on whether it happens to contain one; three checks here were passing by luck. - Resolve
bashabsolutely, never as a bare name.shutil.whichandCreateProcessresolve"bash"to different binaries, and the oneCreateProcesspicks is the WSL launcher, which exits 1 with a UTF-16LE "no installed distributions" message — which reads as the test failing.tests/*.pyshare a_posix_bash()helper that prefers Git Bash and refuses anything underSystem32;CSA_TEST_BASHoverrides it. - The stub-executable technique still works. Tests intercept
npm/gh/claudewith shebang'd stubs on a stub-only PATH. Windows cannot mark them executable andtest -xagrees, but MSYS2 runs them anyway by sniffing the#!. Measure before assuming a POSIX technique is unavailable. tests/test_prompt_visibility.pyis the one genuine casualty and exits 77, not 0. It needs a real pty on both ends; three cheaper harnesses each produced a false negative.- Git Bash has no
pgrepand nops -o ppid=, so the macOS running-client detection cannot be exercised from a Windows box at all (FRICTION-003).
So a green run on Windows is worth slightly less than a green run on macOS — one fewer check
— and check-all.sh says so explicitly rather than leaving you to remember.
- Target macOS only (checks
uname -s). macOS ships bash 3.2, so nodeclare -A. set -euo pipefail.- Idempotent, and interactive by default: show the plan, ask, then act.
NONINTERACTIVE=1for CI/automation — also auto-detected when$CIis set or stdin is not a TTY.- Output helpers
info/warn/error/success/abort(abort = error + exit 1). Colours are stripped when stdout is not a TTY. - Never run as root (
$EUID), except inside a container (/.dockerenv,/run/.containerenv) for CI. - An assignment from a pipeline needs a guard. Under
pipefail+set -e,x="$(cmd | head -1)"kills the script whencmdfails. Write… )" || x="".check-pipeline-assignments.pyenforces it, and the failure is silent — the script dies at that line with nothing printed.
- Target Windows 10/11, require winget.
$ErrorActionPreference = 'Stop'. - Same helper pattern:
Write-Info,Write-Success,Write-Warn,Write-Err,Abort,Has-Command. - Every native command through an
Invoke-Native*wrapper (above).
Homebrew/winget for system tools and desktop apps; the native installer for Claude Code (it auto-updates); npm for the Codex and Gemini CLIs and for Wrangler; uv for Python.
-
Python comes from uv, Homebrew/winget as fallback (DEC-012).
install_uvruns beforeinstall_python.--pythonis not optional on anyuv toolcall: without it uv picks its own managed default and ignores both PATH and anything the installer provisioned — which is how four CSA servers once landed on Python 3.10, the floor ofrequires-python, chosen by nobody. -
install_pythonprovisions nothing when the machine already has a floor-clearing Python, deliberately, so an existing install is never churned. The honest claim is clean machines converge onCSA_PYTHON_PREFERRED, not every machine runs the same version. -
uv creates no PATH shims for interpreters it manages (measured, uv 0.12.10), so
find_usable_python/Find-UsablePythonprobe PATH first and then askuv python finddirectly. PATH probing alone would miss a good uv Python and install a second one. -
Floors are derived from the consumer that asks for most, never picked.
CSA_PYTHON_MIN=3.10from CSA-Document-Pipeline'srequires-python; the Node floor of 22 from Wrangler'sengines.node. Both enforced on both platforms. -
Node is pinned to the LTS line on both platforms (#56):
brew install node@24,winget install OpenJS.NodeJS.LTS.node@24is keg-only, and three things about it were measured on amacos-latestrunner rather than reasoned about.brew install node@24exits 1 even when it succeeds — it installs, prints its Summary, then the keg-only caveat, and returns non-zero — soinstall_nodechecks whether the formula is installed rather than trusting the status; the first version of this aborted a perfectly good install. Keg-only is literal: immediately after a successful install there is nonodeon PATH at all untilbrew link --overwrite --force node@24runs, which is required, not defensive. And npm's global prefix is the Homebrew prefix in every state, sowrangler,codexandgeminiinstalled withnpm -gsurvive the switch. The unversioned formula is removed, not unlinked, becausebrew upgradewould otherwise relink it and flip the machine back to Current between runs.
Measured 2026-10-01, with an entry point held open by a running client:
| case | exit | what actually happened |
|---|---|---|
| dependency-only upgrade | 0 | upgraded |
| the tool's own version changes | 1 | already upgraded — uv's .exe is a version-free trampoline, so the copy it failed on was byte-identical |
--force rebuild |
2 | site-packages already deleted, leaving a husk |
A husk is pyvenv.cfg + Scripts/ with no importable package, which uv tool list reports as
"Failed find package" while exiting 0. Four working servers became four husks in one run that
way. So: ask the environment, never the exit code, and refuse a rebuild before calling uv if
any entry point is held. Generalised fleet-wide as
an-exit-code-is-not-a-state.
CSA_MARKETPLACES, the marketplace-name → repo mapping, CSA_INTERNAL_SETUPS, and the
constants CSA_MCP_NAME / CSA_MCP_URL / CSA_MCP_GATE_REPO are duplicated across six
scripts: macos-ai-tools.sh, macos-update.sh, macos-plugins.sh, windows-ai-tools.ps1,
windows-update.ps1, windows-plugins.ps1.
Change all six, and bump each file's SCRIPT_VERSION. The mapping is a
plugin_marketplace_repo function in the .sh files (a function because bash 3.2 has no
associative arrays) and a $PluginMarketplaceRepos hashtable in the .ps1 files.
tests/test_internal_setup_lists.py asserts the six copies of CSA_INTERNAL_SETUPS agree, and
says in its own docstring what it cannot do: compare them against the private gate repo. The
installer closes that half at run time, reporting drift in both directions
(#95).
The plugin lists are single-source and are the exception: scripts/csa-plugins.txt and
scripts/csa-plugins-internal.txt are fetched from HEAD at runtime, so a list-only change
needs one commit and no script edit or version bump.
All scripts also duplicate their output helpers, precondition checks and utilities. The two
macOS install scripts additionally share install_xcode_cli_tools, install_homebrew,
install_node, setup_gh_auth, setup_git_identity. When changing shared logic, update
every file that uses it — check-duplication.py enforces byte-identical behaviour.
Eleven checkers in tools/ and twelve test files in tests/. ./tools/check-all.sh mirrors CI
exactly — run it before pushing.
A guard here is added only for a mistake that actually shipped, names its issue in the file, and
carries a self-test that breaks its own rule on purpose (ADR-005). Nine of
eleven comply; the two that do not are logged as G1 in TODO.md.
Worth knowing individually:
check-duplication.py— a function duplicated across scripts must be byte-identical in behaviour (comments and whitespace ignored). Deliberate differences live in itsPER_SCRIPTmap with a reason. Its first run found twelve already-drifted functions.check-parity.py— the complement: it catches what duplication structurally cannot, a function that is absent. Every macOS script needs a Windows counterpart, and the twomain()step lists must match once mapped through itsEQUIVtable. Compared as sets, since the platforms order their base layers differently. A one-sided step goes inPLATFORM_ONLYorPER_PAIRwith a reason, and an allowlist entry matching nothing is itself reported — an exception for a step that no longer exists has stopped checking without failing. Run against the scripts before #69 it reports exactly the three gaps that shipped.check-plan-covers-steps.py— every stepmain()runs must be named in the plan the user agreed to, across all six entry points. It importsEQUIVfromcheck-parity.pyso the two cannot disagree about what counts as a counterpart.--rev <ref>audits a historical commit; before #76 it reports nine findings, six of them the defect found by hand three weeks later. It checks presence only and says so — it cannot tell you a row's wording is still true.check-powershell-native.py— the wrapper rule above, plus the five 7-only syntax forms. It self-tests before it reports, because two separate bugs here each turned it into a check that printed "all native calls are guarded" no matter what.check-todo-citations.py—TODO.mdcites findings by function name, never by line number, and every cited function must still exist. The originalfile:linecitations had all rotted within seven months, one by 243 lines onto unrelated code about the same topic — worse than an obviously wrong citation.check-line-endings.py— nothing served to a shell may have a CR in the index. Reads thei/column ofgit ls-files --eol, where the path is whatever follows the last tab; nothing else on the line is reliably delimited.check-log-coverage.py— every screen-output helper routes through the logger, so the debug log contains what the user saw.check-script-versions.py— above.check-paste-safety.py,check-shell-tail-conditionals.py,check-pipeline-assignments.py— a README must not ask for a multi-line paste; no tail-position conditional underset -e; no unguarded pipeline assignment.
It ends by asserting the checks did not change the working tree — git status --porcelain --untracked-files=all before and after, so it passes mid-edit and fails only on what the run
itself did. Every other assertion once passed while test_version_floors.py installed a 167 MB
Python into the repo on each Windows run
(#67); git status found it,
not the suite.
Exit 77 means "could not run here" and is counted — and named — separately from passes
(ADR-006). A check that could not run has not passed. Read the tail of
the output and the exit code; do not grep for all checks passed, which individual checkers
also print, and do not pipe through head. That is exactly how main stayed red for six
commits.
Every test extracts the code under test out of the shipping script rather than copying it, so a
test cannot drift from what runs. A copy looks equivalent and is the defect
tests/test_summary_interpreter.py was built to avoid after mutation testing showed its first
version passing while the guard it tested had been deleted.
The PowerShell half needs three things installed, or those steps print skipped:
brew install shellcheck powershell
pwsh -NoProfile -c 'Install-Module PSScriptAnalyzer -Scope CurrentUser'
pwsh -NoProfile -c 'Install-Module Pester -RequiredVersion 5.7.1 -Scope CurrentUser'Pester is pinned to 5.7.1, deliberately. 6.1.0's manifest claims 5.1 support, but under real
5.1 it aborts the whole run with "a 'break' or 'continue' statement … escaped from your code" —
its own message cites pester/Pester#2669 — with no break anywhere in the suite. Both CI jobs
and check-all.sh pin the same version so the framework is not itself a source of divergence.
Being current matters less than matching CI.
Any single test runs on its own, which is the fast loop:
python3 tests/test_version_floors.pyOn Windows the shell-driving tests find Git Bash themselves; CSA_TEST_BASH overrides. They
must never be handed a bare bash.
All scripts follow main → preconditions → preflight (show the plan) → confirm → steps →
summary. macos-ai-tools.sh adds a migration layer: detect_migrations() during preflight,
then migrate_*() before each tool's install. The *-update scripts snapshot versions before
showing the plan.
The plan must name what actually runs. These scripts are "show the plan → confirm → act", so
a plan line describing something other than the step behind it is a correctness bug — the user
consented to the wrong thing. Three had drifted and were fixed together: Python said "install
via Homebrew" long after uv became the provider; the macOS Node line hid the only destructive
step in the script; and the Windows plan never mentioned uv or the document preflight deps at
all, though Main installed both.
check-plan-covers-steps.py now catches the absent case. It cannot catch stale wording,
so when you add a step to main(), add its plan line in the same change.
The closing instructions used to be scattered across five styles and 200 lines, with the terminal command printed as the primary route when it is the fallback (#110).
Now any script or nested setup script appends to CSA_TODO_FILE, tab-separated:
<order>\t<title>\t<in-claude>\t<terminal>
Orders: 10 restart, 20 sign in, 30 needs someone else, 40+ the installer's own. Items are de-duplicated on the item, and the list renders last — nothing may print after it or it stops being the final thing on screen, which was the original defect.
CSA_TODO_FILE is set by the two *-ai-tools installers only. The *-plugins and
*-update scripts run the same nested setup scripts with no ledger, so
Add-CsaTodo / csa_todo_add return false there and every caller must print the
instruction itself. That is not an edge case — *-plugins is the recommended fast path. A
Windows user once missed "restart Claude Desktop" entirely because three calls in one script
discarded that return.
Sort-Object is not stable on 5.1, so ordering is built from an explicit read-position key.
CSA_DEBUG=1 makes any script write a timestamped, mode-0600 log to $HOME —
desktopsetup-YYYYMMDD-HHMMSS.log — while still printing to screen. An environment variable
rather than a flag, because the documented invocation is curl … | bash / irm … | iex, which
passes no argument vector at all.
Two mechanisms, for a reason:
- PowerShell: the five
Invoke-Native*wrappers log. Nothing at the call sites changed, becausecheck-powershell-native.pyalready guarantees every native command goes through one — the wrappers were already the choke point. - bash:
exec > >(csa_redact | tee -a "$CSA_LOG") 2>&1, process-wide. There is no equivalent choke point, and retrofitting one onto ~1000 lines of direct calls would capture only the calls somebody remembered. Verified on bash 3.2.57: no output is lost on a clean exit, anexit N, or an uncaught failure underset -e.
CSA_LOG is exported, so a nested setup script appends to the same file. One file per run:
the person debugging is being asked to send a log, and "send both of them, and mind the
timestamps" is how half a report goes missing. The bash redactor is line-buffered (sed -l, or
-u on GNU) so the parent's output does not sit in the pipe while the child writes ahead of it.
What the log contains, and the boundary. Screen output is logged too, because a log of
commands without their output misdiagnoses
(#96), and prompts are logged
before they block. A state snapshot records existence, sizes, timestamps, versions and
key names — never a file's contents and never an environment value, because
claude_desktop_config.json holds inlined secrets and the log is designed to be emailed.
Credential shapes are redacted keeping the key: client_secret: <redacted> still says which
line failed. ANSI colour is stripped from the file only. Base64 blobs are replaced by a line
count, a byte count and a SHA-256, because 82% of one log was base64
(#121). The OAuth client
fetch is excluded from logging by construction rather than by pattern, because a base64 blob
has no shape to match.
All four are silent by default: a user outside CSA should not see chatter about repos they cannot see. Shared across the same six scripts.
- If
claudeorghis missing, orghis unauthenticated, return silently. - For each
CSA_MARKETPLACESentry: skip if already registered (parsed fromclaude plugin marketplace list); probe withgh api repos/$repoand silently skip on non-zero; otherwiseclaude plugin marketplace add. - Print only on an actual add, or on an
adderror — with the captured stderr indented underneath, so the schema/auth/network reason is visible. - The updaters additionally run
claude plugin marketplace update, which always prints, since refreshing is their purpose.
Driven by the two list files, fetched from HEAD. Entries are <plugin>@<marketplace>; blank
and # lines ignored. Pass 1 ensures each referenced marketplace is registered (public ones
unconditionally, CSA ones gh-probed). Pass 2 builds the install list without touching the
network. Pass 3 announces Installing N plugin(s): then prints + <plugin> or ! <plugin>
per item, so there is forward motion during a long install. Silent when nothing needs doing.
The preflight preview (install_plugins_preview / Show-PluginsPreview) compares the lists
against claude plugin list without gh-probing, so its count is an upper bound.
csa-mcp → https://cloudsecurityalliance.org/mcp, HTTP transport, OAuth 2.1 + PKCE, Claude
Code only for now.
The server answers unauthenticated callers, so the gate is not about access — registration is
gated behind a gh-probe of CloudSecurityAlliance-Internal/CSA-Plugins as a CSA-membership
proxy, because a public bootstrap should only auto-wire CSA tooling into CSA-eligible accounts.
If already registered, return silently: re-running claude mcp add would either error or
invalidate the user's authenticated OAuth session.
Two different gates, and the wording keeps them apart. Registration is gated on GitHub
CSA-Internal access; authenticating to the running server needs a free CSA account from
https://cloudsecurityalliance.org/ via the site's Sign in or Sign Up control. The account
unlocks member tiers rather than making the server work.
A separate mechanism from the hosted server, and the third place a list drifts. Four servers
today: csa-google-workspace, csa-google-gmail-calendar, csa-skilljar, csa-zendesk.
The function gh-probes the gate repo, then fetches one setup script per server from
CloudSecurityAlliance-Internal/CSA-Plugins/internal-setup/ and executes each with
CSA_NESTED=1. The servers live in their own public repos; the setup scripts live in the
private gate repo because they carry CSA's OAuth client. That repository must never be made
public.
Wire a server up by appending its <name>-setup.{sh,ps1} to CSA_INTERNAL_SETUPS — in all six
scripts, with a version bump each. The loop uses continue, not return, so an absent setup
script cannot disable the servers listed after it — which is also why a stale list produced no
output at all until the drift report was added.
Nothing in CSA notifies this repo when new tooling appears, so run
./tools/sweep-csa-sources.sh weekly, by hand, on a machine whose gh has CSA-Internal read
access. Nothing runs it automatically.
It reports four kinds of drift against four extension points: unregistered marketplaces
(CSA_MARKETPLACES, 6 scripts), published plugins nobody installs (the .txt files,
list-only), MCP servers ready to wire (CSA_INTERNAL_SETUPS, 6 scripts), and version
floors that no longer match upstream — the one thing here that can rot without anyone touching
this repo. Exit 0 no drift, 1 drift, 2 could not complete; 2 means "I learned
nothing", never "no drift".
Deliberately not in check-all.sh: it needs the network and a CSA-Internal token, and a
check that cannot pass in CI is a check that gets deleted.
Do not make its probing parallel. An early version used xargs -P 12 and reported three
repos as lacking marketplace.json when all three have it — a probe that fails under load is
indistinguishable from a repo that genuinely lacks the file, so the sweep under-reports and the
failure looks exactly like success. Runbook: docs/periodic-sweep.md.
Every one carries Cache-Control: no-cache. Keep it.
# macOS — work tools / AI tools / update everything / CSA layer only
bash -c "$(curl -fsSL -H 'Cache-Control: no-cache' https://raw.githubusercontent.com/CloudSecurityAlliance/DesktopSetup/HEAD/scripts/macos-work-tools.sh)"
bash -c "$(curl -fsSL -H 'Cache-Control: no-cache' https://raw.githubusercontent.com/CloudSecurityAlliance/DesktopSetup/HEAD/scripts/macos-ai-tools.sh)"
bash -c "$(curl -fsSL -H 'Cache-Control: no-cache' https://raw.githubusercontent.com/CloudSecurityAlliance/DesktopSetup/HEAD/scripts/macos-update.sh)"
bash -c "$(curl -fsSL -H 'Cache-Control: no-cache' https://raw.githubusercontent.com/CloudSecurityAlliance/DesktopSetup/HEAD/scripts/macos-plugins.sh)"
bash -c "$(curl -fsSL -H 'Cache-Control: no-cache' https://raw.githubusercontent.com/CloudSecurityAlliance/DesktopSetup/HEAD/scripts/clone-and-claude.sh)" -- ORG/REPO# Windows — work tools / AI tools / update everything / CSA layer only
irm https://raw.githubusercontent.com/CloudSecurityAlliance/DesktopSetup/HEAD/scripts/windows-work-tools.ps1 -Headers @{'Cache-Control'='no-cache'} | iex
irm https://raw.githubusercontent.com/CloudSecurityAlliance/DesktopSetup/HEAD/scripts/windows-ai-tools.ps1 -Headers @{'Cache-Control'='no-cache'} | iex
irm https://raw.githubusercontent.com/CloudSecurityAlliance/DesktopSetup/HEAD/scripts/windows-update.ps1 -Headers @{'Cache-Control'='no-cache'} | iex
irm https://raw.githubusercontent.com/CloudSecurityAlliance/DesktopSetup/HEAD/scripts/windows-plugins.ps1 -Headers @{'Cache-Control'='no-cache'} | iex
$env:CSA_REPO='ORG/REPO'; irm https://raw.githubusercontent.com/CloudSecurityAlliance/DesktopSetup/HEAD/scripts/clone-and-claude.ps1 -Headers @{'Cache-Control'='no-cache'} | iexThe macOS bash -c "$(...)" form — not a pipe — is required to preserve interactive stdin.
- Ask the environment, not the exit code. Eight occurrences and counting.
- Know what your check ran on. Its scope is never in its output. A true sentence about an empty set reads exactly like success.
- A verification step is itself a check, and inherits every weakness of one — its own scope, its own runtime, its own ability to fail. A control that cannot fail proves nothing.
Both generalised at fleet level:
an-exit-code-is-not-a-state,
a-green-check-is-only-evidence-about-what-it-ran-on.