Skip to content

Safety

Muxit controls real hardware. Scripts, AI chats, and dashboard widgets all write voltages, move stages, and open valves. One typo on a number can ruin an expensive part.

The safety system adds graduated guard rails without forcing a confirmation prompt on every click. You pick one safety level; the level controls what happens for each kind of operation.

Licensing

Each safety level is a separate license feature — the set you see in the picker is whatever the current tier unlocks, nothing is hardcoded to "Free vs Pro". Server distributions ship with these defaults in MuxitServer/Config/AppSettings.cs:

Feature idDefault tier
safety.level.observefree
safety.level.unrestrictedfree
safety.level.assistedpro
safety.level.activepro
safety.level.custompro
safety.audit (writes to audit.jsonl)free

Adding a new tier (Maker, Lab, Enterprise, …) is a matter of editing AppSettings.Features and AppSettings.Tiers — no changes to the safety subsystem. If Lab wants Observe + Active + Custom but not Unrestricted, drop its RequiredTierId on those three and tighten Unrestricted; the SafetyGate will pick it up via CheckFeature on the next request.

The chip is always visible. Levels the current tier can't select appear in the dialog with a locked radio and a PRO badge (or whatever the gating tier is called). The StatusStrip chip shows the live level on every tier. A workspace moved between tiers that persisted a now-locked level is clamped to Unrestricted on the next start — matching the pre-safety-gate behaviour so existing scripts keep working.

The audit trail is gated at three stops: the safety.audit license feature, the safety.audit.enabled config flag, and the active level's Audit column (Observe/Assisted/Active all default to On, Unrestricted produces no non-Allow outcomes so the file stays empty).

The AI confirmation column is not gated by any of this — it rides on the existing ai license entitlement.

The level table

Four built-in levels plus a user-editable Custom row. Each column tells the gate what to do with that kind of operation. The six outcomes are Allow, Simulate (log-only, hardware never sees the write), Ask once (cache the approval until the level changes), Ask every time, Typed confirm (type a release phrase), and Block (fail with SAFETY_BLOCKED).

LevelWrite (in range)Write (out of range)Large change (>25%)ActionDestructive actionAI confirmationAudit
ObserveSimulateBlockSimulateSimulateBlockAsk every timeOn
AssistedAllowBlockAsk onceAllowAsk every timeAsk every timeOn
ActiveAllowBlockAllowAllowAsk onceAsk every timeOn
UnrestrictedAllowAllowAllowAllowAllowAsk every timeOn
Custom(user)(user)(user)(user)(user)(user)(user)

Column definitions:

  • Write (in range) — a property write whose value is inside the connector's declared limits.min/limits.max.
  • Write (out of range) — a property write outside those hard limits. Only Unrestricted (or a sufficiently permissive Custom row) can let these through.
  • Large change — a write whose delta exceeds limits.largeChangeFraction of the declared range (default 25%). Catches "fat finger" typos where the user meant 30 and typed 300.
  • Action — a non-destructive method call.
  • Destructive action — a method call the connector flagged via confirm: { reset: "always" } or "typed".
  • AI confirmation — whether the AI must restate numeric writes (with units) and summarize write_script/run_code calls before they execute. Ask every time on every built-in level; only Custom may set it to Allow.
  • Audit — whether blocked / simulated / confirmed operations are written to workspace/logs/audit.jsonl.

Level escalation: switching levels applies on click. Unrestricted additionally requires typing I UNDERSTAND in the dialog — the typed phrase is the guard-rail; once granted, the level stays active until the user picks a different one. Custom is treated as equivalent risk to Assisted for escalation purposes, but the dialog flags the case where any Custom column is more permissive than Assisted's.

Open the chip in the status strip to change the level. The change propagates to every connected dashboard within one tick.

Custom

Pick Custom in the dialog to set each column individually. The selected values persist to workspace/config/server.json under safety.custom:

jsonc
{
  "safety": {
    "state": { "level": "Custom", "lastChange": "..." },
    "custom": {
      "writeInRange":      "Allow",
      "writeOutOfRange":   "Block",
      "largeChange":       "AskOnce",
      "action":            "Allow",
      "destructiveAction": "AskOnce",
      "aiConfirm":         "AskEveryTime",
      "audit":             "on"
    }
  }
}

Missing keys fall back to Assisted's row, so a minimal Custom block still works.

Per-connector safety

A connector's .js config can carry an optional safety block:

js
export default {
  driver: "Scpi",
  // ...
  safety: {
    limits: {
      voltage: { min: 0, max: 30, step: 0.01, largeChangeFraction: 0.25 },
      current: { min: 0, max: 3 },
    },
    confirm: {
      voltage: "onLargeChange", // once | always | never | typed | onLargeChange
      reset: "always",          // flags the action destructive
      shutdown: "typed",        // flags destructive + promotes to typed confirm
    },
    rate: {
      voltage: 10,     // max 10 writes/sec
      reset: 0.1,      // max 1 per 10s
    },
    interlock: {
      output_on: "safety_relay.state==true",
    },
    safeState: "setSafeDefaults",
    overrideLevel: "Active", // pin this connector to a specific level vs global
  },
}

limits — hard min/max. The level table decides what to do with out-of-range writes; largeChangeFraction (default 0.25) controls what counts as a "large" delta.

confirm — per-item override of the default outcome. once caches the first confirmation per level; always both flags the action destructive and asks every time; never skips the prompt even when the level asks for one; typed marks destructive and promotes the confirm to the typed-phrase flow; onLargeChange falls through to the large-change column.

rate — simple ops-per-second cap per item name. Exceeding it blocks with SAFETY_BLOCKED.

interlock — simple form "<connector>.<property>==<value>". Interlock false blocks the op.

safeState — method to call for emergency safe-state. Stored but not auto-invoked in Phase 1.

overrideLevel — bidirectional pin. "Active" keeps this connector at Active even during a global Observe session (useful for cheap-to-simulate devices you still want "real" in test envs). "Assisted" keeps it gated during a global Unrestricted (protect an expensive connector from a globally permissive session). Accepts Observe, Assisted, Active, or Unrestricted; Custom is not accepted here.

Per-driver opt-out

Some drivers have no path to physical hardware and no destructive actions — a webcam, a file-access driver, an IP camera. Forcing them through the safety gate is friction for no benefit. A driver author can opt out by declaring requiresSafetyGates: false on the driver itself:

  • C# DLL drivers: [assembly: RequiresSafetyGates(false)] in the driver project. The assembly attribute wins over the interface default.
  • JS drivers: meta: { requiresSafetyGates: false, ... } in the .driver.js.
  • Built-in drivers: implement bool RequiresSafetyGates => false; on the IConnectorDriver class.

When the flag is false, connectors of that driver never go through the safety gate: no limit checks, no confirmations, no rate caps, no simulate/block logic, and no audit rows. Default is true — every existing driver remains gated.

This is orthogonal to the per-connector safety.overrideLevel. overrideLevel pins a gated connector to a specific level at runtime; requiresSafetyGates: false is a driver-author declaration that the gate doesn't apply at all. A connector whose driver has requiresSafetyGates: false ignores overrideLevel too — the gate never runs.

Built-in drivers that ship with the flag off:

  • Webcam — frame capture only, no hardware writes.
  • FileAccess — workspace-scoped file I/O, already guarded by workspace state.
  • Onvif — IP-camera PTZ/streaming, non-destructive and recoverable.

Built-in drivers that stay gated (default):

  • TestDevice — kept gated on purpose, so it serves as the easiest example for exploring the safety system (limits, confirms, simulate, audit).
  • MqttBridge — publishes to topics that may drive downstream IoT devices.

Use the flag sparingly. If there's any way a driver call could affect the physical world, keep it on.

Audit log

Every blocked, simulated, or confirmed operation is appended to workspace/logs/audit.jsonl as a flat JSON line:

json
{"ts":"2026-04-22T14:05:11.123Z","event":"write.blocked","caller":"ai:sess-9c2f","details":{"connector":"psu","item":"voltage","value":99,"rule":"limit.outOfRange"}}

Events: level.change, write.blocked, action.blocked, write.simulated, action.simulated, confirm.granted, confirm.denied. Readers tolerate unknown event names so a future phase can log every allowed write without migrating the file.

Callers appear as script:<name>, ai:<sessionId>, dashboard:<clientId>, agent:<name>, mcp:<id>, or system:<subsystem>.

Rotation: size-based, default 10 MB (configurable via safety.audit.maxSizeMb). Flip safety.audit.rotateDaily: true for nightly UTC rotation instead.

Query the last N records over WebSocket with safety.audit.tail { "n": 200 }.

AI confirmation

The AI Chat Panel's tool-call prompts are driven by the AI confirmation column in the safety matrix. Every built-in level ships with Ask every time; only Custom can set the column to Allow. Switching levels doesn't turn AI confirmation off — the guard is universal because voice input makes unit misinterpretation a first-class risk ("15" → 15 V or 15 mV?).

When the column is on, two classes of tool call are parked for plain-text confirmation:

  1. Numeric writes and actionswrite_property and call_action whose arguments contain any numeric value. The AI must restate every number with its unit ("setting voltage to 15 V, current limit to 0.35 A — confirm?") and wait for "yes" before retrying with identical arguments. Numbers the user just typed or spoke are echoed back too, because voice recognition often drops the unit.
  2. Script authoring and inline codewrite_script and run_code. The AI must summarize what the script will do in plain English (connectors, ranges, step sizes, expected duration) and flag any missing details before it executes. run_script on an already-saved script is not gated — the authoring step already was.

Simple actions pass through unchanged. A parameterless or boolean-only call like power_on, reset, or set_output(true) carries no numbers and isn't a script tool, so the AI executes it without a confirmation round-trip.

The confirmation is one-shot and keyed on the exact argument JSON. Approval releases that one call; if the user changes any value, the gate re-arms for the new request. When the user declines, the AI drops the operation and continues the conversation normally — no tool runs.

Turn the column off for an unattended workflow: pick Custom in the SafetyChip dialog, set AI confirmation to Allow, and apply. The behaviour persists under safety.custom.aiConfirm in server.json.

Upgrading from earlier workspaces

Older safety configs had no aiConfirm field. On first read they're treated as AskEveryTime — the new safe default. The legacy confirm_unknown_values / confirm_all / off modes and their per-level mapping are gone; there is no separate ai.safetyMode to remove.

WebSocket surface

  • safety.get — returns current snapshot: level, allowedLevels, custom?, lastChangeCaller, lastChangeAt, licensed.
  • safety.setLevel { target, typedPhrase?, custom? } — request level change. Escalations prompt via PromptManager; Unrestricted requires typedPhrase; Custom persists the custom block to server.json.
  • safety.audit.tail { n } — last N audit records.
  • Broadcast safety.changed — every connected dashboard updates its chip within a tick.
  • Broadcast audit.log — raw audit record fan-out for future UI viewers.

What's intentionally out of scope in Phase 1

  • safeState is stored but not auto-invoked. Triggering it on emergency comes with the next phase.
  • Interlock parsing supports only the "conn.prop==value" form.
  • The audit log captures level changes, blocked/simulated writes, and confirmations. A future phase can add "write" events for every allowed write without schema migration.
  • Agent-specific limits in AgentSafetyGate are untouched — they run in parallel with the new gate, not merged.

Muxit — Hardware Orchestration Platform