Make S1 handoff read-only by default
All checks were successful
CI Smoke / source-contract (push) Successful in 8s
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s

Assistant: codex
Assistant-Model: gpt-5.6-sol
Assistant-Session: 01a02994-7685-7940-bf34-3555b8256018
This commit is contained in:
codex 2026-08-23 12:41:23 +02:00
parent 24b799ec59
commit 40e295e3bd
16 changed files with 637 additions and 46 deletions

View file

@ -28,6 +28,7 @@ jobs:
python3 -c 'import yaml'
python3 scripts/inventory_contract.py
python3 scripts/baseline_contract.py --check-repo
python3 scripts/handoff_contract.py
python3 scripts/sops_rotation.py --check
python3 scripts/s1_receipt.py docs/evidence/s1-receipts/*.json
python3 -m unittest discover -s tests -v

3
.gitignore vendored
View file

@ -16,3 +16,6 @@ terraform.tfstate*
__pycache__/
*.pyc
.venv/
# Transient verification/rotation receipts; promote reviewed evidence under docs/evidence/
/reports/

View file

@ -74,10 +74,13 @@ validate-inventory: ## Validate adopted/provider-managed host declarations witho
validate-baseline: ## Validate the executable baseline and its Ansible/Goss consumers
python3 scripts/baseline_contract.py --check-repo
validate-handoff-readonly: ## Prove the live S1 handoff playbook has no remote mutation surface
python3 scripts/handoff_contract.py
validate-receipts: ## Validate committed metadata-only S1 receipt examples
python3 scripts/s1_receipt.py docs/evidence/s1-receipts/*.json
s1-handoff: ## Run the live fail-closed S1 verification gate and emit a receipt
s1-handoff: ## Run the read-only live S1 verification gate and emit a receipt
python3 scripts/s1_handoff.py
s1-handoff-dry-run: ## Validate handoff inputs without host access; receipt is not-run
@ -254,7 +257,8 @@ ansible-help: ## Show common Ansible commands
@echo " make converge-firewall HOST=Railiance01 # UFW only (RAIL-HO-WP-0009)"
@echo " make converge-check # dry-run (check mode)"
@echo " make converge-diff # show config diffs"
@echo " make verify-host HOST=Railiance01"
@echo " make verify-host HOST=Railiance01 # read-only host verification"
@echo " make verify-refresh-host HOST=Railiance01 APPROVE_VERIFY_REFRESH=REFRESH-GOSS-Railiance01"
@echo " make goss-status # last on-host timer result"
ansible-inventory: ## Print the dynamic inventory Ansible will use
@ -275,14 +279,11 @@ status: ## Show live security state of all hosts (UFW, fail2ban, SSH hardening)
@echo ""
@echo "--- Hint: run 'make verify' for a structured pass/fail report ---"
verify: ## Run Goss test suite against all hosts, commit TAP reports — exits non-zero on failure
@echo "Running Goss baseline assertions..."
verify: validate-handoff-readonly ## Read-only Goss verification of all hosts; writes TAP only on controller
@echo "Running read-only Goss baseline assertions..."
@cd $(ANS_DIR) && ansible-playbook playbooks/verify.yaml $(ANSIBLE_USER_FLAG) || \
(echo "One or more assertions FAILED — see reports/ for TAP output." && exit 1)
@echo "All assertions passed."
@git add reports/ && \
git diff --cached --quiet && echo "No new reports to commit." || \
git commit -m "chore: Goss verification reports $$(date -u +%Y-%m-%dT%H%M%SZ)"
observe-railiance01: ## Timestamped host capacity observation for resource-control
@mkdir -p docs/evidence/resource-hosteurope-railiance01/observations
@ -294,12 +295,23 @@ observe-railiance01: ## Timestamped host capacity observation for resource-contr
ln -sfn $$stamp.json docs/evidence/resource-hosteurope-railiance01/observations/latest.json; \
echo "wrote $$dest"
verify-host: ## Run Goss against one host: make verify-host HOST=Railiance01
verify-host: validate-handoff-readonly ## Read-only Goss verification: make verify-host HOST=Railiance01
@test -n "$(HOST)" || (echo "Usage: make verify-host HOST=Railiance01"; exit 1)
@echo "Running Goss baseline assertions on $(HOST)..."
@echo "Running read-only Goss baseline assertions on $(HOST)..."
@cd $(ANS_DIR) && ansible-playbook playbooks/verify.yaml $(ANSIBLE_USER_FLAG) -l $(HOST) || \
(echo "One or more assertions FAILED — see reports/ for TAP output." && exit 1)
verify-refresh: ## Refresh Goss on all hosts after review (exact approval required)
@test "$(APPROVE_VERIFY_REFRESH)" = "REFRESH-GOSS-ALL" || \
(echo "Refusing host mutation: set APPROVE_VERIFY_REFRESH=REFRESH-GOSS-ALL after review"; exit 1)
cd $(ANS_DIR) && ansible-playbook playbooks/verify-refresh.yaml $(ANSIBLE_USER_FLAG)
verify-refresh-host: ## Refresh one host: HOST=... APPROVE_VERIFY_REFRESH=REFRESH-GOSS-<HOST>
@test -n "$(HOST)" || (echo "Usage: make verify-refresh-host HOST=Railiance01 APPROVE_VERIFY_REFRESH=REFRESH-GOSS-Railiance01"; exit 1)
@test "$(APPROVE_VERIFY_REFRESH)" = "REFRESH-GOSS-$(HOST)" || \
(echo "Refusing host mutation: set APPROVE_VERIFY_REFRESH=REFRESH-GOSS-$(HOST) after review"; exit 1)
cd $(ANS_DIR) && ansible-playbook playbooks/verify-refresh.yaml $(ANSIBLE_USER_FLAG) -l $(HOST)
goss-status: ## Fetch last on-host Goss timer result (fails if FAILED flag present)
cd $(ANS_DIR) && ansible-playbook playbooks/goss-status.yaml $(ANSIBLE_USER_FLAG)

View file

@ -116,3 +116,8 @@ baseline state.
This includes admin user setup, SSH hardening, firewall rules, essential tooling, and secret handling.
📖 See the full guide here: [Convergence Documentation](docs/convergence.md)
Routine `make verify` and `make s1-handoff` runs are read-only on managed
hosts. Updating the installed Goss surface is deliberately separate and
requires an exact `APPROVE_VERIFY_REFRESH` value; see
[Server Verification](docs/verification.md).

View file

@ -112,7 +112,8 @@ maintenance, evidence collection, and drift checks are ongoing S1 work.
has not been migrated safely to this repo's UFW model
- Verification: the executable baseline now resolves `ufw-managed` and
`external-firewall` profiles into both Ansible and Goss. The fail-closed
handoff command and receipt format exist; a fresh attended all-host receipt
handoff command is remotely read-only, refuses a stale installed Goss
surface, and emits metadata-only receipts; a fresh attended all-host receipt
is still pending
- Provisioning: adopted and provider-managed records now have a validated
schema. Terraform selects only provider-managed Hetzner records, with mock

View file

@ -15,6 +15,7 @@
| workplan | RAIL-HO-WP-0009 | finished | — | workplans/RAIL-HO-WP-0009-firewall-declared-state-and-api-exposure.md |
| workplan | RAIL-HO-WP-0010 | finished | — | workplans/RAIL-HO-WP-0010-new-reef-ports-need-a-grant.md |
| workplan | RAIL-HO-WP-0011 | active | — | workplans/RAIL-HO-WP-0011-reproducible-s1-declaration-and-handoff.md |
| workplan | RAIL-HO-WP-0012 | proposed | — | workplans/RAIL-HO-WP-0012-s1-backup-recovery-loop.md |
| task | ADHOC-2026-08-22-T01 | done | — | workplans/ADHOC-2026-08-22.md |
| task | ADHOC-2026-08-22-T02 | done | — | workplans/ADHOC-2026-08-22.md |
| task | ADHOC-2026-08-22-T03 | done | — | workplans/ADHOC-2026-08-22.md |
@ -49,3 +50,9 @@
| task | RAIL-HO-WP-0011-T06 | done | — | workplans/RAIL-HO-WP-0011-reproducible-s1-declaration-and-handoff.md |
| task | RAIL-HO-WP-0011-T07 | done | — | workplans/RAIL-HO-WP-0011-reproducible-s1-declaration-and-handoff.md |
| task | RAIL-HO-WP-0011-T08 | wait | — | workplans/RAIL-HO-WP-0011-reproducible-s1-declaration-and-handoff.md |
| task | RAIL-HO-WP-0012-T01 | todo | — | workplans/RAIL-HO-WP-0012-s1-backup-recovery-loop.md |
| task | RAIL-HO-WP-0012-T02 | todo | — | workplans/RAIL-HO-WP-0012-s1-backup-recovery-loop.md |
| task | RAIL-HO-WP-0012-T03 | todo | — | workplans/RAIL-HO-WP-0012-s1-backup-recovery-loop.md |
| task | RAIL-HO-WP-0012-T04 | todo | — | workplans/RAIL-HO-WP-0012-s1-backup-recovery-loop.md |
| task | RAIL-HO-WP-0012-T05 | wait | — | workplans/RAIL-HO-WP-0012-s1-backup-recovery-loop.md |
| task | RAIL-HO-WP-0012-T06 | wait | — | workplans/RAIL-HO-WP-0012-s1-backup-recovery-loop.md |

View file

@ -0,0 +1,15 @@
---
# verify-refresh.yaml — Install/refresh Goss, run assertions, fetch TAP.
#
# This is a host-mutating operation. Review the rendered baseline and use it
# only when updating the verification surface. Routine verification and the S1
# handoff use the read-only verify.yaml playbook.
- name: Refresh and verify the managed Goss surface
hosts: all
become: true
gather_facts: true
vars_files:
- ../inventory/group_vars/all.yaml
roles:
- role: goss

View file

@ -1,17 +1,84 @@
---
# verify.yaml — Deploy Goss, run baseline assertions, fetch TAP results.
# Exit code mirrors Goss: 0 = all pass, non-zero = failures.
# verify.yaml — Read-only S1 baseline verification and local TAP collection.
#
# Usage:
# ansible-playbook ansible/playbooks/verify.yaml -u admin
# make verify
# This playbook must not install, template, enable, restart, or otherwise
# mutate a managed host. scripts/handoff_contract.py enforces the allowed
# module and command surface before scripts/s1_handoff.py contacts a host.
# Use verify-refresh.yaml only after reviewing and approving host changes.
- hosts: all
- name: Verify the installed S1 baseline without changing the host
hosts: all
become: true
gather_facts: true
# Same declaration the bootstrap play converges from. Without this the
# firewall assertions render empty and silently assert nothing.
gather_facts: false
vars_files:
- ../inventory/group_vars/all.yaml
roles:
- role: goss
vars:
goss_bin: /usr/local/bin/goss
goss_config: /etc/goss/baseline.yaml
expected_goss_baseline: >-
{{ lookup('ansible.builtin.template',
playbook_dir ~ '/../../goss/baseline.yaml.j2',
keep_trailing_newline=true) }}
expected_goss_sha256: "{{ expected_goss_baseline | hash('sha256') }}"
report_stamp: "{{ now(utc=true, fmt='%Y%m%dT%H%M%SZ') }}"
tasks:
- name: Inspect the installed Goss executable
ansible.builtin.stat:
path: "{{ goss_bin }}"
register: installed_goss
- name: Inspect the installed baseline digest
ansible.builtin.stat:
path: "{{ goss_config }}"
checksum_algorithm: sha256
register: installed_baseline
- name: Require the exact source-rendered verification surface
ansible.builtin.assert:
that:
- installed_goss.stat.exists | default(false)
- installed_goss.stat.executable | default(false)
- installed_baseline.stat.exists | default(false)
- installed_baseline.stat.checksum | default('') == expected_goss_sha256
fail_msg: >-
{{ inventory_hostname }} has no usable Goss surface or its baseline is
stale. Review changes, then run verify-refresh for this host before
retrying the read-only handoff gate.
- name: Run the installed baseline assertions
ansible.builtin.command:
argv:
- "{{ goss_bin }}"
- -g
- "{{ goss_config }}"
- validate
- --format
- tap
register: goss_result
changed_when: false
failed_when: false
- name: Ensure the controller reports directory exists
ansible.builtin.file:
path: "{{ playbook_dir }}/../../reports"
state: directory
mode: "0755"
delegate_to: localhost
become: false
- name: Record TAP evidence on the controller
ansible.builtin.copy:
content: "{{ goss_result.stdout }}\n"
dest: >-
{{ playbook_dir }}/../../reports/goss-{{ inventory_hostname }}-{{ report_stamp }}.tap
mode: "0644"
delegate_to: localhost
become: false
changed_when: false
- name: Fail closed on a baseline assertion failure
ansible.builtin.assert:
that:
- goss_result.rc == 0
fail_msg: "Goss baseline failed on {{ inventory_hostname }}"

View file

@ -10,11 +10,11 @@ conformance (that is 10c, blocked on the family declaration validator).
| Piece | Where | Cadence |
| --- | --- | --- |
| Rendered Goss baseline | `/etc/goss/baseline.yaml` from `goss/baseline.yaml.j2` | every `make verify` |
| Rendered Goss baseline | `/etc/goss/baseline.yaml` from `goss/baseline.yaml.j2` | reviewed converge or `make verify-refresh*` |
| On-host timer | `railiance-goss-baseline.timer` | hourly, plus 5 minutes after boot |
| Wrapper | `/usr/local/sbin/goss-baseline-check` | writes `/var/lib/railiance/goss/` |
| Operator collect | `make goss-status` | on demand |
| Operator run | `make verify` / `make verify-host HOST=…` | on demand; commits TAP to `reports/` |
| Operator run | `make verify` / `make verify-host HOST=…` | on demand; read-only remotely, writes transient TAP locally |
The firewall assertions are generated from the same inventory lists that
converge UFW (`k3s_api_allowed_sources`, `k3s_api_revoked_sources`,
@ -37,7 +37,9 @@ Railiance01 after T01.
to a State Hub `/progress/` endpoint. The wrapper notifies only on a
pass↔fail transition, so a known-fail host (CoulombCore, UFW inactive)
does not spam.
4. **git TAP**`make verify` still commits `reports/goss-<host>-<ts>.tap`
4. **controller TAP**`make verify` writes ignored
`reports/goss-<host>-<ts>.tap`; reviewed durable evidence is promoted
deliberately under `docs/evidence/`
`make goss-status` fails the play if any host still has the `FAILED` flag.

View file

@ -23,11 +23,19 @@ The current profiles are:
- `CoulombCore`: `external-firewall`, which verifies the declared iptables
INPUT default-drop replacement control
The verification play installs or refreshes the Goss check surface before it
runs. Treat the first run after source changes as an attended host change:
review the rendered contract and access path first. The command does not run
Terraform, change provider resources, rotate credentials, or converge the base
role.
The gate is read-only on managed hosts. Before connecting,
`scripts/handoff_contract.py` proves that its playbook contains only remote
`stat`, `assert`, and the fixed Goss validation command, and pins the Goss
assertion commands themselves to a reviewed read-only set. It renders each
selected profile locally and fails if the installed
`/etc/goss/baseline.yaml` digest differs, instead of refreshing it implicitly.
TAP and the aggregate receipt are written only beneath the controller's
ignored `reports/` path.
If a surface is stale, the operator must review it and separately approve
`make verify-refresh-host`; that change cannot be smuggled through a handoff
run. The handoff command does not run Terraform, change provider resources,
rotate credentials, converge a role, write remote files, or change services.
Receipts validate with:

View file

@ -37,12 +37,19 @@ make verify
This runs `ansible/playbooks/verify.yaml` against all hosts. The playbook:
1. Downloads the Goss binary (pinned version) to `/usr/local/bin/goss`
2. Copies `goss/baseline.yaml` to `/etc/goss/baseline.yaml` on each host
3. Runs `goss validate --format tap`
4. Fails the play (non-zero exit) if any assertion fails
5. Fetches the TAP report to `reports/goss-<host>-<timestamp>.tap`
6. Auto-commits the report to git
1. Reads the installed Goss binary and `/etc/goss/baseline.yaml` metadata.
2. Renders the selected profile on the controller and requires its SHA-256
digest to match the installed baseline exactly.
3. Runs the fixed `goss validate --format tap` argument vector.
4. Fails the play if the surface is missing/stale or any assertion fails.
5. Writes TAP evidence only on the controller under `reports/`.
`scripts/handoff_contract.py` statically rejects remote modules other than
`stat`, `assert`, and the exact Goss `command`. Controller writes must be
delegated to `localhost` and remain under `reports/`. It also pins every Goss
command assertion and both profile firewall probes to their reviewed read-only
set. `make verify` therefore does not install packages, rewrite files, reload
systemd, or change services on a managed host.
**All assertions passed** → exit 0
**One or more assertions FAILED** → exit non-zero, TAP report in `reports/`
@ -59,6 +66,20 @@ make verify # assert it got there
Run `make status` for a quick human-readable summary; run `make verify` when
you need a structured, automatable check.
If verification fails because the installed surface is stale, review the
rendered changes and use the explicit mutating interface for only the intended
host:
```bash
make verify-refresh-host \
HOST=Railiance01 \
APPROVE_VERIFY_REFRESH=REFRESH-GOSS-Railiance01
```
Refreshing every host requires the distinct approval value
`APPROVE_VERIFY_REFRESH=REFRESH-GOSS-ALL`. Both refresh targets install or
update Goss, its baseline, wrapper, service, and timer before running checks.
## Goss test file
`goss/baseline.yaml.j2` is rendered per host from the same inventory lists
@ -80,14 +101,15 @@ that converge UFW. The mapping is:
1. Add the desired state to `spec/server-baseline.yaml`.
2. If it introduces a new control kind, teach both consumers that kind.
3. Run `make validate-baseline` and the unit tests.
4. Run `make converge-firewall` and `make verify-host HOST=…` only in the
appropriate reviewed live-change sequence.
4. Run the applicable reviewed convergence/refresh command, then use
`make verify-host HOST=…` as the read-only acceptance check.
An hourly on-host timer (`railiance-goss-baseline.timer`) reruns the last
rendered baseline. See `docs/conformance-loop.md`.
## Reports
TAP reports are committed to `reports/` after each `make verify` run.
They are machine-readable and suitable for CI pipelines. A cleanup policy
for old reports is tracked as extension point EP `78ef4879`.
TAP reports are transient under ignored `reports/`. They are machine-readable
and suitable for CI pipelines. Promote deliberately retained evidence under
`docs/evidence/` after reviewing it for safe metadata. A cleanup policy for old
reports is tracked as extension point EP `78ef4879`.

196
scripts/handoff_contract.py Normal file
View file

@ -0,0 +1,196 @@
#!/usr/bin/env python3
"""Enforce the remote read-only contract of the S1 handoff playbook."""
from __future__ import annotations
import argparse
import json
import re
from pathlib import Path
from typing import Any
import yaml
REMOTE_MODULES = {
"ansible.builtin.assert",
"ansible.builtin.command",
"ansible.builtin.stat",
}
CONTROLLER_MODULES = {
"ansible.builtin.copy",
"ansible.builtin.file",
}
TASK_CONTROL_KEYS = {
"become",
"changed_when",
"delegate_to",
"failed_when",
"name",
"register",
"tags",
"when",
}
EXPECTED_GOSS_ARGV = [
"{{ goss_bin }}",
"-g",
"{{ goss_config }}",
"validate",
"--format",
"tap",
]
EXPECTED_GOSS_COMMANDS = {
"{{ baseline_firewall.verification.command }}",
"ufw status | grep -Ec '6443/tcp[[:space:]]+ALLOW[[:space:]]+Anywhere' || true",
"ufw status | grep -E '6443/tcp[[:space:]]+ALLOW' | grep -vc 'Anywhere' || true",
"ufw status | grep -Ec '8472/udp[[:space:]]+ALLOW[[:space:]]+Anywhere' || true",
"ufw status | grep -E '8472/udp[[:space:]]+ALLOW' | grep -vc 'Anywhere' || true",
"ufw status | grep -Ec '6443/tcp[[:space:]]+ALLOW[[:space:]]+{{ src.address }}' || true",
"grep NOPASSWD /etc/sudoers.d/{{ baseline_user.name }}",
"grep -r HISTCONTROL /etc/profile.d/",
"fail2ban-client status {{ jail }}",
"test -x /usr/local/bin/age",
"test -x /usr/local/bin/sops",
}
EXPECTED_PROFILE_COMMANDS = {"iptables -S INPUT", "ufw status"}
COMMAND_KEY_RE = re.compile(r'^ "(.+)":$', re.MULTILINE)
class HandoffContractError(ValueError):
"""The handoff playbook could change a managed host or execute arbitrary code."""
def _module(task: dict[str, Any], label: str) -> tuple[str, Any]:
modules = [key for key in task if key.startswith("ansible.")]
if len(modules) != 1:
raise HandoffContractError(f"{label} must contain exactly one fully-qualified module")
unknown = set(task) - TASK_CONTROL_KEYS - set(modules)
if unknown:
raise HandoffContractError(f"{label} has unsupported task keys: {', '.join(sorted(unknown))}")
return modules[0], task[modules[0]]
def validate_payload(payload: Any) -> None:
if not isinstance(payload, list) or len(payload) != 1 or not isinstance(payload[0], dict):
raise HandoffContractError("handoff playbook must contain exactly one play")
play = payload[0]
if play.get("gather_facts") is not False:
raise HandoffContractError("handoff playbook must set gather_facts: false")
for forbidden in ("force_handlers", "handlers", "post_tasks", "pre_tasks", "roles"):
if forbidden in play:
raise HandoffContractError(f"handoff playbook must not declare {forbidden}")
tasks = play.get("tasks")
if not isinstance(tasks, list) or not tasks:
raise HandoffContractError("handoff playbook must contain tasks")
command_count = 0
for index, task in enumerate(tasks, start=1):
label = f"task {index}"
if not isinstance(task, dict):
raise HandoffContractError(f"{label} must be an object")
module, arguments = _module(task, label)
delegated = task.get("delegate_to") == "localhost"
if delegated:
if module not in CONTROLLER_MODULES:
raise HandoffContractError(f"{label} uses unsupported controller module {module}")
if task.get("become") is not False:
raise HandoffContractError(f"{label} controller output must set become: false")
if not isinstance(arguments, dict):
raise HandoffContractError(f"{label} module arguments must be an object")
destination = str(arguments.get("path", arguments.get("dest", "")))
if "/reports" not in destination:
raise HandoffContractError(f"{label} may write only beneath the controller reports path")
if module == "ansible.builtin.file":
if arguments.get("state") != "directory" or destination != "{{ playbook_dir }}/../../reports":
raise HandoffContractError(f"{label} may only ensure the reports directory")
elif "src" in arguments or not destination.endswith(".tap"):
raise HandoffContractError(f"{label} may record inline TAP evidence only")
continue
if module not in REMOTE_MODULES:
raise HandoffContractError(f"{label} uses remote module {module}, which is not read-only")
if module == "ansible.builtin.command":
command_count += 1
if not isinstance(arguments, dict) or arguments.get("argv") != EXPECTED_GOSS_ARGV:
raise HandoffContractError(f"{label} command is not the fixed Goss validation argv")
if task.get("changed_when") is not False or task.get("failed_when") is not False:
raise HandoffContractError(
f"{label} command must set changed_when: false and failed_when: false"
)
if command_count != 1:
raise HandoffContractError("handoff playbook must execute exactly one fixed Goss command")
def validate_playbook(path: Path) -> None:
try:
payload = yaml.safe_load(path.read_text(encoding="utf-8"))
except (OSError, yaml.YAMLError) as exc:
raise HandoffContractError(f"cannot read {path}: {exc}") from exc
validate_payload(payload)
def validate_goss_commands(template_path: Path, baseline_path: Path) -> None:
try:
template = template_path.read_text(encoding="utf-8")
baseline = yaml.safe_load(baseline_path.read_text(encoding="utf-8"))
except (OSError, yaml.YAMLError) as exc:
raise HandoffContractError(f"cannot read handoff command inputs: {exc}") from exc
commands = set(COMMAND_KEY_RE.findall(template))
if commands != EXPECTED_GOSS_COMMANDS:
unexpected = sorted(commands - EXPECTED_GOSS_COMMANDS)
missing = sorted(EXPECTED_GOSS_COMMANDS - commands)
detail = []
if unexpected:
detail.append(f"unexpected: {unexpected}")
if missing:
detail.append(f"missing: {missing}")
raise HandoffContractError("Goss command surface changed; " + "; ".join(detail))
profiles = baseline.get("profiles", {}) if isinstance(baseline, dict) else {}
profile_commands = {
profile.get("firewall", {}).get("verification", {}).get("command")
for profile in profiles.values()
if isinstance(profile, dict)
}
if profile_commands != EXPECTED_PROFILE_COMMANDS:
raise HandoffContractError(
f"baseline profile command surface must be exactly {sorted(EXPECTED_PROFILE_COMMANDS)}"
)
def validate_repository_surface(root: Path) -> None:
validate_playbook(root / "ansible" / "playbooks" / "verify.yaml")
validate_goss_commands(
root / "goss" / "baseline.yaml.j2",
root / "spec" / "server-baseline.yaml",
)
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
"playbook",
nargs="?",
type=Path,
default=Path("ansible/playbooks/verify.yaml"),
)
args = parser.parse_args()
playbook = args.playbook.resolve()
root = playbook.parents[2]
validate_repository_surface(root)
print(
json.dumps(
{
"goss_commands": len(EXPECTED_GOSS_COMMANDS) + len(EXPECTED_PROFILE_COMMANDS),
"ok": True,
"playbook": str(args.playbook),
"remote_mutations": 0,
}
)
)
return 0
if __name__ == "__main__":
raise SystemExit(main())

View file

@ -15,6 +15,7 @@ from pathlib import Path
from typing import Any
from baseline_contract import load_spec, profile_hostvars, validate_repo_consumers
from handoff_contract import validate_repository_surface
from inventory_contract import load_inventory
from s1_receipt import validate_receipt
@ -110,6 +111,7 @@ def main() -> int:
inventory = load_inventory(inventory_path)
baseline = load_spec(spec_path)
validate_repo_consumers(ROOT)
validate_repository_surface(ROOT)
hosts = _selected_hosts(inventory, args.host)
for host in hosts:
profile_hostvars(baseline, host["baseline_profile"])

View file

@ -0,0 +1,65 @@
from __future__ import annotations
import copy
import sys
import unittest
from pathlib import Path
import yaml
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / "scripts"))
from handoff_contract import ( # noqa: E402
HandoffContractError,
validate_goss_commands,
validate_payload,
validate_playbook,
)
class ReadOnlyHandoffContractTests(unittest.TestCase):
@classmethod
def setUpClass(cls) -> None:
cls.path = ROOT / "ansible" / "playbooks" / "verify.yaml"
cls.payload = yaml.safe_load(cls.path.read_text(encoding="utf-8"))
def test_repository_playbook_is_read_only(self) -> None:
validate_playbook(self.path)
def test_remote_template_module_is_rejected(self) -> None:
payload = copy.deepcopy(self.payload)
payload[0]["tasks"].append(
{
"name": "Mutate remote config",
"ansible.builtin.template": {"src": "x", "dest": "/etc/goss/baseline.yaml"},
}
)
with self.assertRaisesRegex(HandoffContractError, "not read-only"):
validate_payload(payload)
def test_arbitrary_remote_command_is_rejected(self) -> None:
payload = copy.deepcopy(self.payload)
command = next(
task for task in payload[0]["tasks"] if "ansible.builtin.command" in task
)
command["ansible.builtin.command"]["argv"] = ["systemctl", "restart", "ssh"]
with self.assertRaisesRegex(HandoffContractError, "fixed Goss"):
validate_payload(payload)
def test_controller_write_requires_local_delegation(self) -> None:
payload = copy.deepcopy(self.payload)
output = next(task for task in payload[0]["tasks"] if "ansible.builtin.copy" in task)
del output["delegate_to"]
with self.assertRaisesRegex(HandoffContractError, "not read-only"):
validate_payload(payload)
def test_goss_command_surface_is_pinned(self) -> None:
validate_goss_commands(
ROOT / "goss" / "baseline.yaml.j2",
ROOT / "spec" / "server-baseline.yaml",
)
if __name__ == "__main__":
unittest.main()

View file

@ -179,9 +179,12 @@ interpreting an expected-red exception.
`make s1-handoff` validate source contracts, require a clean revision, run each
host separately, fail the aggregate on any non-zero result, require TAP hashes
for a pass, and record a freshness boundary. Dry-run output is forcibly
`not-run`. The attended all-host run waits for an environment with Ansible and
reviewed permission to refresh the on-host Goss surface; this workstation has
no `ansible-playbook`. No host was contacted.
`not-run`. The live playbook is now remotely read-only: a static contract
rejects mutating modules and arbitrary commands, it requires the installed
Goss surface to match the locally rendered profile digest, and it writes only
controller-side evidence. Refresh is a separate exact-approval interface. The
attended all-host run now waits only for Ansible plus short-lived SSH access;
no host was contacted.
## T06 — Repair and enforce the secret-source contract
@ -286,10 +289,14 @@ decryption receipt or recipient change was attempted.
Current evidence (2026-08-23):
- Python unit suite: 25 tests pass.
- Python unit suite: 30 tests pass, including rejection of a remote template,
arbitrary remote command, non-delegated controller write, and unreviewed
Goss command surface in the handoff path.
- Terraform 1.9.8 mock-provider tests: 2 pass.
- Ansible 2.17.13 syntax checks: bootstrap, verify, and firewall pass in a
disposable environment.
- Ansible 2.17.13 syntax checks: bootstrap, read-only verify, explicit
verify-refresh, and firewall pass in a disposable environment. A disposable
render comparison also proves the template lookup digest matches the bytes
produced by Ansible's deployment template action.
- Both profile-specific Goss templates render and parse as YAML.
- Inventory, baseline, secret metadata, receipt, shell syntax, Python compile,
and whitespace checks pass.

View file

@ -0,0 +1,178 @@
---
id: RAIL-HO-WP-0012
type: workplan
title: "Close the encrypted S1 backup and recovery loop"
domain: financials
repo: railiance-infra
status: proposed
owner: codex
topic_slug: railiance
created: "2026-08-23"
updated: "2026-08-23"
related:
- RAIL-HO-WP-0011
---
# RAIL-HO-WP-0012 — encrypted S1 backup and recovery loop
## Goal
Turn the existing local `railiance-backup-s1` helper into a fail-closed,
scheduled, off-host, and restore-tested S1 recovery capability. Preserve the
repository boundary: this covers host operating-system configuration and
package-selection evidence only, not Kubernetes, database, Forgejo, or
application data.
This workplan does **not** authorize deployment of a systemd timer, access to an
age private key, an off-host upload, a live `/etc` restore, or deletion of a
retained artifact. Each live action retains its normal owner review and exact
operator approval.
## Current State and Risks
`tools/cmd/railiance-backup-s1` currently creates age-encrypted OS-configuration
and package-selection files under `/opt/backup/railiance/infra` and retains the
latest seven of each. It is not scheduled, has no off-host transfer, produces
no manifest or receipt, has no restoration tool or drill, duplicates an age
recipient in executable source, suppresses individual copy failures, and can
leave ambiguous partial output. The output is therefore a useful local helper,
not current proof of recoverability.
## Delivery Order
T01 → T02 → T03 establish a locally testable contract. T04 and T05 may proceed
after T02 but need separate deployment/custody review. T06 depends on all prior
tasks and an attended isolated drill.
## T01 — Define the S1 backup declaration and safety contract
```task
id: RAIL-HO-WP-0012-T01
status: todo
priority: high
```
Create a machine-readable declaration for included paths, optional paths,
artifact classes, public encryption recipients, retention, and excluded
higher-layer data. Validate that every selected path is absolute and bounded,
that no private-key or credential path can be selected, and that the recipient
contract cannot drift silently from the repository's approved public metadata.
**Done when:** the current intended S1 files resolve deterministically, unsafe
or relative paths fail closed, optional absence is explicit, and validation
requires neither root nor a decryption key.
## T02 — Produce atomic encrypted artifacts, manifests, and receipts
```task
id: RAIL-HO-WP-0012-T02
status: todo
priority: high
```
Refactor the backup helper around the T01 declaration. Check privileges before
creating the destination, stage output on the same filesystem, fail on an
unreadable required input, encrypt before publication, and atomically publish
only complete artifacts. Emit a metadata-only manifest and receipt containing
host identity, source revision, declaration digest, artifact class, encrypted
size/hash, completion time, and retention decision.
Exercise it against disposable fixture roots and output directories. Tests
must prove a failed collection or encryption cannot produce a passing receipt
or a final-named partial artifact.
**Done when:** a fixture backup is deterministic in membership, encrypted
before publication, fully described by redacted metadata, and failure-injection
tests leave no ambiguous success state.
## T03 — Add non-destructive inspection and isolated restore validation
```task
id: RAIL-HO-WP-0012-T03
status: todo
priority: high
```
Provide a restore command that defaults to inspection or extraction beneath an
explicit empty staging directory. It must verify receipt/manifest hashes before
decryption, suppress decrypted content from logs, reject path traversal and
links escaping the staging root, and compare restored membership and critical
file modes with the declaration.
Restoring over live `/etc` is outside this task and must remain impossible
without a separately designed exact-approval interface.
**Done when:** a fixture artifact restores into disposable storage, tampering
and unsafe members fail closed, no plaintext enters repository paths or logs,
and no default invocation can overwrite a host file.
## T04 — Define scheduling, freshness, and local retention
```task
id: RAIL-HO-WP-0012-T04
status: todo
priority: medium
```
Add source-controlled systemd service/timer units and Ansible deployment for a
bounded backup cadence. Define maximum acceptable age, failure state, disk
budget, atomic pruning, and an operator status interface. Keep timer deployment
separate from source validation and require explicit host-change approval.
**Done when:** unit rendering and calendar behavior test locally, stale/missing
backup status fails, pruning never crosses the declared backup directory, and
no timer is installed merely by running a verification command.
## T05 — Integrate the governed write-only off-host lane
```task
id: RAIL-HO-WP-0012-T05
status: wait
priority: high
```
Coordinate with `railiance-platform` through the existing
`railiance-backup-offsite-lane` route. Upload only completed encrypted
artifacts plus metadata manifests, use a host-local or workload-owned
credential projection, and record a non-secret remote object identity and
encrypted hash. Do not place WebDAV credentials or an age private key in this
repository, State Hub, command output, or agent prompts.
**Done when:** the owner accepts the exact projection and write contract, a
dry-run proves object naming and collision behavior, a controlled upload is
visible through owner-provided metadata, and local/off-host retention cannot
delete the only recoverable copy.
## T06 — Perform an attended isolated restore drill
```task
id: RAIL-HO-WP-0012-T06
status: wait
priority: high
```
Select a fresh off-host artifact by metadata, retrieve it through the governed
lane, and restore it into an isolated disposable filesystem. Prove manifest
integrity, expected membership and modes, usable SSH/fail2ban/UFW configuration
syntax, package-selection readability, and cleanup of all decrypted temporary
material. Do not restore over a live host in this drill.
**Done when:** a metadata-only drill receipt records artifact and source
identity, checks, duration, cleanup, and result; an independent observer can
distinguish a successful recovery from a backup-only claim without seeing
decrypted configuration.
## Acceptance
- [ ] S1 backup membership, exclusions, recipients, and retention are declared
and validated from source.
- [ ] Backup publication is atomic, encrypted, and accompanied by a safe
manifest and receipt.
- [ ] Restore defaults to isolated inspection and rejects tampering, traversal,
and accidental live overwrite.
- [ ] Scheduling and freshness checks are source-controlled but deployed only
through an explicit host-change approval.
- [ ] A governed off-host copy and an attended isolated restore drill prove the
selected S1 state is recoverable.
- [ ] No cluster, platform, tenant, secret-issuance, or private-key custody
responsibility is absorbed into this repository.