Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
5b1ab14
feat(v1.100 PR-25): commit 1 — TargetAuthority types + state terminal…
itcmsgr Apr 27, 2026
7f5f1cb
feat(v1.100 PR-25): commit 2 — planner / decision bridge (§24)
itcmsgr Apr 27, 2026
93be8ca
feat(v1.100 PR-25): commit 3A — panel→firewall static mapping (§20)
itcmsgr Apr 27, 2026
b4e40be
feat(v1.100 PR-25): commit 3B — safety-net + inline-verify primitives…
itcmsgr Apr 27, 2026
5ca4d0a
feat(v1.100 PR-25): commit 3C — Execute orchestration (§23 six-step s…
itcmsgr Apr 27, 2026
074d832
feat(v1.100 PR-25): commit 4 — dispatcher integration with stub deps
itcmsgr Apr 27, 2026
b09cbad
feat(v1.100 PR-25): commit 4B-1 — production preflight dep (real, rea…
itcmsgr Apr 27, 2026
a79d770
feat(v1.100 PR-25): commit 4B-2 — production safety-net dep (real, na…
itcmsgr Apr 27, 2026
5edff25
docs(v1.100 PR-25): contract Amendment 1 — authorize CSF restore muta…
itcmsgr Apr 27, 2026
d80e748
feat(v1.100 PR-25): commit 4B-3-pre — evidence plumbing only (option α)
itcmsgr Apr 27, 2026
6ba88a1
feat(v1.100 PR-25): commit 4B-3-csf — real CSF restore mutation (Amen…
itcmsgr Apr 27, 2026
e0e7cd0
feat(v1.100 PR-25): commit 4B-4 — real inline-verify dep + safety-net…
itcmsgr Apr 27, 2026
d9d2fb7
feat(v1.100 PR-25): commit 5 — CI gate + stale comment cleanup; §28 e…
itcmsgr Apr 28, 2026
790199d
fix(v1.100 PR-25 commit 5): G4 gate regex — call-anchored, drop britt…
itcmsgr Apr 28, 2026
2be7b27
fix(pr25): remove policy-forbidden suppression token from test prose
itcmsgr Apr 28, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
159 changes: 143 additions & 16 deletions .github/workflows/ci-restore-canonization.yml
Original file line number Diff line number Diff line change
@@ -1,24 +1,32 @@
# =============================================================================
# NFTBan — CI: Restore Canonization Gate (v1.100 PR-24 slice)
# NFTBan — CI: Restore Canonization Gate (v1.100 PR-24 + PR-25)
# =============================================================================
# SPDX-License-Identifier: MPL-2.0
# Purpose: Enforce v1.100 PR-24 scope lock on the authority restoration
# policy decision engine. PR-24 is POLICY ONLY — no execution.
# Purpose: Enforce v1.100 PR-24 (decision) + PR-25 (execution + Amendment 1
# CSF restore mutation) scope locks.
#
# G4-RESTORE-DECISION-CORRECTNESS — fixture rule-path coverage
# (every rule declared in engine.go
# must have a fixture, every fixture
# must point to a declared rule)
# G4-RESTORE-REFUSAL-INTEGRITY — non-PROCEED outputs spawn zero
# external processes and reach zero
# execution branches
# G4-RESTORE-NO-IMPLICIT-EXEC — static scan of the restore package
# for kernel / service / filesystem
# mutation symbols
# G4-RESTORE-DETERMINISM — back-to-back fixture runs yield
# identical output, rule, and reason
# G4-RESTORE-DECISION-CORRECTNESS — fixture rule-path coverage
# (every rule declared in engine.go
# must have a fixture, every fixture
# must point to a declared rule)
# G4-RESTORE-REFUSAL-INTEGRITY — non-PROCEED outputs spawn zero
# external processes and reach zero
# execution branches
# G4-RESTORE-NO-IMPLICIT-EXEC — static scan of the restore package
# for kernel / service / filesystem
# mutation symbols
# G4-RESTORE-DETERMINISM — back-to-back fixture runs yield
# identical output, rule, and reason
# G4-RESTORE-EXEC-NO-OUT-OF-TARGET — closed mutation surface in
# cmd/nftban-installer/restore_deps_csf.go;
# only the §31 A.1-A.7 authorized
# operations are permitted
# (Amendment 1 §§30-36)
#
# Contract: internal/installer/restore/contract.md (merged 2026-04-20)
# Contracts:
# - internal/installer/restore/contract.md Part I (PR-24, merged 2026-04-20)
# - internal/installer/restore/contract.md Part II (PR-25, merged 2026-04-27)
# - internal/installer/restore/contract.md Part III (Amendment 1, merged 2026-04-28)
# =============================================================================

name: Restore Canonization Gate
Expand Down Expand Up @@ -252,6 +260,125 @@ jobs:
fi
echo "G4-RESTORE-DETERMINISM PASS — identical across two independent runs"

# ------------------------------------------------------------------
# G4-RESTORE-EXEC-NO-OUT-OF-TARGET — closed mutation surface in
# the cmd/-side restore-execution code. Per Amendment 1 §31 / §32,
# the only authorized mutations on the CSF restore path are:
#
# - Run("systemctl", "unmask", "csf.service") A.1
# - ServiceEnable("csf.service") A.2
# - Run("mv", "/usr/sbin/csf.disabled",
# "/usr/sbin/csf") A.3
# - ServiceStart("csf.service") A.5
# - ServiceStop("nftband.service") A.6
# - NftDeleteTable("ip", "nftban") A.7
# - NftDeleteTable("ip6", "nftban") A.7
#
# The gate's job is forbidden-symbol coverage at the call-expression
# level. Per-call argument enforcement (which unit is started, which
# path is renamed) is delegated to the in-Go runtime tests in
# restore_deps_csf_test.go (TestCSFMutate_4B3csf_A1_NoUnmaskOfOtherServices,
# …_A2_EnableOnlyCSFService, …_A5_StartsOnlyCSFService,
# …_A6_StopsOnlyNftband_AfterCSFStarts,
# …_HappyPath_NoOutOfTargetMutation) which assert against
# MockExecutor with full Go-level type information. Trying to
# reproduce that in shell regex on identifier arguments
# (csfServiceUnit, oldpath/newpath) gives false confidence.
#
# Forbidden patterns are call-expression-anchored (\bexec\.) so
# they catch real call sites and skip prose / error messages /
# comments / const definitions.
# ------------------------------------------------------------------
- name: G4-RESTORE-EXEC-NO-OUT-OF-TARGET — restore_deps_csf.go closed mutation surface
shell: bash
run: |
set -Eeuo pipefail

target=cmd/nftban-installer/restore_deps_csf.go
if [[ ! -f "$target" ]]; then
echo "::error::G4-RESTORE-EXEC-NO-OUT-OF-TARGET: $target not found"
exit 1
fi

# Forbidden: out-of-Amendment-1 mutation surface. Each pattern
# is anchored to a call expression (\bexec\. or similar) so
# legitimate doc strings, error messages, and const
# definitions don't false-match.
forbidden_patterns=(
# Service-policy mutations (mask/disable/unmask-typed/daemon-reload).
# The csf restore path uses Run("systemctl","unmask",csf.service)
# for A.1; the typed exec.ServiceUnmask method is forbidden so
# we don't accidentally land on a future executor surface that
# bypasses the audit trail.
'\bexec\.ServiceMask\('
'\bexec\.ServiceDisable\('
'\bexec\.ServiceUnmask\('
'\bexec\.DaemonReload\('
# Filesystem-mutation primitives — A.4 cron-restore is a
# soft-skip in 4B-3-csf; any WriteFileAtomic in this file is
# therefore an out-of-scope mutation.
'\bexec\.WriteFileAtomic\('
# Direct OS bypass (must route through the executor).
'"os/exec"'
'\bexec\.Command\('
'\bos\.Rename\('
'\bos\.Remove[All]*\('
'\bos\.WriteFile\('
'\bos\.Create\('
'\bsyscall\.'
# DirectAdmin custombuild rewrite forbidden (§34).
'\bcustombuild\b'
'"build set csf"'
# iptables / ip6tables manual rule restoration forbidden
# (§34: csf rebuilds its own iptables on start).
'iptables-restore'
'ip6tables-restore'
# Generic "fix everything" / "purge" / "rebuild" surface
# — Amendment 1 narrow scope forbids broad sweeps.
'\brebuild\.Run\('
'\brebuild\.Apply\('
'\bpurge\.'
'\bcleanup\.Apply\('
)

fail=0
for pat in "${forbidden_patterns[@]}"; do
if grep -nE "$pat" "$target" 2>/dev/null; then
echo "::error::G4-RESTORE-EXEC-NO-OUT-OF-TARGET: forbidden call expression matching '$pat' found in $target"
fail=1
fi
done

# Allow-list pin: every NftDeleteTable call in $target MUST
# delete only ip:nftban or ip6:nftban — never any other table.
# NftDeleteTable's args in the production code ARE literal
# quoted strings (no constants), so this allow-list pin works
# cleanly without identifier resolution. Per-unit / per-path
# enforcement for systemctl + mv is delegated to the Go runtime
# tests against MockExecutor.
while IFS= read -r line; do
args=$(echo "$line" | grep -oE 'NftDeleteTable\([^)]*\)' || true)
if [[ -n "$args" ]]; then
case "$args" in
'NftDeleteTable("ip", "nftban")'|'NftDeleteTable("ip6", "nftban")')
;;
*)
echo "::error::G4-RESTORE-EXEC-NO-OUT-OF-TARGET: unauthorized NftDeleteTable call: $args"
fail=1
;;
esac
fi
done < <(grep -n 'NftDeleteTable(' "$target" || true)

if [[ "$fail" -ne 0 ]]; then
echo "::error::Amendment 1 §30 / §34 violation — restore_deps_csf.go must contain only the closed §31/§32 mutation surface."
echo "::error::See internal/installer/restore/contract.md §§30-36 for the full authorization."
echo "::error::Per-call argument enforcement is in restore_deps_csf_test.go runtime tests; this gate covers forbidden-symbol scope only."
exit 1
fi

echo "G4-RESTORE-EXEC-NO-OUT-OF-TARGET PASS — restore_deps_csf.go mutation surface is closed"

restore-canonization-summary:
name: Restore Canonization summary
runs-on: ubuntu-24.04
Expand Down
6 changes: 6 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -78,3 +78,9 @@ dist/
# Repomix analysis files
repomix*.json
repomix*.xml

# PR-25 §28 evidence — internal lab snapshots (host names + infra
# detail are private). Real evidence lives at:
# /home/commonfolder/LLMAI4NFTBAN/V1.90_AUDIT_WIKI_CODE/PR25_EVIDENCE_4B4/
evidence/
pr25-evidence/
155 changes: 127 additions & 28 deletions cmd/nftban-installer/restore_decide.go
Original file line number Diff line number Diff line change
Expand Up @@ -55,9 +55,34 @@ import (
"github.com/itcmsgr/nftban/internal/installer/uninstall"
)

// runRestoreDecide orchestrates the PR-24 decision engine invocation.
// Returns the process exit code derived from the decision output.
func runRestoreDecide(_ context.Context, exec executor.Executor, sf *state.StateFile, cfg *config, log *logging.Logger) int {
// runRestoreDecide orchestrates the PR-24 decision engine invocation
// AND, on PROCEED, the PR-25 execution engine.
//
// On result.Output == OutputProceed, the dispatcher calls
// restore.PlanFromDecision to resolve TargetAuthority, then
// restore.Execute to run the §23 six-step sequence, then persists
// whatever terminal state Execute returns. The four PR-25 execution
// terminals (StateRestoreExecuted / StateRestoreFailedExecution /
// StateRestoreFailedVerification / StateRestoreDegraded) replace the
// PR-24 transitional StateRestoreDecided on the PROCEED path.
//
// REFUSE and REQUIRE_EXPLICIT_INTENT paths are byte-identical to
// PR-24: the dispatcher transitions to StateRestoreRefused /
// StateRestoreIntentRequired and exits with the same code PR-24 used.
// No Plan / Execute call on these paths.
//
// Production deps live in restore_deps.go (Preflight, SafetyNet,
// Mutation dispatch, InlineVerify) + restore_deps_csf.go (the csf
// branch of MutateToTarget). Amendment 1 (§§30-36) authorizes csf
// only; non-csf §18.2 firewalls land at StateRestoreFailedExecution
// with ErrCSFRestoreOnlyAuthorized; firewalls outside §18.2 land
// with ErrRestoreMutationUnknownFirewall. The dispatcher persists
// each terminal truthfully and main.go's writeHistory gate
// (mode != "restore", at main.go:132) keeps every restore-mode
// outcome out of update-history.json.
//
// Returns the process exit code derived from the persisted state.
func runRestoreDecide(ctx context.Context, exec executor.Executor, sf *state.StateFile, cfg *config, log *logging.Logger) int {
log.Info("restore decide starting (mode=restore)")

// 1. Classify authority.
Expand Down Expand Up @@ -119,22 +144,33 @@ func runRestoreDecide(_ context.Context, exec executor.Executor, sf *state.State
input.Authority, input.Ambiguity, input.Prior,
input.Flags.Restore, input.Flags.PanelAutoTakeover, input.PanelPresent)

// 9. Transition to terminal state + return exit code.
newState, phase := restoreStateForOutput(result.Output)
_ = sf.Transition(newState, phase, result.Reason)

// Operator-facing output (stdout).
// 9. Branch on output.
switch result.Output {
case restore.OutputProceed:
log.Result("[NFTBan] restore decision: PROCEED — %s", result.Reason)
log.Result("[NFTBan] note: PR-24 is the decision engine only; execution of restoration is deferred to a later PR.")

case restore.OutputRefuse:
// PR-24 byte-identical: terminal refusal, exit ExitRefused.
_ = sf.Transition(state.StateRestoreRefused, state.PhaseDetect, result.Reason)
log.Result("[NFTBan] restore decision: REFUSE — %s", result.Reason)
return sf.State.ExitCode()

case restore.OutputRequireExplicitIntent:
// PR-24 byte-identical: terminal intent-required, exit
// ExitIntentRequired.
_ = sf.Transition(state.StateRestoreIntentRequired, state.PhaseDetect, result.Reason)
log.Result("[NFTBan] restore decision: REQUIRE_EXPLICIT_INTENT — %s", result.Reason)
return sf.State.ExitCode()

case restore.OutputProceed:
// PR-25 commit 4: PROCEED flows to execution. Extracted for
// unit testability — see runRestoreExecutionFromProceed.
log.Result("[NFTBan] restore decision: PROCEED — %s", result.Reason)
return runRestoreExecutionFromProceed(ctx, exec, sf, log, result, input, probe.Record, panel)
}

return sf.State.ExitCode()
// Unreachable: restore.Decide returns one of the three closed-enum
// values. A fourth value here means a contract regression caught
// by the G4-RESTORE-DECISION-CORRECTNESS CI gate.
panic("restore dispatcher: unknown Output value — contract regression")
}

// reducePriorState maps uninstall.ProbeResult onto the normalized
Expand Down Expand Up @@ -185,21 +221,84 @@ func isStale(rec *uninstall.PriorRecord) bool {
return age > time.Duration(restore.StalenessWindowDays)*24*time.Hour
}

// restoreStateForOutput maps the decision output to the state-machine
// terminal (or non-terminal handoff) state. Phase is always
// PhaseDetect for this dispatcher — the engine's job IS detection +
// decision; there is no later phase to attribute to.
func restoreStateForOutput(out restore.Output) (state.InstallState, state.Phase) {
switch out {
case restore.OutputProceed:
return state.StateRestoreDecided, state.PhaseDetect
case restore.OutputRefuse:
return state.StateRestoreRefused, state.PhaseDetect
case restore.OutputRequireExplicitIntent:
return state.StateRestoreIntentRequired, state.PhaseDetect
// (PR-25 commit 4 removed the previous restoreStateForOutput helper.
// PROCEED no longer maps to a single transitional state; it flows
// through PlanFromDecision + Execute to produce one of four PR-25
// execution terminals. REFUSE and REQUIRE_EXPLICIT_INTENT now
// transition inline within runRestoreDecide above; the helper is
// no longer needed and was unused after the refactor.)

// runRestoreExecutionFromProceed orchestrates the PR-25 §23 path on
// a PROCEED decision. Extracted from runRestoreDecide so unit tests
// can exercise the planner + executor + persist flow with controlled
// inputs (without faking the upstream classify/probe/detect
// executor calls).
//
// The function:
//
// 1. Resolves TargetAuthority via restore.PlanFromDecision.
// Planner refusal → StateRestoreFailedExecution; no Execute.
// 2. Constructs ExecuteDeps via the package-level newRestoreDeps
// factory (production path = stubs in commit 4; tests swap).
// 3. Calls restore.Execute and persists whatever terminal state
// it returns.
// 4. Returns the persisted state's exit code.
//
// INV-PR25-AUTHORITY-IMMUTABILITY (§17.3): the planner result is the
// authoritative TargetAuthority. Execute does not re-derive it; the
// dispatcher does not consult anything besides what the planner
// returned + what the deps observe at verification time.
func runRestoreExecutionFromProceed(
ctx context.Context,
exec executor.Executor,
sf *state.StateFile,
log *logging.Logger,
result restore.DecisionResult,
input restore.DecisionInput,
priorRec *uninstall.PriorRecord,
panel detect.PanelType,
) int {
// Step A — Plan: resolve TargetAuthority once.
target, planErr := restore.PlanFromDecision(result, input, priorRec, panel)
if planErr != nil {
log.Error("restore execute: planner refused PROCEED: %v", planErr)
_ = sf.Transition(state.StateRestoreFailedExecution, state.PhaseDetect, planErr.Error())
log.Result("[NFTBan] restore execution: FAILED at planner — %s", planErr.Error())
return sf.State.ExitCode()
}
// Unreachable — Decide's return type is a closed enum guarded by
// TestDecide_OutputClosedEnum. A fourth value here means a
// contract regression.
panic("restore dispatcher: unknown Output value — contract regression")
log.Info("restore execute: target resolved kind=%s firewallType=%s panel=%s",
target.Kind(), target.FirewallType(), target.Panel())

// Step B — Construct deps. 4B-3-pre extended the factory to
// carry priorRec + panel forward to the mutation dep. Both
// values come from the PR-24 path the planner already used —
// the dispatcher does NOT re-probe or re-detect them, per
// INV-PR25-AUTHORITY-IMMUTABILITY (§17.3) + §33 E.7.
deps := newRestoreDeps(exec, log, priorRec, panel)

// Step C — Execute the §23 six-step sequence.
execRes := restore.Execute(ctx, target, deps)
log.Info("restore execute: terminal=%s stage=%s err=%v",
execRes.Terminal, execRes.Stage, execRes.Err)

reason := result.Reason
if execRes.Err != nil {
reason = execRes.Err.Error()
}
_ = sf.Transition(execRes.Terminal, state.PhaseDetect, reason)

// Step D — Operator-facing output reflects the executed terminal.
switch execRes.Terminal {
case state.StateRestoreExecuted:
log.Result("[NFTBan] restore execution: COMPLETED — authorized restore is in effect")
case state.StateRestoreDegraded:
log.Result("[NFTBan] restore execution: COMPLETED with warnings — review inline-verify result")
case state.StateRestoreFailedExecution:
log.Result("[NFTBan] restore execution: FAILED at %s — %s", execRes.Stage, reason)
case state.StateRestoreFailedVerification:
log.Result("[NFTBan] restore execution: FAILED VERIFICATION at %s — safety net retained — %s",
execRes.Stage, reason)
}

return sf.State.ExitCode()
}
Loading
Loading