Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
34 changes: 34 additions & 0 deletions .github/workflows/native-tests.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
# CI — host-native unit tests for the pure, Arduino-free gate logic.
#
# These compile + RUN on a normal host toolchain (no ESP32 cross-compile, no board), so the
# safety-critical DECISION logic is verified on every push — independent of the WIP firmware
# link stage (see build.yml / docs/CI-STATUS.md). Today this locks in the W1 wipe-misfire fix:
# ONLY a clean `sm_wipe` may ever reach the authenticated wipe flow.
name: native-tests

on:
push:
branches: [ master, main ]
pull_request:
workflow_dispatch:

permissions:
contents: read

jobs:
sm-command:
name: sm-command classifier (host g++)
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4

- name: Compile + run the sm-command safety test
shell: bash
run: |
set -euo pipefail
g++ -std=c++17 -Wall -Wextra -Werror \
-I firmware/bootgate \
firmware/test_harness/native/test_sm_command.cpp -o /tmp/test_sm_command
/tmp/test_sm_command
echo "sm-command safety invariant holds: only a clean sm_wipe -> WipeRequest"
111 changes: 58 additions & 53 deletions firmware/bootgate/GateInput_serial.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
#ifdef GATE_INPUT_SERIAL

#include "GateInput.h"
#include "SmCommand.h"
#include <Arduino.h>
#include <string.h>

Expand Down Expand Up @@ -237,50 +238,46 @@ void sendInfo(const GateConfig& cfg) {
Serial.println(F("\"}"));
}

// Process a dashboard command line. Returns true if the line was a recognized SM_ command
// (handled here); false if it should be passed to the normal password extraction flow.
bool processCommand(const char* trimmed, const GateConfig& cfg) {
if (startsWithCmd(trimmed, "sm_status", 9) &&
(trimmed[9] == '\0' || trimmed[9] == ' ')) {
sendStatus(cfg);
return true;
// Process a dashboard command line. Classification is delegated to the pure classifySmCommand()
// (SmCommand.h) so the decision is unit-testable and cannot be conflated with the Serial I/O; this
// function performs the side effects and returns a TRI-STATE (SmDispatch) so the caller can tell a
// deliberate wipe from an unrecognized line. SPEC §6.1: only a clean `sm_wipe` is a WipeRequest; an
// unrecognized `sm_` line is Unknown (the caller rejects + re-prompts, never wipes, never an attempt).
SmDispatch processCommand(const char* trimmed, const GateConfig& cfg) {
switch (classifySmCommand(trimmed)) {
case SmCommand::Status:
sendStatus(cfg);
return SmDispatch::Handled;
case SmCommand::Info:
sendInfo(cfg);
return SmDispatch::Handled;
case SmCommand::Arm:
// ARM requires re-provisioning from the host (the armed flag is in the guardcfg NVS image).
// We cannot modify it at runtime without the host provisioner. Report this.
Serial.println(F("SM>{\"cmd\":\"ARM\",\"error\":\"arming requires re-provisioning from host "
"(provision.py --armed 1). Cannot modify guardcfg NVS at runtime.\"}"));
return SmDispatch::Handled;
case SmCommand::Disarm:
// DISARM also requires re-provisioning. The armed flag is baked into the NVS image.
Serial.println(F("SM>{\"cmd\":\"DISARM\",\"error\":\"disarming requires re-provisioning from host "
"(provision.py --armed 0). Cannot modify guardcfg NVS at runtime.\"}"));
return SmDispatch::Handled;
case SmCommand::SetPassword:
// Password change requires re-provisioning (new salt + hash).
Serial.println(F("SM>{\"cmd\":\"SET_PASSWORD\",\"error\":\"password change requires re-provisioning "
"from host (provision.py). Cannot modify guardcfg NVS at runtime.\"}"));
return SmDispatch::Handled;
case SmCommand::Wipe:
// The deliberate SM_WIPE maps to the existing authenticated `wipe` flow. We announce the
// redirect and hand off; the caller prompts for the password and only a correct one wipes.
Serial.println(F("SM>{\"cmd\":\"WIPE\",\"status\":\"redirecting to authenticated wipe flow\"}"));
return SmDispatch::WipeRequest;
case SmCommand::Unknown:
default:
// Typo / unknown-or-future command / mangled keyword. The caller rejects it — NEVER the wipe
// path, NEVER counted as a password attempt (SPEC §6.1).
return SmDispatch::Unknown;
}
if (startsWithCmd(trimmed, "sm_info", 7) &&
(trimmed[7] == '\0' || trimmed[7] == ' ')) {
sendInfo(cfg);
return true;
}
if (startsWithCmd(trimmed, "sm_arm", 6) &&
(trimmed[6] == '\0' || trimmed[6] == ' ')) {
// ARM requires re-provisioning from the host (the armed flag is in the guardcfg NVS image).
// We cannot modify it at runtime without the host provisioner. Report this.
Serial.println(F("SM>{\"cmd\":\"ARM\",\"error\":\"arming requires re-provisioning from host "
"(provision.py --armed 1). Cannot modify guardcfg NVS at runtime.\"}"));
return true;
}
if (startsWithCmd(trimmed, "sm_disarm", 9) &&
(trimmed[9] == '\0' || trimmed[9] == ' ')) {
// DISARM also requires re-provisioning. The armed flag is baked into the NVS image.
Serial.println(F("SM>{\"cmd\":\"DISARM\",\"error\":\"disarming requires re-provisioning from host "
"(provision.py --armed 0). Cannot modify guardcfg NVS at runtime.\"}"));
return true;
}
if (startsWithCmd(trimmed, "sm_set_password", 15) &&
(trimmed[15] == '\0' || trimmed[15] == ' ')) {
// Password change requires re-provisioning (new salt + hash).
Serial.println(F("SM>{\"cmd\":\"SET_PASSWORD\",\"error\":\"password change requires re-provisioning "
"from host (provision.py). Cannot modify guardcfg NVS at runtime.\"}"));
return true;
}
if (startsWithCmd(trimmed, "sm_wipe", 7) &&
(trimmed[7] == '\0' || trimmed[7] == ' ')) {
// SM_WIPE is mapped to the existing `wipe` command flow — password-authenticated.
Serial.println(F("SM>{\"cmd\":\"WIPE\",\"status\":\"redirecting to authenticated wipe flow\"}"));
// Fall through to the normal wipe handler by returning false and letting the caller
// see "wipe" as the command. We rewrite the intent so the standard wipe path handles it.
return false; // caller will re-process as "wipe"
}
return false; // not a dashboard command
}

} // namespace dashboard
Expand All @@ -299,18 +296,26 @@ InputResult Input::getPassword(const GateConfig& cfg) {
const char* p = line;
while (*p == ' ' || *p == '\t') ++p;

// Dashboard integration: check for SM_ commands first. These are handled without counting as
// password attempts and return got=false so BootGate re-prompts. SM_WIPE falls through to the
// normal wipe handler below.
// Dashboard integration: check for SM_ commands first. These never count as password attempts and
// return got=false so BootGate re-prompts. The tri-state dispatch is the W1 misfire fix: ONLY a
// clean SM_WIPE is routed into the wipe flow; an unrecognized SM_ line is rejected and re-prompted
// (SPEC §6.1) instead of being silently dropped into the authenticated wipe prompt.
if (startsWithCmd(p, "sm_", 3)) {
bool handled = dashboard::processCommand(p, cfg);
if (handled) {
secureZero(line, sizeof(line));
return r; // got=false — re-prompt, not a password attempt
switch (dashboard::processCommand(p, cfg)) {
case dashboard::SmDispatch::Handled:
secureZero(line, sizeof(line));
return r; // got=false — re-prompt, not a password attempt
case dashboard::SmDispatch::WipeRequest:
p = "wipe"; // deliberate SM_WIPE only — fall through to the authenticated wipe handler
break;
case dashboard::SmDispatch::Unknown:
default:
// Typo / unknown / mangled sm_ line: reject and re-prompt. Never the wipe path, never an
// attempt (SPEC §6.1 — recognized-but-deferred error reply).
Serial.println(F("SM>{\"error\":\"unknown command\"}"));
secureZero(line, sizeof(line));
return r; // got=false — re-prompt, not a password attempt, no wipe
}
// SM_WIPE was not fully handled — it falls through to the standard wipe flow below.
// Rewrite p to point to "wipe" so the existing wipe handler picks it up.
p = "wipe";
}

// `wipe` -> AUTHENTICATED host-assisted self-destruct (SPEC §6). We must NOT set wipeRequest
Expand Down
85 changes: 85 additions & 0 deletions firmware/bootgate/SmCommand.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
// SmCommand.h — pure, dependency-free classification of a dashboard "sm_" serial command.
//
// This is the DECISION half of the Cyber-Controller dashboard protocol, split out from the Serial
// I/O in GateInput_serial.cpp so it can be unit-tested and statically compiled on a host with no
// Arduino/Serial/NVS dependencies. It exists to fix the W1 wipe-misfire (audit
// DEADMANS-AUDIT-2026-07-12 §GateInput_serial.cpp:313): the old caller overloaded a `bool` return
// so that BOTH a deliberate `sm_wipe` AND any unrecognized `sm_` line returned `false`, and then
// unconditionally routed `false` into the authenticated wipe prompt. A typo (`sm_reboot`), an
// unknown/future command, or a mangled keyword therefore dropped the operator into the wipe flow;
// entering the correct password there fired an irreversible wipe.
//
// SPEC §6.1: an unrecognized SM_ command is a deferred error reply — NEVER a password attempt, and
// NEVER the wipe path. The tri-state below makes that impossible to conflate: ONLY a clean `sm_wipe`
// maps to WipeRequest; everything else is Handled (in place) or Unknown (rejected + re-prompt).
#ifndef SUICIDE_SM_COMMAND_H
#define SUICIDE_SM_COMMAND_H

#include <stddef.h>

namespace suicide {
namespace dashboard {

// The recognized dashboard commands, plus the catch-all Unknown.
enum class SmCommand {
Status, // sm_status -> emit status JSON (handled in place)
Info, // sm_info -> emit info JSON (handled in place)
Arm, // sm_arm -> "re-provision required" reply (handled in place)
Disarm, // sm_disarm -> "re-provision required" reply (handled in place)
SetPassword, // sm_set_password -> "re-provision required" reply (handled in place)
Wipe, // sm_wipe -> route to the AUTHENTICATED wipe flow
Unknown, // anything else sm_-prefixed -> reject + re-prompt, NEVER wipe, NEVER an attempt
};

// What the getPassword() caller must do with a classified command.
enum class SmDispatch {
Handled, // fully processed here -> re-prompt, not a password attempt
WipeRequest, // the deliberate sm_wipe -> hand off to the authenticated wipe flow
Unknown, // unrecognized sm_ line -> emit an error, re-prompt, never a wipe / never an attempt
};

// Case-insensitive ASCII prefix match (dashboard commands are lowercase ASCII). Mirrors the
// startsWithCmd() helper in GateInput_serial.cpp; kept here so this header stays self-contained.
inline bool smPrefix(const char* line, const char* cmd, size_t cmdLen) {
for (size_t i = 0; i < cmdLen; ++i) {
char c = line[i];
if (c >= 'A' && c <= 'Z') c = static_cast<char>(c - 'A' + 'a');
if (c != cmd[i]) return false;
}
return true;
}

// A recognized command must be the WHOLE token: the keyword followed by end-of-string or a space.
// A trailing tab or any extra byte (`sm_wipex`, `sm_wipe\t`) is therefore NOT a match and falls to
// Unknown -- the safe choice for a misfire fix: a mangled `sm_wipe` must never reach the wipe flow.
// (Preserves the exact '\0'/' ' terminator behaviour of the original processCommand checks.)
inline bool smExact(const char* line, const char* cmd, size_t cmdLen) {
return smPrefix(line, cmd, cmdLen) && (line[cmdLen] == '\0' || line[cmdLen] == ' ');
}

// Classify a trimmed line. The caller guarantees it starts with "sm_"; a bare "sm_" or any
// unrecognized "sm_..." returns Unknown.
inline SmCommand classifySmCommand(const char* trimmed) {
if (smExact(trimmed, "sm_status", 9)) return SmCommand::Status;
if (smExact(trimmed, "sm_info", 7)) return SmCommand::Info;
if (smExact(trimmed, "sm_arm", 6)) return SmCommand::Arm;
if (smExact(trimmed, "sm_disarm", 9)) return SmCommand::Disarm;
if (smExact(trimmed, "sm_set_password", 15)) return SmCommand::SetPassword;
if (smExact(trimmed, "sm_wipe", 7)) return SmCommand::Wipe;
return SmCommand::Unknown;
}

// Map a classified command to the caller's dispatch outcome. ONLY Wipe -> WipeRequest; this is the
// single choke point that guarantees no unrecognized command can ever reach the wipe path.
inline SmDispatch smDispatch(SmCommand cmd) {
switch (cmd) {
case SmCommand::Wipe: return SmDispatch::WipeRequest;
case SmCommand::Unknown: return SmDispatch::Unknown;
default: return SmDispatch::Handled; // Status/Info/Arm/Disarm/SetPassword
}
}

} // namespace dashboard
} // namespace suicide

#endif // SUICIDE_SM_COMMAND_H
68 changes: 68 additions & 0 deletions firmware/test_harness/native/test_sm_command.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
// Native (host) unit test for the pure dashboard-command classifier — SmCommand.h.
//
// Zero Arduino/Serial/NVS dependencies, so it compiles + runs on any host toolchain:
// c++ -std=c++17 -I ../../bootgate test_sm_command.cpp -o t && ./t # exit 0 == all pass
// It is also `-fsyntax-only`-checked under the ESP32 cross toolchain in CI / the build harness.
//
// This locks in the W1 wipe-misfire fix (audit DEADMANS-AUDIT-2026-07-12 §GateInput_serial.cpp:313):
// the SINGLE safety invariant is that ONLY a clean `sm_wipe` ever maps to WipeRequest — a typo, an
// unknown/future command, or a mangled keyword must classify as Unknown and never reach the wipe path.
#include <cassert>

#include "SmCommand.h"

using suicide::dashboard::SmCommand;
using suicide::dashboard::SmDispatch;
using suicide::dashboard::classifySmCommand;
using suicide::dashboard::smDispatch;

int main() {
// --- recognized commands (exact token: keyword + end-of-string or a space) ---
assert(classifySmCommand("sm_status") == SmCommand::Status);
assert(classifySmCommand("sm_info") == SmCommand::Info);
assert(classifySmCommand("sm_arm") == SmCommand::Arm);
assert(classifySmCommand("sm_disarm") == SmCommand::Disarm);
assert(classifySmCommand("sm_set_password") == SmCommand::SetPassword);
assert(classifySmCommand("sm_wipe") == SmCommand::Wipe);

// case-insensitive (commands may arrive upper/mixed case)
assert(classifySmCommand("SM_STATUS") == SmCommand::Status);
assert(classifySmCommand("Sm_Wipe") == SmCommand::Wipe);

// a trailing space (with or without args) is still the same command
assert(classifySmCommand("sm_status ") == SmCommand::Status);
assert(classifySmCommand("sm_wipe ") == SmCommand::Wipe);
assert(classifySmCommand("sm_wipe now") == SmCommand::Wipe);

// --- the misfire cases: these used to be routed into the authenticated wipe prompt ---
assert(classifySmCommand("sm_wipex") == SmCommand::Unknown); // extra byte -> NOT wipe
assert(classifySmCommand("sm_wipe\t") == SmCommand::Unknown); // tab-mangled -> NOT wipe
assert(classifySmCommand("sm_reboot") == SmCommand::Unknown); // unknown/future command
assert(classifySmCommand("sm_stat") == SmCommand::Unknown); // truncated typo
assert(classifySmCommand("sm_statusx") == SmCommand::Unknown); // trailing byte on a known keyword
assert(classifySmCommand("sm_") == SmCommand::Unknown); // bare prefix
assert(classifySmCommand("sm_ wipe") == SmCommand::Unknown); // space before the keyword

// --- dispatch mapping: ONLY Wipe -> WipeRequest ---
assert(smDispatch(SmCommand::Wipe) == SmDispatch::WipeRequest);
assert(smDispatch(SmCommand::Unknown) == SmDispatch::Unknown);
assert(smDispatch(SmCommand::Status) == SmDispatch::Handled);
assert(smDispatch(SmCommand::Info) == SmDispatch::Handled);
assert(smDispatch(SmCommand::Arm) == SmDispatch::Handled);
assert(smDispatch(SmCommand::Disarm) == SmDispatch::Handled);
assert(smDispatch(SmCommand::SetPassword) == SmDispatch::Handled);

// --- THE safety invariant: no input other than a clean `sm_wipe` can produce WipeRequest ---
const char* nonWipe[] = {
"sm_status", "sm_info", "sm_arm", "sm_disarm", "sm_set_password",
"sm_wipex", "sm_wipe\t", "sm_reboot", "sm_stat", "sm_statusx", "sm_", "sm_ wipe",
};
for (const char* line : nonWipe) {
assert(smDispatch(classifySmCommand(line)) != SmDispatch::WipeRequest);
}
// ...and the clean forms DO.
assert(smDispatch(classifySmCommand("sm_wipe")) == SmDispatch::WipeRequest);
assert(smDispatch(classifySmCommand("sm_wipe ")) == SmDispatch::WipeRequest);

return 0; // reaching here == every assert passed
}
Loading