Status: Complete
Tests: 749 total (710 from v47, 39 new), 0 failed
CI Guards: 9 total (7 from v47, 2 new)
Modules modified: wpgovern/core/baseline.py, wpgovern/status/checker.py, wpgovern/utils/invariants.py, wpgovern/config.py
Test files authored: tests/test_h0_config_file_hashing.py
CI guard additions: test_baseline_record_has_optional_config_field, test_no_wp_content_hashing_in_baseline_service (appended to tests/test_ci_guards.py)
Phase H.0 is the only Python-side phase in the operational arc. It closes the gap between WPGovern v47's scope and the strategic plan v1.1's commitments around configuration-file integrity governance, and fixes a stack-alignment defect in BaselineService._docker_wp.
The bash arc (H.1 onward) builds on v48 as its Python foundation. H.0 ships before the bash arc because H.5's "byte-one ceremony" calls wpgovern baseline-create against the four config files that the bash arc generates. If baseline-create cannot capture file hashes at H.5 time, the byte-one ceremony does not deliver the file-hash governance the strategic plan commits to.
New constant:
CONFIG_FILE_PATHS = (
"docker-compose.yml",
"Caddyfile",
"my.cnf",
"wp-config.php",
)Paths are relative to config.install_dir (default: /opt/wpgovern-install/). These are the four config files generated by the bash arc (H.2 generates three; H.4 generates wp-config.php) that WPGovern governs via hash baselining.
BaselineRecord schema extension:
config_file_hashes: dict[str, str] | None = None # H.0-A: optional, None for legacyOptional with default None. Legacy baselines (pre-H.0) load without error because the field is absent from their JSON — it resolves to None at parse time. Only baselines created by H.0-extended create_draft() have the field populated.
create_draft() extension:
Hashes are computed BEFORE AtomicTransaction is entered. If any of the four config files is missing, BaselineError is raised immediately with an operator-actionable message naming the missing path and the recovery step ("ensure the installer has completed phase H.4"). The hash manifest is included in the signed JSON payload.
Hash format: sha256:<64 lowercase hex chars> — matching the image digest pinning pattern elsewhere in the system.
_parse_baseline() extension:
Validates the config_file_hashes field at load time (I-CFG-2 contract):
- Every key is a relative path (no leading
/, no..) - Every value matches
^sha256:[0-9a-f]{64}$ - Non-conforming records raise
BaselineError
_baseline_payload() extension:
Includes config_file_hashes in the dict if non-None (idiomatic with existing submitted_at / approved_at / activated_at pattern).
New module-level helpers:
| Helper | Purpose |
|---|---|
_sha256_hex(data: bytes) -> str |
Returns sha256: + hex SHA-256 digest |
_validate_relative_path(rel_path, context) |
Raises BaselineError on absolute/traversal paths |
_validate_config_file_hashes(raw, baseline_id) |
Full dict validation: types, key safety, value format |
_compute_config_file_hashes(install_dir) -> dict |
Hashes all four CONFIG_FILE_PATHS; fail-closed on missing |
Two new exit codes:
| Code | Meaning |
|---|---|
| 52 | Config file hash mismatch — a baselined file was modified on disk |
| 53 | Config file missing at check time — a baselined file is absent or unreadable |
Step 8.6 added to check() after the existing invariant check (step 8.5). Runs only when an active baseline has a non-None config_file_hashes field. Iterates over hash entries in sorted key order — deterministic result when multiple files diverge.
_evaluate_config_file_hashes(active_baseline) method returns (exit_code, reason) or None (clean). Tested directly; the reason string names the specific file, expected digest, and (for exit 52) actual digest.
| Invariant | ID | Description |
|---|---|---|
| I-CFG-1 | Runtime | Active baseline's config_file_hashes matches live filesystem state |
| I-CFG-2 | Structural | Each hash key is a relative path; each value matches sha256:<64 hex> |
Total invariant count: 29 (was 27).
install_dir docstring updated. The field is now functional — BaselineService.create_draft() and governance-check both read from it. The broader R4 fix (making build_paths honour explicit overrides for all path fields) is deferred to post-H.0 scope.
BaselineService._docker_wp changed:
# Before (v47):
cmd = ["docker", "compose", "exec", "-T", "php", "wp", *wp_args]
# After (H.0-B):
cmd = ["docker", "compose", "run", "--rm", "-T", "cli", "wp", *wp_args]The wordpress:cli image (profile-gated cli service) contains the wp-cli binary. The wordpress:fpm image used by the php service does not. v47 would fail at baseline-create time against the v1.1 stack.
| Class | Tests | Coverage |
|---|---|---|
TestCreateDraftHashesConfigFiles |
3 | Named: all four files → hashes captured. Adjacent: one missing → BaselineError. Adversarial: nonexistent install_dir → clear error. |
TestCreateDraftHashChanges |
3 | Named: file modified → hash differs. Adjacent: unchanged → identical hash. Adversarial: directory instead of file → BaselineError. |
TestCreateDraftInstallDir |
3 | Named: custom install_dir honored. Adjacent: hashes in signed payload. Adversarial: all values are sha256-prefixed. |
TestGovernanceCheckHashes |
3 | Named: hashes match → None. Adjacent: legacy baseline (no hashes) → None. Adversarial: file is directory → (53). |
TestGovernanceCheckExit52 |
3 | Named: one file modified → (52, names file). Adjacent: two modified → (52, first sorted). Adversarial: revert → None. |
TestGovernanceCheckExit53 |
3 | Named: file deleted → (53). Adjacent: multiple missing → (53, first sorted). Adversarial: replaced by directory → (53). |
TestICFG1 |
3 | Named: hashes match → no violations. Adjacent: no active baseline → no violations. Adversarial: mismatch → I-CFG-1 fires. |
TestICFG2 |
3 | Named: canonical paths accepted. Adjacent: absolute path rejected. Adversarial: traversal rejected. |
TestBaselineRecordSchema |
4 | Named: round-trip with hashes. Adjacent: round-trip without hashes (None). Named: legacy v47 baseline loads (field=None). Adversarial: corrupted hash value refused. |
TestDockerWpCliProfile |
2 | Named: _docker_wp produces run --rm -T cli wp. Adjacent: create_draft's wp calls all use cli profile. |
| Utility functions | 7 | _sha256_hex, _validate_config_file_hashes, CONFIG_FILE_PATHS, BaselineRecord default field. |
| Guard | What it enforces |
|---|---|
test_baseline_record_has_optional_config_field |
config_file_hashes defaults to None; prevents future regression to required field |
test_no_wp_content_hashing_in_baseline_service |
baseline.py contains no wp-content/, plugins/, or themes/ filesystem path references; prevents scope creep into the delegated zone |
Per the brief (Section 3):
- wp-content/plugins and wp-content/themes hashing — delegated to Wordfence, Sucuri, MalCare per v1.1 decision
- Reconciliation workflow for legitimate config changes — the existing four-stage approval workflow is the reconciliation workflow
- Per-file approval granularity — approval is per-baseline
- wpgovern.env hashing — wpgovern.env is the secret zone per v1.1; never baselined
- R4 broader fix —
build_pathsdoes not yet honour explicitinstall_diroverrides; only the specificinstall_dirfield is activated - Exit code 54 — "extra file present but not in baseline" — deferred; fail-closed-at-create-time means the manifest always has all four entries
- Bash-side code — H.0 is pure Python
| Suite | Before H.0 | After H.0 |
|---|---|---|
| Fast (no Hypothesis) | 694 | 733 |
| Full (with test extras) | 710 | 749 |
| CI guards | 7 | 9 |
39 net new tests: 37 in test_h0_config_file_hashing.py + 2 CI guards in test_ci_guards.py.
| ID | Description | Disposition |
|---|---|---|
| R9 | governance-check exit 52/53 fires on the first divergent file in sorted order, not all of them. A single run with multiple divergent files returns one exit code. |
Acceptable: the operator re-runs after fixing each file; this is the same model as all other governance-check findings. |
| R10 | config-file hashing follows symlinks (reads the symlink target). A symlink replacement that points to a different file will show as a hash change (exit 52), not a symlink-type change. | Acceptable for H.0; symlink governance is a future scope item. |
v48 is the canonical foundation for the bash arc. H.1 (bash installer skeleton) builds on this codebase.