feat(aosguard): a guarded Netlify domain-alias surface, so the token stays inside #1355

Merged
coilyco-ops merged 1 commit from aos/claude/mt75-netlify-wrap into main 2026-08-28 19:58:37 +00:00
Owner

Closes #1353. Kai confirmed the route directly.

Why a wrap

There was no netlify surface at all - no guardfile, nothing installed. The alternative to building one was fetching /coilysiren/netlify/token into a caller's environment. This keeps it where the Forgejo surfaces keep theirs: resolved from SSM at exec time via the ssm value provider, never in argv, never in a shell.

Two leaves, site (read) and alias (write), both running one packaged module, so no other part of the Netlify API is reachable.

The hazard Vera raised, and why it is not answered by experiment

domain_aliases was flagged as likely replace rather than merge, meaning a wrap taking one alias would silently delete the others.

That cannot be settled by measurement: the experiment is a production write that re-issues a live certificate. So the module does not depend on the answer. Every write is a read-modify-write that sends the union of existing and new aliases, which is correct under either semantic.

Two things found by looking rather than assuming:

  • domain_aliases is currently [], so nothing is at risk today. The trap arms on the second alias, which is exactly the three-alias sequence #1353 describes.
  • custom_domain (www.coilysiren.me) is a separate field, so an alias write cannot clobber the primary domain's record. The certificate concern is real and unrelated to this.

Batching is therefore a safety property, not tidiness: adding a domain re-issues the certificate covering every name on the site, so three calls are three certificate events on a live site.

--site is mandatory and allowlisted

#1349 closed a wrap that documented a fixed target while accepting any. A new surface is exactly where that class comes back, so the acceptance criterion is built in rather than filed later. The site is named in full because the API's other accepted form is an opaque uuid, which has no business in a tracked file.

Verified against the built binary

Testing found two real bugs before they shipped:

  • embed appends the script path last, so pinning the action in argv produced python3 -I -- show <script> - python would have run show as the program. The action moved to a guarded arg0, one per leaf.
  • The API keys sites on the full name; the bare slug returned 404.

Deny paths, each with its own message:

call result
no --site denied, fail-closed
wrong site denied, fail-closed
add words on the site leaf denied on arg0
add with no --alias refused
alias equal to the primary domain refused

The read path was exercised end to end through the guard and returns the live site. A network failure now names itself rather than printing a traceback, which is the #1346 lesson applied at authoring time.

Not done here

The aliases are not added. That write touches a live certificate covering the primary domain and wants someone watching it. The command is ready:

aosguard ops netlify alias add --site coilysiren-dot-me.netlify.app \
  --alias umbra.coilyco.ai --alias agent-compose.coilyco.ai --alias mcp-beaver.coilyco.ai

One call, all three, per the batching rule above.

Docs

In docs/aosguard.md rather than a new page, because docs/*.md is at its 40-file cap (#1341). The section displaced retold prose in the same file and normalized two hard-wrapped paragraphs, the practice AGENTS.md already documents.

Verification

uv run pytest 748 passed including 6 new tests pinning the read-modify-write, the batching, idempotence, and both refusals. pre-commit run --all-files green.

Local note: this workstation's Python has no CA bundle, so the read path needs SSL_CERT_FILE. That is the same local trust-store dead end #1344 wasted time on, not a defect here - the dev-base image's python3 carries certs.

Closes #1353. Kai confirmed the route directly. ## Why a wrap There was no netlify surface at all - no guardfile, nothing installed. The alternative to building one was fetching `/coilysiren/netlify/token` into a caller's environment. This keeps it where the Forgejo surfaces keep theirs: resolved from SSM at exec time via the `ssm` value provider, never in argv, never in a shell. Two leaves, `site` (read) and `alias` (write), both running one packaged module, so no other part of the Netlify API is reachable. ## The hazard Vera raised, and why it is not answered by experiment `domain_aliases` was flagged as likely **replace** rather than merge, meaning a wrap taking one alias would silently delete the others. That cannot be settled by measurement: **the experiment is a production write that re-issues a live certificate.** So the module does not depend on the answer. Every write is a read-modify-write that sends the union of existing and new aliases, which is correct under either semantic. Two things found by looking rather than assuming: * `domain_aliases` is currently `[]`, so nothing is at risk today. The trap arms on the *second* alias, which is exactly the three-alias sequence #1353 describes. * `custom_domain` (`www.coilysiren.me`) is a **separate field**, so an alias write cannot clobber the primary domain's record. The certificate concern is real and unrelated to this. Batching is therefore a safety property, not tidiness: adding a domain re-issues the certificate covering every name on the site, so three calls are three certificate events on a live site. ## `--site` is mandatory and allowlisted #1349 closed a wrap that documented a fixed target while accepting any. A new surface is exactly where that class comes back, so the acceptance criterion is built in rather than filed later. The site is named in full because the API's other accepted form is an opaque uuid, which has no business in a tracked file. ## Verified against the built binary Testing found two real bugs before they shipped: * `embed` appends the script path **last**, so pinning the action in `argv` produced `python3 -I -- show <script>` - python would have run `show` as the program. The action moved to a guarded `arg0`, one per leaf. * The API keys sites on the full name; the bare slug returned 404. Deny paths, each with its own message: | call | result | |---|---| | no `--site` | denied, fail-closed | | wrong site | denied, fail-closed | | `add` words on the `site` leaf | denied on arg0 | | `add` with no `--alias` | refused | | alias equal to the primary domain | refused | The read path was exercised end to end through the guard and returns the live site. A network failure now names itself rather than printing a traceback, which is the #1346 lesson applied at authoring time. ## Not done here **The aliases are not added.** That write touches a live certificate covering the primary domain and wants someone watching it. The command is ready: ``` aosguard ops netlify alias add --site coilysiren-dot-me.netlify.app \ --alias umbra.coilyco.ai --alias agent-compose.coilyco.ai --alias mcp-beaver.coilyco.ai ``` One call, all three, per the batching rule above. ## Docs In `docs/aosguard.md` rather than a new page, because `docs/*.md` is at its 40-file cap (#1341). The section displaced retold prose in the same file and normalized two hard-wrapped paragraphs, the practice AGENTS.md already documents. ## Verification `uv run pytest` 748 passed including 6 new tests pinning the read-modify-write, the batching, idempotence, and both refusals. `pre-commit run --all-files` green. Local note: this workstation's Python has no CA bundle, so the read path needs `SSL_CERT_FILE`. That is the same local trust-store dead end #1344 wasted time on, not a defect here - the dev-base image's python3 carries certs.
feat(aosguard): a guarded Netlify domain-alias surface, so the token stays inside
All checks were successful
ci / aos-cli-tests (pull_request) Successful in 26s
ci / aos-eval-tests (pull_request) Successful in 6s
ci / gate (pull_request) Successful in 45s
566559d54a
Adding the vanity-domain aliases needed the Netlify token. There was no
netlify surface at all, so the alternative was fetching the secret into a
caller's environment. This keeps it where the Forgejo surfaces keep theirs:
resolved from SSM at exec time, never in argv, never in a shell.

Two leaves, `site` for the read and `alias` for the write, both running one
packaged module, so no other part of the Netlify API is reachable.

Every write sends the whole alias list. The API replaces `domain_aliases`
rather than merging, so a call carrying one alias would delete the rest. The
module reads, adds, and sends the union, which is correct whichever way the
API behaves and stops the caller needing to know. Vera raised this as a
hazard to confirm; it cannot be confirmed by experiment, because the
experiment is a production write that re-issues a live certificate. The
read-modify-write makes the answer not matter, which is the better outcome
than knowing it.

Batching is a safety property. Adding a domain re-issues the certificate
covering every name on the site, the primary domain included, so three calls
are three certificate events.

`--site` is required and allowlisted. #1349 closed a wrap that documented a
fixed target while accepting any, and a new surface is exactly where that
class returns.

Verified against the built binary, including the read path end to end: an
absent site, a wrong site, an action on the wrong leaf, an empty alias list,
and an alias equal to the primary domain all fail closed with their own
message. Testing found two real bugs first. `embed` appends the script path
last, so the pinned action produced `python3 -I -- show <script>` and python
would have run `show`; the action moves to a guarded arg0. And the API keys
sites on the full name, so the bare slug 404'd.

Docs go in aosguard.md rather than a new page: docs/*.md is at its 40-file
cap (#1341), so the section displaced retold prose in the same file.

The aliases themselves are not added here. That write touches a live
certificate on the primary domain and wants someone watching it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: Kai Siren <coilysiren@gmail.com>
Co-authored-by: Claude <noreply@anthropic.com>
Agent-Role: platform
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
coilyco-flight-deck/agentic-os!1355
No description provided.