Skip to content

Commit cb021c4

Browse files
feat(scripts): extend §1.5 verb-canary coverage (Phase E) (#215)
## Summary Tightens `scripts/hcg-policy-smoke.sh` against three verb-governance regression classes the original three canaries (DELETE/PUT/PATCH on `/cartridges` and `/health`) don't catch. Single-lane HCG tier-2 channel (`standards#91`); Phase E (`standards#100`) is the active phase. ## What this PR adds Three new deny-mode verb-canary probes: 1. **`OPTIONS /cartridges`** — `global_verbs: [GET, POST]` bans OPTIONS, but a CORS preflight auto-responder added later would silently bypass policy. The canary fails closed against that regression class. 2. **`DELETE /cartridge/probe/invoke`** — exercises the regex route `^/cartridge/[A-Za-z0-9_.-]+/invoke$` under a banned verb. The existing exact-path canaries don't catch a regex-matcher regression where the path is accepted under any verb instead of only the verb the rule lists. 3. **`GET /cartridges/ssg-mcp/webhook`** — the path is in the policy as a documented public exception, but only for POST. The canary verifies the `{path, verb}` pairing is enforced: GET on the same path must default-deny because no rule covers it. Runbook §1.5 description updated to enumerate the expanded canary set; runbook version bump 0.4 → 0.5. ## What this PR deliberately does NOT do - **Probe HEAD.** Curl with `-X HEAD` (vs `--head`) waits for a body the server will not send, which interacts badly with the script's `--max-time 10`. HEAD enforcement remains covered by the gateway's own unit tests; the §1.5 operator pre-check focuses on probes that survive curl's method quirks. The reasoning is captured inline in the script comment so a future maintainer doesn't add it back as an oversight. - **Extend the `--with-backend` allow-path matrix.** The authenticated routes the script probes in allow mode are the ones actually wired in `BojRest.Router`; the additional policy entries (`graphql`, `sse`, `order`, `umoja/*`, etc.) are declared-not-yet-wired per contract §8 and would 404 from BoJ, which the `allow_or_upstream` pattern misdiagnoses as gateway-deny. They stay in the deny matrix until they are wired in BoJ. The `ssg-mcp-webhook-post` route is not added to the `--with-backend` allow probes for the same reason. - **Auto-derive the probe matrix from the policy YAML.** The matrix stays hand-maintained and the parity-with-policy property remains a manual maintenance discipline. PR #210's commitment ("the script doubles as a policy-completeness checklist") is preserved. - **Close `standards#100`.** Per runbook §6.5 the joint-close happens after the §6.4 Trustfile flip, which itself follows the §3.3 100% production-soak window. Using `Refs` per the Phase E PR convention (#38, #208, #210). ## Verification - [x] `bash -n scripts/hcg-policy-smoke.sh` — syntax check passes. - [x] Synthetic always-403 mock on `:18443` — `PASS=31 FAIL=0` (was 28); the three new canaries report PASS; exit 0. - [x] `--help` and bad-args exit codes unchanged (64). - [x] SPDX header `MPL-2.0` unchanged. - [x] Runbook cross-references resolve. ## Channel position ``` standards#91 (parent, open) ├── #96 Phase A — closed ├── #97 Phase B — closed ├── #98 Phase C — closed ├── #99 Phase D — closed (joint-closed via boj-server#168) └── #100 Phase E — IN PROGRESS ├── E5 runbook draft — boj-server#128 (landed) ├── E1 loopback prereqs — boj-server#130/#131/#132/#165/#173 (landed) ├── E1 deploy spec — http-capability-gateway#38 (landed) ├── E1 live policy promotion — boj-server#208 (landed) ├── §1.5 operator pre-check smoke — boj-server#210 (landed) ├── §1.5 verb-canary expansion — THIS PR (in review) ├── E1 .ctp signing — owner follow-up ├── E2 staging cut-over — owner follow-up ├── E3 telemetry verification — owner follow-up ├── E4 production rollout — owner follow-up └── §6.4 Trustfile flip + §6.5 joint-close — owner-only ``` Refs hyperpolymath/standards#91 Refs hyperpolymath/standards#100 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- _Generated by [Claude Code](https://claude.ai/code/session_01NgeisSDPZ7NJSjkhU95qYS)_ Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 4bf4641 commit cb021c4

2 files changed

Lines changed: 34 additions & 10 deletions

File tree

docs/integration/hcg-tier2-rollout-runbook.md

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,9 @@
33

44
# HCG tier-2 — rollout & rollback runbook
55

6-
**Version:** 0.4 (policy-deny smoke script landed, Phase E in-progress)
7-
**Date:** 2026-06-10 (rev. from 2026-06-09)
8-
**Status:** Phase E deliverables E1 (deploy spec) + E5 (rollback runbook) drafted; live gateway policy (`config/gateway-policy-boj.yaml`) promoted from the worked example (§1.5); `scripts/hcg-policy-smoke.sh` lands as the checked-in §1.5 operator pre-check (deny-path covers gateway-alone; `--with-backend` adds allow-path coverage). Owner-input markers (`!OWNER:`) remain to be filled before any traffic-shift action is taken.
6+
**Version:** 0.5 (smoke-script verb-canary expansion, Phase E in-progress)
7+
**Date:** 2026-06-13 (rev. from 2026-06-10)
8+
**Status:** Phase E deliverables E1 (deploy spec) + E5 (rollback runbook) drafted; live gateway policy (`config/gateway-policy-boj.yaml`) promoted from the worked example (§1.5); `scripts/hcg-policy-smoke.sh` lands as the checked-in §1.5 operator pre-check (deny-path covers gateway-alone; `--with-backend` adds allow-path coverage); §1.5 verb-canary block extended to cover OPTIONS, regex-route DELETE, and wrong-verb-on-listed-path so the operator pre-check fails closed against more verb-governance regression classes. Owner-input markers (`!OWNER:`) remain to be filled before any traffic-shift action is taken.
99
**ADR:** [`docs/decisions/0004-adopt-http-capability-gateway.md`](../decisions/0004-adopt-http-capability-gateway.md)
1010
**Plan:** [`docs/integration/http-capability-gateway-plan.md`](http-capability-gateway-plan.md) (§ Phase E)
1111
**Contract:** [`docs/integration/http-capability-gateway-boj-contract.md`](http-capability-gateway-boj-contract.md)
@@ -88,7 +88,7 @@ These cannot be inferred from the code/contract; the owner must fill them before
8888
- [x] `container/gateway-deploy.k9.ncl` exists in the gateway repo (plan §E1) — http-capability-gateway#38 (2026-06-03). Five-level k9-svc pedigree (Snout / Scent / Leash / Gut / Muscle) modelled on `boj-server:container/deploy.k9.ncl`; per-environment `BACKEND_URL` (`http://127.0.0.1:7700` staging, `http://unix:/run/boj/gnosis.sock:/` production); trust source `"header"` staging → `"mtls"` production after §2.4 rehearsal; `max_unavailable = 0`; `failure_mode = "fail-closed"` matching the `[SEAMS] gateway-boj-gnosis` declaration.
8989
- [x] Gateway policy file in place: `config/gateway-policy-boj-example.yaml`, covering all BoJ surface routes (`/.well-known/boj-node-pubkey`, `/health`, `/menu`, `/cartridges`, `/cartridge/:name`, `/cartridge/:name/invoke`, `/cartridge/:name/sse`, plus any added since contract v1.0). Re-verified 2026-05-28 against `BojRest.Router`; the `POST /cartridge/:name/sse` route (router.ex line 130, wired since the SSE landing — ADR-0013 §6, STATE entry 2026-05-18) was the only drift since contract v1.0 and is now governed by the `cartridge-sse-post` rule alongside `cartridge-invoke-post` (boj-server#165).
9090
- [x] Live policy file (`config/gateway-policy-boj.yaml`) promoted from the example. Content-identical to the example at promotion time; future BoJ-surface evolution lands in the live file and the example remains as the worked-example artefact (Phase A A3). Both §2.1 staging and §3.1 production load the live file via `POLICY_PATH`.
91-
- [ ] Gateway has been smoke-tested in isolation with the policy, returning expected allow/deny on each route. Run `scripts/hcg-policy-smoke.sh --gateway-url <staging-gateway-url>` against the gateway loaded with `config/gateway-policy-boj.yaml`; the script exercises a no-trust-header deny probe for every non-public route plus default-deny verb canaries (DELETE/PUT/PATCH on `/cartridges` and `/health`) and is fully gateway-internal — BoJ does **not** need to be reachable for this run. Once BoJ is up behind the gateway, re-run with `--with-backend` from a trusted-proxy IP (loopback by default) to also cover the allow path on authenticated/internal routes including the `POST /cartridge/:name/sse` authenticated/untrusted pair carried over from boj-server#165's test plan. Attach the script's PASS/FAIL summary to the cut-over ticket; a single FAIL is a stop-the-rollout condition (gateway loaded the policy but is not enforcing as declared, or BoJ is unreachable from the gateway, or the script is being run from a non-trusted-proxy IP and the trust header is being stripped).
91+
- [ ] Gateway has been smoke-tested in isolation with the policy, returning expected allow/deny on each route. Run `scripts/hcg-policy-smoke.sh --gateway-url <staging-gateway-url>` against the gateway loaded with `config/gateway-policy-boj.yaml`; the script exercises a no-trust-header deny probe for every non-public route (25 in the live policy) plus six default-deny verb canaries — DELETE/PUT/PATCH on listed exact paths, OPTIONS on a listed path (no CORS-preflight bypass), DELETE on a regex-matched route (no per-verb regex regression), and GET on the POST-only `ssg-mcp-webhook` public route (the `{path, verb}` pairing must be enforced even when the path itself is in the policy) — and is fully gateway-internal — BoJ does **not** need to be reachable for this run. Once BoJ is up behind the gateway, re-run with `--with-backend` from a trusted-proxy IP (loopback by default) to also cover the allow path on authenticated/internal routes including the `POST /cartridge/:name/sse` authenticated/untrusted pair carried over from boj-server#165's test plan. Attach the script's PASS/FAIL summary to the cut-over ticket; a single FAIL is a stop-the-rollout condition (gateway loaded the policy but is not enforcing as declared, or BoJ is unreachable from the gateway, or the script is being run from a non-trusted-proxy IP and the trust header is being stripped).
9292

9393
---
9494

scripts/hcg-policy-smoke.sh

Lines changed: 30 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -193,12 +193,36 @@ probe POST /coprocessor/select deny "internal:coprocessor-select-post"
193193
probe GET /sdp/status deny "internal:sdp-status-get"
194194

195195
# Default-deny verb canaries — global_verbs is [GET, POST], so any
196-
# DELETE/PUT/PATCH on a known path must be denied via the no-match
197-
# (or unknown-method) path. Verifies the verb-governance core invariant
198-
# of ADR-0004.
199-
probe DELETE /cartridges deny "verb-canary:DELETE /cartridges"
200-
probe PUT /health deny "verb-canary:PUT /health"
201-
probe PATCH /cartridges deny "verb-canary:PATCH /cartridges"
196+
# DELETE/PUT/PATCH/OPTIONS on a known path must be denied via the
197+
# no-match (or unknown-method) path. Verifies the verb-governance core
198+
# invariant of ADR-0004.
199+
#
200+
# OPTIONS is named in the policy header's banned-verb list and gets its
201+
# own canary because a CORS preflight auto-responder in the gateway
202+
# would silently bypass policy.
203+
#
204+
# Regex-route verb canary (DELETE on cartridge-invoke-post) catches a
205+
# class of bug the exact-path canaries miss: a regression where the
206+
# regex matcher accepts the path under any verb instead of only the
207+
# verb its rule lists.
208+
#
209+
# Wrong-verb-on-listed-path canary (GET on the ssg-mcp webhook, which
210+
# only lists POST) verifies the {path, verb} pairing is enforced: the
211+
# path is in the policy as a public exception, but only for POST; GET
212+
# on the same path must default-deny because no rule covers it.
213+
#
214+
# HEAD is also banned by the policy header but is deliberately not
215+
# canaried here — curl with `-X HEAD` (vs `--head`) waits for a body
216+
# the server will not send, which interacts badly with `--max-time` in
217+
# this script. HEAD enforcement remains covered by the gateway's own
218+
# unit tests; the operator pre-check focuses on probes that survive
219+
# curl's method quirks.
220+
probe DELETE /cartridges deny "verb-canary:DELETE /cartridges"
221+
probe PUT /health deny "verb-canary:PUT /health"
222+
probe PATCH /cartridges deny "verb-canary:PATCH /cartridges"
223+
probe OPTIONS /cartridges deny "verb-canary:OPTIONS /cartridges (preflight must not bypass)"
224+
probe DELETE /cartridge/probe/invoke deny "verb-canary:DELETE on regex route (cartridge-invoke-post)"
225+
probe GET /cartridges/ssg-mcp/webhook deny "verb-canary:GET on POST-only public route (ssg-mcp-webhook-post)"
202226

203227
if [ "$WITH_BACKEND" = "1" ]; then
204228
echo

0 commit comments

Comments
 (0)