KeiSeiKit-1.0/skills/vm-provision/phase-5-verify.md
Parfii-bot eee5eecc20 feat(skills): /vm-provision 6-phase pipeline
Hub-and-spoke skill:
- SKILL.md (index) + phase-1-select-provider, phase-2-plan,
  phase-3-provision, phase-4-harden, phase-5-verify, phase-6-handoff.

Pipeline: select provider → Plan Mode doc → provision (hetzner/vultr
primitives, SSH first-contact TOFU) → harden-base.sh over SSH →
ssh-check + firewall-diff HARD GATE → artefact ledger + optional
/web-deploy handoff.

Invariants:
- ≥ 6 AskUserQuestion calls (Phase 1×2, 2×1, 3×1, 4×1, 5×1).
- Hard gate: Phase 6 refuses to run unless ssh-check AND firewall-diff
  both exit 0. "Ignore and proceed" is BLOCKED by design.
- RULE 0.8 (secrets ENV-ref only), RULE 0.4 (cite provider specifics),
  RULE 0.5 (plan.md written to <run-dir>/plan.md before provisioning),
  RULE -1 (every failure branch returns 2-3 constructive paths).

Defensive-only — no scanning tools, no CVE probes, no third-party
attack-surface analysis. Every phase file ≤ 200 LOC per Constructor
Pattern.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 21:00:14 +08:00

3.9 KiB

Phase 5 — Verification Hard Gate (ssh-check + firewall-diff)

Goal: fail-closed verification. Phase 6 refuses to run unless BOTH ssh-check AND firewall-diff exit 0. Verify criterion: SSH_CHECK_OK = true AND FW_DIFF_OK = true.


5.a — Pull config artefacts from the VM

scp "${ADMIN_USER}@${VM_IP}:/etc/ssh/sshd_config"            <run-dir>/sshd_config
ssh "${ADMIN_USER}@${VM_IP}" "sudo tar -C /etc/ssh -cf - sshd_config.d" \
  | tar -C <run-dir>/ -xf -
ssh "${ADMIN_USER}@${VM_IP}" "sudo ufw status numbered"      > <run-dir>/ufw-status.txt

The ufw status requires sudo on most distros — the admin user has it via NOPASSWD:ALL from harden-base.sh. If sudo requires TTY, prefix sudo -n and surface the failure.

All captured files are READ ONLY, for ssh-check / firewall-diff to parse. We NEVER push config back from the workstation.


5.b — Run ssh-check

_primitives/_rust/ssh-check/target/release/ssh-check \
  --config  <run-dir>/sshd_config \
  --drop-in <run-dir>/sshd_config.d \
  --allow-user "${ADMIN_USER}" \
  --json > <run-dir>/ssh-check.json
SSH_EXIT=$?

Exit 0 → SSH_CHECK_OK=true. Exit 2 → SSH_CHECK_OK=false and <run-dir>/ssh-check.json lists the violating directives with file:line precision. Exit 1 → usage/parse error; surface the stderr and loop back to Phase 4.


5.c — Run firewall-diff

_primitives/_rust/firewall-diff/target/release/firewall-diff \
  --intent <run-dir>/firewall-intent.yaml \
  --status-file <run-dir>/ufw-status.txt \
  --json > <run-dir>/firewall-diff.json
FW_EXIT=$?

Exit 0 → FW_DIFF_OK=true. Exit 2 → the JSON lists missing (in intent, not live) and extra (in live, not intent) rules; default_mismatches flags a non-deny inbound policy.


5.d — Decision tree

ssh-check firewall-diff Action
0 0 Proceed to Phase 6.
2 0 Loop to 4.a with the sshd_config.d fix + re-ship harden-base.sh.
0 2 Ask user: apply the missing/extra deltas via ufw commands, or update firewall-intent.yaml (the intent was wrong). ONE AskUserQuestion.
2 2 Both failed — show both JSON reports; recommend a single fresh harden-base.sh re-run first (common-mode fix), then re-verify.
1 1 Workstation issue (missing binary, bad path) — NOT a VM problem. Rebuild the Rust primitives (cargo build --release in _primitives/_rust/).

5.e — The AskUserQuestion

Exactly ONE AskUserQuestion, gated on the decision tree above:

Verification results: ssh-check=<PASS|FAIL>, firewall-diff=<PASS|FAIL>. Pick one:

  • Proceed (only shown when both PASS) → Phase 6.
  • Fix and retry → loop to Phase 4 (or to 5.c if intent YAML is wrong).
  • Ignore and proceedBLOCKED. The hard-gate invariant refuses this path per SKILL.md. You can abort, but you cannot bypass.

5.f — Verify criterion

  • ssh-check exit 0.
  • firewall-diff exit 0.
  • <run-dir>/ssh-check.json and <run-dir>/firewall-diff.json saved.

Emit: Phase 5 done: hard-gate PASSED. Artefacts in <run-dir>/.

Proceed to Phase 6.


5.g — Non-obvious pitfalls

  • sshd_config.d drop-in not loaded. Debian 12's default /etc/ssh/sshd_config includes the .d directory via an Include directive. We don't follow Include on purpose (security — includes can escape the intended tree). Pass --drop-in explicitly.
  • ufw status shows IPv6 rules as duplicates. Intent is IPv4-only by default; firewall-diff's normalisation treats (v6) rules with same port/proto as "expected" and does not flag them. If you need strict v6-only rules, open a separate intent file.
  • MaxAuthTries at 6 or 10 (Debian default). harden-base.sh sets 3. If a previous manual edit raised it and we re-ran without rewriting, ssh-check will FAIL maxauthtries. Fix: re-run harden-base.sh.