Skip to content

v1.100 PR-25: restore execution + Amendment 1 CSF restore#511

Merged
itcmsgr merged 15 commits intomainfrom
feat/v1.100-pr25-restore-execution
Apr 28, 2026
Merged

v1.100 PR-25: restore execution + Amendment 1 CSF restore#511
itcmsgr merged 15 commits intomainfrom
feat/v1.100-pr25-restore-execution

Conversation

@itcmsgr
Copy link
Copy Markdown
Owner

@itcmsgr itcmsgr commented Apr 28, 2026

1. What PR-25 implements

Restore execution in Go, completing the lifecycle layer that PR-24 left open as decision-only:

  • TargetAuthority model — closed enum + per-Kind constructors (None / RecordedPrior / PanelNative); §18.
  • Planner / dispatcher bridge — single-resolution PlanFromDecision; dispatcher integration from runRestoreDecide PROCEED into restore.Execute; §§24, §23 6-step.
  • Safety-net insertion/removal — emergency-SSH allow rule via existing switchop.Inject/RemoveEmergencySSH; SSH port from detect.SSHPort 4-source chain; §§21.3, 23.2/23.5.
  • CSF-only restore mutation under Amendment 1 — A.1 unmask, A.2 enable, A.3 atomic binary rename, A.4 cron soft-skip, A.5 start, A.6 stop nftband, A.7 nftban kernel release; non-CSF §18.2 firewalls return ErrCSFRestoreOnlyAuthorized; firewalls outside §18.2 return ErrRestoreMutationUnknownFirewall; §§30–36.
  • Real inline verification — three §21.1 minimum-sufficient assertions (target firewall active, current authority class, safety-net removal safe); kernel/service evidence only (no nft-list parsing, no CLI-truth dependency).
  • Closed mutation-surface CI gateG4-RESTORE-EXEC-NO-OUT-OF-TARGET static-scans restore_deps_csf.go for the closed authorized set; runtime tests carry per-call argument enforcement against MockExecutor.

2. Contract authority

  • PR docs(v1.100 PR-25): contract — append §§16-29 PR-25 execution contract (doc only) #510 / commit 587b50d5 made the restore contract §§16–29 (PR-25 execution scope) authoritative.
  • Amendment 1 (commit 5edff252) appends §§30–36, authorizing the inverse-of-install CSF restore mutations and their evidence preconditions (E.1–E.7).
  • Non-CSF targets remain unsupported / refused. ufw / firewalld / iptables return typed unsupported sentinels until separately amended; firewalls outside §18.2 return typed unknown.

3. Evidence boundary

  • lab2 / lab4 real-host evidence is non-destructive. --mode=restore runs on each host reach the PR-24 lattice and refuse without entering PR-25 mutation: lab2 → REFUSE / exit 5 (G1/AuthorityNFTBan), lab4 → REQUIRE_EXPLICIT_INTENT / exit 6 (G3.3/NoRecord+NoFlag). Both runs leave update-history.json untouched.
  • Destructive CSF A.1–A.7 path is covered by Go tests with MockExecutor. 30 TestCSFMutate_4B3csf_*, 17 TestInlineVerify_4B4_*, 8 TestPreflightTarget_4B1_*, 14 TestSafetyNetDep_4B2_* — all PASS on lab2 and lab4.
  • Live destructive CSF restore soak is deferred post-merge / operator-approved. Real-host §28 destructive evidence requires a staged DirectAdmin host with prior CSF state.
  • srv3 (DirectAdmin) is supplemental only, not required evidence. Not part of this PR's evidence pack.

Evidence files (lab2 + lab4 env snapshots, build artefacts, test traces, real-host dispatcher logs, post-state diffs) are kept private at the operator's internal handoff path; the repo .gitignore excludes evidence/ and pr25-evidence/.

4. Known limitation

  • A.4 cron restore is soft-skip. The install-time switchop.disarmCSFArtifacts does not currently write a cron-backup manifest, so the §33 E.5 precondition for restoring /etc/cron.d/csf-cron and /etc/cron.d/lfd-cron is never satisfied. PR-25 logs a warning and continues; no cron files are recreated. Tracked as a separate installer-side amendment (out of PR-25 scope).

5. Safety guarantees

  • No history success write in restore mode. §19.2 layer 4 / main.go:132 mode-gate retained; update-history.json is untouched on every restore-mode invocation regardless of outcome.
  • No service disable / mask. Forbidden by Amendment 1 §34; CI-enforced by \bexec\.ServiceDisable\( and \bexec\.ServiceMask\( patterns in G4-RESTORE-EXEC-NO-OUT-OF-TARGET.
  • No DirectAdmin custombuild rewrite. Forbidden by §34; CI-enforced by \bcustombuild\b pattern.
  • No broad cleanup / purge / rebuild. Forbidden by §34; CI-enforced by \bpurge\., \bcleanup\.Apply\(, \brebuild\.Run\(, \brebuild\.Apply\( patterns.
  • No nftban table release before CSF active AND SSH-safe. A.7 is gated on (a) A.5 returned nil, (b) post-A.5 ServiceActive(csf.service) == true, (c) safetyNetRemovalSafeFn returns (true, nil). The predicate is wired in the production factory to inlineVerify.IsSafetyNetRemovalSafe(ctx) — same instance Execute step 4 consults.
  • Safety-net removal only after inline verification passes. §21.3 hard invariant; rendered as explicit verifiedSafe bool argument to RemoveSafetyNet.

Test plan

  • CI matrix passes (Restore Canonization, Architecture, Bash, Docs, Go, Smoke, Update Canonization, Uninstall Canonization, CodeQL, etc.)
  • G4-RESTORE-EXEC-NO-OUT-OF-TARGET reports fail=0 against cmd/nftban-installer/restore_deps_csf.go
  • G4-RESTORE-NO-IMPLICIT-EXEC static scan still passes (PR-24 invariant unchanged)
  • G4-RESTORE-DECISION-CORRECTNESS rule-path coverage
  • G4-RESTORE-DETERMINISM two-run equality
  • go test ./... PASS on the matrix
  • go test -race PASS for cmd/nftban-installer + internal/installer/restore + internal/installer/state

Commit ladder (14 commits)

  1. 5b1ab144 TargetAuthority types + state terminals (§§18, 19, 22)
  2. 7f5f1cb1 planner / decision bridge (§24)
  3. 93be8ca3 panel→firewall static mapping (§20)
  4. b4e40be2 safety-net + inline-verify primitives (§§21.1, 23.2/23.5)
  5. 5ca4d0a6 Execute orchestration (§23 six-step sequence)
  6. 074d832f dispatcher integration with stub deps
  7. b09cbadc 4B-1 — production preflight dep (real, read-only)
  8. a79d7703 4B-2 — production safety-net dep (real, narrow)
  9. 5edff252 contract Amendment 1 — CSF restore mutations
  10. d80e7487 4B-3-pre — evidence plumbing (option α)
  11. 6ba88a16 4B-3-csf — real CSF restore mutation (A.1–A.7)
  12. e0e7cd00 4B-4 — real inline-verify dep + safety-net-safe wiring
  13. d9d2fb7e commit 5 — CI gate + stale comment cleanup; §28 evidence kept private
  14. 790199d0 commit 5 fix — G4 gate regex (call-anchored, drop brittle pins)

🤖 Generated with Claude Code

itcmsgr and others added 14 commits April 27, 2026 20:30
…s (§§18, §§19, §§22)

PR-25 code phase, segmented commit 1 of 5. Types/constants only — no
execution behavior, no mutation, no caller wiring.

Implements contract.md (commit 587b50d):
- §18 TargetAuthority concretization
- §19.2 layer 1+2 (type-level distinctness + IsRestoreExecuted helper)
- §22 four state terminals + four exit codes

New file — internal/installer/restore/target_authority.go:
- type TargetAuthorityKind (closed enum, 3 variants)
- type TargetAuthority (struct with unexported fields)
- TargetNone() / TargetRecordedPrior(fwt) / TargetPanelNative(panel)
  per-Kind constructors; public API returns errors, never panics
- knownFirewallTypes set local to restore (guardrail 4: no import of
  uninstall authority types)
- ErrUnknownFirewallType / ErrPanelNoneForPanelNative sentinel errors
- mustBeKnownKind() internal panic helper restricted to default-branch
  use only (guardrail 3)

New file — internal/installer/restore/target_authority_test.go:
- Zero-value == TargetNone() equivalence test (§18.2)
- All 4 known firewall types accepted by TargetRecordedPrior (§18.2)
- Unknown firewall types rejected with ErrUnknownFirewallType
- All 7 non-PanelNone PanelType values accepted by TargetPanelNative
- PanelNone rejected with ErrPanelNoneForPanelNative
- Read-only-accessor immutability check
- Set-content pin against contract §18.2
- mustBeKnownKind panics on unknown Kind

Modified — internal/installer/state/machine.go:
- 4 new state constants: StateRestoreExecuted, StateRestoreFailedExecution,
  StateRestoreDegraded, StateRestoreFailedVerification
- 4 new exit codes: ExitRestoreExecuted=7, ExitRestoreFailedExecution=8,
  ExitRestoreDegraded=9, ExitRestoreFailedVerification=10
  (allocated as next-available integers per guardrail 1; verified
  no collisions with existing 0-6)
- IsRestoreExecuted(s) helper (§19.2 layer 2): true ONLY for
  StateRestoreExecuted + StateRestoreDegraded; false for everything
  else including StateRestoreDecided (§19.3 consumer rule)
- IsFailed(): adds StateRestoreFailedExecution + StateRestoreFailedVerification
  (StateRestoreDegraded is NOT a failure, mirrors StateDegraded)
- IsTerminal(): adds the 2 success-class terminals (the 2 failure-class
  ones are picked up via IsFailed); all 4 are now terminal
- ExitCode(): maps each new state to its new exit code

Modified — internal/installer/state/machine_test.go (additive):
- TestInstallState_PR25_NewStatesPresent — distinct constants, no
  collision with StateRestoreDecided
- TestExitCode_PR25_NoDuplicates — guardrail 1 (no exit-code collisions
  across all 11 constants)
- TestExitCode_PR25_DistinctFromContractedSet — §19.4 rule
- TestInstallState_PR25_ExitCodeMapping — state→exit mapping
- TestInstallState_IsRestoreExecuted — §19.2 layer 2 + §19.3 consumer
  rule
- TestInstallState_PR25_IsFailed — Degraded is NOT failure
- TestInstallState_PR25_IsTerminal — all 4 terminal
- TestInstallState_PR25_NotApplyTerminal — restore-mode gated
  separately at main.go:132 (§19.2 layer 4)

Verified on lab2:
- go build ./... clean
- go test ./internal/installer/restore/... PASS
- go test ./internal/installer/state/... PASS
- go mod tidy no-op (md5 matches local)

Out of scope for commit 1 (per locked plan):
- Planner / decision bridge (commit 2)
- Execution engine (commit 3)
- Dispatcher wiring (commit 4)
- CI gate update + real-host evidence (commit 5)

Lifecycle fence: §17.1 permitted set only. No touch of
internal/installer/restore/{engine,types}.go (PR-24 surface),
cmd/nftban-installer/restore_decide.go, main.go:132 writeHistory gate,
or any uninstall package.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
PR-25 code phase, segmented commit 2 of 5. Planner only — no
mutation, no live re-detection, no dispatcher wiring, no execute,
no panel mapping implementation, no safety net, no inline verify.

Implements contract §24 (PR-25 inputs PR-25 may consume) with
locked Shape (ii) semantics: planner resolves TargetAuthority
exactly once from PR-24-approved inputs.
INV-PR25-AUTHORITY-IMMUTABILITY (§17.3) begins after
PlanFromDecision returns successfully.

New file — internal/installer/restore/planner.go:
- PlanFromDecision(decision, input, priorRec, panel) — single
  entry point. Returns (TargetAuthority, error). Errors only,
  no panics. Per code-phase guardrail 3.
- Sentinel errors: ErrPlanNotProceed, ErrPlanMissingPriorRecord,
  ErrPlanInvariantViolation. Callers may use errors.Is to
  discriminate.
- Mapping (locked from PR-24 PROCEED rule analysis):
    Flags.PanelAutoTakeover == true  -> TargetPanelNative(panel)
    Flags.PanelAutoTakeover == false -> TargetRecordedPrior(
                                          priorRec.FirewallType)
  This covers all PR-24 PROCEED rules (G3.1+NoFlag/Restore/PanelAuto,
  G3.3+PanelAuto, G4.1+NoFlag/Restore, G4.3+OrphanPanelAuto).
- Defensive guards for caller-corruption: both flags set,
  panel-auto without panel, missing priorRec on RecordedPrior
  branch, empty/unknown FirewallType, non-strong PriorState on
  RecordedPrior branch.
- No imports of os/exec, no syscalls, no DetectPanel call, no
  uninstall.Probe call, no uninstall.Classify call, no
  restore.Decide call. Planner is a pure function over its
  inputs.

New file — internal/installer/restore/planner_test.go:
- 6 fixture builders mirroring PR-24's actual PROCEED rules:
    G3.1/StrongPrior+NoFlag      -> RecordedPrior
    G3.1/StrongPrior+Restore     -> RecordedPrior
    G3.1/StrongPrior+PanelAuto   -> PanelNative (priorRec ignored)
    G3.3/NoRecord+PanelAuto      -> PanelNative
    G4.1/OrphanStrong+Restore    -> RecordedPrior
    G4.3/OrphanPanelAuto         -> PanelNative
- Acceptance test per fixture.
- Q4 §20.3 verification: PanelAuto branch ignores priorRec even
  when supplied (FirewallType stays empty per §18.3 invariant).
- Rejection tests: REFUSE / REQUIRE_EXPLICIT_INTENT both error
  with ErrPlanNotProceed.
- RecordedPrior failure cases: nil priorRec, empty FirewallType,
  unknown FirewallType, non-strong PriorState.
- PanelNative failure cases: PanelNone, PanelPresent mismatch.
- Both-flags-set defensive guard.
- Kind=None unreachability: every valid PROCEED fixture produces
  Kind != None.
- File-scan test: planner.go contains zero forbidden patterns
  (os/exec, exec.Command, os.{Write,Create,Remove,Rename}File,
  syscall., "nft "/"systemctl ", DetectPanel(, uninstall.Probe(,
  uninstall.Classify(, restore.Decide().

Verified on lab2:
- go build ./... clean
- go test ./internal/installer/restore/... PASS (commits 1+2)
- go test ./internal/installer/state/... PASS
- go mod tidy no-op (md5 matches local; no new module deps)

Out of scope for commit 2 (per locked plan):
- Execution engine (commit 3)
- Panel mapping (commit 3)
- Safety net (commit 3)
- Inline verification (commit 3)
- Dispatcher wiring (commit 4)
- CI gate update + real-host evidence (commit 5)

Lifecycle fence: §17.1 permitted set only. No touch of
cmd/nftban-installer/restore_decide.go, main.go:132 writeHistory
gate, or any other surface outside the planner.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
PR-25 code phase, segmented commit 3A of 5 (3 split into 3A/3B/3C).
Static mapping infrastructure only — no Execute, no safety net,
no inline verification, no dispatcher wiring, no mutation.

Implements contract §20 (panel-auto target consistency) with
intentionally sparse content per the locked safety posture: for
live restore, refusal is safe; a guessed firewall mapping can
disable or mutate the wrong authority and violate the lifecycle
contract.

Authorized entries (PR-25 commit 3A):
- PanelDirectAdmin → "csf"
  Evidence: internal/installer/switchop/takeover.go:111 and :116
  (DirectAdmin-specific CSF disarm via custombuild). The bundled
  / canonical native firewall is CSF.

Intentionally unmapped (require explicit operator-authority
citation in subsequent commits before being added):
- PanelCPanel
- PanelPlesk
- PanelCyberPanel
- PanelHestia
- PanelVesta
- PanelCWP
- PanelInterWorx

New file — internal/installer/restore/panel_mapping.go:
- panelToFirewall: sparse compile-time map[detect.PanelType]string
- ResolvePanelFirewall(panel) — single entry. Pure function over
  the static map. No live detection, no exec, no syscall.
- Sentinel errors: ErrUnmappedPanel (no fallback per §20.3),
  ErrPanelNoneNotMappable (PanelNone is not a valid key per §20.1),
  ErrPanelMappingInvalid (programmer-error invariant: every map
  entry must validate against §18.2 knownFirewallTypes).

New file — internal/installer/restore/panel_mapping_test.go:
- TestResolvePanelFirewall_DirectAdmin_MapsToCSF — authoritative entry
- TestResolvePanelFirewall_DirectAdmin_OutputIsKnownFirewallType —
  output validates against §18.2 + is constructable as
  TargetRecordedPrior firewallType
- TestResolvePanelFirewall_OtherPanels_RefuseAsUnmapped —
  CPanel/Plesk/CyberPanel/Hestia/Vesta/CWP/InterWorx all refuse
  with ErrUnmappedPanel
- TestResolvePanelFirewall_PanelNone_Refuses — explicit refusal
- TestResolvePanelFirewall_UnknownFuturePanel_Refuses — future
  PanelType values, typos (case, trailing space) all refuse
- TestResolvePanelFirewall_NoGuessedDefault — confirms resolver
  never leaks any §18.2 firewall type on the error path
- TestPanelToFirewall_SparseMapPin — pins exactly 1 entry
  (DirectAdmin→csf); fails if any other panel is added without
  a separate commit
- TestPanelToFirewall_AllEntriesValidateAgainstKnownSet —
  invariant: every map entry produces a §18.2-known firewall
- TestPanelMapping_NoMutationSurface_FileScan — file-scan for
  forbidden patterns (os/exec, exec.Command, os.{Write,Create,
  Remove,Rename}File, syscall., "nft "/"systemctl ",
  DetectPanel(, uninstall.Probe(, uninstall.Classify()

Verified on lab2:
- go build ./... clean
- go test -run 'PanelFirewall|PanelToFirewall|PanelMapping'
  ./internal/installer/restore/... PASS (9 test functions, all
  subtests PASS)
- go test ./internal/installer/restore/... full PASS
  (commits 1+2+3A together)
- go mod tidy no-op (no new deps)

Out of scope for commit 3A (per locked plan):
- Safety net + inline verification primitives (commit 3B)
- Execute orchestration (commit 3C)
- Dispatcher wiring (commit 4)
- CI gate update + real-host evidence (commit 5)

Lifecycle fence: §17.1 permitted set only. No touch outside the
two new files.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
… (§21.1, §23.2/§23.5)

PR-25 code phase, segmented commit 3B of 5. Dependency-injected
primitives only — no orchestration, no Execute, no dispatcher
wiring, no terminal-state selection, no six-step sequence
(those belong to commit 3C).

Implements contract:
- §23.2 / §23.5 safety-net insertion + removal primitives
- §21.3 hard invariant: removal refused unless caller asserts
  verifiedSafe == true (rendered as an explicit boolean argument)
- §21.1 minimum-sufficient three-assertion inline verification
  (target firewall active / nftban authority class correct /
  safety-net removal safe). NOT the PR-26 full validator gate.

New file — internal/installer/restore/safety_net.go:
- SafetyNetDep interface (2 methods: InsertEmergencySSH,
  RemoveEmergencySSH). Production implementation NOT in this
  commit; tests use a fake.
- InsertSafetyNet(ctx, dep) — thin wrapper, uniform error class
  via ErrSafetyNetInsertFailed.
- RemoveSafetyNet(ctx, dep, verifiedSafe) — refuses with
  ErrSafetyNetRemoveBeforeVerification when verifiedSafe=false;
  delegates to dep when true.
- Sentinel errors: ErrSafetyNetInsertFailed / RemoveFailed /
  RemoveBeforeVerification / NilDep.

New file — internal/installer/restore/inline_verify.go:
- InlineVerifyDep interface (3 methods, one per §21.1 assertion).
- VerifyResult struct: 4 booleans (TargetFirewallActive,
  AuthorityClassCorrect, SafetyNetRemovalSafe, SafeToRemove) +
  ObservedAuthority + Err.
- InlineVerify(ctx, dep, targetFirewall, expectedAuthority) —
  short-circuits on dep error, validates inputs (rejects empty
  firewallType + invalid expectedAuthority), computes
  SafeToRemove ONLY when all three assertions are true AND no
  dep error occurred.
- Sentinel errors: ErrInlineVerifyNilDep / DepFailed /
  InvalidAuthority / EmptyFirewallType.

New file — internal/installer/restore/safety_net_test.go (8 tests):
- HappyPath insert/remove
- NilDep refusal (both)
- DepError wrapping (both)
- §21.3 hard invariant: RemoveSafetyNet(verifiedSafe=false)
  refuses without calling dep
- TestSafetyNet_NoBroadBehavior_FileScan: production-code scan
  for forbidden patterns (os/exec, exec.Command,
  os.{Write,Create,Remove(,Rename}File, syscall., "nft "/"systemctl ",
  enable, disable, mask, unmask, purge, force-delete, fix all,
  fallback, best effort, best-effort).

New file — internal/installer/restore/inline_verify_test.go (12 tests):
- All-pass SafeToRemove=true happy path
- Each of three assertions failing individually
- Multiple assertions failing simultaneously
- Dep error on each assertion (with short-circuit verification)
- NilDep + EmptyFirewallType + InvalidExpectedAuthority refusals
- Acceptable expected authorities (External, NFTBan, None)
- TestInlineVerify_NoFullValidatorBehavior_FileScan: production
  scan forbids os/exec, exec.Command, os.Write/Create/Remove/Rename,
  syscall., shell-out fragments, nftban-validate binary,
  validator.Validate function, module names (botguard, loginmon,
  ddos, portscan, feeds, geoip).

Self-referential file-scan pattern (test file scanning itself for
forbidden patterns it lists as string literals) was removed during
verification — that design is circular by construction. The
production-code file-scans are the real enforcement; tests use
fakes by construction.

Verified on lab2:
- go build ./... clean
- go test ./internal/installer/restore/... PASS (commits 1+2+3A+3B)
- go test -run 'SafetyNet|InlineVerify' PASS (all 20 commit-3B tests)
- go mod tidy no-op (no new deps)

Out of scope for commit 3B (per locked plan):
- Execute orchestration (commit 3C)
- Dispatcher wiring (commit 4)
- CI gate update + real-host evidence (commit 5)
- Terminal-state selection (commit 3C)
- Six-step §23 sequence (commit 3C)
- Production implementations of SafetyNetDep / InlineVerifyDep
  (commit 3C wires the dispatcher; production deps come with that)

Lifecycle fence: §17.1 permitted set only. No touch of any other
package; no service/file/kernel mutation in either production
file or any test (all goes through injected fakes).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…equence)

PR-25 code phase, segmented commit 3C of 5. Execute orchestrates
the §23 six-step ordered sequence using primitives from commits
3A and 3B. No dispatcher wiring, no history writes, no main.go
changes — those belong to commit 4.

Step order (no reordering, no skipping):
  1. preflight target validation       (§23.1)
  2. safety-net insertion              (§23.2)
  3. minimal target-specific mutation  (§23.3)
  4. inline verification               (§23.4 / §21.1)
  5. safety-net removal                (§23.5 / §21.3 hard gate)
  6. terminal-state selection          (§23.6)

Refusal-before-mutation paths (no kernel/filesystem touch):
- Kind=None / zero-value TargetAuthority    → ErrExecuteRefusedNoneTarget
- Unmapped panel (PanelNative)              → ErrUnmappedPanel chain (§20.2)
- Nil deps                                  → ErrExecuteNilDeps
- Preflight refusal / dep error             → ErrExecutePreflightRefused
- Insert failure                            → ErrExecuteInsertFailed (no mutate)

Mutation-then-fail paths:
- Mutate failure                            → StateRestoreFailedExecution
                                              (safety net retained;
                                               verify+remove skipped)
- Verify logical fail or dep error          → StateRestoreFailedVerification
                                              (safety net retained per §21.3)
- Remove failure (after verify pass)        → StateRestoreFailedVerification
                                              (non-success terminal — must
                                               not report success)

Success path → StateRestoreExecuted (Stage=complete).

StateRestoreDegraded is intentionally NOT used in commit 3C — no
degraded-but-executed condition is implemented (per locked plan
"do not invent one"). Adding a degraded path requires a contract
amendment.

New file — internal/installer/restore/execute.go:
- ExecuteDeps struct bundling 4 dep interfaces:
    PreflightDep      (§23.1: PreflightTarget)
    SafetyNetDep      (§23.2 + §23.5: Insert/RemoveEmergencySSH)
    MutationDep       (§23.3: MutateToTarget)
    InlineVerifyDep   (§21.1: 3 assertion methods)
- ExecuteResult: Terminal state + Stage label + Err + VerifyResult
- Stage constants: preflight / insert / mutate / verify / remove /
  complete (mapped 1:1 to §23 steps for ordering tests)
- Sentinel errors: ErrExecuteNilDeps / RefusedNoneTarget /
  PreflightRefused / InsertFailed / MutateFailed / VerifyFailed /
  RemoveFailed
- expectedAuthorityFor(kind) — both RecordedPrior and PanelNative
  expect AuthorityExternal post-mutation. Kind=None panics in the
  default branch (unreachable per Execute's step-0 refusal).
- resolveFirewallType(t) — RecordedPrior reads its field;
  PanelNative goes through ResolvePanelFirewall (§20 mapping);
  None returns ErrExecuteRefusedNoneTarget.
- Execute(ctx, t, deps) — pure orchestrator. INV-PR25-AUTHORITY-
  IMMUTABILITY: t is read-only; no mid-flight re-resolution. No
  call to detect.DetectPanel, uninstall.Probe, uninstall.Classify,
  or restore.Decide.

New file — internal/installer/restore/execute_test.go (18 tests):
- TestExecute_ZeroValue_RefusesBeforeAnyDepCall
- TestExecute_TargetNone_RefusesBeforeAnyDepCall
- TestExecute_PreflightLogicalRefusal_StopsImmediately
- TestExecute_PreflightDepError_StopsImmediately
- TestExecute_InsertFailure_StopsBeforeMutate
- TestExecute_MutateFailure_FailedExecution_SafetyNetRetained
- TestExecute_VerifyAssertionFails_NoRemoval (§21.3)
- TestExecute_VerifyDepError_NoRemoval (§21.3)
- TestExecute_RemoveFailure_NonSuccessTerminal
- TestExecute_SuccessPath_ExactCallOrder (pins 7-step trace)
- TestExecute_PanelNative_DirectAdmin_ResolvesToCSF (§20)
- TestExecute_PanelNative_UnmappedPanel_RefusesBeforeAnyDepCall
- TestExecute_PanelNative_UnmappedPanel_NoMutationFirewallLeak
- TestExecute_NilDeps (5 sub-cases for each missing dep)
- TestExecuteDeps_NoLiveResolutionMethods (compile-time interface pin)
- TestExecute_NoForbiddenSurfaces_FileScan (call-expression patterns
  only — no os/exec, no exec.Command, no live-detection calls, no
  writeHistory(, no runRestoreDecide(, no shell-out)
- TestExecute_AllTerminalStatesUsedTruthfully (table-driven mapping)
- TestExecute_DegradedNotUsedInCommit3C (no scenario returns Degraded)

Trace-fake design: a single fakeDeps struct implements all 4
interfaces and records every call into a shared trace slice, so
ordering tests assert the exact §23 sequence without correlating
counts across multiple structs.

Verified on lab2:
- go build ./... clean
- go test ./internal/installer/restore/... PASS
  (commits 1+2+3A+3B+3C all together)
- 18 commit-3C tests PASS via -run TestExecute
- go mod tidy no-op (no new deps)

Out of scope for commit 3C (per locked plan):
- Dispatcher wiring (commit 4) — restore_decide.go extension
- CI gate update + real-host evidence (commit 5)
- Production implementations of the 4 dep interfaces — commit 4
  wires them with real-host backing

Lifecycle fence: §17.1 permitted set only. Execute touches no
package outside restore + state + uninstall (read-only enum
imports). No service/file/kernel direct call; all mutation flows
through the four injected deps.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
PR-25 code phase, segmented commit 4 of 5. Dispatcher integration
ONLY — no real mutation, no flags, no main.go touch, no history-gate
change.

Implements contract integration:
- §24 dispatcher bridge: PROCEED → PlanFromDecision → Execute
  → persist returned restore terminal state
- §17.3 INV-PR25-AUTHORITY-IMMUTABILITY at the integration point:
  planner resolves TargetAuthority once; Execute consumes it
  read-only; deps observe, never re-resolve

PROCEED behavior intentionally replaces PR-24's transitional
StateRestoreDecided with the four §22 PR-25 execution terminals.
PR-24's REFUSE / REQUIRE_EXPLICIT_INTENT paths are byte-identical.

Modified — cmd/nftban-installer/restore_decide.go:
- runRestoreDecide:
    * `_ context.Context` → `ctx context.Context` (now used to
      thread context to Execute)
    * The single switch over result.Output now branches:
        OutputRefuse                 → State*Refused inline
        OutputRequireExplicitIntent  → State*IntentRequired inline
        OutputProceed                → runRestoreExecutionFromProceed(...)
    * REFUSE / REQUIRE_EXPLICIT_INTENT byte-identical to PR-24
- New helper runRestoreExecutionFromProceed (extracted for unit
  testability):
    * Plan via restore.PlanFromDecision (planner refusal →
      StateRestoreFailedExecution; no Execute)
    * Construct deps via the package-level newRestoreDeps factory
      (production stubs in commit 4; tests swap)
    * Run restore.Execute (§23 six-step sequence)
    * Persist whatever terminal Execute returns; surface reason
      in StateFile.FailureReason
    * Operator-facing log.Result reflects executed terminal
- Removed the now-unused restoreStateForOutput helper (dead after
  refactor; the three case arms transition inline)

New file — cmd/nftban-installer/restore_deps.go:
- ErrRestoreExecutionUnavailable: typed sentinel returned by every
  stub-dep method. Distinct from restore-package sentinels so
  callers can tell "no real impl" apart from "real impl refused
  for a contract reason"
- 4 stub structs implementing the 4 restore dep interfaces:
    productionPreflightDep      → PreflightTarget refuses
    productionSafetyNetDep      → Insert/RemoveEmergencySSH refuse
    productionMutationDep       → MutateToTarget refuses
    productionInlineVerifyDep   → all 3 assertions refuse
- newProductionRestoreDeps(exec, log) factory wiring the four-tuple
- newRestoreDeps package-level var defaulting to the production
  factory; tests swap it for the duration of one test (cleanup
  via t.Cleanup)
- Every method body is inert: no kernel call, no `nft`, no
  `systemctl`, no filesystem mutation. exec/log fields are stored
  for commit 4B's real impl but never read in commit 4
  (//nolint:unused on each)

New file — cmd/nftban-installer/restore_decide_test.go (11 tests):
- StubDeps_RecordedPrior_PersistsFailedExecution
- StubDeps_PanelNativeDirectAdmin_PersistsFailedExecution
- PanelNative_UnmappedPanel_FailsExecution (§20.2 refusal at
  Execute step 0; ErrUnmappedPanel surfaced)
- NonProceedAccident_PlannerErrors (defensive: caller corrupted
  inputs; planner returns ErrPlanNotProceed)
- NeverPersistsStateRestoreDecided (PROCEED no longer maps to
  Decided; PR-25 produces §22 execution terminals)
- FakeDeps_HappyPath_PersistsExecuted (newRestoreDeps swap +
  exact call counts: preflight 1, insert 1, mutate 1, remove 1)
- FakeDeps_MutateFailure (FailedExecution; remove not called)
- FakeDeps_VerifyFailure_NoRemove (FailedVerification; §21.3)
- Dispatcher_NonProceedArms_DoNotCallExecutionHelper (structural
  pin — exactly 2 occurrences of the helper name in source =
  definition + single call)
- Dispatcher_NoLocalGroupKindMapping (forbids TargetRecordedPrior(
  / TargetPanelNative( / Kind constants in dispatcher source)
- Dispatcher_NoForbiddenSurfaces_FileScan

New file — cmd/nftban-installer/restore_deps_test.go (6 tests):
- Each of the 4 stub structs returns ErrRestoreExecutionUnavailable
- newProductionRestoreDeps populates all 4 ExecuteDeps fields
- newRestoreDeps default points at newProductionRestoreDeps
  (reflect.Pointer comparison — guards against test-leaked swaps)
- Interface compliance compile-time pin
- ErrRestoreExecutionUnavailable message contains "execution
  dependency" + "not implemented" substrings
- File-scan: restore_deps.go has zero forbidden patterns

Verified on lab2:
- go build ./... clean
- go test ./cmd/nftban-installer/... PASS
- go test ./internal/installer/restore/... PASS
- go test ./internal/installer/state/... PASS
- go mod tidy no-op (no new deps)

Files NOT touched (per locked instruction):
- cmd/nftban-installer/main.go (writeHistory gate at :132 preserved)
- cmd/nftban-installer/flags.go (no new opt-in flag)
- internal/installer/state/machine.go
- internal/installer/restore/{execute,planner,panel_mapping,
  safety_net,inline_verify}.go
- .github/workflows/

Out of scope for commit 4 (per locked plan):
- Real production dep implementations (commit 4B):
    * real `nft` insert/remove of emergency-SSH allow rule
    * real service-run-state mutation (stop nftband, start target)
    * real post-mutation classify
    * real safety-net-removal-safe predicate
- CI gate update (commit 5)
- §28 real-host evidence on lab2/lab4 (commit 5)
- StateRestoreDegraded usage (no degraded condition implemented)

Real-host evidence DOES NOT EXIST in commit 4. The PROCEED path
on a real host would persist StateRestoreFailedExecution at
Stage=preflight with ErrRestoreExecutionUnavailable in the chain.
This is intentional and tested. PR-25 cannot ship until commit 4B
replaces the stubs with real implementations.

Lifecycle fence: §17.1 permitted set + the 4 dispatcher files
locked for commit 4. No history schema change. No widening of
IsApplyTerminal (verified by file-scan + main.go untouched).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…d-only)

PR-25 commit 4B-1 of 5 sub-commits in 4B. Replaces the stub
productionPreflightDep with a real, read-only presence check.
Other deps (safety-net, mutation, inline-verify) remain stubs and
will land in 4B-2 / 4B-3 / 4B-4.

Per contract §23.1: preflight refusal is non-mutating. This
implementation calls only the read-only Executor methods
CommandExists() and FileExists(). It does NOT call ServiceActive,
ServiceStart, ServiceStop, ServiceEnable, ServiceDisable, ServiceMask,
NftAddElement, NftDeleteTable, DaemonReload, WriteFileAtomic, Run,
or any nft/systemctl shell-out. Verified by file-scan.

Modified — cmd/nftban-installer/restore_deps.go:
- New struct field doc on productionPreflightDep (exec/log now used,
  //nolint:unused removed)
- New map preflightKnownFirewalls — sparse, compile-time, exactly
  the §18.2 known set:
    "ufw"       -> ufw binary + ufw.service unit
    "firewalld" -> firewall-cmd|firewalld + firewalld.service
    "iptables"  -> iptables + iptables.service|netfilter-persistent
                   .service (distro-aware)
    "csf"       -> csf + csf.service
- 4 sentinel errors:
    ErrPreflightUnknownFirewall — defensive: planner should validate
    ErrPreflightBinaryMissing   — no canonical binary in PATH
    ErrPreflightUnitMissing     — no canonical unit-file path
    ErrPreflightNilExecutor     — defensive: dep constructed without
                                  executor
- PreflightTarget(ctx, fwt):
    1. defensive nil-executor check
    2. lookup firewallType in preflightKnownFirewalls
    3. CommandExists for at least one binary in OR-list
    4. FileExists for at least one unit file in OR-list
    5. return (true, nil) if all pass; else typed error

Modified — cmd/nftban-installer/restore_deps_test.go:
- Removed the old stub TestProductionPreflightDep_ReturnsUnavailable
  (replaced by the 9-test 4B-1 suite below)
- Added 4B-1 test section:
  - TestPreflightTarget_4B1_HappyPath_AllKnownFirewalls (4 fwts)
  - TestPreflightTarget_4B1_DistroAware_UnitPaths (15 path/fwt combos)
  - TestPreflightTarget_4B1_MissingBinary (4 fwts)
  - TestPreflightTarget_4B1_MissingUnitFile (4 fwts)
  - TestPreflightTarget_4B1_UnknownFirewallType (6 cases incl.
    typo+case-sensitivity)
  - TestPreflightTarget_4B1_NilExecutor
  - TestPreflightTarget_4B1_NoMutationCalls (4 scenarios; mock.Commands
    must remain empty across happy + refusal paths)
  - TestPreflightTarget_4B1_NoFallbackBetweenFirewalls (caller asks
    for ufw on a host with only csf — refuses)
  - TestPreflightKnownFirewalls_MapContentPin (exactly the §18.2 set;
    every entry has at least one binary and one unit-file path)
- TestRestoreDeps_NoMutationSurface_FileScan: refined forbidden list
  (changed "exec.Command" -> "exec.Command(" so it doesn't false-
  match the Executor method exec.CommandExists() which preflight
  uses); added explicit mutation-API forbiddens (ServiceStart/Stop/
  Enable/Disable/Mask, NftAddElement, NftDeleteTable, DaemonReload,
  WriteFileAtomic).

Modified — cmd/nftban-installer/restore_decide_test.go:
- New helper stubExecutorForPreflightFirewall(fwt) builds a
  MockExecutor with the canonical binary + unit file present so the
  (now real) preflight passes through to the next stub.
- Updated 3 stub-deps integration tests that previously passed nil
  executor (now required to pass through preflight to subsequent
  stubs):
    StubDeps_RecordedPrior_PersistsFailedExecution (ufw)
    StubDeps_PanelNativeDirectAdmin_PersistsFailedExecution (csf)
    NeverPersistsStateRestoreDecided (ufw)
  Tests now assert: preflight passes, safety-net insert (still a
  stub) refuses with ErrRestoreExecutionUnavailable, and the
  dispatcher persists StateRestoreFailedExecution at Stage=insert.

Verified on lab2:
- go build ./... clean
- go test ./cmd/nftban-installer/... PASS
- go test ./internal/installer/restore/... PASS (commits 1-3C unchanged)
- go test ./internal/installer/state/... PASS
- 9 commit-4B-1 test functions PASS (with multi-fwt subtests)
- go mod tidy no-op (no new deps)

Out of scope for 4B-1 (per locked plan):
- Real safety-net dep (4B-2: nft insert/remove emergency-SSH)
- Real mutation dep (4B-3: minimal target-specific run-state change)
- Real inline-verify dep (4B-4: 3 §21.1 assertions)
- Real-host evidence harness on lab2/lab4 (4B-5)

PR-25 still NOT shippable: PROCEED on a real host now passes
preflight (assuming target firewall is installed) but lands at
StateRestoreFailedExecution Stage=insert because the safety-net
stub still refuses with ErrRestoreExecutionUnavailable. This is
the intended segmented-rollout shape.

Lifecycle fence: only 3 of 4 permitted files touched
(restore_decide.go itself was not modified — its dispatcher logic
is unchanged from commit 4). main.go, flags.go, internal/* all
untouched.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…rrow)

PR-25 commit 4B-2 of 5 sub-commits in 4B. Replaces the stub
productionSafetyNetDep with a real implementation that wraps the
existing battle-tested switchop.InjectEmergencySSH /
switchop.RemoveEmergencySSH primitives. SSH port resolved via
detect.SSHPort (no fallback to port 22).

Per PR-25 contract §23.2 / §23.5 / §21.3:
- Insert mutates kernel ONLY for the emergency-SSH allow rule.
- Remove deletes ONLY the emergency table (which contains only
  the safety-net rule). Production nftban tables untouched in
  both Insert and Remove.
- Insert is idempotent: stale emergency table is deleted-then-
  recreated.
- IPv4+IPv6 dual-stack via inet family in a single table —
  explicit dual-stack support, no silent partial behavior.
- No service start/stop/enable/disable/mask.
- No file writes outside /tmp/.nftban-emergency-ssh.nft.
- No fallback / best-effort cleanup.

Modified — cmd/nftban-installer/restore_deps.go:
- Added imports: detect (for SSHPort), switchop (for the safety-net
  primitive), fmt (for error wrapping).
- productionSafetyNetDep struct gains sshPortFn field for
  testability — production wiring sets it to a closure calling
  detect.SSHPort(exec, log); tests inject a fixed-port closure.
- Added emergencySafetyNetTable const = "nftban_install_emergency"
  mirroring switchop's unexported const (sync verified by
  TestSafetyNetDep_4B2_TableNameMatchesSwitchop).
- Added 5 sentinel errors:
    ErrSafetyNetNilExecutorProd
    ErrSafetyNetSSHPortUnknown    (no fallback to hardcoded port)
    ErrSafetyNetInvalidSSHPort    (range 1-65535)
    ErrSafetyNetSwitchopFailed
    ErrSafetyNetRemoveCallFailed  (post-removal verify)
- Replaced both stub bodies:
    InsertEmergencySSH:
      1. nil-executor refusal (typed)
      2. nil-sshPortFn refusal (typed)
      3. sshPortFn() — refuse on err, refuse on out-of-range
      4. switchop.InjectEmergencySSH(exec, sshPort, log)
      5. wrap any non-nil error in ErrSafetyNetSwitchopFailed
    RemoveEmergencySSH:
      1. nil-executor refusal
      2. NftTableExists(inet, emergency) false → no-op (idempotent)
      3. switchop.RemoveEmergencySSH(exec, log)
      4. verify-after-removal: re-check NftTableExists; if still
         present, return ErrSafetyNetRemoveCallFailed
- Updated newProductionRestoreDeps to wire sshPortFn = detect.SSHPort.

Modified — cmd/nftban-installer/restore_deps_test.go:
- Removed the old TestProductionSafetyNetDep_BothMethodsReturnUnavailable
  stub test (replaced by 14-test 4B-2 suite below).
- Added pf4B2TestLogger helper (logger writing to t.TempDir()).
- Added fakeSSHPortFn helper for fixed-port injection.
- Added 4B-2 test section (14 functions, 24 PASS lines incl. subtests):
    Insert_HappyPath_EmitsOnlyExpectedCommands
        — exactly one Run("nft","-f",tmpPath); exactly one
          WriteFileAtomic; tmp path is the documented value;
          file content has "tcp dport 22 accept" + "table inet
          nftban_install_emergency"
    Insert_StaleEmergencyTable_IdempotentDelete
        — pre-seeds inet:nftban_install_emergency + ip:nftban +
          ip6:nftban; verifies production tables are NOT deleted
          (CRITICAL §17 protection)
    Insert_SSHPortFromConfigSource (5 ports: 22/2222/55000/65535/1)
        — file content contains exactly the resolved port
    Insert_SSHPortUnknown_NoMutation
        — sshPortFn returns err → no Run, no WriteFile
    Insert_NilSSHPortFn_NoMutation
        — sshPortFn nil → no Run, no WriteFile
    Insert_InvalidSSHPort_NoMutation (5 invalid ports)
        — out-of-range port → ErrSafetyNetInvalidSSHPort, no
          mutation
    Insert_NilExecutor
        — typed refusal, no panic
    Insert_NoForbiddenSurfaces
        — no systemctl, no nft args other than "-f", no file
          writes outside the documented tmp path, no nft set
          mutation
    Remove_HappyPath_DeletesOnlyEmergencyTable
        — production tables preserved
    Remove_NoEmergencyTable_NoOp
        — idempotent on absent table
    Remove_NilExecutor
        — typed refusal
    Remove_NoForbiddenSurfaces
        — no Run, no WriteFile, no nft set churn
    DualStackExplicit
        — config uses "table inet"; rejects "table ip" / "table
          ip6" single-stack alternatives
    TableNameMatchesSwitchop
        — switchop's actual write reaches a config that contains
          the local mirror const literal; catches drift if
          switchop renames its internal const
- TestRestoreDeps_NoMutationSurface_FileScan: comments documenting
  switchop-side mutations were reworded to avoid false-positive
  matches on the forbidden literals (NftDeleteTable). Production
  code in restore_deps.go calls switchop wrappers, NOT the
  Executor mutation primitives directly.

Modified — cmd/nftban-installer/restore_decide_test.go:
- stubExecutorForPreflightFirewall now seeds /etc/ssh/sshd_config
  with "Port 22" so the (now real) safety-net's SSH-port detection
  via detect.SSHPort succeeds (Source 2 sshd_config). Without this,
  the dispatcher integration tests would refuse at safety-net
  Insert with ErrSafetyNetSSHPortUnknown before reaching the
  still-stub mutation step.

Verified on lab2:
- go build ./... clean
- go test ./cmd/nftban-installer/... PASS
- go test ./internal/installer/restore/... PASS
- go test ./internal/installer/state/... PASS
- 24 commit-4B-2 PASS lines via -run 'SafetyNetDep_4B2'
- go mod tidy no-op (no new deps; switchop + detect already
  transitively imported by the cmd package)

Out of scope for 4B-2 (per locked plan):
- Real mutation dep (4B-3: minimal target-specific run-state change)
- Real inline-verify dep (4B-4: 3 §21.1 assertions)
- Real-host evidence harness on lab2/lab4 (4B-5)

PR-25 still NOT shippable: PROCEED on a real host now passes
preflight (4B-1) AND emergency-SSH safety net (4B-2 — actual
kernel mutation: tiny, narrow, auditable, idempotent, dual-stack)
but lands at StateRestoreFailedExecution Stage=mutate because
the mutation stub still refuses with ErrRestoreExecutionUnavailable.

Lifecycle fence: only 3 of 4 permitted files touched
(restore_decide.go itself was not modified). main.go, flags.go,
internal/* (other than imports) all untouched.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…tions

Doc-only commit. PR-25 commit 4B-pre / Amendment-1 of the CSF
restore work. Pauses 4B-3 code; opens the contract authority that
4B-3-csf requires.

Origin:
Inspection during PR-25 commit 4B-3 revealed that install-time
switchop.DisableConflicts (internal/installer/switchop/takeover.go:32)
performs persistent, file-level mutations on a CSF host:
  1. ServiceStop + ServiceDisable + ServiceMask on csf.service
  2. Flush iptables/ip6tables filter/nat/mangle
  3. Remove /etc/cron.d/lfd-cron + /etc/cron.d/csf-cron
  4. Rename /usr/sbin/csf -> /usr/sbin/csf.disabled
  5. DirectAdmin custombuild "build set csf no"

A real CSF restore must reverse the operations nftban actually
performed. The original §§17-29 PR-25 contract forbids every
operation needed for that reversal (no enable/disable, no unit-
file edits, no file writes, no broad cleanup). Without this
amendment, PR-25 csf restore is impossible — any partial
implementation would leave the host in a broken state (csf
service started but binary renamed = ExecStart failure).

What changed (single file, doc-only):

internal/installer/restore/contract.md:
- Appends Part III: AMENDMENT 1 (§§30-36).
- §1-§29 untouched. main.go:132 writeHistory gate (§19.2 L4)
  untouched.
- §30 Scope and applicability (CSF-only; activates only on
  PROCEED + RecordedPrior+csf OR PanelDirectAdmin+csf).
  Explicitly out of scope: ufw/firewalld/iptables, non-DirectAdmin
  panels, paths bypassing PR-24 PROCEED.
- §31 Authorized inverse-of-install mutations (A.1-A.7), each
  gated on specific evidence preconditions:
    A.1 ServiceUnmask("csf.service")
    A.2 ServiceEnable("csf.service")
    A.3 Restore /usr/sbin/csf from .disabled (refuse on ambiguous
        both-binaries-present state)
    A.4 Restore CSF/LFD cron files from manifest backup (E.5-
        gated; if installer doesn't yet write a backup manifest,
        A.4 is documented unimplementable until installer-side
        prerequisite lands)
    A.5 ServiceStart("csf.service")
    A.6 ServiceStop("nftband.service")
    A.7 NftDeleteTable ip+ip6 nftban (gated on csf-active +
        SSH-still-protected post-mutation)
- §31.2 Explicit out-of-amendment list (DirectAdmin custombuild
  rewrite, lfd binary restore until separately authorized,
  iptables rule re-population, no-named services).
- §32 Required ordering — 11 steps extending §23. Includes
  failure-mode safety-net retention table (§32.1).
- §33 Evidence preconditions (E.1-E.7) consolidated.
- §34 CSF-specific forbidden behaviors (extends §25):
  no guessed restore, no broad cron restoration, no DirectAdmin
  custombuild rewrite, no iptables re-population, no fallback,
  no service ops beyond the three named units, no nftban flush
  (only NftDeleteTable on whole tables), no retry loops.
- §35 New tests + §28 evidence requirements:
  §35.1 unit tests for each A.1-A.7 (happy / precondition-false /
        idempotency / no-out-of-target)
  §35.2 5 integration scenarios
  §35.3 lab2 (DEB/Ubuntu/DirectAdmin) + lab4 (RPM/AlmaLinux/cPanel)
        real-host evidence with exec-trace + final-state
        verification
  §35.4 §28 evidence remains merge-blocking
- §36 Reviewer checklist for 4B-3-csf (16 items).
- Amendment history: v3 entry (2026-04-28) documenting the
  authority gap, scope, and locked invariants that this
  amendment does NOT modify.

Locked invariants preserved (NOT modified by this amendment):
- INV-PR25-AUTHORITY-IMMUTABILITY (§17.3)
- §19.2 L4 main.go:132 writeHistory mode-gate
- §19.4 exit codes for the four §22 terminals
- §20.3 no-fallback rule
- §21.3 safety-net retention on verify-fail
- §23 base ordering (extended, not reordered)
- §28 real-host evidence is merge-blocking

Out of scope for this commit:
- 4B-3-csf production mutation code (lands AFTER this amendment
  is reviewed and merged)
- Any non-CSF firewall (ufw/firewalld/iptables remain typed-
  unsupported)
- Any installer-side change to write a CSF cron manifest backup
  (E.5 dependency; if absent, A.4 is documented unimplementable;
  separate amendment required for the installer-side change)
- Any state machine, exit code, or main.go change

PR-25 status: still NOT shippable. After this amendment merges,
4B-3-csf will implement A.1-A.7 against §§30-36 as authoritative
contract. Reviewer checklist for 4B-3-csf is §36 of this
amendment.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
PR-25 commit 4B-3-pre. Mechanical evidence-plumbing slice between
4B-2 (real safety-net) and 4B-3-csf (real CSF mutation). No real
mutation behavior; productionMutationDep remains a stub returning
ErrRestoreExecutionUnavailable in this commit.

Origin:
Amendment 1 §33 (commit 5edff25) authorizes inverse-of-install
mutations gated on evidence (E.1, E.2, E.7) the planner already
read. The factory signature in 4B-2 was newProductionRestoreDeps
(exec, log) — no path to carry priorRec / panel into the mutation
dep. This commit closes that plumbing gap.

Locked design (Path B, option α):
- Extend deps factory signature to take priorRec + panel
- Production deps store evidence in immutable struct fields
- Dispatcher passes already-read probe.Record + panel forward
- No context.Value plumbing
- No setter methods
- No re-derivation
- No new live Probe / Classify / DetectPanel calls

Modified — cmd/nftban-installer/restore_deps.go:
- productionMutationDep struct gains read-only fields:
    priorRec *uninstall.PriorRecord
    panel    detect.PanelType
  (//nolint:unused — 4B-3-csf will read them for the §31 A.1–A.7
  evidence gates; safety-net-safe predicate field is intentionally
  deferred to 4B-4 because it would naturally point at the
  inline-verify dep's still-stubbed IsSafetyNetRemovalSafe.)
- New factory newProductionRestoreDepsWithEvidence(exec, log,
  priorRec, panel) wires evidence into the mutation dep. Other
  three deps (preflight / safety-net / inline-verify) are
  unchanged.
- Old newProductionRestoreDeps(exec, log) preserved as a thin
  wrapper that calls the new factory with priorRec=nil + panel=
  PanelNone. Kept to avoid breaking the audit-script + interface
  tests that reference it.
- New type alias restoreDepsFactory captures the evidence-aware
  function shape.
- Package var newRestoreDeps changed from newProductionRestoreDeps
  to newProductionRestoreDepsWithEvidence (the dispatcher's actual
  call shape).

Modified — cmd/nftban-installer/restore_decide.go (single line + 1
comment):
- runRestoreExecutionFromProceed now calls
    newRestoreDeps(exec, log, priorRec, panel)
  instead of the old (exec, log) shape. The priorRec and panel
  values come from the same PR-24 path the planner consumed —
  ZERO new Probe / Classify / DetectPanel calls.
- Added comment citing INV-PR25-AUTHORITY-IMMUTABILITY (§17.3) +
  §33 E.7 forbidding re-derivation.
- All other dispatcher code byte-identical.

Modified — cmd/nftban-installer/restore_deps_test.go:
- TestNewRestoreDeps_DefaultIsProductionFactory: updated to expect
  newProductionRestoreDepsWithEvidence (the new default).

Modified — cmd/nftban-installer/restore_decide_test.go:
- withFakeDeps signature updated to match the new factory shape
  (ignores the new priorRec + panel args).
- New helper withFakeDepsRecordingEvidence captures every factory
  call into a slice so tests can assert exactly which evidence
  reached the dep.
- Added 12 4B-3-pre tests:
    PassesPriorRecToFactory (E.1, E.2 plumbing — verifies non-nil
        priorRec + ActiveAtInstall flow through)
    PassesPanelToFactory (E.7 plumbing for PanelDirectAdmin)
    PassesNilPriorRecForNoRecordPath (NoRecord+PanelAuto path —
        rec=nil correctly flows through)
    NoLiveReDetectionInExecuteHelper (file-scan: helper body
        contains no DetectPanel/Probe/Classify/Decide calls)
    NonProceedDoesNotConstructDeps (newRestoreDeps( appears
        exactly 1× in source — only inside the helper)
    StubStillRefuses (mutation dep still returns
        ErrRestoreExecutionUnavailable even with evidence
        populated)
    PROCEEDStillFailsAtMutate (real preflight + real safety-net +
        stub mutation = StateRestoreFailedExecution at Stage=mutate)
    MainGoUntouched (writeHistory gate exact substring preserved)
    FlagsGoUntouched (no new --execute-restore / --unsafe-stub-
        restore / executeRestore / unsafeStubRestore symbols)
    NoHistoryWriteInDispatcher (writeHistory( absent from
        restore_decide.go)
    NoContextValuePlumbing (context.WithValue / ctx.Value absent
        from both restore_decide.go and restore_deps.go)
    NoSetterMethods (productionMutationDep has no
        SetPrior/SetPanel/SetEvidence methods — option γ
        plumbing forbidden)

Verified on lab2:
- go build ./... clean
- go test ./cmd/nftban-installer/... PASS
- go test ./internal/installer/restore/... PASS (commits 1-3C
  + 4B-2 unchanged)
- go test ./internal/installer/state/... PASS
- 12 4B-3-pre tests PASS via -run '4B3pre' filter
- go vet ./cmd/nftban-installer ./internal/installer/restore/...
  ./internal/installer/state/... clean (no output)
- go mod tidy no-op (no new deps)

Out of scope for 4B-3-pre (per locked plan):
- Real CSF mutation (4B-3-csf — A.1–A.7 implementation against
  the now-plumbed evidence)
- Real inline-verify dep (4B-4)
- Safety-net-safe predicate plumbing — deferred to 4B-4
  (predicate naturally points at inline-verify dep's
  IsSafetyNetRemovalSafe; wiring now would reference still-
  stubbed behavior)
- §28 real-host evidence (commit 5)

Files NOT touched:
- main.go (writeHistory gate at :132 byte-identical, verified by
  4B-3-pre's MainGoUntouched test)
- flags.go (no new flags, verified by FlagsGoUntouched test)
- internal/installer/restore/* (interface signatures unchanged)
- internal/installer/state/*
- contract.md (Amendment 1 untouched; 4B-3-pre is the plumbing
  Amendment 1 implicitly assumed)
- .github/workflows/

PR-25 still NOT shippable: PROCEED on a real host now passes
preflight (4B-1) + emergency-SSH safety-net (4B-2 — real kernel
mutation) but lands at StateRestoreFailedExecution Stage=mutate
because the mutation stub still refuses. Same end-state as 4B-2
— this commit is plumbing-only.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…dment 1 §31 A.1-A.7)

PR-25 commit 4B-3-csf. Implements productionMutationDep.MutateToTarget
for firewallType=="csf" only, per Amendment 1 §31 A.1-A.7 / §32 11-step
ordering. Other §18.2 firewalls (ufw/firewalld/iptables) return the
typed unsupported sentinel. Unknown firewallType returns the typed
unknown sentinel. Consumes priorRec + panel evidence wired by 4B-3-pre;
no live re-detection.

A.7 (nftban kernel release) is gated on a safety-net-safe predicate.
4B-3-csf intentionally leaves the predicate unwired (4B-4 lands the
wiring); A.7 always refuses on real hosts with ErrCSFRestoreNftReleaseUnsafe.
Tests inject a closure to exercise the available/true branch.

Files added:

cmd/nftban-installer/restore_deps_csf.go
- Path/unit constants: csfBinary, csfBinaryDisabled, csfServiceUnit,
  nftbandUnit, csfCronPath, lfdCronPath.
- Sentinel errors: ErrCSFRestoreOnlyAuthorized,
  ErrRestoreMutationUnknownFirewall, ErrCSFRestoreNilExecutor,
  ErrCSFRestoreEvidenceMissing, ErrCSFRestoreCSFUninstalled,
  ErrCSFRestoreAmbiguousBinary, ErrCSFRestoreUnmaskFailed,
  ErrCSFRestoreEnableFailed, ErrCSFRestoreBinaryRestoreFailed,
  ErrCSFRestoreServiceStartFailed, ErrCSFRestorePostStartInactive,
  ErrCSFRestoreServiceStopFailed, ErrCSFRestoreNftReleaseUnsafe.
- knownNonCSFFirewalls allow-list pinning §18.2 minus csf.
- Helpers: evidenceE1, evidenceE7, isCSFServiceMasked, unmaskCSFService,
  renameAtomicViaExec — each maps to one §31/§32 concern.
- mutateToCSFTarget: 11-step §32 implementation with evidence gates.

cmd/nftban-installer/restore_deps_csf_test.go
- 27 tests covering the §35.1 unit-test matrix:
  evidence (E.1/E.7/E.3 ambiguous/uninstalled), A.1 unmask gating +
  no-other-services + failure, A.2 enable-only-csf, A.3 rename branches
  (rename/skip/ambiguous-already-covered/failure), A.4 cron soft-skip
  + zero file writes, A.5 starts-only-csf + post-start-inactive,
  A.6 stops-only-nftband-after-csf + idempotency, A.7 predicate
  unwired/false/error refusals + true-deletes-only-nftban-tables,
  no-early-deletion on pre-A.5 failure, no-DirectAdmin-custombuild,
  file-scan no-live-re-detection, file-scan no-direct-OS-calls,
  happy-path no-out-of-target mutation, no-nolint-unused on consumed
  fields, PR-25-non-shipping pin (default factory leaves predicate nil),
  ordering pin across the §32 sequence.

Files modified:

cmd/nftban-installer/restore_deps.go
- productionMutationDep gains safetyNetRemovalSafeFn field (deferred
  wiring to 4B-4); //nolint:unused dropped from exec/log/priorRec/panel
  (now consumed in mutateToCSFTarget).
- MutateToTarget switches on firewallType: csf -> mutateToCSFTarget;
  ufw/firewalld/iptables -> ErrCSFRestoreOnlyAuthorized; other ->
  ErrRestoreMutationUnknownFirewall.

cmd/nftban-installer/restore_deps_test.go
- TestProductionMutationDep_ReturnsUnavailable replaced by
  TestProductionMutationDep_NonCSFKnown_ReturnsTypedUnsupported (ufw/
  firewalld/iptables -> ErrCSFRestoreOnlyAuthorized) and
  TestProductionMutationDep_UnknownFirewall_ReturnsTypedUnknown.
- File-scan TestRestoreDeps_NoMutationSurface_FileScan unchanged —
  restore_deps.go itself stays free of mutation primitives; mutation
  lives in restore_deps_csf.go.

cmd/nftban-installer/restore_decide_test.go (minimal track-with-prod)
- TestRunRestoreExecutionFromProceed_StubDeps_RecordedPrior_PersistsFailedExecution:
  expected FailureReason updated from ErrRestoreExecutionUnavailable
  to ErrCSFRestoreOnlyAuthorized (ufw is now known-but-unauthorized,
  not stub-unavailable). End-state unchanged: StateRestoreFailedExecution
  at Stage=mutate.
- TestProductionMutationDep_4B3pre_StubStillRefuses renamed to
  TestProductionMutationDep_4B3pre_DispatchesCSFWithEvidence: confirms
  csf dispatch lands on mutateToCSFTarget by observing
  ErrCSFRestoreNilExecutor (the nil-executor refusal inside the real
  csf path).

Verified on lab2 (Ubuntu 24.04, go1.22.2):
- go build ./... clean
- go test ./cmd/nftban-installer/... ./internal/installer/restore/... ./internal/installer/state/... PASS
- go test ./... PASS (full suite)
- go test -race ./cmd/nftban-installer ./internal/installer/restore/... ./internal/installer/state/... PASS
- go vet clean
- go mod tidy no-op

Constraints honored:
- Files touched: only restore_deps.go + restore_deps_test.go +
  restore_deps_csf.go + restore_deps_csf_test.go (allowed list) +
  restore_decide_test.go (minimal: 2 existing tests adjusted to track
  the dispatch behavior change; restore_decide.go itself untouched).
- main.go, flags.go, history schema, internal/installer/restore/*,
  internal/installer/state/*, internal/installer/uninstall/*,
  workflows, contract.md: untouched.
- No DirectAdmin custombuild rewrite. No iptables re-population. No
  service mask/disable. No daemon-reload. No cron writes (E.5
  manifest doesn't exist; A.4 soft-skips with operator warning).
- No direct os/exec, os.Rename, os.Remove. No context.Value, no
  setters. All operations through executor abstraction
  (Run("systemctl", ...), Run("mv", ...), typed methods).
- E.1 / E.2 / E.7 evidence read from priorRec + panel; never re-derived.

PR-25 still NOT shippable until 4B-4 wires the safety-net-safe predicate
(then A.7 actually deletes nftban tables on real hosts) and §28 lab2/
lab4 evidence is captured. The user offered srv3 as the DirectAdmin
host for §28 — captured for commit 5.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…-safe wiring

PR-25 commit 4B-4. Replaces the productionInlineVerifyDep stubs with
real implementations of the three §21.1 minimum-sufficient assertions.
Wires productionMutationDep.safetyNetRemovalSafeFn in the production
factory so A.7 (nftban kernel release) consults the inline-verify dep
at §32 step 7.

After this commit, all four PR-25 dependencies (Preflight, SafetyNet,
Mutation, InlineVerify) are production-real. PR-25 is code-complete;
§28 lab2/lab4 real-host evidence + CI gate is the remaining work
(commit 5).

Real method behavior:

  IsTargetFirewallActive(ctx, firewallType)
    - csf            -> ServiceActive("csf.service")
    - ufw/firewalld/iptables -> ErrInlineVerifyOnlyCSFAuthorized
    - other          -> ErrInlineVerifyUnknownFirewall
    - read-only; no service start/stop/enable/disable/mask

  CurrentAuthorityClass(ctx)
    - calls uninstall.Classify (read-only per its own contract)
    - returns the State field unchanged
    - result is consumed by InlineVerify ONLY; never fed back into
      the planner, never used to re-derive TargetAuthority
    - does NOT call uninstall.Probe / detect.DetectPanel /
      restore.Decide / restore.PlanFromDecision

  IsSafetyNetRemovalSafe(ctx)
    - detect.SSHPort succeeds (kernel-listener evidence)
    - at least one external firewall service is active
      (inlineVerifyExternalFirewallServices: csf, ufw, firewalld,
      iptables, netfilter-persistent — nftband intentionally NOT in
      the list, since A.6 stops it before this check runs)
    - both must hold -> true; else false / typed error
    - no mutation (only ServiceActive + ss-listener probes)
    - no nft-list / CLI-truth dependency

Factory wiring:

  newProductionRestoreDepsWithEvidence constructs inlineVerify first,
  then captures the reference in productionMutationDep.safetyNetRemovalSafeFn
  via a closure that calls inlineVerify.IsSafetyNetRemovalSafe(ctx).
  Same instance is also placed in ExecuteDeps.InlineVerify so Execute
  step 4 verification and the mutation dep's A.7 gate hit identical
  state.

Files modified:

cmd/nftban-installer/restore_deps.go
- productionInlineVerifyDep: real impls of all three methods.
- New sentinels: ErrInlineVerifyNilExecutor, ErrInlineVerifyOnlyCSFAuthorized,
  ErrInlineVerifyUnknownFirewall, ErrInlineVerifyClassifyFailed,
  ErrInlineVerifySSHPortUnknown, ErrInlineVerifyInvalidSSHPort.
- inlineVerifyKnownFirewallServices map: §18.2 set with canonical units.
- inlineVerifyExternalFirewallServices list: post-mutation external-FW
  candidates whose active state proves SSH protection outside the
  emergency table.
- newProductionRestoreDepsWithEvidence: constructs inlineVerify first,
  wires safetyNetRemovalSafeFn closure on the mutation dep.
- ErrRestoreExecutionUnavailable removed (no consumer remains; all
  four deps are real).
- File header rewritten to reflect production state.

cmd/nftban-installer/restore_deps_test.go
- TestProductionInlineVerifyDep_AllThreeMethodsReturnUnavailable
  retitled in spirit: now pins nil-executor defensive guards
  (ErrInlineVerifyNilExecutor) rather than stub Unavailable.
- TestErrRestoreExecutionUnavailable_MessageIsExplicit removed
  (sentinel deleted).
- Forbidden file-scan list updated:
    REMOVED uninstall.Classify( — §21.1.2 explicitly authorizes
      Classify as a verification step.
    KEPT uninstall.Probe(, detect.DetectPanel(, restore.Decide(,
      restore.PlanFromDecision( — these would violate
      INV-PR25-AUTHORITY-IMMUTABILITY (§17.3) / §33 E.7.
    KEPT all mutation primitives (ServiceStart/Stop/Enable/Disable/Mask,
      NftAddElement/NftDeleteTable, DaemonReload, WriteFileAtomic) —
      restore_deps.go itself is dispatcher-shell + read-only verify;
      mutations live in restore_deps_csf.go.

cmd/nftban-installer/restore_deps_csf_test.go
- TestCSFMutate_4B3csf_PR25NonShipping_PredicateUnwiredByDefault:
  assertion FLIPPED from "predicate must be nil" to "predicate must
  be non-nil". Test name kept stable so auditor history can grep
  the flip.

Files added:

cmd/nftban-installer/restore_deps_inlineverify_test.go
- 18 tests covering the user's full required matrix:
  IsTargetFirewallActive csf-only / non-csf-known-typed-unsupported /
  unknown-typed-unknown; CurrentAuthorityClass returns Classifier state
  for AuthorityNFTBan + non-NFTBan host configurations; file-scan no
  decision-path calls; IsSafetyNetRemovalSafe true when external-FW +
  SSH observable / false when only emergency protects / typed error
  when SSH port unknown / no mutation; production-factory wires
  predicate non-nil; integration A.7 deletes nftban tables when wired
  predicate true / refuses when false; full InlineVerify three-assertion
  run; file-scan no full-validator / module-health / CLI-truth surface;
  no history references; no direct OS bypass.

Verified on lab2 (Ubuntu 24.04, go1.22.2):
- go build ./... clean
- go test ./cmd/nftban-installer/... ./internal/installer/restore/... ./internal/installer/state/... PASS
- go test ./... PASS (full suite)
- go test -race ./cmd/nftban-installer ./internal/installer/restore/... ./internal/installer/state/... PASS
- go vet clean
- go mod tidy no-op
- 17 4B-4 tests + companion CSF integration tests all PASS

Constraints honored:
- Files touched: only restore_deps.go + restore_deps_test.go +
  restore_deps_csf_test.go (PR25NonShipping flip) +
  restore_deps_inlineverify_test.go (new). main.go, flags.go,
  restore_decide.go, restore_deps_csf.go, internal/installer/restore/*,
  internal/installer/state/*, internal/installer/uninstall/*,
  workflows, contract.md: untouched.
- No §28 real-host evidence captured here — that is commit 5.
- No CI gate update — that is commit 5.
- No Probe / DetectPanel / restore.Decide calls in inline-verify path.
- No nft-list parsing, no CLI-truth dependency. Kernel/service evidence
  only.
- No safety-net removal from inline-verify; no kernel mutation; no
  nftban table delete; no history writes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…vidence kept private

PR-25 commit 5 — final code-side commit before merge. Three things:

1) Minimal CI gate: G4-RESTORE-EXEC-NO-OUT-OF-TARGET
2) Stale ErrRestoreExecutionUnavailable doc-comment cleanup
3) §28 real-host evidence captured and stored PRIVATELY (NOT in this
   repo) — see "Evidence location" below.

No production behavior changes. PR-25 is now merge-ready pending the
auditor pass over this commit.

Files modified:

.github/workflows/ci-restore-canonization.yml
- New gate G4-RESTORE-EXEC-NO-OUT-OF-TARGET. Static scan of
  cmd/nftban-installer/restore_deps_csf.go for the closed mutation
  surface authorized by Amendment 1 §31 / §32.
- Forbidden-symbol scan: ServiceMask, ServiceDisable, ServiceUnmask
  (typed; we only allow Run("systemctl","unmask",csf.service)),
  DaemonReload, "os/exec", exec.Command, os.Rename, os.Remove,
  os.WriteFile, os.Create, syscall., custombuild, "build set csf",
  options.conf, iptables-restore, ip6tables-restore, "iptables -X",
  WriteFileAtomic, "/etc/cron.d/, rebuild.Run, rebuild.Apply,
  purge., cleanup.Apply.
- Allow-list pin on NftDeleteTable: literal args MUST be
  ("ip","nftban") or ("ip6","nftban"); any other table name fails.
- Allow-list pin on Run("systemctl",...): literal third arg MUST be
  csf.service.
- Allow-list pin on Run("mv",...): only the A.3 binary restore
  shape (csf.disabled -> csf) is allowed; the helper indirection
  Run("mv", oldpath, newpath) is allow-passed (renameAtomicViaExec
  is the sole caller, bounded by file scope).
- Title block updated to reference Parts I + II + III of the
  contract.

.gitignore
- evidence/ and pr25-evidence/ added to the ignore set. Real-host
  evidence captures contain hostnames and infrastructure detail
  that must NOT be published. Added a comment pointing at the
  private storage path so future captures land in the right place.

cmd/nftban-installer/restore_decide.go
- runRestoreDecide doc comment: stub-era prose replaced with the
  current sentinels (ErrCSFRestoreOnlyAuthorized for known §18.2
  non-csf, ErrRestoreMutationUnknownFirewall for §18.2-unknown).
- Header block reflects post-4B-4 state: all four deps are
  production-real, mutation lives in restore_deps_csf.go,
  inline-verify is the §21.1 three-assertion check.

cmd/nftban-installer/restore_decide_test.go
- 4 prose blocks rewritten to drop the now-deleted
  ErrRestoreExecutionUnavailable reference. Logic unchanged. Test
  names preserved for grep history.

Evidence location (PRIVATE — DO NOT publish):

  /home/commonfolder/LLMAI4NFTBAN/V1.90_AUDIT_WIKI_CODE/PR25_EVIDENCE_4B4/
  ├── README.md                  # evidence shape + reproduction
  ├── lab2/                      # Ubuntu 24.04 / DEB / Plesk
  │   ├── env-snapshot.txt
  │   ├── nft-pre.txt
  │   ├── systemctl-pre.txt
  │   ├── update-history-pre.txt
  │   ├── build.txt              # binary sha256 + --version
  │   ├── test-trace.txt         # PASS counts + -race + vet
  │   ├── restore-dry-run.txt    # negative case
  │   ├── restore-real-host.txt  # real --mode=restore output
  │   └── post-state.txt
  └── lab4/                      # AlmaLinux 9 / RPM / cPanel
      └── (same shape minus restore-dry-run.txt)

Real-host evidence summary (captured 2026-04-28 against e0e7cd0):

| Host | OS / panel        | Authority | Prior   | PR-24 output            | Exit | Mutation? | history changed |
|------|-------------------|-----------|---------|-------------------------|------|-----------|-----------------|
| lab2 | Ubuntu 24.04/Plesk| nftban    | none    | REFUSE                  | 5    | none      | no              |
| lab4 | AlmaLinux 9/cPanel| none      | none    | REQUIRE_EXPLICIT_INTENT | 6    | none      | no              |

Both runs refused to enter PR-25 mutation. Both runs left
/var/lib/nftban/state/update-history.json untouched, confirming the
§19.2 layer-4 mode-gate at main.go:132. Tests on both hosts:
CSFMutate_4B3csf 30 PASS, InlineVerify_4B4 17 PASS, PreflightTarget_4B1
8 PASS, SafetyNetDep_4B2 14 PASS, full suite PASS, -race PASS, vet
clean, mod tidy no-op.

Constraints honored:
- Files touched in-repo: only the four explicitly allowed for
  cleanup + CI + .gitignore. main.go, flags.go, restore_deps.go,
  restore_deps_csf.go, restore_deps_test.go,
  restore_deps_csf_test.go, restore_deps_inlineverify_test.go,
  internal/installer/restore/*, internal/installer/state/*,
  internal/installer/uninstall/*, contract.md: untouched.
- Production behavior: zero changes. Code-complete after 4B-4.
- Evidence stays out of the public repo per operator policy.

PR-25 status after this commit: code-complete + §28 captured
(privately) + CI gate landed. Ready for the final auditor pass; on
GO, push branch and open the PR with all 13 commits.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…le pins

Auditor flagged G4-RESTORE-EXEC-NO-OUT-OF-TARGET as P0 — the original
regex shape failed on its own source. Two false-positive matches and
two silent no-op allow-list pins.

False positives caught (would have blocked PR CI):

  ServiceUnmask\( matched
    - restore_deps_csf.go:117 (ErrCSFRestoreUnmaskFailed error message)
    - restore_deps_csf.go:290 (// A.1: ServiceUnmask("csf.service") doc)

  "/etc/cron\.d/ matched
    - restore_deps_csf.go:72-73 (csfCronPath / lfdCronPath const defs;
      A.4 only logs a warning, never writes — these consts are
      forward-compatible placeholders for when E.5 manifest lands)

Silent no-op pins (gave false sense of enforcement):

  Run("systemctl", "[^"]+", "[^"]+") — required all three args quoted,
    but production uses Run("systemctl", "is-enabled", csfServiceUnit)
    and Run("systemctl", "unmask", csfServiceUnit). Identifier args =
    no regex match = case never fires.

  Run("mv", "[^"]+", "[^"]+") — same problem; production calls
    Run("mv", oldpath, newpath) inside renameAtomicViaExec.

Fixes:

1) Forbidden patterns rewritten to call-expression-anchored form
   (\bexec\.). Catches real call sites; skips prose, error messages,
   doc comments, const definitions:

     ServiceMask\(       -> \bexec\.ServiceMask\(
     ServiceDisable\(    -> \bexec\.ServiceDisable\(
     ServiceUnmask\(     -> \bexec\.ServiceUnmask\(
     DaemonReload\(      -> \bexec\.DaemonReload\(
     WriteFileAtomic\(   -> \bexec\.WriteFileAtomic\(
     custombuild         -> \bcustombuild\b
     build set csf       -> "build set csf"  (literal-quoted only)
     iptables -[A-Z]     -> dropped (the iptables-restore /
                            ip6tables-restore patterns are
                            sufficient; -[A-Z] would false-match
                            doc text)
     options.conf        -> dropped (same false-positive class as
                            "/etc/cron.d/")
     "/etc/cron\.d/      -> dropped (cron writes are now caught
                            structurally by \bexec\.WriteFileAtomic\()
     rebuild.Run\(       -> \brebuild\.Run\(
     rebuild.Apply\(     -> \brebuild\.Apply\(
     purge\.             -> \bpurge\.
     cleanup\.Apply      -> \bcleanup\.Apply\(
     os.Rename / os.Remove / os.WriteFile / os.Create / syscall. /
       exec.Command — all anchored with \b

2) Brittle systemctl/mv allow-list pins removed. Per-call argument
   enforcement was always going to be unreliable in shell regex when
   the production code uses constants (csfServiceUnit) and helper
   indirection (renameAtomicViaExec). The Go runtime tests already
   cover this:

     TestCSFMutate_4B3csf_A1_NoUnmaskOfOtherServices
     TestCSFMutate_4B3csf_A2_EnableOnlyCSFService
     TestCSFMutate_4B3csf_A5_StartsOnlyCSFService
     TestCSFMutate_4B3csf_A6_StopsOnlyNftband_AfterCSFStarts
     TestCSFMutate_4B3csf_HappyPath_NoOutOfTargetMutation

   These assert against MockExecutor with full Go-level type
   information — the authoritative per-call check. The CI gate's
   value is forbidden-symbol coverage; per-call literal-arg parsing
   is delegated to the runtime tests and called out in the gate's
   header comment.

3) NftDeleteTable allow-list pin retained. NftDeleteTable's args in
   the production code ARE literal quoted strings — the regex works
   cleanly for this one without identifier resolution.

4) Stale meta:description in cmd/nftban-installer/restore_deps_test.go
   refreshed (auditor P1-A): replaces "every stub method returns
   ErrRestoreExecutionUnavailable / no real mutation surface exists in
   commit 4" with current wording covering production-real wiring +
   forbidden-surface checks across all PR-25 staged commits.

Local gate replay (against cmd/nftban-installer/restore_deps_csf.go):
  FORBIDDEN_FAIL=0
  3 NftDeleteTable calls — all 3 OK (ip:nftban / ip:nftban / ip6:nftban)
  TOTAL_FAIL=0

lab2 (Ubuntu 24.04, go1.22.2) post-fix:
  go build ./... clean
  go test cmd+restore+state PASS (cached + fresh)
  go test -race PASS
  go vet clean

This is a CI-gate-only fix — no production code, no evidence files
touched. Production behavior unchanged.

Amends commit d9d2fb7 in spirit (re-commit captured both the gate
fix and the meta:description cleanup); pushed as a follow-up commit
to preserve audit linearity.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented Apr 28, 2026

Dependency Review

✅ No vulnerabilities or license issues or OpenSSF Scorecard issues found.

Scanned Files

None

Architecture Policy / Policy Gates → Suppression comment audit failed
on PR #511 with one false-positive grep hit:

  ./cmd/nftban-installer/restore_deps_csf_test.go:829:
  // 4B-3-csf — Test #17: no //nolint:unused on consumed mutation fields.

The literal substring //nolint: appears only as prose inside the test's
section-header comment — it is NOT an actual suppression directive on
a code line. The policy gate's grep does not distinguish prose from a
trailing-of-line directive, so the gate fails on its own description.

Rephrased to "consumed mutation fields have no stale lint-suppression
annotations." — semantics unchanged, no zero-width characters, no
literal forbidden substring.

The companion test
TestCSFMutate_4B3csf_NoNolintUnusedOnMutationFields (function name,
not a comment) is a Go identifier and does not contain the //nolint:
substring; it is unaffected. Test behavior unchanged.

Local grep replay (the exact failing command):
  grep -r '//nolint:' --include="*.go" .
  → 0 hits

lab2 (Ubuntu 24.04, go1.22.2):
  go test ./cmd/nftban-installer/... PASS
  go test ./...                       PASS

No production code touched. No workflow touched. No other test
touched.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@itcmsgr itcmsgr merged commit 6a0ab67 into main Apr 28, 2026
63 checks passed
@itcmsgr itcmsgr deleted the feat/v1.100-pr25-restore-execution branch April 28, 2026 07:12
itcmsgr added a commit that referenced this pull request Apr 28, 2026
…seed (#512)

* docs(v1.100 PR-26): contract seed — Part IV §§37-50 (restore verification / evidence hardening)

PR-26 contract seed. Doc-only commit. No production code, no CI changes.

Appends Part IV (§§37-50) to internal/installer/restore/contract.md
with Q1-Q5 proposed locks for the restore verification / evidence
hardening lane. Mirrors PR-24 / PR-25 / Amendment-1 contract-first
discipline: this seed must be reviewed and locked before any code
phase opens.

Pinned sentence (§37):

  PR-25 answered: "can restore execute safely?"
  PR-26 answers:  "can we prove the restore outcome is correct
                   after execution?"

Sections added:

  §37  Pinned sentence
  §38  Scope (permitted / forbidden / scope-bounding invariants)
  §39  Q1 — Verification authority (proposed lock):
         - kernel evidence (NftTableExists, ServiceActive)         BLOCKING
         - target-specific safety predicate                          BLOCKING
         - authority class via uninstall.Classify                    BLOCKING
         - update-history.json unchanged (sha256 pre/post)           BLOCKING
         - terminal == StateRestoreExecuted                          BLOCKING
         - external SSH continuity (out-of-band session)             ADVISORY
         - validator full sweep                                      NOT REQUIRED
         - CLI ruleset parsing                                       NOT REQUIRED
  §40  Q2 — Real-host destructive soak scope:
         - staged DirectAdmin VM merge-blocker
         - lab2/lab4 fixture coverage acceptable for code-A/B/C
         - srv3 supplemental, operator-approved per run only
         - evidence private by default; redaction rules locked
  §41  Q3 — Safety-net-safe predicate tightening:
         - resolved target's specific unit (csf.service for csf restore)
         - sshd via running listener only (Source 1)
         - target firewall has loaded SSH-allow rule (kernel evidence)
         - non-csf still typed-unsupported (Amendment 1 §30.2 unchanged)
  §42  Q4 — Cron backup / A.4 restore:
         - install-time: switchop.disarmCSFArtifacts writes manifest
         - manifest schema_version + sha256 per file
         - A.4 preconditions: manifest present + valid + integrity ok +
           target paths absent + E.1/E.7
         - corrupt-manifest → typed sentinel ErrCSFRestoreCronManifestCorrupt
         - existing installs without manifest → soft-skip (graceful migration)
  §43  Q5 — Executor hardening:
         - add typed ServiceUnmask(unit) + Rename(old, new)
         - migrate restore_deps_csf.go off raw Run("systemctl",unmask,…) +
           Run("mv",…)
         - tighten G4-RESTORE-EXEC-NO-OUT-OF-TARGET to forbid mutating
           Run("systemctl",…) verbs + Run("mv",…)
         - per-call unit allow-list pinned to {csf.service, nftband.service}
  §44  Proposed invariants (6 named):
         INV-PR26-VERIFICATION-IS-PROOF-NOT-DECISION
         INV-PR26-NO-NEW-MUTATION-PRIMITIVES
         INV-PR25-HISTORY-GATE  (carry-forward)
         INV-PR26-EVIDENCE-PRIVATE-BY-DEFAULT
         INV-PR26-TARGET-SPECIFIC-PREDICATE
         INV-PR26-RAW-RUN-FORBIDDEN-FOR-MUTATION
  §45  Merge-blocking evidence requirements (8 rows)
  §46  CI gate requirements:
         - tighten G4-RESTORE-EXEC-NO-OUT-OF-TARGET (forbid + per-call)
         - new G4-RESTORE-EVIDENCE-RECORD
         - new G4-RESTORE-CRON-MANIFEST-INTEGRITY
  §47  Reviewer checklist (PR-26 code-phase merge-blocking)
  §48  Open questions (7 explicitly marked):
         §48.1 target-firewall SSH-rule kernel-evidence mechanism
         §48.2 firewallType vs targetUnit plumbing
         §48.3 cron-backup directory path
         §48.4 cron-backup manifest schema details
         §48.5 unmask/rename helper inline-vs-keep
         §48.6 evidence-record file path + schema
         §48.7 staging VM source
  §49  Non-goals (explicit):
         no ufw/firewalld/iptables authorization
         no panels other than DirectAdmin
         no §22/§19.4 changes
         no validator full sweep / module-health
         no repo hygiene / UX / GOTH / metrics / module cleanup
         no PR-24 lattice changes
         no read-only systemctl probe promotion
  §50  Sequencing recommendation:
         PR-26-doc        → contract seed (this commit)
         PR-26-code-A     → target-specific safety predicate (Q3)
         PR-26-code-B     → typed executor methods + migration (Q5)
         PR-26-code-C     → cron backup manifest at install + A.4 (Q4)
         PR-26-code-D     → evidence-record file + new CI gates (Q1)
         PR-26-code-E     → destructive staged DA soak evidence
         PR-26-final      → CHANGELOG + release prep

Hard fence honored:
- §§1-36 untouched (PR-24 + PR-25 + Amendment 1)
- No production code in this commit
- No CI workflow changes in this commit
- No installer/update/uninstall/service lifecycle code touched
- Cron-backup amendment to switchop/takeover.go is contracted in §42
  but NOT implemented here — that lands in PR-26-code-C after seed lock

Verified locally:
- contract.md section ladder: §§1-50 + Amendment history (1182 lines)
- doc compiles cleanly; no production-code diff

Awaiting auditor + operator pass over Q1-Q5 proposed locks before any
code-phase commit opens. The seven §48 open questions are explicitly
flagged for lock decisions.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs(v1.100 PR-26): contract seed amendment — auditor P0/P1 fixes

Self-audit pass on b509f63 surfaced 1 P0 + 4 P1 issues. This commit
amends the seed in place (still doc-only, still no production code,
still no CI workflow change).

Edits:

1) P0-A — INV invariant rename + rewrite
- Renamed: INV-PR26-NO-NEW-MUTATION-PRIMITIVES
       to: INV-PR26-NEW-MUTATION-SURFACES-BOUNDED
- Old wording was self-contradictory ("no new" but listed three
  exceptions) and contained an arithmetic error ("four executor
  additions" — only two exist).
- New wording enumerates exactly three new mutation surfaces:
  (1) typed executor.ServiceUnmask
  (2) typed executor.Rename
  (3) install-time CSF/LFD cron-backup manifest write under
      /var/lib/nftban/state/csf-cron-backup/
- "No fourth mutation surface is permitted without a new contract
  amendment."
- §38.3 + §44 row 2 updated in lockstep.

2) P1-B / P1-C — §46 CI gate wording hardened
- New §46.1 "Locked discipline for text-grep gates":
  - production-code gates exclude *_test.go files
  - grep gates ignore line-leading comments
    (grep -vE '^[[:space:]]*//' or equivalent)
  - future complex write-path gates use Go AST or structural
    runtime tests, not raw grep
- §46 gate table renamed §46.2 and references §46.1 discipline
  in the gate-rationale column.
- G4-RESTORE-EVIDENCE-RECORD rewritten as a STRUCTURAL requirement:
  "All evidence-record writes must route through a single helper
  using a named constant evidenceRecordDir. Tests must assert
  every WriteFileAtomic call in restore_evidence.go uses that
  helper/constant. CI may grep for forbidden direct WriteFileAtomic
  calls outside the helper."
- G4-RESTORE-CRON-MANIFEST-INTEGRITY similarly tightened to
  structural requirement (sha256 helper symbols present in writer
  + reader; behavior tests assert refusal on mismatch).
- Goal: prevent the same false-positive class that hit Policy
  Gates / Suppression-comment audit on PR #511.

3) P1-A / P1-D — §39.1 row 11 + §48.1
- §39.1 row 11 rationale rephrased from nft-only wording to
  typed-evidence wording:
  "CLI ruleset parsing is not required and must not be used as
  truth. Kernel/service truth must come from typed executor methods,
  such as NftTableExists, ServiceActive, and any additional typed
  introspection method explicitly authorized by Q5/§48.1."
- §48.1 marked HARD BLOCKER for PR-26-code-A. Decision fork made
  explicit:
  - Option A: add typed IptablesRuleExists(table, chain, port);
    keep row 6 BLOCKING. Q5's bounded-3-MUTATION-method invariant
    is unaffected (the new method is READ-ONLY introspection;
    mutation count remains 2).
  - Option B: do not add iptables introspection; downgrade row 6
    to ADVISORY.
- "No PR-26-code-A may start until operator/auditor chooses
  Option A or Option B."

4) §50 sequencing — internal ordering note for code-C
- Added: install-time manifest creation in switchop/takeover.go
  MUST land in the same commit as — and BEFORE — the A.4
  restore-from-manifest enablement in restore_deps_csf.go.
- A.4 must remain skip/refuse-only on hosts where the manifest
  is absent (existing pre-PR-26 installs). The two changes are
  co-required: enabling A.4 without the writer would break on
  first run; writing the manifest without consuming it leaves
  dead state.

Self-audit re-check (fixed sections only):
- P0-A: INV-PR26-NEW-MUTATION-SURFACES-BOUNDED appears in §38.3
  AND §44 row 2 verbatim; old name returns 0 grep hits — PASS.
- P1-B: §46.1 discipline section present with all three locked
  rules; gate table references it — PASS.
- P1-C: G4-RESTORE-EVIDENCE-RECORD now specifies the named
  helper / named constant pattern + the structural test
  requirement — PASS.
- P1-A/D: §39.1 row 11 rationale rewritten to allow future typed
  introspection without lying about current scope — PASS.
- §48.1: HARD BLOCKER tag present + Option A/B fork explicit +
  Q5-invariant interaction documented — PASS.
- §50: code-C row carries the internal-ordering paragraph — PASS.

Stats: +30 / -9 lines, all in internal/installer/restore/contract.md.
§§1-36 still byte-identical to main.

Awaiting independent auditor pass + operator Q1-Q5 lock signal
before push. Code phase is gated on §48.1 Option-A-or-B decision.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
itcmsgr added a commit that referenced this pull request Apr 28, 2026
…name + raw-Run policy tightening (#515)

PR-26-code-B — restore verification / evidence hardening, slice B.
Executor hardening per §43 lock + §51.5-A2 invariant. CSF restore
A.1 + A.3 migrate from raw Run("systemctl","unmask",…) +
Run("mv",…) indirections to typed executor methods.

Authority:
- PR #512 / contract.md Part IV §§37-50
- PR #513 / §51 lock record (§51.5-A2: read-only typed introspection
  is outside the mutation cap; this commit's ServiceUnmask + Rename
  ARE mutation surfaces, but already enumerated by §44 row 2)
- PR #514 / code-A merge 4e98ff5
- §43 executor hardening
- §46 CI gate requirements
- §51 code-B authorization

Behavior delta:
- Before: A.1 used exec.Run("systemctl","unmask",csfServiceUnit) wrapped
  by helper unmaskCSFService; A.3 used exec.Run("mv",old,new) wrapped
  by helper renameAtomicViaExec.
- After: A.1 uses m.exec.ServiceUnmask(csfServiceUnit); A.3 uses
  m.exec.Rename(csfBinaryDisabled, csfBinary). Both helpers REMOVED.
  Mutation surface is unchanged in operational meaning; the typed
  call shape lets the CI gate enforce per-call discipline at the
  symbol level instead of via fragile Run-arg parsing.

Files changed (6):

internal/installer/executor/executor.go
- Executor interface gains:
    ServiceUnmask(unit string) error  // inverse of ServiceMask
    Rename(oldpath, newpath string) error  // atomic same-FS rename
- Header doc updated to list both new methods.

internal/installer/executor/real.go
- RealExecutor.ServiceUnmask runs systemctl unmask via Run with the
  same typed error wrapping pattern as ServiceMask.
- RealExecutor.Rename calls os.Rename directly (consistent with
  WriteFileAtomic's existing os.Rename usage). No process spawn.

internal/installer/executor/mock.go
- MockExecutor.ServiceUnmask records ("systemctl","unmask",unit) and
  returns m.ServiceUnmaskErr (nil by default).
- MockExecutor.Rename records ("rename",oldpath,newpath), returns
  m.RenameErr if non-nil; otherwise simulates the rename in the
  in-memory Files map.
- New error-injection fields: ServiceUnmaskErr, RenameErr — mirror
  the RunResults exit-code injection pattern.

cmd/nftban-installer/restore_deps_csf.go
- Helpers unmaskCSFService and renameAtomicViaExec REMOVED. Replaced
  by a comment block documenting the §43.2 lock.
- A.1 call site: m.exec.ServiceUnmask(csfServiceUnit).
- A.3 call site: m.exec.Rename(csfBinaryDisabled, csfBinary).
- No raw Run("systemctl","unmask",…) and no raw Run("mv",…) remain.
- Log messages preserved; error wrapping (ErrCSFRestoreUnmaskFailed
  + ErrCSFRestoreBinaryRestoreFailed) preserved.

cmd/nftban-installer/restore_deps_csf_test.go
- buildCSFFixture: unmaskFailsExit injects mock.ServiceUnmaskErr;
  mvBinaryFailsExit injects mock.RenameErr (the previous
  RunResults-based simulation is removed; the OnCommand callback
  that simulated the rename in the Files map is also removed —
  Mock.Rename does that natively now).
- TestCSFMutate_4B3csf_A3_* tests updated: assertions move from
  CommandCalled("mv", …) to CommandCalled("rename", …) because
  Mock.Rename records "rename", not "mv".
- HappyPath_NoOutOfTargetMutation allow-list and OrderingPin
  expected sequences updated: A.3's recorded shape becomes
  ("rename", oldpath, newpath) instead of ("mv", oldpath, newpath).
- Seven new TestCSFMutate_PR26B_* tests added:
    1. A1_ServiceUnmaskOnlyCSFService — pins ServiceUnmask called
       only on csf.service
    2. A3_RenameOnlyCSFBinaryRestore — pins Rename called only with
       (csf.disabled → csf)
    3. NoRawSystemctlUnmaskRun_FileScan — pins no raw
       Run("systemctl","unmask",…) remains in production source
    4. NoRawMvRun_FileScan — pins no raw Run("mv",…) remains
    5. A1_UnmaskFailure_TypedErrorPreserved — error contract
       preserved through the migration
    6. A3_RenameFailure_TypedErrorPreserved — same for A.3
    7. RemovedHelpersGone_FileScan — pins removal of the
       unmaskCSFService and renameAtomicViaExec function definitions

.github/workflows/ci-restore-canonization.yml
- G4-RESTORE-EXEC-NO-OUT-OF-TARGET strengthened per §43.4 + §46.1:
    * \bexec\.ServiceUnmask\( REMOVED from forbidden list (now the
      authorized typed method for A.1).
    * Added forbidden patterns for raw Run("systemctl",…) with any
      mutating verb (start/stop/enable/disable/mask/unmask/restart/
      reload/daemon-reload). Read-only Run("systemctl","is-enabled",…)
      remains authorized.
    * Added forbidden pattern for raw Run("mv",…). Typed Rename is
      the only authorized atomic-rename path.
    * §46.1 line-skipping discipline applied: gate strips
      line-leading "//" comments before pattern matching, preventing
      the false-positive class that broke Policy Gates on PR #511.
    * Header rewritten to reflect the post-PR-26-code-B authorized
      mutation set (typed ServiceUnmask / typed Rename; no raw Run
      for mutating systemctl verbs or mv).

Constraints honored (per §51.6 + operator scope):

IN scope:
- typed executor.ServiceUnmask ✓
- typed executor.Rename ✓
- migration of CSF restore A.1 + A.3 to typed methods ✓
- raw Run policy tightening (CI gate) ✓
- G4-RESTORE-EXEC-NO-OUT-OF-TARGET strengthened ✓

OUT of scope (and untouched):
- cron backup / A.4 (PR-26-code-C)
- destructive soak (PR-26-code-E)
- IptablesRuleExists / iptables introspection (Option B lock)
- target-specific predicate changes (already done in code-A)
- inline verification behavior changes
- restore decision lattice
- TargetAuthority / planner
- main.go history gate
- state machine / exit codes
- contract.md
- repo hygiene / UX / GOTH / metrics / module cleanup

Verified on lab2 (Ubuntu 24.04, go1.22.2):
- go build ./... clean
- go test ./cmd/nftban-installer/... ./internal/installer/restore/... ./internal/installer/state/... ./internal/installer/executor/... PASS
- go test ./... PASS (full suite)
- go test -race -count=1 ./cmd/nftban-installer ./internal/installer/restore/... ./internal/installer/state/... PASS
- go vet (cmd + restore + state + executor) clean
- go mod tidy no-op
- 7 new TestCSFMutate_PR26B_* tests all PASS
- Local replay of strengthened G4-RESTORE-EXEC-NO-OUT-OF-TARGET gate:
  FAIL=0 against the migrated restore_deps_csf.go (only authorized
  NftDeleteTable("ip","nftban") + NftDeleteTable("ip6","nftban")
  calls; no raw mutating Run, no os.* bypass, no
  custombuild/iptables/rebuild/purge symbols).

Awaiting auditor pass before push.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
itcmsgr added a commit that referenced this pull request Apr 28, 2026
… restore (#516)

* feat(v1.100 PR-26-code-C1): install-time cron-backup manifest writer + executor.Stat

PR-26-code-C is split into two reviewable sub-slices on the same
branch. C1 (this commit) lands the WRITER side; C2 (next commit)
lands the READER side. §50 ordering lock: writer commit BEFORE reader.

Authority:
- PR #512 / contract.md Part IV §§37-50
- PR #513 / §51 lock record (§51.5-A2: read-only typed introspection
  is OUTSIDE the bounded-3 mutation cap)
- PR #514 / code-A merge 4e98ff5
- PR #515 / code-B merge 45fc63e
- §42 cron backup / A.4 contract
- §51.6 entry criteria (code-B merged)

C1 scope (this commit):

1. Add typed executor.Stat read-only introspection method.
   - executor/executor.go: new FileMeta struct + Stat method on
     Executor interface.
   - executor/real.go: RealExecutor.Stat via os.Stat + syscall.Stat_t.
     UID/GID extracted from the platform-specific Sys() interface
     (Linux-only target).
   - executor/mock.go: MockExecutor.Stat reads from new FileStats map
     (path → FileMeta); falls back to (0644, 0:0, len(content)) if
     the path is in Files but not FileStats. Returns os.ErrNotExist
     if neither holds.
   - Per §51.5-A2 invariant: read-only introspection is OUTSIDE the
     bounded-3 mutation surface cap of
     INV-PR26-NEW-MUTATION-SURFACES-BOUNDED. Stat does NOT count
     against §44 row 2's mutation budget.

2. New shared cron-manifest module: internal/installer/switchop/cron_manifest.go.
   - Constants:
       CronManifestSchemaVersion  = "1.0.0"
       CronManifestDir            = "/var/lib/nftban/state/csf-cron-backup"
       CronManifestFile           = "/var/lib/nftban/state/csf-cron-backup/manifest.json"
       CronCSFSrcPath             = "/etc/cron.d/csf-cron"
       CronLFDSrcPath             = "/etc/cron.d/lfd-cron"
   - Types: CronManifestEntry (path / backup_name / sha256 / mode /
     uid / gid / size) + CronManifest (schema_version / captured_at /
     files).
   - Helpers:
       ComputeCronBackupSHA256(content) — single source of truth
         shared by writer + reader; identical bytes-to-hex semantics
         in both directions.
       WriteCronBackupManifest(exec, log) — install-time writer.
         For each of {csf-cron, lfd-cron} that exists: read content,
         Stat for mode/uid/gid/size, compute sha256, copy under
         CronManifestDir, append manifest entry. Then write
         manifest.json. Files absent at capture time are skipped (no
         entry recorded; no fabrication).
       ReadCronBackupManifest(exec, log) — used by the C2 reader.
         Three return shapes: absent (zero, false, nil), present-but-
         corrupt (zero, true, ErrCronManifestParseFailed/
         ErrCronManifestSchemaMismatch/ErrCronManifestUnknownEntry),
         present-and-valid (manifest, true, nil).
       VerifyCronBackupEntry(exec, entry) — sha256 integrity check
         against the on-disk backup.
   - Sentinels: ErrCronManifestSchemaMismatch,
     ErrCronManifestSHA256Mismatch, ErrCronManifestUnknownEntry,
     ErrCronManifestParseFailed.

3. Modified disarmCSFArtifacts in switchop/takeover.go to call
   WriteCronBackupManifest BEFORE the existing rm -f of the cron
   files. Writer failure is logged but non-fatal: the rm path MUST
   still execute (nftban-takeover correctness invariant). Hosts
   installed before PR-26-code-C ship without a manifest; A.4 stays
   soft-skip on those hosts (§42.2 graceful migration).

4. Tests in internal/installer/switchop/cron_manifest_test.go:
   - WriteCronBackupManifest_BothPresent_RecordsBoth
   - WriteCronBackupManifest_OnlyOnePresent_OnlyOneRecorded
   - WriteCronBackupManifest_NeitherPresent_EmptyManifest
   - WriteCronBackupManifest_WritesOnlyManifestDir (no writes
     outside CronManifestDir)
   - WriteCronBackupManifest_ManifestPathPinnedExact
   - WriteCronBackupManifest_OnlyAuthorizedSrcPaths (writer ignores
     non-{csf-cron, lfd-cron} cron files; never invents content)
   - WriteCronBackupManifest_SHA256ComputedCorrectly
   - ReadCronBackupManifest_AbsentReturnsFalse (graceful skip path)
   - ReadCronBackupManifest_ParseFailure (corrupt JSON refused)
   - ReadCronBackupManifest_SchemaMismatch
   - ReadCronBackupManifest_UnknownEntryPath (defense-in-depth)
   - ReadCronBackupManifest_HappyPath
   - CronManifest_WriteThenRead_Roundtrip
   - VerifyCronBackupEntry_HappyPath
   - VerifyCronBackupEntry_SHA256Mismatch

Constraints honored (per §51.6 + operator C scope):

IN scope (C1):
- install-time cron-backup manifest writer ✓
- only the two §42.2-locked cron files (csf-cron, lfd-cron) ✓
- only writes under CronManifestDir ✓
- manifest records: path, sha256, mode, uid, gid, size, schema_version ✓
- no template regeneration ✓
- no DirectAdmin custombuild ✓
- no unrelated cron files ✓
- absent files cleanly skipped (no fabrication) ✓

OUT of scope (and untouched):
- A.4 reader / restore path (PR-26-code-C2 in next commit on same branch)
- Destructive real-host CSF soak (PR-26-code-E)
- IptablesRuleExists / iptables introspection (Option B lock)
- main.go / state-machine / exit codes / history gate (untouched)
- Restore planner / TargetAuthority / PR-24 lattice (untouched)
- contract.md (untouched)
- Repo hygiene / UX / GOTH / metrics / module cleanup (untouched)

Verified on lab2 (Ubuntu 24.04, go1.22.2):
- go build ./... clean
- go test ./internal/installer/switchop/... PASS

C2 lands the reader side in the next commit on this branch. Both
ship in PR-26-code-C; auditor checkpoint after C1+C2 compile +
tests pass before push.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(v1.100 PR-26-code-C2): A.4 manifest-restore in mutateToCSFTarget step 3

PR-26-code-C2 — companion to C1. C1 lands the install-time manifest
writer; C2 (this commit) flips A.4 from soft-skip to manifest-restore
when the §42.2 cron-backup manifest is present + integrity-clean.

Authority:
- C1 commit on this branch (cron_manifest.go writer + executor.Stat)
- §42.2 cron-backup contract (manifest-only restore; no template
  regeneration; no cron files NFTBan did not back up itself)
- §51.6 entry criteria

Behavior delta:

- Before (C1): A.4 always soft-skipped with a generic warning.
- After (C2): A.4 reads switchop.ReadCronBackupManifest. Three paths:

  - Manifest absent (pre-PR-26 host) → graceful soft-skip, no
    /etc/cron.d/* writes, A.5 runs.
  - Manifest present but corrupt / schema-mismatch / unknown-entry /
    sha256-mismatch → soft-skip with a specific operator warning,
    no /etc/cron.d/* writes, A.5 still runs (per §42.2-D: csf can
    function without cron; LFD just won't auto-restart).
  - Manifest present + integrity-clean → for each entry whose target
    is currently absent, restore via WriteFileAtomic (preserves
    mode) + Chown (preserves uid/gid). Targets that already exist
    are skipped (operator may have re-created a different version
    post-takeover; A.4 must not overwrite operator content).

Files changed (2):

cmd/nftban-installer/restore_deps_csf.go
- New typed sentinel: ErrCSFRestoreCronManifestCorrupt (exported for
  observability + test assertion via errors.Is). Per §42.2-D, A.4
  emits this informationally and continues to A.5; the overall
  mutation does NOT abort on cron failure.
- A.4 step rewritten: calls switchop.ReadCronBackupManifest,
  switches on (absent / corrupt / present), per-entry sha256
  verification via switchop.VerifyCronBackupEntry, restoration via
  exec.WriteFileAtomic + exec.Chown.
- New imports: "os" (for os.FileMode), "switchop" (for the shared
  manifest module).
- New local helper fileModeFromUint32 — single-purpose conversion
  for the manifest's uint32 mode bitfield to os.FileMode. Keeps os
  import scoped narrowly.

cmd/nftban-installer/restore_deps_csf_test.go
- New seedCronManifest helper writes a sha256-valid manifest +
  matching backup files into the mock for end-to-end A.4 tests.
- 8 new TestCSFMutate_PR26C2_* tests:
    1. A4_ManifestAbsent_SoftSkip — pre-PR-26 host case
    2. A4_HappyPath_RestoresBothFiles — manifest present + integrity
       clean + targets absent
    3. A4_TargetExists_SkipsRestore — operator content not overwritten
    4. A4_SHA256Mismatch_SoftSkip_A5StillRuns — §42.2-D non-abort
    5. A4_SchemaMismatch_SoftSkip_A5StillRuns — §42.2-D non-abort
    6. A4_OnlyAuthorizedTargetPaths — no broad /etc/cron.d/* writes
    7. TypedSentinelExported — ErrCSFRestoreCronManifestCorrupt visible
    8. A4_UnknownEntryPath_Rejected — defense-in-depth refusal

Constraints honored (per §51.6 + operator C scope):

IN scope (C2):
- A.4 reader / restore path enabled when manifest is present ✓
- soft-skip with warning for pre-PR-26 hosts ✓
- typed refusal (sentinel surfaced) for corrupt / hash-mismatch /
  ambiguous cases ✓
- restore only the two §42.2-locked cron files ✓
- preserve mode/uid/gid via WriteFileAtomic + Chown ✓
- no write outside the two backup-target paths ✓
- no cron restore unless evidence says NFTBan backed up the file ✓

OUT of scope (and untouched):
- Destructive real-host CSF soak (PR-26-code-E)
- IptablesRuleExists / iptables introspection (Option B lock)
- main.go / state-machine / exit codes / history gate
- Restore planner / TargetAuthority / PR-24 lattice
- contract.md
- Repo hygiene / UX / GOTH / metrics / module cleanup

§42.2-D semantics preserved: A.4 corrupt-manifest does NOT abort A.5.
csf can function without cron; LFD just won't auto-restart. The
operator-warning log line is more specific than 4B-3-csf's generic
warning (states which precondition failed). The typed sentinel is
exposed for higher-layer observability.

Verified on lab2 (Ubuntu 24.04, go1.22.2):
- go build ./... clean
- go test ./cmd/nftban-installer/... ./internal/installer/restore/...
  ./internal/installer/state/... ./internal/installer/executor/...
  ./internal/installer/switchop/... PASS
- 8 new TestCSFMutate_PR26C2_* tests all PASS
- existing TestCSFMutate_4B3csf_A4_SoftSkip_ZeroFileWrites still
  passes (manifest-absent fixture takes the new soft-skip path)

Awaiting C1+C2 auditor checkpoint before push. CI gate update
(G4-RESTORE-CRON-MANIFEST-INTEGRITY) lands as a third commit on
the same branch.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* ci(v1.100 PR-26-code-C): add G4-RESTORE-CRON-MANIFEST-INTEGRITY structural gate

Strengthens the Restore Canonization workflow with the §46 cron-
manifest integrity gate locked at §51.6 entry criteria for code-C.

Authority:
- §42 cron backup / A.4 contract (manifest-only restore)
- §46 CI gate requirements (structural, not loose grep)
- §46.1 line-skipping discipline (production-code-only,
  comment-stripped)

Gate scope (writer + reader cross-pin):

WRITER required symbols (internal/installer/switchop/cron_manifest.go):
- CronManifestSchemaVersion = "1.0.0" const
- CronManifestDir / CronManifestFile constants pinned to the exact
  /var/lib/nftban/state/csf-cron-backup/{,manifest.json} paths
- CronCSFSrcPath / CronLFDSrcPath constants pinned to the exact
  /etc/cron.d/{csf-cron,lfd-cron} source paths
- func ComputeCronBackupSHA256(content []byte) string — single
  source of truth for the sha256 helper
- func WriteCronBackupManifest(...), ReadCronBackupManifest(...),
  VerifyCronBackupEntry(...) — the three exported API points
- sha256.Sum256 — proves the writer actually computes sha256 (not a
  no-op stub)

Pattern shape: whitespace-flexible ([[:space:]]+) so the patterns
don't break when gofmt re-aligns the const block.

READER required symbols (cmd/nftban-installer/restore_deps_csf.go):
- switchop.ReadCronBackupManifest( — A.4 reads the manifest
- switchop.VerifyCronBackupEntry( — A.4 verifies sha256 BEFORE
  restoring (this is the integrity guarantee §42.2-D requires)
- ErrCSFRestoreCronManifestCorrupt — the typed sentinel surfaced
  on integrity failure

If any required symbol is absent, the gate fails — proves the
integrity check is consumed, not just imported.

WRITER + READER forbidden patterns:
- \bcustombuild\b — defense-in-depth (§34: no DirectAdmin custombuild)
- iptables-restore — defense-in-depth (§34: csf manages its own)
- "/etc/cron.d/*" glob literal — no broad cron sweep
- WriteFile to /etc/cron.d/* with non-csf-prefixed leaf (rough check)

READER allow-list pin:
- Every WriteFileAtomic call in restore_deps_csf.go that targets a
  /etc/cron.d/* literal MUST equal one of the two §42.2-locked
  literals: "/etc/cron.d/csf-cron" OR "/etc/cron.d/lfd-cron".
- The reader uses the named constants csfCronPath / lfdCronPath, so
  in practice this grep returns zero matches (named-constant
  reference, not string-literal in WriteFileAtomic args). Defense-
  in-depth structural pin against accidental future literal-arg
  drift.

§46.1 discipline applied: production-code-only files, comment-
stripped before pattern matching. Avoids the false-positive class
that hit Policy Gates on PR #511 (//-comment text matching forbidden
substrings).

Local replay against the PR-26-code-C1 + C2 source:
  WRITER_MISS / READER_MISS / FORBIDDEN_HIT / BAD_LITERAL: all 0
  FAIL=0

Verified on lab2 (Ubuntu 24.04, go1.22.2):
- go build ./... clean
- go test ./... PASS (64 packages)
- go test -race -count=1 ./cmd/nftban-installer
  ./internal/installer/restore/... ./internal/installer/state/...
  ./internal/installer/switchop/... PASS
- go vet ./... clean
- go mod tidy no-op

Auditor checkpoint: C1 + C2 + CI gate are now all locally compiled,
tested, and gate-replayed clean. Awaiting focused auditor pass before
push.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(v1.100 PR-26-code-C2): A.4 corrupt-manifest is hard refusal, not soft-skip (auditor verdict)

Auditor focused-audit on PR-26-code-C flagged a semantic risk in
the A.4 corrupt-manifest branch: previously a corrupt /
hash-mismatch / unknown-entry / parse-failure manifest was a
soft-skip with an informational sentinel, and A.5 still ran. The
auditor argued — correctly — that proceeding to start csf.service
when restore evidence is on disk but cannot be trusted weakens the
evidence chain.

Locked rule (per auditor verdict):

  manifest absent      → soft-skip warning, continue to A.5  [migration gap, kept]
  manifest incomplete  → ErrCSFRestoreCronManifestCorrupt, stop before A.5
  hash mismatch        → ErrCSFRestoreCronManifestCorrupt, stop before A.5
  target exists dirty  → ErrCSFRestoreCronTargetExists, stop before A.5
  manifest clean       → restore exact files, then continue to A.5

Behavior delta (this commit only — C1 + C2 + CI gate semantics
remain otherwise unchanged):

- Manifest parse failure / schema mismatch / unknown-entry path
  → A.4 returns wrapped ErrCSFRestoreCronManifestCorrupt; A.5 does
  NOT run; the existing §32 step-3 failure path retains the safety
  net.
- Per-entry sha256 mismatch → same hard refusal.
- Operator-content collision (target /etc/cron.d/<name> already
  exists) → A.4 returns wrapped ErrCSFRestoreCronTargetExists; A.5
  does NOT run.
- Manifest absent (pre-PR-26 host) → unchanged: graceful soft-skip
  with operator warning, control falls through to A.5.
- Manifest clean → unchanged: restore both files, fall through to
  A.5.

Files changed:

cmd/nftban-installer/restore_deps_csf.go
- ErrCSFRestoreCronManifestCorrupt docstring rewritten: now
  documents hard-refusal semantics (was: informational soft-skip).
  Wording updated: "refusing before A.5 (operator must inspect)".
- New typed sentinel ErrCSFRestoreCronTargetExists for the
  operator-content-collision case. Distinct from
  ErrCSFRestoreCronManifestCorrupt for cleaner classification: a
  collision is an evidence conflict, not a manifest-trust failure.
- A.4 step rewritten:
    * manifestErr branch now returns the wrapped sentinel instead
      of falling through.
    * Per-entry sha256 verify failure now returns instead of skip.
    * Per-entry unauthorized-Path now returns instead of skip.
    * Per-entry target-exists collision now returns
      ErrCSFRestoreCronTargetExists instead of skip.
    * Per-entry WriteFileAtomic failure now returns instead of skip.
    * Chown failure remains soft (logged warning, content already
      restored — partial-restore is recoverable; the integrity
      chain is unaffected).

cmd/nftban-installer/restore_deps_csf_test.go
- Renamed + retargeted three tests to assert hard-refusal:
    PR26C2_A4_TargetExists_SkipsRestore
      → PR26C2_A4_TargetExists_HardRefuses_StopsBeforeA5
        + asserts errors.Is(err, ErrCSFRestoreCronTargetExists)
        + asserts NOT mock.CommandCalled("systemctl","start",csf.service)
    PR26C2_A4_SHA256Mismatch_SoftSkip_A5StillRuns
      → PR26C2_A4_SHA256Mismatch_HardRefuses_StopsBeforeA5
        + asserts errors.Is(err, ErrCSFRestoreCronManifestCorrupt)
        + asserts A.5 NOT called
    PR26C2_A4_SchemaMismatch_SoftSkip_A5StillRuns
      → PR26C2_A4_SchemaMismatch_HardRefuses_StopsBeforeA5
        + asserts errors.Is(err, ErrCSFRestoreCronManifestCorrupt)
        + asserts A.5 NOT called
    PR26C2_A4_UnknownEntryPath_Rejected
      → PR26C2_A4_UnknownEntryPath_HardRefuses_StopsBeforeA5
        + asserts errors.Is(err, ErrCSFRestoreCronManifestCorrupt)
        + asserts A.5 NOT called
- 3 new tests pinning the kept-behavior branches:
    PR26C2_A4_HappyPath_ContinuesToA5 — clean restore continues
    PR26C2_A4_ManifestAbsent_ContinuesToA5 — migration soft-skip continues
    PR26C2_A4_ParseFailure_HardRefuses_StopsBeforeA5 — parse failure stops

Push criteria (all met as of this commit):

- manifest absent = migration soft-skip ✓ (test #10 above)
- manifest corrupt/hash mismatch = typed refusal before A.5 ✓ (tests #4, #5, #8, #11)
- target cron path broad writes = impossible ✓ (allow-list + writer scope)
- writer-before-reader invariant = tested ✓ (C1's roundtrip + C2's HappyPath_RestoresBothFiles)
- G4-RESTORE-CRON-MANIFEST-INTEGRITY = PASS (local replay clean)
- go test ./... + race + vet = PASS on lab2

Verified on lab2 (Ubuntu 24.04, go1.22.2):
- go build ./... clean
- go test ./... (full repo) PASS
- go test -race -count=1 cmd + restore + state + switchop PASS
- go vet ./... clean
- 11 TestCSFMutate_PR26C2_* tests all PASS (3 hard-refusal tests
  retargeted; 1 unchanged; 7 unchanged or new)
- existing PR-25 / PR-26-code-A / PR-26-code-B tests all still pass
- G4-RESTORE-CRON-MANIFEST-INTEGRITY local replay: FAIL=0

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* ci(v1.100 PR-26-code-C): drop stale WriteFileAtomic forbid from G4-RESTORE-EXEC-NO-OUT-OF-TARGET

Classification: CI gate stale after authorized A.4 write became real,
not a production-code defect.

The G4-RESTORE-EXEC-NO-OUT-OF-TARGET gate was authored before A.4
became real (PR-25 commit 5 + tightened in PR-26-code-B). At that
time, A.4 was a soft-skip with no legitimate file-write path, so a
broad \bexec\.WriteFileAtomic\( forbid was correct.

PR-26-code-C2 changed that: A.4 now legitimately writes to
/etc/cron.d/csf-cron and /etc/cron.d/lfd-cron (and ONLY those two
paths) when the §42.2 manifest is present and integrity-clean. The
broad forbid is now stale and trips on legitimate code.

Resolution per auditor verdict + operator decision: drop the
\bexec\.WriteFileAtomic\( line from G4-RESTORE-EXEC-NO-OUT-OF-TARGET
forbidden_patterns and rely on the dedicated
G4-RESTORE-CRON-MANIFEST-INTEGRITY gate (added in commit 93e86e2)
to authorize and constrain A.4 writes structurally:

  G4-RESTORE-EXEC-NO-OUT-OF-TARGET   = forbid broad / unrelated
                                       mutation surfaces
  G4-RESTORE-CRON-MANIFEST-INTEGRITY = authorize and constrain the
                                       exact A.4 cron-restore writes
                                       (writer + reader symbol pin,
                                       cron-target literal allow-list,
                                       sha256-helper presence)

Carving line-exceptions into the EXEC gate was rejected — that
recreates the regex-brittleness class flagged at PR #515. Two
gates with separate scopes is cleaner than one gate with carve-outs.

Files changed: only .github/workflows/ci-restore-canonization.yml.
- Removed pattern: '\bexec\.WriteFileAtomic\('
- Added explanatory comment block above the forbidden_patterns
  pointing at G4-RESTORE-CRON-MANIFEST-INTEGRITY for cron-write
  authorization.

Kept (unchanged):
- os.WriteFile / os.Create / os.Remove / os.Rename / exec.Command
  forbids
- ServiceMask / ServiceDisable / DaemonReload forbids
- raw mutating Run("systemctl", verb, …) forbids (9 verbs)
- raw Run("mv", …) forbid
- NftDeleteTable allow-list pin (ip:nftban / ip6:nftban only)
- §46.1 line-skipping discipline
- G4-RESTORE-CRON-MANIFEST-INTEGRITY gate (entirely)

Local replay (exact CI workflow bash, against PR-26-code-C head):

  G4-RESTORE-EXEC-NO-OUT-OF-TARGET   fail=0
  G4-RESTORE-CRON-MANIFEST-INTEGRITY fail=0

No production code touched. Production semantics from C1 + C2 + the
hard-refusal fix (f7be0c4) all unchanged. Pre-PR-26 hosts continue
to soft-skip; A.4 hard-refuses on corrupt evidence; A.5 only runs
when restore evidence is trusted.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant