Fix #7: derive action risk from UDS SIDs; fix response parsing

Untrusted profiles could bypass the confirmation and responses were mis-parsed:

- effective_risk(action): risk is now DERIVED from the actual service IDs the
  steps send — any write/actuator/reset/transfer SID (2F/31/11/14/2E/27/34-37/…)
  forces 'danger'; unknown SID / non-default session / security block force
  'caution'. A profile can only RAISE risk, never label a reflash 'safe'. GUI
  gates the confirmation (and the risk badge) on this derived value.
- Response checks use CONTIGUOUS subsequence matching + a hard '7F <sid>'
  negative-response guard, so an NRC data byte (e.g. 0x7E) can't false-pass as a
  positive response; applied to session/security/step checks.
- 0x78 (responsePending) is treated as in-progress, not terminal failure.
- controller.run_action restores slow ELM timing for the run (0x78 window).
- Tests: risk-cannot-be-downgraded, NRC false-positive rejected, pending handled.

Closes #7

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016yT89n4zR4qbrySoSiEyZs
This commit is contained in:
2026-07-01 19:29:44 -04:00
parent 0f029b724a
commit b5e0c96763
4 changed files with 149 additions and 25 deletions
+17 -1
View File
@@ -159,7 +159,23 @@ class Controller:
def run_action(self, action):
from obdcore.actions import run_action
return self._oneoff(lambda: run_action(action, self.link), timeout=20.0)
def go():
# actions/routines can take longer than the fast polling window and
# may reply 0x78 (pending) — restore slow ELM timing for the run
try:
self.link.fast_timing(False)
except Exception:
pass
try:
return run_action(action, self.link)
finally:
try:
self.link.fast_timing(True)
except Exception:
pass
return self._oneoff(go, timeout=25.0)
# -- trip / performance (fed from the live store each GUI tick) --
def update_trip(self):
+11 -4
View File
@@ -506,13 +506,15 @@ class MainWindow(QtWidgets.QMainWindow):
"<b>Caution:</b> these send commands to the vehicle. Read each warning."))
scroll = QtWidgets.QScrollArea(); scroll.setWidgetResizable(True)
inner = QtWidgets.QWidget(); il = QtWidgets.QVBoxLayout(inner)
from obdcore.actions import effective_risk
for a in acts:
row = QtWidgets.QFrame()
row.setStyleSheet("QFrame{border:1px solid #333;border-radius:6px;}")
rl = QtWidgets.QHBoxLayout(row)
er = effective_risk(a)
txt = QtWidgets.QLabel(
f"<b>{a.name}</b> "
f"<span style='color:{self._RISK_COLOR.get(a.risk,'#999')}'>[{a.risk}]</span>"
f"<span style='color:{self._RISK_COLOR.get(er,'#999')}'>[{er}]</span>"
f"<br><span style='color:#999'>{a.description}</span>")
txt.setWordWrap(True)
rl.addWidget(txt, 1)
@@ -527,11 +529,16 @@ class MainWindow(QtWidgets.QMainWindow):
dlg.exec()
def _run_action(self, action):
if action.risk in ("caution", "danger"):
msg = (action.warning or "This sends a command to the vehicle.") + \
from obdcore.actions import effective_risk
risk = effective_risk(action) # derived from the actual UDS SIDs
if risk != "safe":
note = ("" if risk == action.risk else
f"\n\n(The profile labels this \"{action.risk}\", but its commands are "
f"{risk}-level — confirming anyway.)")
msg = (action.warning or "This sends a command to the vehicle.") + note + \
"\n\nProceed?"
btn = QtWidgets.QMessageBox.warning(
self, f"Run: {action.name}", msg,
self, f"Run [{risk}]: {action.name}", msg,
QtWidgets.QMessageBox.Yes | QtWidgets.QMessageBox.No,
QtWidgets.QMessageBox.No)
if btn != QtWidgets.QMessageBox.Yes: