Conversation
…le (symlink to /dev/null)
Surfaced by Amendment-2-code-E v2 corrected run on srv3 (2026-04-28T21:30:13Z):
the dispatcher reached G1/AuthorityNFTBan/OrphanProceed PROCEED, code-B
csf.disabled relaxation correctly accepted, §32 step 2 safety-net
inserted, A.1 unmask succeeded — then A.2 ServiceEnable failed with
"Unit file csf.service does not exist" because the canonical unit-file
path (/etc/systemd/system/csf.service) was a mask symlink to /dev/null
with NO real backing unit-file anywhere on the host. Result:
RESTORE_FAILED_EXECUTION at stage=mutate, exit=8, partial mutation
(safety-net retained, csf still unmasked).
This is the third (and final, on the §32 path) implementation gap
surfaced by the destructive-cycle audit chain. Like code-B, the fix is
implementation-class — no contract amendment, no decision-lattice
change, no §32 ordering change.
Surgical patch
==============
In productionPreflightDep.PreflightTarget's unit-file OR-list loop:
- After exec.FileExists returns true for a candidate path, route
through exec.Run("readlink", "-f", <path>) to obtain the resolved
canonical path (read-only per §43.3 raw-Run policy for read-only
probes).
- If resolved == "/dev/null", treat the candidate as not-found and
continue the OR-list. Records a maskOnlyObserved flag.
- A real file (resolved == path) or symlink to a real file
(resolved != /dev/null) is accepted.
- If every candidate is absent: preserve existing
ErrPreflightUnitMissing semantic.
- If at least one candidate was a mask-only symlink and no real
backing path resolved: refuse with new typed sentinel
ErrPreflightUnitFileMaskingOnly.
The new sentinel:
ErrPreflightUnitFileMaskingOnly = errors.New(
"restore preflight: unit file is a mask symlink (points to
/dev/null); no real unit file backs the service")
Existing exit code remains 8. Existing terminal remains
RESTORE_FAILED_EXECUTION. Same hardening applies uniformly to ufw /
firewalld / iptables / csf — every firewall in §18.2.
Tests added (10 new test functions)
===================================
AMD2C-1: regular file at /etc/systemd/system/csf.service → PASS
AMD2C-2: real unit at /usr/lib/systemd/system/csf.service → PASS
AMD2C-3: mask symlink only → REFUSE ErrPreflightUnitFileMaskingOnly
AMD2C-4: mask at /etc/ AND real at /usr/lib/ → PASS (OR-list resolves)
AMD2C-5: plain regular file → PASS
AMD2C-6: symlink to real backing file → PASS
AMD2C-7: ufw / firewalld / iptables mask hardening (3 sub-tests)
AMD2C-8: unknown firewallType → REFUSE ErrPreflightUnknownFirewall
AMD2C-9: NoMutationCalls — readlink only, zero mutation primitives
AMD2C-10: no candidate path at all → REFUSE ErrPreflightUnitMissing
(distinguished from MaskingOnly)
4B-1.7 NoMutationCalls test updated: now allows `readlink` Run calls
(read-only per §43.3) while still asserting ZERO mutation primitives.
Test results (lab4 build host)
==============================
- go test ./cmd/nftban-installer/... -run 'Preflight|RestoreDeps' → PASS
- go test ./cmd/nftban-installer/... → PASS
- go test ./internal/installer/restore/... → PASS
- go test ./... → 64 pkgs PASS, 0 FAIL
Files changed
=============
cmd/nftban-installer/restore_deps.go | +61 / -8 (53 line net)
cmd/nftban-installer/restore_deps_test.go | +252 / -4 (10 new tests + 1 update)
No files outside the operator-allowed two.
Invariants preserved
====================
- §23.1 preflight read-only: PRESERVED — readlink is read-only per §43.3.
- INV-PR26-NEW-MUTATION-SURFACES-BOUNDED: PRESERVED — zero new surfaces.
- §32 ordering: UNTOUCHED — A.1/A.2 not modified; mask detection happens at preflight before §32 step 2.
- §22 / §19.4 terminals + exit codes: UNTOUCHED — same exit=8, same RESTORE_FAILED_EXECUTION.
- Amendment 1 §30.2 CSF-only mutation scope: PRESERVED — non-CSF firewalls get the same mask-symlink hardening but still strict no-.disabled-relaxation.
- §19.2 layer 4 / main.go:132 history gate: UNTOUCHED.
- Existing 4B-1 fixture matrix: behavior unchanged on real-file paths.
- Code-B csf.disabled acceptance: PRESERVED — §54 binary relaxation still fires before unit-file check.
No CI workflow edit. No state machine change. No flag change. No
contract change. No execute.go change. No engine.go change. No
main.go change.
No host action.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Contributor
Dependency Review✅ No vulnerabilities or license issues or OpenSSF Scorecard issues found.Scanned FilesNone |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Closes the third (and final, on the §32 path) implementation gap surfaced by the Amendment-2-code-E destructive-cycle audit chain.
Surfaced by: Amendment-2-code-E v2 corrected run on srv3 (2026-04-28T21:30:13Z). The dispatcher reached
G1/AuthorityNFTBan/OrphanProceedPROCEED, code-B csf.disabled relaxation correctly accepted, §32 step 2 safety-net inserted, A.1 unmask succeeded — then A.2 ServiceEnable failed withUnit file csf.service does not existbecause/etc/systemd/system/csf.servicewas a mask symlink to/dev/nullwith no real backing unit-file. Result:RESTORE_FAILED_EXECUTIONat stage=mutate, exit=8, partial mutation (safety-net retained, csf still unmasked).Like code-B, this is implementation-class — no contract amendment, no decision-lattice change, no §32 ordering change.
Surgical patch
In
productionPreflightDep.PreflightTarget's unit-file OR-list loop:exec.FileExistsreturns true for a candidate path, route throughexec.Run("readlink", "-f", <path>)to obtain the resolved canonical path (read-only per §43.3 raw-Run policy).resolved == "/dev/null"→ treat the candidate as not-found, continue the OR-list, recordmaskOnlyObserved.ErrPreflightUnitMissingsemantic.ErrPreflightUnitFileMaskingOnly.Same hardening applies uniformly to ufw / firewalld / iptables / csf — every firewall in §18.2.
Tests added (10 new test functions)
AMD2C-1/etc/systemd/system/csf.serviceAMD2C-2/usr/lib/systemd/system/csf.serviceAMD2C-3ErrPreflightUnitFileMaskingOnlyAMD2C-4/etc/AND real at/usr/lib/AMD2C-5AMD2C-6AMD2C-7AMD2C-8ErrPreflightUnknownFirewallAMD2C-9AMD2C-10ErrPreflightUnitMissing(NOT MaskingOnly)4B-1.7 NoMutationCallsupdated: allowsreadlinkRun calls (read-only per §43.3) while still asserting ZERO mutation primitives.Test results (lab4 build host)
go test ./cmd/nftban-installer/... -run 'Preflight|RestoreDeps'→ PASSgo test ./cmd/nftban-installer/...→ PASSgo test ./internal/installer/restore/...→ PASSgo test ./...→ 64 packages PASS, 0 FAILFiles changed
cmd/nftban-installer/restore_deps.gostringsimport)cmd/nftban-installer/restore_deps_test.goFiles NOT touched
internal/installer/restore/contract.md✅internal/installer/restore/engine.go✅internal/installer/restore/execute.go✅internal/installer/state/machine.go✅cmd/nftban-installer/flags.go✅cmd/nftban-installer/main.go✅.github/workflows/*✅Invariants preserved
INV-PR26-NEW-MUTATION-SURFACES-BOUNDED.main.go:132history gate.Out of scope
Readlink/Lstatexecutor surfaces — using rawRun("readlink",-f,...)per §43.3.After merge
Per auditor disposition: srv2 selected as final destructive-cycle host. Operator stages srv2 through canonical NFTBan switchover (no manual mutation), fresh Tier 1 build on lab4, fresh signoff, fresh GO, one final restore run.
🤖 Generated with Claude Code