satz housekeeping
A handful of files are derived from something outside the repository — Google's provider schema, Google's constraint catalogue, Google's asset-type list, a CIS benchmark release. When the source changes, these files go stale without any test failing.
The first half of this page lists each of them: what refreshes it, what makes it
stale, and what detects it. The second half describes each script: the refreshes and the gates. Every script in
scripts/ has a section, and so does the one script a pack binds as an action
(presets, which lives under presets/ because get-presets
ships it).
At a glance
| file | refreshed by | trigger | what catches staleness |
|---|---|---|---|
tests/schemas/google.json |
scripts |
the provider pin moves | nothing — run --check |
presets/cai-asset-types.txt |
scripts, from Google's published list |
new asset types appear; Google retires one | --check against the page, run by hand; indirect: unfilled import-config rows, and a live import naming the retired types it left out |
presets/import-config.yaml (rows) |
scripts |
the provider pin moves | cargo test: provider_version must equal the pin |
presets/import-config.yaml (asset_type) |
scripts, then --probe <parent> |
the CAI list above changes; Google changes what ListAssets serves | smoke: "every derivable asset_type is filled"; a type ListAssets refuses: the live import leaves it out and names it with the rows that asked for it |
presets/type-map.yaml — in an estate's presets_dir, not in this repository |
satz map-types, from the Discovery Documents and the provider schema |
the provider pin moves; Google changes an API | nothing — an import without it still flattens nested values onto the attributes the provider schema names, and reports what it could not place; what the file adds is the renamed BLOCKS |
presets (the data source, keys, permission and attributes per type the interfaces read back) |
by hand, from the provider's data source schemas (tofu providers schema -json) |
the provider pin moves; an export needs a type the table has no row for | the rows the showcase uses: smoke, tofu validate of every interfaces/<…>/hcl module; the other rows: nothing |
presets/attach-points.yaml (the attachment types an export may allow, the argument that names the shared object, and the estate's authoritative form of each membership) |
by hand, from the provider's resource documentation (which attachment resources exist, and which attribute of the shared resource they fight with) | the provider pin moves; a new attachment resource or a changed ignore_changes requirement in the provider's docs |
the rows' shape: cargo test (every); whether a row matches the provider: nothing |
presets |
scripts |
Google ships a new managed twin | cargo test catches the effect, not the table |
presets/docs/*.md |
satz doc-packs |
any pack changes | smoke: doc-packs --check |
skills/<name>.skill (the packaged form of skills/<name>/, for Cowork and the desktop app) |
scripts/build-skills.py |
any file of the skill's folder changes | smoke: build-skills.py --check, byte for byte |
presets/pack-graph.json |
satz pack-graph |
any pack or the map's offers entries change |
smoke: pack-graph --check; cargo test (the) |
presets/cis/*-dry-run.satz |
scripts |
the enforcing fragment it is derived from changes | smoke: --check, which compares byte for byte |
presets/CHANGELOG.md (## Changelog) |
by hand, one row per pack version | a pack version changes | doc-packs --check: fails on a version with no row |
presets/catalogs/*.yaml |
by hand, from the benchmark | a benchmark release | nothing |
src/prerequisites.rs (the prerequisite table: roles and APIs per type) |
by hand, from Google's predefined roles | a pack emits a new resource type; Google changes a role | a new type: cargo test (prerequisites_gate); a changed role: nothing — run scripts |
tests |
UPDATE_CORPUS=1 cargo test |
emission changes | cargo test (that is the gate) |
| provider version pin | by hand | a provider release | nothing |
| crate versions | cargo update |
routine | cargo test after the fact |
NOTICE (its third-party entries) |
by hand, one entry per piece of foreign material | material from outside the project enters or leaves the tree | nothing |
THIRD-PARTY-LICENSES.md |
scripts |
Cargo.lock moves — a dependency added, removed or updated |
the checks job of smoke.yml: --check, which compares byte for byte |
| the published releases | .github, after every release |
a release | it keeps the five newest AND the release satz names, read out of the source at that commit — a refusal that sends an operator to a release that was pruned is a dead end, and --cleanup-tag takes the tag with it |
.github |
dist generate, run with the cargo-dist version dist-workspace.toml pins |
dist-workspace.toml changes; the cargo-dist-version pin moves |
the release-workflow job of smoke.yml: dist generate --check, which prints the diff |
assets/fonts/ — eight faces and the two licence texts |
by hand, copied out of the typst-assets crate in the dependency tree |
the typst pin moves |
a missing face: cargo test (each); a face behind the crate: nothing |
src/privacy_shapes.rs (the gate's content rules, a second copy of scripts/check-names.sh's) |
by hand, in the same change as the script | a rule of the gate changes | cargo test (the): both copies over tests/privacy-shapes/, token by token |
docs/competitive.md |
a battle review | quarterly, or a phase gate | nothing |
editors/zed/extension.toml (the pinned tree-sitter grammar) |
by hand: a commit in the grammar repository, then the pin | the language changes (crates) |
scripts/check-grammar.sh: the grammar job of smoke.yml on every push and PR, and the grammar repository's own weekly CI against a fresh clone of this one |
Six have no automatic check, and the IaC role table has none for a changed role, nor the fonts for a face behind the crate, nor the interface lookups for a row the showcase does not use, nor the attach points for whether a row matches the provider: refresh them on their trigger.
The provider schema fixture
tests/schemas/google.json — 58 resource types, cut from the real provider.
The corpus and the smoke estate classify types through this fixture exactly the way
production classifies them through a real schema. A type missing from it loses
schema-derived detail without an error (an alert policy's notification_channels,
for example), and a type whose real schema has changed makes the snapshots pin an
older provider than the pin says.
uv run scripts/update_schema_fixture.py --check
uv run scripts/update_schema_fixture.py --add google_compute_firewall_policy_rule
--check downloads the pinned provider and reports, per fixture type, whether it is
gone or whether its attribute and block surface has drifted. Run it when the
provider pin moves; no other check reports it.
The script never deletes types, because a scan of the sources misjudges usage in both
directions. Three fixture types appear in no .satz file because the emitter
produces them from structural nodes —
google_folder_iam_member from a grant map inside a folder,
google from members, google_logging_project_sink
from a sink. And google_compute_address is referenced, inside a raw hcl { }
block, where no schema is needed. Trimming by apparent use would remove three types
that are used.
The Cloud Asset Inventory type list
presets/cai-asset-types.txt — 601 asset types, Google's list, one per line.
satz import can only discover a resource type that carries a CAI asset type, so
this list is the ceiling on discovery coverage. scripts fills the asset_type column of presets/import-config.yaml from it
(453 of the 1280 rows carry one) — the column is never filled by hand.
Refreshing it is scripts, then
update_import_config.py --cai-types. The script reads the names from Google's
asset-types page with the markup removed: the page breaks each name for wrapping with
<wbr> tags, which is why matching the raw HTML finds only a fraction of them. A type
the file carries that the page no longer names stops the run and is listed; delete its
line by hand once checked. --check says whether the file is behind the page and
writes nothing.
The 388 rows that stay TODO/UNKNOWN are Terraform types Google's list does not carry
under the name the deriver builds — most are not Cloud Asset resources at all. Filling
one takes a KIND_OVERRIDES entry in update_import_config.py, written by hand for a
type worth discovering; a refreshed list does not fill them.
Staleness shows up indirectly: rows in import-config.yaml whose asset_type stays
TODO/UNKNOWN when the type demonstrably has one. The smoke step "import-config:
every derivable asset_type is filled" fails when the config is behind the list — it
does not notice when the list itself is behind Google.
The other direction — a type the list carries and Cloud Asset Inventory has since
retired — shows up in a live satz import: the sweep finds it by halving the request
the API refused, leaves it out and names it at the end of the run with the rows that
asked for it. That is the signal to refresh, and the type is out of the estate until
it is acted on.
The managed/legacy constraint pairing
presets — 15 pairs Google declares, 1 this
repository adds.
Where Google replaces a legacy org-policy constraint with a managed one, the packs run the replacement alone and declare the twin off; see Superseded legacy constraints in the presets guide. The pairing is data, generated from a live organisation, so a test checks the rule.
uv run scripts/update_constraint_equivalents.py
Needs ADC and a quota project. Nothing about the organisation reaches the file — the
constraint catalogue is Google's and identical for every customer. The section below
the CURATED marker is preserved: it holds the one pair Google does not declare.
The rule is gated offline by
constraint, which fails when a
pack enforces a superseded constraint or enables a replacement without declaring its
twin off. A twin Google added after the last refresh is not in the table, so the gate
cannot check it: refresh the table when adding a constraint to a pack.
The catalogs
presets/catalogs/ — cis-gcp-4.0.yaml, cis-gcp-5.0.yaml, iso27001-2022.yaml.
Control ids, titles and paraphrases, transcribed from the published benchmark. There is no machine-readable source to generate them from, so this is hand work with no check: a benchmark revision is absent until someone adds it, and reports keep using the older one without saying so.
Two rules for transcribing:
- Check renumbered controls, not only new ones. CIS 5.0 renumbered 1.1→1.2, 1.4→1.5, 1.5→1.6, 1.16→1.17, 3.8→3.10 while the content stayed the same. A claim on the old number compiles and reports the wrong control.
- Prefer a machine-readable third party over the PDF. Prowler ships its CIS mappings as data; check the numbers against them (Cloud SQL public IP is 6.6 in 4.0 and 6.7 in 5.0; the API-key constraint is 1.14/1.15).
An unclaimed control reads unmet, not absent, so catalog ids can be added before the packs that implement them.
The pack surface
presets/docs/*.md(48 pages + the index) — generated bysatz doc-packs, gated bydoc-packs --checkin the smoke matrix and bycargo test. Regenerate in the same commit as any pack change; CI fails otherwise. A changelog row is a pack change too: each page carries its pack's history, so editing the table below makes a page stale.presets/pack-graph.json— the pack graph, generated bysatz pack-graphfrom the map'soffersentries and the packs, gated bypack-graph --checkin the smoke matrix and bycargo test. A new pack needs anoffersentry inpresets/estate-map.satzbefore the graph can be written; the command names what is missing. Regenerate in the same commit as any pack change.presets/library-groups.txt— the library's groups in reading order and the packs in each, by hand (ADR 0076).doc-packsfails on a pack in no group or in two, a line naming no pack, an empty group, and a grouppresets/README.mdhas no## <group>heading for or carries out of order. A new pack gets its line in the same commit; the indexpresets/docs/README.mdand the site's side menu follow the file.presets/CHANGELOG.md## Changelog— one row per pack version, by hand.doc-packsparses it and fails on three things: a pack version with no row, a row naming a pack that does not exist, and a table whose header or cell count has been reflowed.- Pack headers are input to the index. The index prints the first sentence
of each header comment, so
doc-packsrefuses one that is empty, a bare URL, a leftoverInclude …instruction, or too long for a table cell. A pack whose shape does not decide how it is used — a bare list of labels with no claims — must state its ownuseline in the header; the error shows the line to add. - A pack's id is its file name,
-written_, and unique in the library (ADR 0077):doc-packsandreview-packrefuse any other. The ids that predate the rule stand inLEGACY_PACK_IDS(src/doc_packs.rs), which refuses a row whose pack now conforms, so the table only shrinks. A string param that defaults to""— a value the operator must supply — has aquestionin the same file, or both commands refuse the pack.
The IaC role table
src/prerequisites.rs — per resource type, the permission the IaC service account needs
to manage it and the predefined roles that carry that permission, plus the reads every
estate needs. satz update-prerequisites <estate> checks an estate against it and writes the
missing roles into the estate, --report-only lists them and writes nothing; satz whoami <estate> tests the permissions live. satz update-prerequisites --format json prints it.
Two triggers make it stale:
- A pack emits a type the table has no row for.
prerequisites_gateincargo testcompiles the cases undertests/iac/— together they use every pack, each one unconditionally — and fails on an emitted type without a row, and on a pack no case uses. A new pack gets a line in one of those cases; a new type gets its row. - Google changes a predefined role — a permission renamed, or moved out of the role
the table names. Nothing in the repository sees it.
scriptsreads every role the table names from the IAM API and fails when an entry's permission is in none of its roles: run it when adding a row, and on each provider pin move./check _prerequisites .py
The third-party notice
NOTICE names every piece of material in this repository that came from outside it:
the provider schema fixture and the provider's own text inside it, the release
workflow cargo-dist generates, and the control identifiers the catalogs carry. Apache
2.0 §4(d) makes that file travel with every redistribution of satz, so material that
arrives from outside and is not named there goes unattributed everywhere satz is
redistributed. The crates satz is compiled from are the one thing it does not
enumerate: their texts are in THIRD-PARTY-LICENSES.md, which NOTICE points at.
Nothing checks it. Write the entry in the same change that brings the material in, and delete the entry in the change that takes it out.
The third-party licence texts
THIRD-PARTY-LICENSES.md carries the licence text of every crate compiled into the
binary — one copy per crate, as the crate ships it, because a copyright line is part
of the MIT and BSD texts. MIT and Apache 2.0 ask a binary redistribution to carry
them, so the file travels in every release archive: include in
dist-workspace.toml names it beside NOTICE.
It is generated from Cargo.lock, never edited:
update-third-party-licenses
writes it and --check fails on a stale one. about.toml holds the allow-list of
licences and the four release targets, about.hbs the layout. The privacy gate skips
the file: it is upstream authors' e-mail addresses and domains, written by a tool.
The two files divide the subject: a crate's licence is here, and everything that is
not a crate — the provider schema fixture, the fonts satz typesets with, the control
identifiers in the catalogs — is in NOTICE.
The embedded fonts
assets/fonts/ holds the faces --format pdf typesets with, compiled into the
binary by include_bytes! in src/pdf.rs: Libertinus Serif and DejaVu Sans Mono,
each in regular, bold, italic and bold italic. They are the typst-assets crate's
own files, copied out of the crate in the dependency tree, so the bytes are the ones
that engine version renders with:
cp ~/.cargo/registry/src/*/typst-assets-<version>/files/fonts/{LibertinusSerif-{Regular,Bold,Italic,BoldItalic}.otf,DejaVuSansMono{,-Bold,-Oblique,-BoldOblique}.ttf} assets/fonts/
Eight faces are 2.3 MB; the crate's whole bundle is 9.7 MB, the difference being New
Computer Modern text and math, which nothing here sets. Two families in four styles
is the square the markup can ask for: a heading, a strong span and a table header are
bold, an emphasised span is italic, a code span takes the weight and slant of
whatever it sits in. Typst answers a request it has no face for with the nearest face
it has and reports nothing, so cargo test
(each) resolves all eight and fails when one
collapses onto another.
The trigger to refresh is the typst version pin in Cargo.toml moving: nothing
compares the committed faces with the crate's. The licence texts beside them —
LICENSE-Libertinus-OFL.txt (SIL Open Font License 1.1) and LICENSE-DejaVu.txt
(the Bitstream Vera licence) — come from the same crate's NOTICE and are what those
licences ask a redistributor to carry with the files; include in
dist-workspace.toml names both, so they travel in every release archive beside
NOTICE and THIRD-PARTY-LICENSES.md. The privacy gate skips the whole directory:
upstream bytes and upstream licence text, written by nobody here.
Versions
- Provider pin (
provider_version, currently 7.14.1, in each estate'sconfig.tomland intests/smoke/config.toml). A provider release is the trigger. After bumping it, runupdate_schema_fixture.py --check: the fixture is the only schema the tests see. - Crates.
cargo updateroutinely, thencargo test. Every dependency is on its current version; one held back carries its reason next to it.
Recurring reviews
- Competitive re-audit — quarterly or at a phase gate, appended as a dated entry
to
docs/competitive.md. Keeps the framework inputs, never replaces them. - Fleet re-transpile after every satz release — the estates live outside this
repository; the check is in it:
fleet-v1.sh. An estate whose emitted HCL differs from what the current binary produces blocks the release. Run it after each release, so a difference points at that release.
The scripts
Everything in scripts/ exists because it cannot be a satz command or a
preset. Two kinds live here: cloud steps with no provider resource (nothing
in Terraform can express them, so they stay gcloud), and build-time helpers
that maintain the repo's own data files.
A cloud step cannot be written in Satz, so it is a script. An estate can
declare and invoke it: an
action names
the step, binds it to a script, and builds its arguments from the estate's own
params, so satz run-actions runs it with the organisation id the estate
already has. Nothing runs at transpile time, and an action carries no claim: the
compliance plane cannot see into the script.
One of these files lives under presets/. get-presets downloads
presets/** and nothing else, so a pack that
declares an action must carry its script inside presets/ or ship an action
that cannot find what it runs. scc-enable-all.sh is therefore
presets, beside the pack that binds it. The rule that
follows: a script a customer runs belongs under presets/; a script only this
repository runs belongs in scripts/. Everything else on this page is the
second kind.
| script | kind | what it does |
|---|---|---|
presets |
cloud step | enable every SCC service at the org, inherit below. Under presets/ so get-presets ships it and the SCC pack can bind it as an action |
update_import_config.py |
helper | keep presets/import-config.yaml current: new provider types, and asset_type filled from Google's Cloud Asset Inventory list |
update_cai_asset_types.py |
helper | refresh presets/cai-asset-types.txt from Google's published asset-type list; refuses when a type it carries is no longer published, --check says whether it is behind |
build-skills.py |
helper + gate | package every skills/<name>/ as skills/<name>.skill, stored and sorted so the bytes are the same on every platform; --check names a zip behind its folder |
smoke.sh |
gate | every estate-consuming command end to end against tests/smoke/; CI runs it on every PR and every push to main |
mcp-probe.py |
gate | every tool of satz mcp over raw JSON-RPC, per capability group: no stdout byte that is not JSON-RPC, results valid against their published schema, refusals as prose, each tool's largest result. Run by hand, not by CI |
fleet-v1.sh |
gate | every estate you operate, re-transpiled on the current binary and compared block by block against what it emitted before. Not run by CI — CI has no estates. Run it after every release |
update |
helper | refresh presets — which managed constraint replaces which legacy one — from a live organisation's ListConstraints |
update_schema_fixture.py |
helper | keep tests/schemas/google.json in step with the types the packs emit; --check names what is missing |
inspect_schema.py |
helper | print one resource type's schema out of a provider schema dump |
build_dry_run_fragments.py |
helper | generate each CIS extension's dry-run twin from the enforcing fragment: spec becomes dry_run_spec, every claim is dropped. --check fails on a stale twin |
check_prerequisites.py |
gate | hold the prerequisite table's roles (src/prerequisites.rs) against Google's predefined role definitions; needs ADC, not run by CI |
build-satz-doc.py |
helper | render one docs/*.md as a self-contained, theme-aware HTML page (SVGs inlined) |
build-site.py |
build | render the documentation site (README, the docs/*.md named in SITE_DOCS, the presets docs) into _site/ with a sticky navigation header, a per-page contents column (on a pack page followed by the library, grouped as presets/library-groups.txt sorts it) and a client-side search over every page's headings and text (search-index.js, no external dependencies; / focuses the box). Publishing is explicit: a doc must be listed in SITE_DOCS or SITE_DOCS_EXCLUDED or the build fails naming it. .github/workflows/pages.yml publishes on GitHub Pages on every release tag and on demand |
check-names.sh |
gate | refuse any identifier that is not one of the example customers (docs/examples.md); judged per TOKEN (an allowed address never shields a private one beside it); CI on every PR and every push to main (--commits A..B, an unusable range is a failure, never a pass), --staged from the pre-commit hook, --message FILE from the commit-msg hook, FILE… for one file (missing file = failure). Its allow-lists are scripts, which satz compiles in for review-pack |
update-third-party-licenses |
helper | regenerate THIRD-PARTY-LICENSES.md, the licence text of every crate compiled into satz, from Cargo.lock with cargo about; --check fails on a stale file or a licence outside the allow-list. CI runs --check in the checks job |
check-grammar.sh |
gate | hold the tree-sitter grammar editors/zed/extension.toml pins against the parser: every keyword in STATEMENT_KEYWORDS must be a node the grammar declares, and every .satz under presets/ and tests/ must parse with no ERROR or MISSING node; CI runs it on every PR and every push to main (smoke.yml, job grammar) |
check-names.sh — the privacy gate
This repository is public; the estates it serves are private. The gate refuses any
identifier shaped like private data that is not one of the documented example
values in docs/examples.md — directory ids, org/project/folder
numbers, billing accounts, GUIDs and their dashless 32-hex form, project ids,
e-mail addresses, domains that are neither IANA-reserved nor a known vendor host,
repository URLs and checkout paths — in files and in commit messages. It also
refuses the local files that must never be staged (CLAUDE.local.md,
*.local.md, .claude/, attestations.yaml, evidence/) and any commit whose
author or committer is not a GitHub noreply address
(<id>+<user>@users, noreply@github.com).
scripts/check-names.sh # the whole tree (CI)
scripts/check-names.sh --staged # staged files + the identity about to commit
scripts/check-names.sh --commits A..B # identities and messages of a commit range
scripts/check-names.sh --message FILE # one commit message
scripts/check-names.sh FILE... # specific files
It knows no customer, no company and no person: the allowlists are the example
values and the vendor defaults every customer shares, and everything else of that
shape is refused. It judges tokens, not lines — an allowed address never
shields a private one beside it — and an unusable commit range or a missing file
is a failure, never a pass. It runs under bash 3.2 (/bin/bash on macOS) as well
as bash 5.
The allow-lists — the example values, the vendor hosts, the vendor-default GUIDs — are
one file, scripts: one entry per line, <list> <ERE>, the entries
of a list joined into one alternation. The script refuses to run without it or with a
list that has no entry.
The content rules exist twice. The script is the gate: it runs with no toolchain and on
a tree that does not compile. src/privacy_shapes.rs is the same rules in satz, so
satz review-pack refuses a pack that carries a private-looking token (kind
private-shape) on any machine, with no checkout of this repository. Both read the one
allow-list file, and a cargo test runs the script (FILE mode, LC_ALL=C) and satz
over every file in tests/privacy-shapes/ and fails on any token that one flags and the
other does not, on a clean- file that flags anything, on a hit- file that flags
nothing and on a rule no file exercises. A change to a rule changes both copies and adds
the fixture that shows it. The fixtures carry every private-looking value split by %%,
which the test removes before either reads them, so the gate finds nothing in the
committed files. For that test, a content rule prints one <file>:<line>: <token>
row per token under its ✗ <rule> title.
What it cannot see is a NAME. A display name or a company in prose has no
shape, and "Log Admins" and a real customer's project name are the same kind of
string. That is what the local, never-committed denylist ($NAMES_DENYLIST) is
for, and what review is for.
Two settings come from the environment, so that no personal value is written into
this public script. $NAMES_DENYLIST is the path of the denylist file, one
extended regex per line; without it the denylist rule does not run. $NAMES_IDENT
is an extended regex of one more address that may author or commit, appended to the
noreply forms; without it only those forms pass, and a commit under any other
address is refused with what to do: set git config user.email to the GitHub
noreply address, or NAMES_IDENT in the clone that needs another one. CI sets
neither, so what it judges is the same everywhere.
Enable the hooks once per clone — they run the gate before a commit and again on the message:
git config core.hooksPath .githooks
.github runs it on every pull request, over the tree
and the commits the pull request adds, and on every push to main, over the
commits that push adds. main is never force-pushed: a push whose previous
main does not resolve fails the gate.
smoke.sh — the command matrix
The unit tests cover the engines and pin the bugs that were fixed; scripts/smoke.sh
covers the commands, end to end, one check of each command's core behaviour. It
runs, offline, against the fixture estate in tests/smoke/ (the shipped CIS,
contacts and monitoring packs, a group with a member, org grants, a project with
services and a bucket): transpile (then tofu validate when tofu is on PATH —
no state, no cloud), require, check-presets against the repository's own
presets (must be clean), import in its state shape (with the skipped report and
import blocks), hcl shape (translated, then tofu validate; a vendor-shaped
configuration that asks for the organisation, in tests/smoke/tf-handwritten/; and --wrap-all),
the refusal a .yaml estate gets from import and from transpile, adopt (a
credentials error without ADC), scan against a stand-in checkov that prints a
report, every MCP tool, then cargo test. The providers every tofu init needs are
downloaded once, into TF_PLUGIN_CACHE_DIR (default
~).
scripts/smoke.sh # builds target/release/satz first
SATZ=~/.cargo/bin/satz scripts/smoke.sh # uses that binary, never rebuilt
SMOKE_SKIP_CARGO_TEST=1 scripts/smoke.sh # leaves out the closing cargo test
.github/workflows/smoke.yml runs it with OpenTofu installed, once per commit: on
every pull request, and on the push to main that merges it or bumps the release.
A branch push without a pull request runs nothing, and a release tag runs nothing,
because it tags a commit main already tested. A newer push to a pull request
cancels the run it supersedes, in this workflow and in the privacy gate; a run on
main is never cancelled or queued behind another. The Rust jobs restore the cargo
registry and the dependencies' build from Swatinem/rust-cache, so a run compiles only
satz's own crates. The smoke job sets SMOKE_SKIP_CARGO_TEST=1, because the
checks job runs cargo test on the same commit, and restores the provider cache
with actions/cache. A new command that reads an estate gets a step here in the same PR.
mcp-probe.py — every MCP tool through the raw pipe
satz mcp is driven by clients, not by hand, so the probe is the client. It starts the
server, speaks MCP 2025-06-18 to it and reads its stdout pipe itself. It does not use
an MCP SDK: an SDK drops a line that is not JSON, so a test built on one passes while a
real client's stream is corrupt.
uv run scripts/mcp-probe.py # offline, every group
uv run scripts/mcp-probe.py --groups read # only what read allows
uv run scripts/mcp-probe.py --tools satz_triage,satz_fmt
uv run scripts/mcp-probe.py --mode net # + upstream presets, a real Checkov
uv run scripts/mcp-probe.py --mode live --live-dir <estate-checkout> --live-estate <main>.satz
--satz names the binary (default target/release/satz, which cargo build --release
writes); --work the directory it stages into and logs to (default a new temporary
one), which holds each server's stderr and report.json, every result as data.
What each call is checked for. Any stdout line that is not a JSON-RPC message fails
the run, charged to the call in flight. A success carries structuredContent that
validates against the tool's outputSchema and equals the JSON in its text block. A
refusal is an isError result whose text is prose. A missing or mistyped argument is
refused, and the probe reports the form — isError or invalid_params. Each call's
duration and size are recorded.
Where MCP 2025-06-18 is not met. The protocol requires an object outputSchema
and an object structuredContent. KNOWN_DEVIATIONS in the script lists the tools
that do not meet it, and is empty; a deviation it lists is reported as KNOWN, one it
does not list fails, and one it lists that no longer occurs fails too, so the list
shrinks with the fix.
The servers. One satz mcp process per concern, each over a copy of presets/,
tests/smoke/ and tests/schemas/ staged into one root, so presets_dir sits inside
--root, and each staged root a git repository, because merge-presets edits an estate
only where the edit can be undone:
| server | started with | what it proves |
|---|---|---|
main |
the selected groups | the exact tools/list set and each tool's schemas; a call before satz_open refused; a missing and a mistyped argument; then each tool once at the level it needs — the interview created and answered, remediation items annotated, transpile and then Checkov over what it wrote; satz_adopt refused without credentials (offline) |
gp |
read,write |
get-presets from a pristine library into an estate that has none, then merge-presets report and write |
ceiling |
--allow read |
every write and exec tool refused naming the group; every path outside the root refused, an existing and a missing one alike |
gated |
--self-gated |
satz_restrict narrows and never widens again |
identity |
--allow read |
runs_as and satz_whoami follow each estate opened in turn; an estate with a mode the compile refuses, or cloud mode without its account, is refused by every call that would act as it |
live |
--allow read |
--mode live only: whoami online answers runs_as, report-compliance reads the inventory, adopt resolves without execute |
The modes. Each includes the one before.
| mode | needs | adds |
|---|---|---|
offline |
nothing: the ADC path names a file that does not exist, and Checkov is a stand-in on PATH that prints a fixed report |
every tool that works without credentials, and satz_adopt's refusal without them |
net |
the network | satz_check_presets against the upstream library (one GitHub API request), satz_scan_checkov with a real Checkov — checkov, else uvx checkov; skipped, by name, when neither is on PATH |
live |
ADC that may impersonate the estate's IaC service account; --live-dir, --live-estate, --live-config (default config.toml) |
the live server over a copy of that estate. It writes nothing to the organisation. Run it on the test organisation, never on a customer's |
The report. After the per-call lines come the stray stdout lines, the protocol
deviations, how each malformed argument was refused, and a table of each tool's largest
result in bytes and in tokens estimated at four bytes each — the number
MAX_MCP_OUTPUT_TOKENS is set from (satz mcp). A result over
--limit-tokens (25,000, Claude Code's default) is a failed check of its own, because a
client cuts an oversized result mid-JSON. The exit status is non-zero on any failed check
or stray line.
When to run it. CI does not: run offline before a pull request that changes a tool,
its arguments or its report type, and live on the test organisation before a release
that changes a live tool.
fleet-v1.sh — every estate on the current binary
The script answers: does every estate you operate still compile on the current
binary, and does it emit the same infrastructure? The corpus tests the compiler
against fixtures, the smoke matrix tests the commands against one estate, and
tofu plan tests one estate against its own organisation with that estate's
credentials; none of them re-transpiles the fleet.
scripts/fleet-v1.sh ~/estates/acme # one estate
scripts/fleet-v1.sh --roster ~/fleet.tsv # the fleet
scripts/fleet-v1.sh --roster ~/fleet.tsv -v # ... with the diffs
The roster
Which estates exist, and where, is not in this repository; it is operator
state. The script takes it as an
argument (or $FLEET_ROSTER) in either of two shapes:
# code<TAB>path, comments allowed
E01 ~/estates/acme
E02 ~/estates/bolt
or a markdown table, so a fleet note you already keep works unchanged — the code
is the first cell and the checkout is the first backticked absolute-or-~
path, which may carry a note after it:
| E01 | Customer A | `~/estates/acme` (also elsewhere) | … |
A row without both is skipped as prose. The parser is strict: a lenient one would read ordinary prose and unrelated tables as estates, report each as UNAVAILABLE, and hide the estates that really were not checked.
Bare paths on the command line need no roster at all.
Reading the result
| outcome | what it means | what to do |
|---|---|---|
| clean | the address set is identical and no block body moved | nothing |
| delta | same address set, some block body differs | not a release blocker. Carry it into the estate's next pickup and commit the re-transpiled hcl/ |
| BLOCKER | the estate does not transpile, or its address set moved — a resource appeared or disappeared | stop the release until the change is explained |
| UNAVAILABLE | no checkout, no config.toml, no hcl/ to compare against — or no provider schema, which estate repos gitignore as a derived cache, so a fresh clone has none |
run it where the checkouts are, or satz update-schema in that one. --require-all turns this into a failure |
Exit codes follow: 0 clean, 1 blocker (or --require-all with an unchecked
estate), 2 delta only.
A hand-written .tf in hcl/ is listed, not compared. The comparison set is what
satz emits; a file this emission did not produce is listed with ! and its blocks
are left out of the count. An estate may keep one: a write-only secret cannot come
from the estate, so the variable block declaring it lives in hcl/ beside the
generated files. Compared, its blocks would read as deletions. The script cannot tell
a hand-written file from one an older satz emitted, so it names the file; check a
named file that is not hand-written.
A subdirectory in hcl/ is the same thing one level up. satz emits flat files into
hcl/ and never below it, so a modules/ or landing-zones/ tree an estate keeps
there is its own, and the main.tf inside is not the emitted main.tf that shares the
name. The walk is flat: a subdirectory is listed with ! and nothing in it is
compared. Walked, such a tree would read as deletions on an estate whose emission
has not changed at all.
Why a moved address blocks. A body delta renders the same resources differently. A moved address means the next plan creates or destroys a resource.
When the delta is the state backend
A delta in the terraform block's backend is a changed state backend, and tofu
refuses the next command until it is re-initialised, once per estate:
cd hcl/ && tofu init -reconfigure
Local-mode estates need nothing, and satz migrate --mode cloud re-initialises
on its own.
What it does not do
- It never writes into a checkout. Every estate is copied to a scratch
directory and transpiled there, because estate repositories carry work in
progress. Pass
--scratch DIRto keep the emitted trees. - It does not diff text.
git diff hcl/also reports a block that only moved. Blocks are matched by address, so content counts and order does not. - It does not treat a missing checkout as a pass; it reports it as UNAVAILABLE.
- It reads no cloud. It compares what satz emits. What the organisation has is
report-compliance, with that estate's credentials.
build-site.py — which docs become pages
build-site.py publishes the docs it names in one list and skips those in another,
and fails naming any docs/*.md that is in neither:
SITE_DOCS— published.SITE_DOCS_EXCLUDED— not published, each with its reason.
The gate is a build error in both directions: a new doc cannot slip onto the site unreviewed, and cannot be silently left off it either. The smoke matrix runs the site build, so the check is enforced in CI.
The menu is a third list, gated the same way. NAV_ORDER names every page in
reading order — satz, language, library, changelog, workflows, interview,
mcp, examples, housekeeping, competitive, llms — and the build fails on a page
it does not name, or on a name that is not a page, so the menu stays a table of
contents rather than a directory listing. A page's title is its menu word after
satz (# satz language, # satz library), with no trailing explanation; what the
page is goes in its opening line. The browser tab shows that title as plain text,
taken from the rendered heading, and a page without a # title fails the build.
The 48 per-pack pages under presets/docs/ are rendered and linked from the
preset library, but carry no menu entry of their own — someone looking for a pack
starts at the library. Each pack page's side column carries, beneath its own
contents, the whole library: every pack page under its group, in the order of
presets/library-groups.txt, the page itself marked. The library page's contents
column shows its ## group headings as groups with the sections under them. A group
folds: the one that holds the current page, or the section the reader is in, is open,
and the others show their title alone. The build fails on a pack page in no group of
that file and on an entry with no page.
An excluded doc stays in the repository and stays linkable — a link to one from a
published page is rewritten to its GitHub blob URL rather than left as a .md
href that 404s. So is a link to any other repository file the site does not
publish, an ADR under docs/adr/ for one; a link to a file that does not exist
fails the build, naming the page and the link.
Excluded, and why:
| doc | why not published |
|---|---|
security-toolset-integration |
a proposal under rework; it describes an audit loop satz does not implement |
fast-delta.md |
source material for the competitive matrix, which carries the conclusions |
stage-b.md |
how the pipeline was built. The language reference is how it is used, and the migration commands are in the README |
One page at a time
build-satz-doc.py is the renderer build-site.py imports. It turns one
docs/*.md into a self-contained, theme-aware HTML page, inlining any SVG that sits
beside the markdown and recolouring it through CSS tokens so it follows the viewer's
theme. It parses with cmark-gfm, GitHub's own parser
(ADR 0008), so a page
shows what GitHub shows and every heading carries the anchor GitHub gives it. Both
scripts declare that dependency themselves, so a bare uv run runs them. A link to an
anchor its target page does not carry fails the site build.
Inline code never pushes a page or a column wide. In a table cell it breaks between
its words and never inside one, so a long command cannot claim its column's whole
width; in running text a word of up to 27 characters is kept whole as well, so
--help never ends a line as --. A word too long to share a table row with two
others, in a cell or in running text, may also break before a /, . or _, and a
piece of it that is still that long where a lowercase letter meets a capital. In a
heading every word may break before a /, . or _, because a pack's name at
heading size does not fit a phone line. Whatever still cannot fit a line breaks where
it must; a code block scrolls inside itself instead.
Run on its own, it renders the language reference:
uv run scripts/build-satz-doc.py [MD] [OUT.html] [TITLE]
Navigating a long page
Each page carries a contents column built from its own h2/h3 headings, sticky
beside the text, marking the section in view. Below 1180px it becomes a collapsed
block above the content. It is built from the rendered headings.
update_import_config.py — keep the type table current
Three passes over presets/import-config.yaml, all from data, never by hand;
comments and row order survive (ruamel.yaml):
--schema-dir <dir> --provider-version <v>makes the rows the provider's resource types: it reads every provider schema JSON there (google and google-beta), adds a row for each type the table lacks (import: false,asset_type: TODO/UNKNOWN), removes the rows no schema knows, and recordsvas the table'sprovider_version.cargo testfails when that differs from the pin intests/smoke/config.toml. The schemas come fromtofu providers schema -json > <dir>/schemas.json, run in a directory initialized with both providers at the pinned version.--cai-types presets/cai-asset-types.txtresolves theTODO/UNKNOWNrows: the Cloud Asset Inventory name is derived from the Terraform type (google_dns_managed_zone→dns, with an alias table for the services whose provider name is not their API host) and kept ONLY when it is in Google's published list —.googleapis .com /ManagedZone cai-asset-types.txtis that list, dated in its header. Rows that are not Cloud Asset resources at all (IAM members/bindings, org-policy v1 shapes, provider constructs, andgoogle_billing_budget, whichadoptresolves through the Billing API) lose theasset_typekey: known, not unknown, and so do the firewall-policy rules and associations, which Cloud Asset carries inside their policy'scomputeasset. What stays.googleapis .com /FirewallPolicy TODO/UNKNOWNis printed with what was tried, and a row withimport: trueamong them is counted on its own line. The smoke matrix runs this pass and fails when the table is behind the list or when that count is not zero;cargo testplans a sweep over the shipped table, by default and under--all.--probe <parent>asks Cloud Asset Inventory for one page of every named row's asset type under<parent>(organizations/<n>,folders/<n>,projects/<id>), with the Application Default Credentials through gcloud and 80 requests a minute (--probe-rate), below the ListAssets quota. Google's list names types ListAssets refuses at the row'scontent_type— the Access Context Manager, Cloud Identity, Cloud SQL user and database types among them; the refusal names the type and points at Google's supported-asset-types page, while a type the scope merely has none of answers with an empty page. A refused row loses itsasset_type, gets a comment naming the refusal, and is state shape only. Any other error ends the run. It needs a live organization, so no gate runs it; run it after--cai-types.
uv run --with ruamel.yaml scripts/update_import_config.py \
--config-file presets/import-config.yaml \
--schema-dir <dir-of-schema-json> --provider-version <pinned version> \
--cai-types presets/cai-asset-types.txt
uv run --with ruamel.yaml scripts/update_import_config.py \
--config-file presets/import-config.yaml --probe organizations/<n>
To refresh the list itself, run uv run scripts first
(the list).
At the live shape an enabled row with asset_type: TODO/UNKNOWN (or an
unknown content_type) is a hard error; an enabled row with no asset_type
(a type Cloud Asset does not carry — Cloud Identity groups, say) is reported
once as "state shape only" and skipped there.
build-skills.py — the packaged skills
python3 scripts/build-skills.py writes skills/<name>.skill for every skills/<name>/SKILL.md —
the zip a Claude skill is uploaded as (Cowork, the desktop app), with the folder's files under
<name>/. The folder is the source and the zip is derived: entries are stored, not compressed
(so no zlib version shows), sorted by path, dated 1980-01-01, mode 0644 (0755 under scripts/),
and __pycache__, .pyc and .DS_Store are left out, so one folder gives one byte sequence on
every platform. --check exits 1 naming each zip behind its folder; the smoke matrix runs it,
so a skill change that forgets the zip fails CI. A clone that wants the skills in Claude Code
links the folder once, ln -sfn ../skills .claude/skills.
update_cai_asset_types.py — the asset-type list against Google's page
uv run scripts rewrites presets/cai-asset-types.txt from
Google's published list of Cloud Asset Inventory types, reading the names out of the page's
text with every tag removed (the page breaks a name with <wbr>). A type the file carries
that the page no longer names is not dropped: the run refuses and lists it, because a type
Google stopped publishing is a decision for whoever runs the script — delete the line by hand
and run again. --check exits 1 when the file is behind the page. Then
uv run --with ruamel.yaml scripts fills the import config
from the new list.
update_constraint_equivalents.py — which managed constraint replaces which
Google publishes, per org-policy constraint, the constraint that replaces it
(equivalentConstraint). The packs act on that pairing — run the managed replacement
alone, declare the legacy twin off — so it lives in the repository as generated data:
uv run scripts/update_constraint_equivalents.py # auto-detect org + quota project
uv run scripts/update_constraint_equivalents.py --org 123456789012
Writes presets. Needs ADC and a quota project —
the OrgPolicy API refuses bare ADC. Nothing about the organisation reaches the file: the
constraint catalogue is Google's and identical for every customer.
The script:
- Reads the equivalence in both directions. The declaration is asymmetric — far more managed constraints name their legacy twin than the reverse — so reading one side finds a fraction of the pairs.
- Preserves the curated section. Below the
CURATEDmarker sit pairs Google does not declare, each with a note saying why it is asserted. The one such pair isiam↔.allowedPolicyMemberDomains iam: different names, no declared equivalence in either direction, the same control. The generated section above the marker is rewritten wholesale; do not edit it by hand..managed .allowedPolicyMembers
The table is enforced offline by constraint
(src/main.rs), which compiles every corpus case and fails when a pack enforces a legacy
constraint that has a replacement, or enables a replacement without declaring its twin off.
Refreshing the table needs credentials; the rule is checked on every cargo test
without any.
update_schema_fixture.py — keep the test schema current
tests/schemas/google.json is a real provider schema trimmed to the types the
fixtures use, and the corpus classifies types through it as production does through
the full schema. A type missing from it loses schema-derived detail without an error;
a type whose real schema has changed makes the snapshots pin an older provider than
the pin says.
uv run scripts/update_schema_fixture.py --check
uv run scripts/update_schema_fixture.py --add google_compute_firewall_policy_rule
--check downloads the pinned provider (from provider_version in
tests/smoke/config.toml) and reports each fixture type as GONE or DRIFTED, listing
the attributes and blocks that appeared or vanished. Run it when the provider pin
moves; no other check does. --add inserts or re-cuts types from the real schema
and leaves the rest byte for byte; regenerate the corpus afterwards.
It never deletes: three fixture types appear in no .satz source because the
emitter produces them from structural nodes, and one referenced type needs no schema
because it sits in a raw hcl { } block, so usage in the sources does not show which
types are needed.
Needs tofu on PATH; talks to no organisation.
build_dry_run_fragments.py — the dry-run twins
An org policy can carry dry_run_spec instead of spec. Google evaluates every rule,
writes a violation to the audit log for each action it WOULD have blocked, and blocks
nothing — so a breaking control is sized against a live organisation before it is
enforced.
The twin must measure exactly the policy that is later enforced, or its numbers answer a question nobody asked. So it is derived, not written:
uv run scripts/build_dry_run_fragments.py # write the twins
uv run scripts/build_dry_run_fragments.py --check # fail if any is stale
DRY_RUNNABLE at the top of the script is the list, with the param that gates each
twin. A fragment reaches it when its constraint supports a dry run; five extensions do
not, each for a stated reason in that table — the legacy constraints (Shielded VM, both
CMEK list constraints) have no dry-run form, Access Approval is not an org policy, and
the two on-by-default extensions have nothing to size.
Three things the derivation does:
specbecomesdry_run_spec— except aspec { reset = true }, which declares a superseded legacy twin OFF. That is not a control being enforced and there is nothing to size about it, so it stays as it is.- Every
claimis dropped. A dry run discharges nothing while it measures, sorequirereports the control unmet, which is the truth. A claim over a dry-run policy would be contradicted by its own witness (ADR 0013). - The pack name gains
_dry_runand its version tracks the fragment it came from.
Read the violations in Cloud Logging:
protoPayload.metadata."@type"="type.googleapis.com/google.cloud.audit.OrgPolicyViolationInfo"
Promote by switching the dry-run param off and the enforcing param on. Both on at once is refused, naming both params and what each choice means.
check_prerequisites.py — the prerequisite table's roles against Google's roles
The IaC role table names, per resource type, a permission and the predefined roles
that carry it. The script reads the table from satz update-prerequisites --format json (of this
checkout through cargo run, or of an installed binary with --satz), fetches each
role it names from the IAM API (roles.get), and fails when none of an entry's roles
carries the entry's permission: a typo in the table, a permission Google renamed, or a
role Google narrowed.
uv run scripts/check_prerequisites.py # the table of this checkout
uv run scripts/check_prerequisites.py --satz satz # the table of an installed binary
Needs Application Default Credentials; predefined roles are Google's and the same for every organisation, so any credential that may call the IAM API will do. Workspace entries are not IAM roles and are skipped. Run it when adding a row and on each provider pin move; CI has no credentials to run it.
check-grammar.sh — the editor grammar against every Satz file
The Zed extension in editors/zed/ highlights Satz through a tree-sitter grammar that
mirrors the parser by hand (crates). The grammar lives in its
own repository, satz-tree-sitter, and editors/zed/extension.toml pins one commit of
it. A statement the parser gains and the grammar has not followed reads as an error in
the editor — or, where the statement is shaped like a resource block, as a resource
block: nothing reports an error, and the editor reads the statement differently from satz.
scripts/check-grammar.sh clones the pinned commit into a temporary directory and runs
two checks against it.
The two statement sets. STATEMENT_KEYWORDS in crates is
every keyword that opens a top-level statement — action, claim, estate, hcl,
notice, offers, pack, params, question, suppress, use. The script reads
that list and the node and token types the grammar declares in its src/node-types.json,
and fails naming any keyword the grammar models nothing for. The list is derived rather
than remembered: the unit test statement reads
the dispatch in parse out of the source between its two markers and fails when the two
differ, so a statement the parser gains reaches this gate.
The files. Every *.satz under presets/ and tests/ is parsed with the grammar,
failing on any ERROR or MISSING node; *.diff.satz files are unified diffs and are
skipped. GRAMMAR=<path> uses a local checkout of the grammar instead of cloning. It
needs the tree-sitter CLI (brew install tree-sitter-cli) or node, through which it
fetches the CLI, and python3 for the statement comparison.
CI runs it on every PR and every push to main (.github/workflows/smoke.yml, job
grammar), and the
grammar repository's own CI parses a fresh clone of this one on every push and once a
week. A language change is a grammar commit first, then the pin bump here, in the PR
that changes the parser; the job fails on a PR that changes the parser without it.
update-third-party-licenses.sh — the crates' licence texts
cargo about reads Cargo.lock, finds each crate's licence file in its unpacked
source, and renders about.hbs into THIRD-PARTY-LICENSES.md: a summary table, then
every licence text with the crates it belongs to. The script is the only way that file
is written. It runs cargo about offline, so the sources on disk are the whole input
and the network cannot change what the file says; a dependency whose source is not
unpacked yet needs cargo fetch --locked first.
scripts/update-third-party-licenses.sh # rewrite it
scripts/update-third-party-licenses.sh --check # fail when it is stale, print the diff
about.toml decides what the file contains:
acceptedis the allow-list, and the preference order for a crate that offers a choice. A dependency under a licence it does not name fails the run, naming the crate, so a new licence reaches a person. Apache-2.0 comes first: a crate offered asMIT OR Apache-2.0is taken under Apache 2.0, satz's own licence.targetsare the four release targets (ADR 0029), so a dependency only Windows or only Linux pulls in is in the file wherever it is generated.- Dev dependencies are out —
cargo testcompiles them and no archive carries them. Build dependencies are in: a build script is how the C inaws-lc-sysandzlib-rsreaches the binary. [private] ignore = truekeepssatz,satz-coreandsatz-hclout; all three carrypublish = false, and their licence isLICENSE. The root manifest therefore also carries[package.metadata.dist] dist = true, orpublish = falsewould hide the binary from dist and a release would build nothing.
The script requires one exact cargo-about version and names it when another is
installed (cargo binstall cargo-about@<version>). A different version can read a
crate's licence file differently, and the file would then differ between a machine and
CI with nothing in the repository having changed. That WANT= line is where the
version lives: moving it is one edit plus a regenerated file in the same commit.
CI runs --check in the checks job of .github/workflows/smoke.yml, last, where
the cargo cache already is. The job installs the version the script names — read out
of the script — and runs cargo fetch --locked first, because a Linux build unpacks
what Linux compiles and the file also covers the crates only Windows and macOS pull
in.
build-satz-doc.py — one page, self-contained
build-site.py renders the whole site; this renders ONE docs/*.md as a single
theme-aware HTML file with its SVGs inlined, for sending a page to someone who will
open it from disk rather than from the site:
uv run scripts/build-satz-doc.py docs/language.md out/language.html ["Page title"]
Both paths are optional: with none it renders docs/language.md beside itself.
Nothing gates it — the page it writes is not published, and the site build does not
read its output.
inspect_schema.py — look at one type
Prints one resource type's schema out of a terraform providers schema -json
dump, and lists near-miss key names when the type is not found. Edit the target
variable to change which type it reports.
python3 scripts/inspect_schema.py <schema.json>
presets/scc/scc-enable-all.sh — Security Command Center services
Turns every SCC service on at the organization and makes everything below the organization inherit it.
presets/scc/scc-enable-all.sh --organization 123456789012 # dry run
presets/scc/scc-enable-all.sh --organization 123456789012 --apply # write
Or let the estate supply the organisation id. presets
is a pack whose only content is an action binding this script, since service
enablement has no provider resource:
use "presets/scc/scc-service-enablement.satz"
satz run-actions estate.satz # print the resolved command line, run nothing
satz run-actions estate.satz --check # the dry run above
satz run-actions estate.satz --execute # adds --apply
args carries the reading form of the command, execute_args the one flag that
writes. The pack declares
phase = "before-apply", because enablement is a prerequisite for the SCC
resources a later pack will declare.
Defaults
Every service is enabled except the ones below. A detector for a workload that does not exist yet costs nothing, so Container Threat Detection is on before the first GKE cluster exists.
These are opt-in:
| flag | what it adds | why it is not the default |
|---|---|---|
--with-optional |
WEB_SECURITY_SCANNER |
it actively crawls the customer's web applications, which needs the customer's consent |
--with-optional |
ARTIFACT_ANALYSIS |
billed per image scan |
--with-multicloud |
the AWS/Azure connector services | they fail until an AWS or Azure connector exists |
Without the flag those services are left alone: not enabled at the org, and not
swept to INHERITED below it. Sweeping them would make descendants take the org's
value, which is unset, and switch off a scanner enabled on one project.
Why it is a script
google/google-beta carry no binding for
securitycentermanagement's SecurityCenterService. Turning
Security Health Analytics, Event Threat Detection, Container Threat Detection,
VM Threat Detection, Web Security Scanner or DSPM on or off therefore cannot be
written as a resource in any language satz compiles, and neither can tier
activation. Provider 7.14.1 ships 35 google_scc_* / google_securityposture_*
types and none of them is service enablement or a tier — the
google_scc_management_* ones are custom modules only.
Everything downstream of activation is codeable and belongs in a preset:
custom modules (SHA + ETD), sources and source IAM, notification configs,
BigQuery exports, mute configs, and
google. Use the v2 notification
resources: the v1 API answers This API is no longer available. Please use API V2 on a live org, so google does not work;
google is the one to write.
It calls the API, not gcloud scc manage
The SDK knows 13 of the 17 services the API exposes. ARTIFACT_GUARD,
ARTIFACT_ANALYSIS, AGENT and EXTERNAL_EXPOSURE have
no gcloud name at all — the CLI answers "is not a valid service name" — while the
API sets them. The script therefore calls the API, which also needs no translation
between its names and the CLI's (SECURITY_HEALTH_ANALYTICS against
security-health-analytics); discovery returns the API's form. gcloud supplies the
credentials and walks the hierarchy.
The API needs a quota project — --quota-project, defaulting to the active
gcloud project — and refuses the call without one. --apply is the difference
between validateOnly and a write.
What a live organization answers
Against a real organization, 38 of 41 calls succeed; the three that fail are the organization's own state:
- The four services the CLI cannot name enable through the API, verified end to end.
- VM Manager cannot be enabled at all here. The API answers
Invalid intended_enablement_state. ENABLED is not a valid enablement state: SCC mirrors whether GCE's VM Manager is running. It is skipped in the org pass with a note and still swept toINHERITED, which the API does accept. - Security Health Analytics answers
FAILED_PRECONDITIONat every level on an org where it is disabled and the subscription does not carry it. The script passes the API's message through. - The built-in fallback list is in the API's own names, which is what discovery returns; a CLI spelling there names no service the API knows.
What it does
Two passes:
- org pass — each service →
ENABLEDatorganizations/<id>. - descendant sweep — each service →
INHERITEDat every folder and every project under the org.
The second pass makes the organization the one place the state is set: a descendant
with its own ENABLED/DISABLED overrides the organization, so enabling at the org
alone does not reach it. (INHERITED is only a legal state below the org, which is
why the two passes set different values.)
With --reset-modules each service's individual modules are set to
INHERITED as well, clearing per-module overrides on top of the per-service ones.
Flags
| flag | effect |
|---|---|
--organization ID |
numeric org id (required); organizations/123 is accepted too |
--apply |
actually write — without it every call carries validateOnly |
--services "a b c" |
use this service list verbatim instead of discovering it |
--with-optional |
also enable Web Security Scanner and Artifact Analysis |
--with-multicloud |
include the AWS/Azure connector services |
--quota-project ID |
project the API bills the call to; defaults to the active gcloud project |
--org-only |
enable at the org, skip the sweep |
--descendants-only |
only sweep folders/projects to INHERITED |
--reset-modules |
also set every module to INHERITED |
--targets-file FILE |
explicit folders/<id> / projects/<id> list instead of hierarchy discovery |
Before running it
It is a dry run by default. Every call carries validateOnly until --apply is
passed.
The service list is read from the org, not hardcoded. It comes from the API's
own securityCenterServices listing, so a service Google adds later is picked up
without touching the script. The built-in list of 14 is only a fallback for when
that call cannot be made, and the AWS/Azure connectors in it are skipped unless
--with-multicloud, because they fail on an org with no such connector.
Activate the SCC tier first. Tier activation has no API the script could call, and enabling a service on an org on the Standard tier fails at the API.
Failures it classifies
Three failures are classified in the output instead of being left as a raw API error:
PERMISSION_DENIED— the caller lackssecuritycentermanagement(.securityCenterServices .update roles/securitycenter.adminat the org, or the settings admin role).- service unavailable — the org's SCC tier does not carry that service, or its API is off.
- blocked by a constraint — the CIS §1.1 lock refused Google's role grant to
a service agent that
alloweddoes not name. The pack's default names SCC's five agents; the SCC section of_policy _member _subjects presets/README.mdlists them and says how to allow another.
Exit status is non-zero if any call failed, so it can gate a runbook step.
Requirements
gcloud and jq on PATH, and a shell — it is written for bash 3.2, the macOS
default, so no mapfile and no unguarded empty-array expansion.
The shape of the rule
Anything derived gets, in order of preference:
- a script in
scripts/, so the refresh is reproducible and reviewable; - a gate that fails offline when the derived file and its consumers disagree;
- failing both, a line on this page saying what goes stale and when.
A derived file with none of the three can go stale while every test passes.