Skip to content

Commit ae7532a

Browse files
feat(scripts): hcg-policy-smoke.sh — stealth-profile status canary (Phase E) (#224)
## Summary Extends the §1.5 operator pre-check (`scripts/hcg-policy-smoke.sh`) with four exact-status stealth-profile canaries that pin the deny *status code*: internal+stealth routes (`sdp-status-get`, `cartridge-load-post`) must return exactly **404** (capability existence hidden), and authenticated routes without a `stealth_profile` (`status-get`, `cartridges-list-get`) must return exactly **403** (capability exists, caller lacks credentials). `probe()` gains a three-digit literal pattern (`[1-5][0-9][0-9]`) to support the assertion; existing `deny` and `allow_or_upstream` patterns are unchanged. ## Why The existing `deny` pattern accepts any 4xx. A misconfiguration where `:stealth_profiles` is not populated at runtime (the runtime config that `application.ex configure_stealth/1` sets from `policy.stealth.enabled: true`) would silently demote internal+stealth routes from 404 to bare 403. The deny probes would still pass — but the security property would be broken: untrusted callers could now distinguish "route exists, you can't access it" (403) from "route does not exist" (404), leaking capability existence. This is the exact threat the `sdp-status-get` rule narrative calls out (`"not confirmable from outside"`). The two code paths fixed by this canary are both in `http-capability-gateway/lib/http_capability_gateway/gateway.ex`: - `handle_denial/3` returns `stealth_profiles["default"][trust_str]` for rules with `stealth_profile: "default"` and bare 403 otherwise. - A regression that drops the rule's `stealth_profile` field or that nils-out the application env would surface here. The pre-check now catches both. ## Changes - `scripts/hcg-policy-smoke.sh`: - `probe()` gains a `[1-5][0-9][0-9]` exact-status case clause. - Four new probes between the existing verb-canary block and the unknown-path canary: two internal+stealth (expect 404), two authenticated (expect 403). - Top-of-file docstring updated to mention the new class alongside the verb / path canaries. - `docs/integration/hcg-tier2-rollout-runbook.md`: §1.5 acceptance prose updated to describe the new canary class; version 0.6 → 0.7; date 2026-06-14 → 2026-06-15. ## Test plan - [x] `bash -n scripts/hcg-policy-smoke.sh` — script parses cleanly. - [x] `bash scripts/hcg-policy-smoke.sh --help` — usage still rendered correctly (the new probes do not change the option surface). - [ ] Operator-side: when the gateway is up in staging, `scripts/hcg-policy-smoke.sh --gateway-url <staging>` reports four new `PASS` lines (`stealth-canary:GET /sdp/status (internal+stealth → 404)`, …) alongside the existing 25 deny probes, six verb canaries, and one path canary. A `FAIL` on the 404-expected probes specifically means stealth is not armed — investigate `:stealth_profiles` before flipping the §1.5 checkbox. ## Phase E channel notes - Channel: `hyperpolymath/standards#91` (parent) → Phase E (`#100`) is the active sub-issue. - This PR is a Phase E §1.5 sub-task; per rollout runbook §6.5 (single-lane channel discipline), Phase E close is owner-driven and gated on §3.3 (100% soak), §6.4 (Trustfile flip), and cerro-torre `.ctp` signing. Each Phase E sub-task therefore lands as a `Refs`-only advance — this PR deliberately does **not** `Closes #100`. 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_015gHmHcGoRXocbo1N77662D)_ Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
1 parent c710e25 commit ae7532a

2 files changed

Lines changed: 44 additions & 7 deletions

File tree

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

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,9 @@ Copyright (c) Jonathan D.A. Jewell <j.d.a.jewell@open.ac.uk>
66

77
# HCG tier-2 — rollout & rollback runbook
88

9-
**Version:** 0.6 (smoke-script unknown-path canary, Phase E in-progress)
10-
**Date:** 2026-06-14 (rev. from 2026-06-13)
11-
**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 covers OPTIONS, regex-route DELETE, and wrong-verb-on-listed-path; a path-canary now exercises the no-match default-deny branch (synthetic unknown path with a `global_verbs` verb) so the operator pre-check fails closed against both unknown-method and unknown-path regression classes. Owner-input markers (`!OWNER:`) remain to be filled before any traffic-shift action is taken.
9+
**Version:** 0.7 (smoke-script stealth-profile canary, Phase E in-progress)
10+
**Date:** 2026-06-15 (rev. from 2026-06-14)
11+
**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 covers OPTIONS, regex-route DELETE, and wrong-verb-on-listed-path; a path-canary exercises the no-match default-deny branch (synthetic unknown path with a `global_verbs` verb); and a stealth-profile canary now pins the deny *status code* — internal+stealth routes must return 404 (capability existence hidden) and authenticated routes must return 403, so a missing-`:stealth_profiles` misconfiguration is caught instead of slipping past the generic any-4xx deny pattern. Owner-input markers (`!OWNER:`) remain to be filled before any traffic-shift action is taken.
1212
**ADR:** [`docs/decisions/0004-adopt-http-capability-gateway.md`](../decisions/0004-adopt-http-capability-gateway.md)
1313
**Plan:** [`docs/integration/http-capability-gateway-plan.md`](http-capability-gateway-plan.md) (§ Phase E)
1414
**Contract:** [`docs/integration/http-capability-gateway-boj-contract.md`](http-capability-gateway-boj-contract.md)
@@ -91,7 +91,7 @@ These cannot be inferred from the code/contract; the owner must fill them before
9191
- [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.
9292
- [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).
9393
- [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`.
94-
- [ ] 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), 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 one path canary (GET on a synthetic `/__phase-e-canary-unknown-path__` URL that matches no exact rule, no regex rule, and no public exception) which isolates the no-match → default-deny branch of the gateway's three-tier lookup; the verb canaries cover the unknown-method path, the path canary covers the unknown-path path, and both must default-deny on independent code branches. The whole script 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).
94+
- [ ] 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), 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) — a path canary (GET on a synthetic `/__phase-e-canary-unknown-path__` URL that matches no exact rule, no regex rule, and no public exception) which isolates the no-match → default-deny branch of the gateway's three-tier lookup, and four stealth-profile canaries (two internal+stealth routes pinned to exactly 404, two authenticated routes pinned to exactly 403) which catch a `:stealth_profiles` misconfiguration that would otherwise let an internal route silently regress to 403 — leaking capability existence to untrusted callers, the exact threat called out in the `sdp-status-get` narrative — while still satisfying the generic any-4xx deny pattern; the verb canaries cover the unknown-method path, the path canary covers the unknown-path path, and the stealth canaries cover the deny-status-code shape, all three failing closed on independent code branches. The whole script 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).
9595

9696
---
9797

scripts/hcg-policy-smoke.sh

Lines changed: 40 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,12 @@
99
#
1010
# The default mode probes the *deny* path for every non-public route in
1111
# `config/gateway-policy-boj.yaml` plus a default-deny verb canary for
12-
# DELETE/PUT/PATCH. The deny path is fully gateway-internal — it does
13-
# not require BoJ to be reachable, so this script is the cheapest way
14-
# to confirm policy enforcement before staging cut-over.
12+
# DELETE/PUT/PATCH, an exact-status stealth-profile canary that pins
13+
# internal+stealth routes to 404 (vs authenticated routes to 403), and
14+
# an unknown-path no-match canary. The deny path is fully gateway-
15+
# internal — it does not require BoJ to be reachable, so this script
16+
# is the cheapest way to confirm policy enforcement before staging
17+
# cut-over.
1518
#
1619
# With `--with-backend`, the script additionally sends an authenticated
1720
# (or internal) probe per route and asserts the gateway *forwarded* it
@@ -152,6 +155,19 @@ probe() {
152155
return
153156
fi
154157
;;
158+
[1-5][0-9][0-9])
159+
# Exact-status pattern — three-digit literal, e.g. 404 or
160+
# 403. The stealth-profile canary block below uses this to
161+
# distinguish stealth (404) from plain forbidden (403) on
162+
# the deny path; the generic `deny` pattern above accepts
163+
# both, so a regression that demoted a stealth route to
164+
# 403 would slip through it.
165+
if [ "$code" = "$pattern" ]; then
166+
printf ' PASS %-65s %s\n' "$label" "$code"
167+
PASS=$((PASS + 1))
168+
return
169+
fi
170+
;;
155171
esac
156172
printf ' FAIL %-65s %s (expected %s)\n' "$label" "$code" "$pattern"
157173
FAIL=$((FAIL + 1))
@@ -224,6 +240,27 @@ probe OPTIONS /cartridges deny "verb-canary:OPTIONS /cartr
224240
probe DELETE /cartridge/probe/invoke deny "verb-canary:DELETE on regex route (cartridge-invoke-post)"
225241
probe GET /cartridges/ssg-mcp/webhook deny "verb-canary:GET on POST-only public route (ssg-mcp-webhook-post)"
226242

243+
# Stealth-profile canary — confirms the security property that the deny
244+
# *status code* differs between internal+stealth routes (404, capability
245+
# existence hidden) and authenticated routes (403, capability exists,
246+
# caller lacks credentials). The generic `deny` pattern above accepts
247+
# both, so a misconfiguration where `:stealth_profiles` is not populated
248+
# at runtime would silently demote internal+stealth routes to 403 —
249+
# leaking existence to untrusted callers, the exact threat the
250+
# `sdp-status-get` rule narrative calls out ("not confirmable from
251+
# outside"). The gateway's `handle_denial/3` returns
252+
# `stealth_profiles["default"][trust_str]` for rules with
253+
# `stealth_profile: "default"` and bare 403 otherwise; this canary fixes
254+
# both ends of that switch.
255+
#
256+
# Internal+stealth — exact 404 expected.
257+
probe GET /sdp/status 404 "stealth-canary:GET /sdp/status (internal+stealth → 404)"
258+
probe POST /cartridge/probe/load 404 "stealth-canary:POST /cartridge/:name/load (internal+stealth → 404)"
259+
260+
# Authenticated, no stealth_profile — exact 403 expected.
261+
probe GET /status 403 "stealth-canary:GET /status (authenticated → 403, not stealthed)"
262+
probe GET /cartridges 403 "stealth-canary:GET /cartridges (authenticated → 403, not stealthed)"
263+
227264
# Unknown-path canary — a synthetic path that matches no exact rule,
228265
# no regex rule, and no public exception. The verb (GET) is in
229266
# `global_verbs`, so this probe isolates the no-match → default-deny

0 commit comments

Comments
 (0)