Agent safety: Emergency Stop and Isolated Session Provisioning
Two related safety features for MCEC’s Agent Mode. When MCEC is agent-driving the desktop, the target app has focus, not MCEC; so the operator needs (a) a way to instantly intervene when a run goes wrong, and (b) assurance that a session can’t leave the machine in an unsafe state. Both features exist so the operator stays in control.
1. Emergency Stop: a global dead-man’s-switch hotkey
Goal
A single keystroke, hittable from any focused window, that instantly halts the active agent session; the human override. The agent must not be able to trip it accidentally or defeat it deliberately.
The hotkey
- Default:
Ctrl+Alt+Shift+S(mnemonic Stop); a four-key chord no app uses and the agent never synthesizes, so accidental triggering is near-zero. - Configurable via
AppSettings.EmergencyStopHotkey(a+-separated spec, e.g.Pause,Ctrl+Alt+Q). Parsed byEmergencyStopHotkey; left/right modifier variants collapse toctrl/alt/shift/win.
What “stop” does
Engaging the stop (EmergencyStop.Trigger):
- Latches the actuation gate.
AgentRuntime.EmergencyStoppedis set;AgentServer.CallToolrefuses every tool call (actuation, observation, and rawsend_command) with the distinctemergency-stoppederror until re-armed. - Aborts in-flight actuation. Any recording is stopped; an in-flight
dragobserves the latch between waypoints and bails out (releasing the button). Theinvokemodal-grace worker runs a synchronous UIA call that can’t be safely aborted mid-flight, but the latch refuses every follow-up and the input release neutralizes anything left held. - Releases held input. All mouse buttons up; shift/ctrl/alt/win reset (the same reset
MainWindowruns on exit); no stuck drag or chord. - Loud feedback. A persistent red
⛔ STOPPED by operatorbanner on the overlay (replacing the routine centeredMCEC is controlling your PCbanner it shows while running), anAGENT-AUDIT:log line, a status-bar message, and a stamp into theAgentSessionrecord. - Latches until re-armed. Re-arming is always a deliberate operator action; the latch is never
cleared automatically, and it deliberately has no MCP surface (an agent that could re-arm itself
would defeat the human override). GUI host: the ⛔ Re-arm (Emergency Stop) menu item (visible
only while stopped). Headless
--mcp: a modal re-arm prompt (EmergencyStopRearmDialog) opens the moment the stop engages; Re-arm resumes, Leave stopped (also Enter/Esc/close; the fail-safe default) keeps the latch, and pressing the chord again reopens the prompt (EmergencyStop.Retriggered). The prompt is safe as a re-arm surface because the latch refuses every tool call while it is up, so only physical input can reach its buttons.
Step 1 (the latch) is the only work performed inside the low-level hook callback; steps 2–4 run
immediately after on a background thread. A slow log or serial write can therefore never stall the
WH_KEYBOARD_LL callback past LowLevelHooksTimeout, which would make Windows silently evict the hook;
and the panic hotkey with it. Nothing actuates in the gap: the latch is already set, so every tool call is
refused before the background steps even run.
Why it can’t be tripped or defeated by the agent
MCEC’s own agent actuation injects keystrokes and mouse input. Windows flags injected low-level events
with LLKHF_INJECTED. The emergency stop reuses MCEC’s existing global WH_KEYBOARD_LL hook
(HookManager) and reacts to physical input only;
injected key events never arm a modifier and never trigger. This is what makes the hotkey a true human
override rather than something the agent could press or hold to defeat.
Design / where it lives
| Concern | Location |
|---|---|
| Injected-flag surfaced on a physical-key event | HookManager.Callbacks.cs → GlobalKeyEventArgs (KeyDownExt/KeyUpExt) |
| Chord state machine (pure, unit-tested) | EmergencyStopDetector |
| Hotkey parsing | EmergencyStopHotkey |
| Hook wiring + trigger/re-arm orchestration | EmergencyStop |
| The latch | AgentRuntime.EmergencyStopped |
| Actuation-gate refusal | AgentServer.CallTool (emergency-stopped) |
| Cooperative drag abort | MouseCommand.PerformDrag |
| Overlay banner | CommandOverlayWindow |
| Re-arm affordance + host lifecycle (GUI) | MainWindow (Start/Stop, SetUpEmergencyStopUi) |
| Re-arm affordance + host lifecycle (headless) | HeadlessOperatorUi + EmergencyStopRearmDialog |
The LL hook needs a message loop on its installing thread, so each host arms it where one pumps: the
GUI host arms on the UI thread whenever EmergencyStopEnabled and the agent front door could be
driving (McpServerEnabled || AgentCommandsEnabled); headless --mcp arms on
HeadlessOperatorUi’s dedicated STA pump thread whenever EmergencyStopEnabled (the stdio transport
is always a live front door, so there is no McpServerEnabled qualifier). That same headless pump
thread hosts the command overlay, so a headless session narrates and shows the ⛔ STOPPED banner
exactly like the GUI host.
Limitations
- Elevated targets / secure desktop (UIPI). A non-elevated MCEC’s LL hook won’t receive keys while an elevated window is foreground, and never on the secure desktop (UAC/lock). If MCEC drives elevated apps, run MCEC elevated too. Documented, not solved here.
- Non-interactive headless launches. If
--mcpis started without an interactive desktop (a service, a disconnected session), the hook and the windows are refused;HeadlessOperatorUilogs each piece and skips it, and the protocol serves normally. That is also the configuration where no actuation could reach a desktop anyway.
2. Isolated session provisioning
Isolated session provisioning gives an authorized agent a fresh, disposable copy of MCEC to drive instead
of the operator’s installed instance. provision-session hands the agent a throwaway directory containing
mcec.exe + dependencies and a co-located, agent-ready config (agent commands enabled only inside
that copy). The agent runs from there and deletes it when done, so any enabled state lives only in the
throwaway copy, “cleanup” is rm -rf <dir>, and a crashed or killed session leaves the real install
untouched. Concurrent sessions each get their own directory and never contend.
The installed copy enforces its side of this: mcec.exe running from Program Files refuses
mcp/--mcp and refuses to start the MCP/HTTP endpoint (Program.IsProgramFilesInstall), with an
error pointing at provisioning or a manual writable copy. The operator’s install cannot be turned into
an agent server even by editing its settings.
Flow
- Operator opts in once: the Allow agents to provision disposable instances checkbox on the
Settings dialog’s Agent tab (
AppSettings.AllowSessionProvisioning); the one thing that can’t be self-served, or the isolation is theater. This is the sole opt-in a non-technical operator performs to let an agent drive MCEC; it grants access only to a disposable copy, never this installed instance. The tab refuses to toggle it from inside a provisioned copy, so a driving agent can never widen its own permissions. - Agent calls
provision-session(optionalmcpServer,commands). MCEC (SessionProvisioner):- creates
%LOCALAPPDATA%\MCEC\sessions\<id>, - copies the binaries from the running exe’s dir (excluding the installed
mcec.settings/mcec.commands/*.log), - writes a co-located
mcec.settings(AgentCommandsEnabled=true,ActAsServer=falseto avoid the firewall prompt, MCP bound to a free loopback port,AllowSessionProvisioning=falseso it can’t re-provision, andMcpAuthToken=<token>; see below) and amcec.commandsenabling the requested agent commands, - returns
{ sessionId, directory, exePath, mcpEndpoint?, token, launch, teardown }.
- creates
- Agent runs
mcec.exefromdirectoryand drives it. Over the session’s HTTP endpoint every request must carryAuthorization: Bearer <token>(the instance’s own bearer-token gate enforces its configuredMcpAuthToken); the stdio transport is process-ownership-authenticated and needs no header. - Agent calls
end-session { sessionId, token }(after stopping the session’s exe) → the directory is deleted.
The session token
token is the session credential, doing real work on both surfaces:
- Connect: it is the provisioned instance’s
McpAuthToken, so the session’s localhost MCP/HTTP endpoint refuses requests without the matchingAuthorization: Bearerheader; another local process can’t hijack a session it didn’t provision. - Teardown:
end-sessionvalidates the presented token against the session’s co-located config (SessionProvisioner.ValidateTeardownToken) before deleting anything.end-sessionis deliberately not behind theAllowSessionProvisioninggate (teardown must always be possible), so without the token any MCP caller could delete any session it could name. A wrong/missing token is refused witherror.code:session-token-invalid; an already-gone session stays idempotent success. Validation is fail-closed: if the session’s config can’t be read, the token can’t be verified and teardown is refused; the age-based reaper still collects the directory later.
The credential lives in the session directory itself (never in the installed config), so it survives installed-instance restarts and is retired the moment the directory is deleted.
Isolation approach: copy binaries
We copy the binaries into the session directory (rather than a --config-dir redirect of the installed
exe). Because the copy runs from a non-Program Files location, Program.ConfigPath resolves to the
session directory itself, so it reads its own co-located config with no launch flags. This is isolated by
construction, and supersedes the earlier hand-rolled “copy the build and write a config” practice.
Teardown & reaping
- Explicit:
end-session(or just deleting the directory). - Operator: the Settings dialog’s Agent tab lists every provisioned instance (id, age, size,
running/stale) and offers per-row Delete and Delete all, so the operator can clean up copies an
agent left behind without hunting through
%LOCALAPPDATA%. Deleting is the same directory removalend-sessionand the reaper perform; a running instance is locked and skipped (stop it first). - Belt-and-suspenders: on every launch (
Program.Main) and before each provision, MCEC reaps session directories older thanAgentServer.SessionReapAgeHours(12h). A running session’s files are locked, so a live session is never reaped; it’s collected on a later launch after it exits.
Design / where it lives
| Concern | Location |
|---|---|
| Provision / teardown / reap | SessionProvisioner |
| Handoff descriptor | ProvisionedSession |
| MCP tools + authorization gate | AgentServer (provision-session, end-session) |
| Operator opt-in + management UI | Settings dialog Agent tab (AgentSettingsTab) → AppSettings.AllowSessionProvisioning |
| Session enumeration for the UI | SessionProvisioner.ListSessions |
| Reap on launch | Program.Main |
Limitations
- Authorization depth.
AllowSessionProvisioningis a single operator opt-in; there is no per-session authorization or token-scoping. - Auto-launch.
provision-sessionreturns a directory and how to launch it; it does not spawn the process (the caller/host does). - Artifacts. Evidence bundles live under the session directory, so teardown collects them.