Skip to content

feat(v1.100 PR-26): code-B typed executor.ServiceUnmask + Rename + raw-Run policy tightening#515

Merged
itcmsgr merged 1 commit intomainfrom
feat/v1.100-pr26-code-b-executor-hardening
Apr 28, 2026
Merged

feat(v1.100 PR-26): code-B typed executor.ServiceUnmask + Rename + raw-Run policy tightening#515
itcmsgr merged 1 commit intomainfrom
feat/v1.100-pr26-code-b-executor-hardening

Conversation

@itcmsgr
Copy link
Copy Markdown
Owner

@itcmsgr itcmsgr commented Apr 28, 2026

PR-26-code-B: executor hardening only

IN

  • Typed executor.ServiceUnmask(unit string) error — replaces the raw Run("systemctl","unmask",…) indirection in CSF restore A.1.
  • Typed executor.Rename(oldpath, newpath string) error — replaces the raw Run("mv",…) indirection in CSF restore A.3. RealExecutor.Rename calls os.Rename directly (consistent with WriteFileAtomic's existing os.Rename usage); no process spawn.
  • Migrate CSF restore A.1 / A.3 off raw Run indirections in cmd/nftban-installer/restore_deps_csf.go. Helpers unmaskCSFService and renameAtomicViaExec removed; call sites use the typed methods directly.
  • Strengthen G4-RESTORE-EXEC-NO-OUT-OF-TARGET raw-Run policy in .github/workflows/ci-restore-canonization.yml:
    • Drop the now-stale \bexec\.ServiceUnmask\( forbid (typed method is the new authorized surface).
    • Forbid Run("systemctl",mutating-verb,…) for all 9 mutating verbs (start / stop / enable / disable / mask / unmask / restart / reload / daemon-reload). Read-only Run("systemctl","is-enabled",…) and is-active remain authorized.
    • Forbid raw Run("mv",…). Typed Rename is the only authorized atomic-rename path.
    • Apply §46.1 line-skipping discipline: gate strips line-leading // comments before pattern matching to avoid the false-positive class that broke Policy Gates on PR v1.100 PR-25: restore execution + Amendment 1 CSF restore #511.

OUT (intentionally not touched)

  • Cron backup / A.4 — that is PR-26-code-C.
  • Destructive real-host soak — that is PR-26-code-E.
  • IptablesRuleExists / iptables introspection — Option B lock per §51.3.
  • main.go / history gate / state machine / exit codes — INV-PR25-HISTORY-GATE retained.
  • Restore planner / TargetAuthority / PR-24 lattice.
  • Repo hygiene / UX / GOTH / metrics / module cleanup.

Authority

Behavior delta

Path Before After
A.1 (csf service unmask) raw Run("systemctl","unmask",csf.service) via helper m.exec.ServiceUnmask(csfServiceUnit) (typed)
A.3 (csf binary restore) raw Run("mv",old,new) via helper m.exec.Rename(csfBinaryDisabled, csfBinary) (typed; os.Rename underneath in RealExecutor)
Mock-recorded shape for A.3 {Name="mv", Args=[old,new]} {Name="rename", Args=[old,new]}
Error contracts ErrCSFRestoreUnmaskFailed, ErrCSFRestoreBinaryRestoreFailed preserved
A.7 release gate / inline-verify / target-specific predicate (code-A) live unchanged

Files changed (6)

  • internal/installer/executor/executor.go — interface adds ServiceUnmask + Rename.
  • internal/installer/executor/real.goRealExecutor implements both.
  • internal/installer/executor/mock.goMockExecutor implements both with ServiceUnmaskErr + RenameErr injection fields.
  • cmd/nftban-installer/restore_deps_csf.go — migrate A.1 + A.3; remove helpers.
  • cmd/nftban-installer/restore_deps_csf_test.go — fixture updated; +7 new PR26B tests; existing tests migrated to "rename" command shape.
  • .github/workflows/ci-restore-canonization.yml — strengthened gate per §46.1 + §43.4.

Self-audit + auditor

Host OS / Go go build go test ./... go test -race go vet gate replay
lab2 Ubuntu 24.04 / go1.22.2 PASS 64/64 PASS cmd + restore + state PASS clean FAIL=0 against real restore_deps_csf.go

Synthetic positive probe (24 forbidden examples across all 9 mutating-systemctl verbs + raw mv + os.Rename / Remove / WriteFile / Create / syscall + exec.ServiceMask / ServiceDisable / DaemonReload / WriteFileAtomic / exec.Command + custombuild / "build set csf" / iptables-restore / ip6tables-restore + rebuild.Run / rebuild.Apply / purge / cleanup.Apply): PROBE_HITS=25 — gate catches every category.

Synthetic negative probe (12 authorized examples: Run("systemctl","is-enabled",…), Run("systemctl","is-active",…), typed ServiceUnmask / Rename / ServiceEnable / ServiceStart / ServiceStop / NftDeleteTable("ip","nftban") / ("ip6","nftban") / FileExists / CommandExists / ServiceActive): OK_PROBE_HITS=0 — zero false positives.

7 new TestCSFMutate_PR26B_* tests all PASS:

  • A1_ServiceUnmaskOnlyCSFService
  • A3_RenameOnlyCSFBinaryRestore
  • NoRawSystemctlUnmaskRun_FileScan
  • NoRawMvRun_FileScan
  • A1_UnmaskFailure_TypedErrorPreserved
  • A3_RenameFailure_TypedErrorPreserved
  • RemovedHelpersGone_FileScan

Test plan

  • Restore Canonization Gate matrix (ubuntu-24.04 + almalinux-9) green
  • G4-RESTORE-EXEC-NO-OUT-OF-TARGET green against the migrated restore_deps_csf.go
  • G4-RESTORE-NO-IMPLICIT-EXEC green (no executor import in internal/installer/restore/)
  • Architecture Policy / Policy Gates green (no //nolint: regression)
  • Go Build & Test, go test -race, full Build NFTBan Packages matrix (4 DEB + 2 RPM), CodeQL, Semgrep, Secure Go (gosec / govulncheck), OSV, Gitleaks, GitGuardian all green
  • ShellCheck, Bash Validation, Docs Quality green

Merge only if CI returns clean.

🤖 Generated with Claude Code

…name + raw-Run policy tightening

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>
@github-actions
Copy link
Copy Markdown
Contributor

Dependency Review

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

Scanned Files

None

@itcmsgr itcmsgr merged commit 45fc63e into main Apr 28, 2026
63 checks passed
@itcmsgr itcmsgr deleted the feat/v1.100-pr26-code-b-executor-hardening branch April 28, 2026 10:32
itcmsgr added a commit that referenced this pull request Apr 28, 2026
…STORE-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>
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>
itcmsgr added a commit that referenced this pull request Apr 28, 2026
…6 lock) (#517)

* feat(v1.100 PR-26-code-D): post-restore evidence record (§39.3 / §48.6 lock)

PR-26-code-D — restore verification / evidence hardening, slice D.
Adds the structured post-restore evidence-record writer per §39.3
+ §48.6 operator lock. Recording-only — does NOT re-run PR-24
decisions, rebuild TargetAuthority, or add validator/module-health
probes (operator design call).

Authority:
- PR #512 / contract.md Part IV §§37-50
- PR #513 / §51 lock record
- PR #514 / code-A merge 4e98ff5
- PR #515 / code-B merge 45fc63e
- PR #516 / code-C merge 6d8386d
- §39 Q1 BLOCKING evidence rows
- §39.3 evidence-record file requirement
- §46 CI gate requirements
- §48.6 (operator-locked at this commit's open):
    - path:    /var/lib/nftban/state/restore-evidence/
    - filename: restore-evidence-<UTC-RFC3339-basic>-<short-random>.json
    - schema:   1.0.0
    - writer helper: writeRestoreEvidenceRecord(ctx, exec, record)
    - path constant: restoreEvidenceDir
- §51.5-A2 (read-only typed introspection outside mutation cap)

Files added (2):

cmd/nftban-installer/restore_evidence.go
- Constants:
    restoreEvidenceSchemaVersion  = "1.0.0"
    restoreEvidenceDir            = "/var/lib/nftban/state/restore-evidence"
    restoreEvidenceFilenamePrefix = "restore-evidence-"
    restoreEvidenceMode           = 0o640
    restoreEvidenceDirMode        = 0o750
- Schema types: RestoreEvidenceRecord (schema_version, timestamp_utc,
  mode, phase, target, result, verification, history_gate, warnings) +
  the 4 nested structs.
- Sentinels: ErrEvidenceWriteFailed, ErrEvidenceNilExecutor,
  ErrEvidenceNilRecord.
- writeRestoreEvidenceRecord — the SINGLE helper. MkdirAll, marshal,
  WriteFileAtomic. Filename: prefix + UTC RFC3339-basic stamp +
  "-" + 8-hex random suffix + ".json".
- buildRestoreEvidenceRecord — recording-only assembler. Sources:
  target.Kind/FirewallType/Panel, execRes.Terminal/Stage/VerifyResult,
  exec.NftTableExists for emergency + nftban tables, detect.SSHPortWithSource.
  No re-derivation; no Probe / Decide / DetectPanel calls.
- evidenceShortRandom — crypto/rand-backed 8-hex suffix to avoid
  same-second filename collisions.

cmd/nftban-installer/restore_evidence_test.go
- 10 tests:
    1. WriteRestoreEvidence_HappyPath — filename pattern + single write
    2. WriteRestoreEvidence_RoundTripsJSON — schema_version + mode +
       phase + history_gate flags
    3. WriteRestoreEvidence_NilExecutor — defensive guard
    4. WriteRestoreEvidence_NilRecord — defensive guard
    5. WriteRestoreEvidence_OnlyHelperWritesUnderEvidenceDir_FileScan —
       single-WriteFileAtomic invariant
    6. WriteRestoreEvidence_NoForbiddenSurfaces_FileScan —
       recording-only invariant pin
    7. BuildRestoreEvidenceRecord_RecordedPriorHappy — full happy
       path with ss-listener SSH port resolution
    8. BuildRestoreEvidenceRecord_NftbanTablesPresent_Recorded —
       post-mutation kernel observation
    9. BuildRestoreEvidenceRecord_AuthorityClassDivergenceWarning —
       ObservedAuthority diverging from AuthorityExternal surfaces
       in warnings
    10. RestoreEvidenceConstants_LockPin — §48.6 path/version/prefix
        pinned exactly

Files modified (4):

internal/installer/detect/ssh.go
- Added detect.SSHPortWithSource (read-only). Same 4-source priority
  chain as detect.SSHPort but also returns the source name (ss /
  sshd_config / state / config) — required by the §48.6 schema's
  ssh_port_source enum. Per §51.5-A2 outside the mutation cap.

cmd/nftban-installer/restore_decide.go
- runRestoreExecutionFromProceed gains a Step D (between Execute
  and Transition):
    1. buildRestoreEvidenceRecord(target, execRes)
    2. writeRestoreEvidenceRecord(ctx, exec, rec, log)
- §48.6 downgrade rule: if evidence-write fails AFTER a successful
  StateRestoreExecuted, downgrade to StateRestoreDegraded
  (state.machine.go:152 already supports this terminal). The state
  model supports the downgrade; no contract amendment needed.
- Operator-facing log line on Degraded now includes the evidence-
  write failure reason.
- No state-machine / exit-code / history-gate change. main.go:132
  mode-gate untouched.

cmd/nftban-installer/restore_decide_test.go
- TestRunRestoreExecutionFromProceed_FakeDeps_HappyPath_PersistsExecuted
  + 4 other dispatcher tests updated: pass executor.NewMockExecutor()
  instead of nil so the new evidence-write step succeeds and the
  terminal stays at StateRestoreExecuted (fake happy path). The 3
  tests that pass nil exec via _ = runRestoreExecutionFromProceed
  do not assert on sf.State so they still pass under the downgrade.

.github/workflows/ci-restore-canonization.yml
- New gate G4-RESTORE-EVIDENCE-RECORD (§46). Structural — pins the
  named-constant + single-helper invariant:
    * restore_evidence.go declares restoreEvidenceDir,
      restoreEvidenceSchemaVersion, restoreEvidenceFilenamePrefix
      verbatim + locked values
    * restore_evidence.go declares writeRestoreEvidenceRecord +
      buildRestoreEvidenceRecord + RestoreEvidenceRecord struct
    * exactly ONE WriteFileAtomic call in restore_evidence.go
      (the single-helper invariant — locked by §48.6)
    * forbidden-symbol scan: restore.Decide /
      restore.PlanFromDecision / uninstall.Probe / detect.DetectPanel
      / writeHistory / update-history.json / mutation primitives /
      direct OS bypass (recording-only invariant)
    * dispatcher (restore_decide.go) calls BOTH
      writeRestoreEvidenceRecord AND buildRestoreEvidenceRecord
      (proves evidence is consumed, not just imported)
- §46.1 line-skipping discipline applied (production-code-only,
  comment-stripped).

Recording-only invariant (operator design call) honored:
- No restore.Decide / restore.PlanFromDecision calls
- No uninstall.Probe call
- No detect.DetectPanel call (only detect.SSHPortWithSource —
  read-only typed introspection)
- No validator full-sweep / module-health probe
- No update-history.json write (§19.2 layer 4 / main.go:132 retained)
- No new mutation primitive

Constraints honored (per operator scope):

IN:
- evidence record type + schema ✓ (§48.6 lock)
- evidence writer helper ✓ (single helper writeRestoreEvidenceRecord)
- production write after restore execution path ✓ (dispatcher Step D)
- structural CI gate G4-RESTORE-EVIDENCE-RECORD ✓
- tests proving all writes stay under restoreEvidenceDir ✓
- tests proving update-history is untouched ✓ (HistoryGate flags +
  no writeHistory references in evidence module)

OUT:
- destructive soak (PR-26-code-E)
- A.4 cron changes (already shipped in code-C)
- executor new mutation methods (Stat is read-only, shipped in code-C)
- iptables introspection (Option B lock)
- main.go history gate changes (untouched)
- state/exit-code changes — only the existing StateRestoreDegraded
  is consumed, no new state added
- repo hygiene / UX / GOTH / metrics / module cleanup

Verified on lab2 (Ubuntu 24.04, go1.22.2):
- go build ./... clean
- go test ./... PASS (full repo, 64 packages)
- go test -race -count=1 cmd + restore + state + switchop + detect PASS
- go vet ./... clean
- go mod tidy no-op
- 10 new TestWriteRestoreEvidence_* / TestBuildRestoreEvidenceRecord_* /
  TestRestoreEvidenceConstants_LockPin tests all PASS
- existing 5 dispatcher fake-deps tests updated + still PASS
- All 3 G4 gates (NO-OUT-OF-TARGET / CRON-MANIFEST-INTEGRITY /
  EVIDENCE-RECORD) local replay: FAIL=0

Awaiting auditor pass before push.

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

* test(v1.100 PR-26-code-D): add 5 dispatcher-level evidence-failure semantics tests (auditor checkpoint)

Auditor focused-audit on 849b372 flagged that PR-26-code-D's Step D
introduces a real operator-visible terminal transition:

  StateRestoreExecuted + evidence write failure → StateRestoreDegraded

The 10 unit tests already covered the writer + builder + recording
invariants but did NOT pin the dispatcher-level downgrade semantics.
This commit adds 5 dispatcher-level tests to close that gap.

Tests added:

cmd/nftban-installer/restore_decide_test.go

  1. PR26D_ExecutedPlusEvidenceFail_DowngradesToDegraded
     fake deps return StateRestoreExecuted; writeFailExec wrapper
     forces evidence WriteFileAtomic to fail. Asserts:
     - sf.State == StateRestoreDegraded (downgrade fires)
     - exit code == StateRestoreDegraded.ExitCode()
     - sf.State != StateRestoreExecuted (no false claim)
     Note: sf.FailureReason stays empty by design (Transition only
     populates FailureReason on .IsFailed() states; Degraded is
     success-with-warnings). The downgrade reason surfaces via
     log.Result, which is the authoritative operator channel for
     Degraded outcomes.

  2. PR26D_FailedExecutionPlusEvidenceFail_TerminalPreserved
     fake.mutateErr forces FailedExecution; writeFailExec forces
     evidence-write failure. Asserts:
     - sf.State == StateRestoreFailedExecution (terminal preserved)
     - exit == StateRestoreFailedExecution.ExitCode()
     Evidence failure is warning-only on non-Executed terminals.

  3. PR26D_FailedVerificationPlusEvidenceFail_TerminalPreserved
     fake.activeRet=false forces inline-verify SafeToRemove=false →
     FailedVerification; writeFailExec forces evidence-write fail.
     Asserts terminal + exit code unchanged from FailedVerification.

  4. PR26D_ExecutedPlusEvidenceOk_PreservesExecuted
     Plain MockExecutor (writes succeed). Asserts:
     - sf.State == StateRestoreExecuted (no downgrade on clean write)
     - exit == StateRestoreExecuted.ExitCode()
     - exactly one file written under restoreEvidenceDir
     - no writes outside restoreEvidenceDir

  5. PR26D_NoUpdateHistoryWrite_FileScan
     File-scan against restore_decide.go. Strips line-leading // per
     §46.1; asserts no production-code reference to writeHistory(
     or update-history.json. Pins the §19.2 layer-4 invariant stays
     untouched after PR-26-code-D adds Step D.

writeFailExec wrapper (test-only):
  Wraps *executor.MockExecutor and overrides only WriteFileAtomic
  to fail. Avoids changing the production MockExecutor; uses the
  same composition pattern as flakyCSFActiveExec (introduced in
  PR-25 4B-3-csf for analogous test purposes).

Verified on lab2 (Ubuntu 24.04, go1.22.2):
- go build ./... clean
- go test ./cmd/nftban-installer/... PASS
- 5 new TestRunRestoreExecutionFromProceed_PR26D_* /
  TestDispatcher_PR26D_* tests all PASS
- go test -race -count=1 cmd + restore + state PASS
- existing PR-25 + PR-26-code-A/B/C tests still PASS

No production code change. No CI workflow change. No contract
amendment needed. Restore semantics from §48.6 lock + §19.2 layer-4
invariant are both now structurally pinned by tests.

Awaiting auditor sign-off + push signal.

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