Skip to content

Commit 001e07f

Browse files
itcmsgrclaude
andauthored
feat(v1.100 PR-24): authority restoration policy decision engine (#494)
Merged only to implement the PR-24 authority restoration policy engine: pure decision logic, no restore execution, no kernel/service mutation, and no implicit re-entry paths. Auditor APPROVE (no semantic drift, no scope bleed, no purity violation, no hidden permissive path). Three observations documented as non-blocking limitations: closed-enum is enforced by tests not Go's type system; dispatcher purity relies on import-layer checks rather than full symbol grep; real-host evidence is manual by design. Real-host decision evidence (2026-04-20, binary sha256 0cfe8db8...7f5896): - lab4 (Alma/RPM, AuthorityNone + NoRecord): bare → REQUIRE_EXPLICIT_INTENT (G3.3/NoRecord+NoFlag), --restore-prior-authority → REQUIRE_EXPLICIT_INTENT (G3.3/NoRecord+Restore), zero mutation spawns. - lab2 (Ubuntu/DEB, AuthorityNFTBan): bare → REFUSE (G1/AuthorityNFTBan), zero mutation spawns. - Host state unchanged before/after on both. Dangerous branches remain fixture-only per seed §11. Restoration intent resolution is now implemented. Restoration execution remains deferred to PR-25+. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent d2b5b44 commit 001e07f

8 files changed

Lines changed: 1638 additions & 14 deletions

File tree

Lines changed: 267 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,267 @@
1+
# =============================================================================
2+
# NFTBan — CI: Restore Canonization Gate (v1.100 PR-24 slice)
3+
# =============================================================================
4+
# SPDX-License-Identifier: MPL-2.0
5+
# Purpose: Enforce v1.100 PR-24 scope lock on the authority restoration
6+
# policy decision engine. PR-24 is POLICY ONLY — no execution.
7+
#
8+
# G4-RESTORE-DECISION-CORRECTNESS — fixture rule-path coverage
9+
# (every rule declared in engine.go
10+
# must have a fixture, every fixture
11+
# must point to a declared rule)
12+
# G4-RESTORE-REFUSAL-INTEGRITY — non-PROCEED outputs spawn zero
13+
# external processes and reach zero
14+
# execution branches
15+
# G4-RESTORE-NO-IMPLICIT-EXEC — static scan of the restore package
16+
# for kernel / service / filesystem
17+
# mutation symbols
18+
# G4-RESTORE-DETERMINISM — back-to-back fixture runs yield
19+
# identical output, rule, and reason
20+
#
21+
# Contract: internal/installer/restore/contract.md (merged 2026-04-20)
22+
# =============================================================================
23+
24+
name: Restore Canonization Gate
25+
26+
on:
27+
pull_request:
28+
branches: [main, master]
29+
paths:
30+
- 'cmd/nftban-installer/**'
31+
- 'internal/installer/restore/**'
32+
- 'internal/installer/state/**'
33+
- 'internal/installer/uninstall/**'
34+
- '.github/workflows/ci-restore-canonization.yml'
35+
push:
36+
branches: [main]
37+
workflow_dispatch:
38+
39+
concurrency:
40+
group: ci-restore-canonization-${{ github.ref }}
41+
cancel-in-progress: true
42+
43+
permissions:
44+
contents: read
45+
46+
jobs:
47+
restore-canonization:
48+
name: Restore Canonization (${{ matrix.label }})
49+
strategy:
50+
fail-fast: false
51+
matrix:
52+
include:
53+
- label: ubuntu-24.04
54+
runner: ubuntu-24.04
55+
container: ''
56+
- label: almalinux-9
57+
runner: ubuntu-24.04
58+
container: almalinux:9
59+
runs-on: ${{ matrix.runner }}
60+
container: ${{ matrix.container }}
61+
steps:
62+
- name: Checkout
63+
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
64+
65+
- name: Install system dependencies (DEB)
66+
if: matrix.label == 'ubuntu-24.04'
67+
run: |
68+
sudo apt-get update -qq
69+
sudo apt-get install -y jq
70+
71+
- name: Install system dependencies (RPM)
72+
if: matrix.label == 'almalinux-9'
73+
run: |
74+
dnf -y install epel-release
75+
dnf -y install jq git tar gzip procps-ng findutils sudo golang
76+
77+
- name: Set up Go (DEB only)
78+
if: matrix.label == 'ubuntu-24.04'
79+
uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5.6.0
80+
with:
81+
go-version: '1.25'
82+
83+
# ------------------------------------------------------------------
84+
# G4-RESTORE-NO-IMPLICIT-EXEC — static scan of the restore package
85+
# for any symbol that could cause kernel / service / filesystem
86+
# mutation. Per contract seed §10, the restore package must contain
87+
# zero such symbols. Fires BEFORE tests so a reviewer sees the
88+
# scope violation immediately.
89+
# ------------------------------------------------------------------
90+
- name: G4-RESTORE-NO-IMPLICIT-EXEC — static scan of restore package
91+
shell: bash
92+
run: |
93+
set -Eeuo pipefail
94+
95+
# Scope: restore package, non-test Go files only. Test files
96+
# may reference these symbols as fixtures / negative-test
97+
# coverage, which is legitimate.
98+
files=$(find internal/installer/restore -type f -name '*.go' 2>/dev/null | grep -vE '_test\.go$' || true)
99+
if [[ -z "$files" ]]; then
100+
echo "::error::G4-RESTORE-NO-IMPLICIT-EXEC: restore package files not found"
101+
exit 1
102+
fi
103+
104+
# Forbidden symbols per contract seed §10 "Forbidden surfaces"
105+
# and §12 G4-RESTORE-NO-IMPLICIT-EXEC assertion.
106+
forbidden_patterns=(
107+
# Process spawn paths
108+
'exec\.Run\('
109+
'exec\.RunWithStderr\('
110+
'exec\.CommandContext\b'
111+
'"os/exec"'
112+
# Direct mutation commands by name
113+
'exec\.Run\("nft"'
114+
'exec\.Run\("iptables"'
115+
'exec\.Run\("ip6tables"'
116+
'exec\.Run\("systemctl"'
117+
'exec\.Run\("ufw"'
118+
'exec\.Run\("firewall-cmd"'
119+
# Service lifecycle helpers
120+
'exec\.ServiceStop\('
121+
'exec\.ServiceStart\('
122+
'exec\.ServiceRestart\('
123+
'exec\.ServiceEnable\('
124+
'exec\.ServiceDisable\('
125+
'exec\.ServiceMask\('
126+
'exec\.ServiceUnmask\('
127+
# Filesystem write APIs
128+
'exec\.WriteFileAtomic\('
129+
'os\.Create\('
130+
'os\.WriteFile\('
131+
'os\.OpenFile\('
132+
'os\.Rename\('
133+
'os\.Remove\('
134+
'os\.RemoveAll\('
135+
'os\.MkdirAll\('
136+
'os\.Mkdir\('
137+
'ioutil\.WriteFile\('
138+
# Rebuild / restoration execution helpers
139+
'rebuild\.Run\('
140+
'rebuild\.Apply\('
141+
'switchop\.'
142+
'services\.Enable'
143+
'services\.Disable'
144+
'services\.Mask'
145+
'services\.Unmask'
146+
)
147+
148+
fail=0
149+
for pat in "${forbidden_patterns[@]}"; do
150+
if grep -nE "$pat" $files 2>/dev/null; then
151+
echo "::error::G4-RESTORE-NO-IMPLICIT-EXEC: forbidden symbol '$pat' found in restore package"
152+
fail=1
153+
fi
154+
done
155+
156+
if [[ "$fail" -ne 0 ]]; then
157+
echo "::error::PR-24 scope violation — restore package must be pure decision; no kernel / service / filesystem mutation symbols permitted."
158+
echo "::error::See internal/installer/restore/contract.md §10 for the full forbidden list."
159+
exit 1
160+
fi
161+
162+
echo "G4-RESTORE-NO-IMPLICIT-EXEC PASS — restore package is symbolically pure"
163+
164+
# ------------------------------------------------------------------
165+
# G4-RESTORE-DECISION-CORRECTNESS — rule-path coverage.
166+
#
167+
# The restore engine test TestRuleCoverage_EveryRuleExercised
168+
# asserts: every Rule* constant declared in engine.go MUST be hit
169+
# by at least one fixture. A new rule without a fixture, or a
170+
# fixture referencing a non-declared rule, fails this test.
171+
# ------------------------------------------------------------------
172+
- name: G4-RESTORE-DECISION-CORRECTNESS — rule-path coverage
173+
shell: bash
174+
env:
175+
TMPDIR: /var/tmp
176+
run: |
177+
set -Eeuo pipefail
178+
mkdir -p /var/tmp
179+
go test -v -count=1 -run 'TestDecide_FixtureMatrix|TestRuleCoverage_EveryRuleExercised|TestDecide_OutputClosedEnum|TestDecide_LockedAmendment|TestDecide_HardStopsDominateAnyFlag|TestDecide_OrphanPanelAutoRefused' ./internal/installer/restore/...
180+
181+
# ------------------------------------------------------------------
182+
# G4-RESTORE-REFUSAL-INTEGRITY — non-PROCEED outputs reach no
183+
# execution branch.
184+
#
185+
# Implementation: the Decide() function is pure (no executor
186+
# argument). Any execution path would require the dispatcher to
187+
# invoke additional code after the Decide call. This gate asserts
188+
# structurally that the restore package has no executor dependency
189+
# AND that the dispatcher does not call into mutation-capable
190+
# packages. Combined with G4-RESTORE-NO-IMPLICIT-EXEC, this closes
191+
# the refusal-integrity claim.
192+
# ------------------------------------------------------------------
193+
- name: G4-RESTORE-REFUSAL-INTEGRITY — no executor dependency in restore package
194+
shell: bash
195+
run: |
196+
set -Eeuo pipefail
197+
198+
non_test_files=$(find internal/installer/restore -type f -name '*.go' 2>/dev/null | grep -vE '_test\.go$' || true)
199+
if [[ -z "$non_test_files" ]]; then
200+
echo "::error::G4-RESTORE-REFUSAL-INTEGRITY: restore package files not found"
201+
exit 1
202+
fi
203+
204+
# The restore package must not import the executor package in
205+
# non-test code. If it did, the engine could grow a side-effect
206+
# path. Fixtures / tests may reference it for simulation
207+
# purposes, so test files are excluded from this check.
208+
if grep -nE '"github.com/itcmsgr/nftban/internal/installer/executor"' $non_test_files 2>/dev/null; then
209+
echo "::error::G4-RESTORE-REFUSAL-INTEGRITY: restore package imports executor — Decide must remain pure"
210+
exit 1
211+
fi
212+
213+
# The dispatcher (restore_decide.go) uses the executor to
214+
# gather inputs (Classify / Probe / DetectPanel) — that is
215+
# permitted. But after the Decide() call returns a non-PROCEED
216+
# output, the dispatcher must not call any mutation helper.
217+
# This check enforces structural layering: mutation helpers
218+
# are imported only by uninstall_apply.go, never by
219+
# restore_decide.go.
220+
if grep -nE '"github.com/itcmsgr/nftban/internal/installer/switchop"|"github.com/itcmsgr/nftban/internal/installer/services"' cmd/nftban-installer/restore_decide.go 2>/dev/null; then
221+
echo "::error::G4-RESTORE-REFUSAL-INTEGRITY: restore dispatcher imports mutation-capable package"
222+
exit 1
223+
fi
224+
225+
echo "G4-RESTORE-REFUSAL-INTEGRITY PASS — restore engine is pure + dispatcher is layering-clean"
226+
227+
# ------------------------------------------------------------------
228+
# G4-RESTORE-DETERMINISM — same inputs → same outputs across runs.
229+
# TestDecide_Determinism runs every fixture twice via reflect.DeepEqual.
230+
# ------------------------------------------------------------------
231+
- name: G4-RESTORE-DETERMINISM — repeated evaluation equality
232+
shell: bash
233+
env:
234+
TMPDIR: /var/tmp
235+
run: |
236+
set -Eeuo pipefail
237+
mkdir -p /var/tmp
238+
go test -v -count=1 -run 'TestDecide_Determinism' ./internal/installer/restore/...
239+
# Run a second time from scratch and diff nothing — the
240+
# test itself asserts determinism within one run; this
241+
# outer loop asserts determinism ACROSS runs (no cached
242+
# state, no env reliance).
243+
go test -v -count=1 -run 'TestDecide_Determinism' ./internal/installer/restore/... 2>&1 | tee /tmp/run1.log
244+
go test -v -count=1 -run 'TestDecide_Determinism' ./internal/installer/restore/... 2>&1 | tee /tmp/run2.log
245+
# Strip timing lines (ok line carries elapsed seconds) and diff.
246+
grep -vE '^ok\s|^FAIL\s' /tmp/run1.log > /tmp/run1.norm
247+
grep -vE '^ok\s|^FAIL\s' /tmp/run2.log > /tmp/run2.norm
248+
if ! diff -q /tmp/run1.norm /tmp/run2.norm >/dev/null; then
249+
echo "::error::G4-RESTORE-DETERMINISM: test output differs across runs"
250+
diff /tmp/run1.norm /tmp/run2.norm || true
251+
exit 1
252+
fi
253+
echo "G4-RESTORE-DETERMINISM PASS — identical across two independent runs"
254+
255+
restore-canonization-summary:
256+
name: Restore Canonization summary
257+
runs-on: ubuntu-24.04
258+
needs: [restore-canonization]
259+
if: always()
260+
steps:
261+
- name: Summarize
262+
run: |
263+
if [[ "${{ needs.restore-canonization.result }}" != "success" ]]; then
264+
echo "::error::Restore Canonization FAILED"
265+
exit 1
266+
fi
267+
echo "Restore Canonization: all G4 gates passed across matrix"

cmd/nftban-installer/flags.go

Lines changed: 38 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,42 @@ func parseFlags() *config {
148148
}
149149

150150
if !cfg.showVersion && !cfg.repair {
151+
// v1.100 PR-24: --mode=restore — authority restoration policy
152+
// decision engine (pure, no mutation). Validation rules here
153+
// exist to keep the invocation surface tight per seed §2: the
154+
// engine consumes --restore-prior-authority / --panel-auto-
155+
// takeover as decision inputs; other execution-oriented flags
156+
// are rejected because they are meaningless (and risk misleading
157+
// the operator).
158+
if cfg.mode == "restore" {
159+
if cfg.confirmMutation {
160+
fmt.Fprintln(os.Stderr, "error: --confirm-mutation is not valid with --mode=restore")
161+
fmt.Fprintln(os.Stderr, " --mode=restore invokes the PR-24 decision engine; it performs NO mutation.")
162+
os.Exit(state.ExitFatal)
163+
}
164+
if cfg.dryRun {
165+
fmt.Fprintln(os.Stderr, "error: --dry-run is not valid with --mode=restore")
166+
fmt.Fprintln(os.Stderr, " --mode=restore is ALWAYS pure policy decision — no mutation path exists.")
167+
os.Exit(state.ExitFatal)
168+
}
169+
if cfg.takeover {
170+
fmt.Fprintln(os.Stderr, "error: --takeover is not valid with --mode=restore")
171+
os.Exit(state.ExitFatal)
172+
}
173+
if cfg.force {
174+
fmt.Fprintln(os.Stderr, "error: --force is not valid with --mode=restore")
175+
os.Exit(state.ExitFatal)
176+
}
177+
if cfg.rpm || cfg.deb || cfg.source {
178+
fmt.Fprintln(os.Stderr, "error: package-origin flags (--rpm/--deb/--source) are not valid with --mode=restore")
179+
os.Exit(state.ExitFatal)
180+
}
181+
if cfg.purge || cfg.forceDeleteOperatorConfig {
182+
fmt.Fprintln(os.Stderr, "error: uninstall mode flags (--purge/--force-delete-operator-config) are not valid with --mode=restore")
183+
os.Exit(state.ExitFatal)
184+
}
185+
return cfg
186+
}
151187
if cfg.mode == "uninstall" {
152188
// PR-22B: flag combos that are only meaningful for uninstall
153189
// are validated here, because the uninstall block early-returns
@@ -185,8 +221,8 @@ func parseFlags() *config {
185221
return cfg
186222
}
187223
if cfg.mode != "install" && cfg.mode != "upgrade" {
188-
fmt.Fprintf(os.Stderr, "error: --mode must be 'install' or 'upgrade' (got %q)\n", cfg.mode)
189-
fmt.Fprintf(os.Stderr, "usage: nftban-installer --mode=install|upgrade [flags]\n")
224+
fmt.Fprintf(os.Stderr, "error: --mode must be 'install', 'upgrade', 'uninstall', or 'restore' (got %q)\n", cfg.mode)
225+
fmt.Fprintf(os.Stderr, "usage: nftban-installer --mode=install|upgrade|uninstall|restore [flags]\n")
190226
fmt.Fprintf(os.Stderr, " nftban-installer --repair [flags]\n")
191227
os.Exit(state.ExitFatal)
192228
}

cmd/nftban-installer/main.go

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -121,7 +121,15 @@ func main() {
121121
// is an explicit pre-PR-24 (or parallel) follow-up item; until
122122
// that lands, uninstall events are forensically visible only in
123123
// the installer log, and update-history.json stays clean of them.
124-
if !cfg.dryRun && cfg.mode != "uninstall" && state.IsApplyTerminal(sf.State) {
124+
//
125+
// PR-24 extension: --mode=restore is ALSO excluded. The three
126+
// restore states (RESTORE_DECIDED / RESTORE_REFUSED /
127+
// RESTORE_INTENT_REQUIRED) all return IsApplyTerminal=false per
128+
// contract seed §7, so the existing allowlist gate already blocks
129+
// history writes for this mode. The explicit mode check here is
130+
// belt-and-braces defense in case a future edit inadvertently
131+
// marks a restore state apply-terminal.
132+
if !cfg.dryRun && cfg.mode != "uninstall" && cfg.mode != "restore" && state.IsApplyTerminal(sf.State) {
125133
writeHistory(sf, cfg, previousVersion, hostname, log)
126134
}
127135

@@ -171,6 +179,12 @@ func run(ctx context.Context, exec executor.Executor, sf *state.StateFile, cfg *
171179
}
172180
return runUninstallDryRun(ctx, exec, sf, cfg, log)
173181
}
182+
// v1.100 PR-24 restore-policy-engine dispatch. Pure decision only;
183+
// performs NO kernel / service / filesystem mutation. flags.go
184+
// validates that --mode=restore is not combined with mutation flags.
185+
if cfg.mode == "restore" {
186+
return runRestoreDecide(ctx, exec, sf, cfg, log)
187+
}
174188
// v1.99 PR-16 (G3-U1/U2/U3/U4): update-mode dry-run short-circuits to
175189
// preflight + version-detect + plan render. No mutation — all apply
176190
// logic is deferred to PR-18 and reuses the rebuild pipeline per

0 commit comments

Comments
 (0)