Security Design¶
Agentic Hardware-in-the-Loop (Agentic HIL) is a local MCP stdio server for agent-driven embedded hardware workflows. Its security design focuses on keeping host and hardware actions explicit, narrow, configured, and auditable.
Threat Model¶
Agentic HIL assumes an agent can request hardware actions and edit every file in the project workspace, including .mcp.json and test plans. The one authoritative project configuration is therefore stored outside the workspace, discovered from the canonical project path or selected through an absolute AGENTIC_HIL_CONFIG override, bound to the exact workspace path by mandatory workspace_root, and controlled by the operator.
The primary risks are:
- Arbitrary command execution through debugger, COM-port, or CAN escape hatches.
- Self-granting permissions or adding resources by editing project configuration.
- Flashing unintended firmware artifacts or files outside approved project roots.
- Performing destructive or consequential hardware actions, such as mass erase or resuming a halted target under a debug session, without explicit authorization.
- Confusing MCP JSON-RPC control output with plain serial text output.
- Concurrent or crashed frontends leaving physical hardware ownership or safe state ambiguous.
- Leaking host paths, serial logs, hardware identifiers, or local configuration details in reports.
Where Enforcement Sits¶
A reader who scopes database and Kubernetes access to the credential expects the same shape here, and that analogy carries the answer rather than contradicting it: in that world the server enforces, through grants and RBAC, and never the client or the shell around it. A debug probe, a serial port and a CAN adapter have no such server and no permission model of their own, so any process that can open the device can do anything to it, and the enforcement point has to be the thing directly in front of the hardware. This tool is that thing, in the role the database engine or the API server plays elsewhere; putting the policy into the MCP host would be putting the grants into the client. The host's own permission system stays and is complementary, but it answers a different question: it judges a shell string or a tool name and decides whether the agent may call at all, it is spelled differently by every host, and it cannot hold a machine-wide device lock across processes. The bench's permissions judge the hardware action itself, against the envelope the operator configured (which probe is pinned, which CAN ids may be sent, which firmware validates, which interlocks refuse), and they travel with the bench rather than with the host: MCP is one entry point, and doctor, com-stdio, pytest, CI and the test reactor walk the same gate. The credential in this model is a file, deliberately outside the workspace the agent can edit, where a host's settings sit inside it: a device the file does not declare does not exist here (unknown_device to a run that declares it, com_port_not_configured or can_bus_not_configured to a port or a bus tool that names it), a device it declares carries its own grants, and "read-only on production" is listen_only plus reads that need no grant at all. Both layers are worth having, and this is the one this product can vouch for.
Mitigations¶
- MCP startup fails unless the discovered config or absolute-path override is outside the workspace and its
workspace_rootexactly matches the current project. - The authoritative config contains hardware resources, permissions, validation requirements, allowlists, and limits. It is the only project/hardware configuration used by MCP,
doctor,com-stdio, pytest, and the test reactor. agentic-hil initcreates and reports the external config, and since 0.8.0 it grants every permission in it (including all threepermissions.allow_config_*) exceptallow_raw_debugger_commandsandallow_mass_erase, which default to false because flashing is refused while either is true and neither has a tool behind it. The operator narrows what this bench should not have: themselves, or by telling an agent, which can writefalseand nothing else. It reads the attached bench whether or not the workspace ships anagentic-hil.config.example.yaml: setup runs only fixed STM32CubeProgrammer list/HOTPLUG identity commands and writes the detected bindings, granting everything except the interlocked pair above, which default false, and honouring whatever a profile itself names in either direction: a profile that sets an interlock true opens it and the generated file carries that grant, which is the one placeinitwidens rather than narrows and why the missing-configuration remediation tells a reader to read the reported permissions rather than assume the pair is false. Over MCPproject_config_createwrites the pair false by construction when it generates with no loaded configuration, and for a logical entry a regeneration introduces whose name the loaded configuration did not carry, because there the profile is repository-controlled data the generation never reads into a permission; a regeneration of an existing bench instead carries over the permissions this server loaded at startup for the entries whose names it still carries, either interlock included, keyed on the entry name and not on whether a probe was discovered before, so a pair an operator opened on a nameddutplaceholder stays open across the call that first writes a physical probe id into it as surely as across any other call made to refresh one. A profile decides how a found bench is named and shaped, never whether looking is permitted, and without one the same fixed defaultproject_config_createuses fills the template. Only when nothing is found does it write the skeleton, whosedebuggersentry names no toolchain and therefore drives nothing until somebody sets itsexecutable; and the result says that discovery ran and what it answered, so placeholders cannot be read as a bench nobody looked for. Bootstrap is unavailable over MCP and cannot reset, halt, erase, flash, run raw commands, or open serial ports.- MCP tools expose named, high-level actions (probe, flash, reset, report retrieval, and configured COM/CAN sessions) instead of a raw debugger shell or direct host device access.
- Firmware artifacts must be under configured artifact roots, match configured extensions, and pass format plausibility checks before flashing or upload resolution; path traversal is rejected.
- Before backend use, artifacts are reopened without following links, checked for replacement and multiple links, and copied to a private process staging directory. Debuggers never reopen the agent-controlled source path.
- Uploaded artifacts are size-limited and identified with SHA-256 metadata.
- COM and CAN access use only
port_id/bus_idvalues present in the authoritative config. - Configured debugger, GDB, CAN process-bridge executables, and OpenOCD scripts are resolved and pinned at startup; missing, relative OpenOCD-script, and workspace-resident paths are rejected. The one exemption is an entry that names no toolchain, and it is exempt because nothing can drive it: the untouched starter entry keeps its relative script names and does not resolve an executable off
PATHeither, so "not validated" and "drives nothing" stay the same set. From the moment an entry names an executable it is validated whole anddoctorchecks it. - Artifact roots and report/log/upload directories are frozen to lexical workspace paths. Symlink pivots and symlinked output files fail closed.
- Empty symbol allowlists mean deny-all. Unrestricted symbol access requires
debug.allow_all_symbols: truein the authoritative config. - Reading a device requires no permission from configuration version 2 on, including the reads that perturb the target: attach-with-halt through
probe_target, CAN reception outsidelisten_only, and a port open that raises DTR. Exclusivity replaced the read permission (decision 0018): a run declares its devices, holds them machine-wide for its whole duration, is refused withdevice_busynaming the holder if another owner has one, and is refused withundeclared_deviceif it reaches past its own declaration. A version 1 file: one with noversion:key: keeps the old gates, where runtime debugger discovery/execution includingdebugger_probes_liststill requiresallow_probe, so an update never widens an existing bench. Target reset still requiresallow_reset. - A
com_portsentry must name hardware from configuration version 3 on, and that is decided from the document alone rather than from what is attached: the entry carries aserial_number, aresource_idor a/dev/serial/by-id/...device name, or it declares what identifies it instead withidentity_source:vid_pidfor an adapter that publishes USB ids but no serial number,devicefor one that publishes neither. An entry with none of those refuses the configuration at load, naming the entry andagentic-hil adopt-hardware, because a kernel name likeCOM7or/dev/ttyACM0is an enumeration order and attaching a second adapter can hand one entry the other's board. Versions 1 and 2 keep loading such an entry with the runtime warningdoctorhas always reported, so an update never adds a requirement to a bench that did not ask for one;initandadopt-hardwarewrite version 3 going forward. The declaration grants nothing and identifies nothing on its own: one that disagrees with the entry's own keys is refused too. can_buses.<name>.listen_only: trueis enforced, not recorded. It is the one configuration flag whose whole value is that it is a proof: a controller outside listen-only sends dominant ACK bits, and on a bus with one other participant that ACK decides whether the sender considers its frame delivered: so a downgrade to "listening anyway" would be worse than not offering the flag. Each adapter is held to it by its own mechanism:peaksets PCAN's passive state, re-asserts it after the channel initializes (python-can applies the constructor's state beforePCANBasic.Initializeand discards theSetValueresult) and readsPCAN_LISTEN_ONLYback;socketcanreads the kernel's control mode and sets nothing, since the mode belongs to the CAN netdev; aprocessbridge must confirm the mode in itsopenresponse, because forwarding a flag to a bridge that ignores it is the same defect one process further out. The SocketCAN reading takesipfrom/sbin,/usr/sbin,/binor/usr/binand never fromPATH: a proof read from a PATH-resolved binary is not a proof. Unenforceable refuses before contact (can_listen_only_unsupported,retry_safe), unconfirmed refuses after and closes the adapter (can_listen_only_unconfirmed). A transmit on such a bus refuses too, and as a mode rather than a permission:can_listen_only_mode, raised before any driver call and without consultingpermissions.allow_write, because a bus is not made transmit-capable by granting a permission on it. Enforcement and verification reach exactly as far as the driver's or the kernel's own report of its mode; beyond that this product makes no claim.- Setup bootstrap is the sole pre-policy exception: it runs before any configuration exists, and package code fixes its backend and its commands.
- Device exclusivity is kept at
~/.agentic-hil/device-locks, never understate_root: a lock kept per configuration is not a bench lock, and two sessions whosestate_rootdiffered once held the same board without seeing each other. The location is fixed and has no override. Ownership is the operating system's advisory lock, so a crashed owner's devices are free immediately; the deliberate trade for that is that hardware a crashed owner may have left in an unknown state is still guarded only by that project's own quarantine record, as before. - Flashing requires
allow_flash; an explicit post-flash reset additionally requiresallow_reset. - Resuming a halted target inside a debug session requires
allow_debug_executionon that probe, checked ondebug_continueand nothing else: opening a session and inspecting one while it is halted (breakpoints, symbol reads, memory dumps,debug_halt) reach no further thanallow_probealready does, because an attach halts the core the way any other read does and only lifting it off that halt is a write. Session teardown reconfirms the halt rather than assuming it:stop_sessionand service shutdown both re-interrupt the target if its last known state was not already a confirmed halt, and a session that ends without that proof is reported unconfirmed (safe_state_confirmed: false,halt_not_confirmed: true) rather than released as a clean stop, and that report is what the caller acts on: the incident it raises stands down when the call ends, the way an unconfirmed flash or reset does, and the next attach proves the state again by halting the core. The GDB connection that ends a session also overrides OpenOCD's owngdb-detachandgdb-endtarget events to a no-op before it disconnects, because their default is to resume the target on their own, which is exactly the outcome this permission exists to gate. - Serial/CAN writes are size-capped; reads are buffer-capped; debugger calls run with timeouts and with OpenOCD's TCP servers disabled.
- Flashing is refused while
allow_raw_debugger_commandsorallow_mass_eraseis enabled. The reason is what those two can reach: both act on flash outside the path this server validates (an arbitrary debugger command writes whatever it is given, a mass erase clears whatever a flash has just written), so once either is allowed, a flash report's claim about what is on the device is no longer a claim this server can stand behind. That is what makes validated flashing and unrestricted debugger access mutually exclusive policies. Neither flag has an MCP tool behind it, so the pair withholds flashing rather than granting anything, and every site that reads either reads it as a prohibition. mcp-stdiois reserved for JSON-RPC. Plain serial text uses the separatecom-stdiopath only when explicitly requested..agentic-hil/testconfig.yamland--test-configselect test steps only. A test plan cannot select hardware, grant permissions, or replace the discovered config or its override.- Whether a debugger run failed is decided by the backend's own report of it rather than by the words anywhere in its transcript: an OpenOCD run that exits 0 and prints the success marker its command string
echoes after the operation is a success, because OpenOCD stops evaluating that string at the first command that fails and could not have reached theechootherwise. Failure-worded lines printed on the way to a marker that did arrive come back verbatim on the successful result asbackend_warnings, so the evidence stays in front of the reader instead of turning a reset the board performed into areset_failed. Without the marker, or on a non-zero exit, the transcript is classified exactly as it was, and OpenOCD's own report that the sectors an image covers were not erased keeps deciding either way. - Reports and structured errors include
ok,error_type,backend_error_type,summary,likely_causes,report_path, andlog_pathso failures can be audited without bypassing configured controls. Every written report additionally carriesconfig_in_force: the digest of the exact configuration bytes the action was permitted by, and whether that version had already diverged from the file on disk when it ran. The authoritative configuration is the permission boundary and its path is constant while its content is not, so an entry that named only the path could not be tied to the policy that was actually in force. - Every frontend acquires service-owned, cross-process leases for the project and each physical resource before backend effects. Lease records contain random owner tokens, PID/start metadata, config identity, and resource keys. Live leases cannot be stolen; stale or uncertain leases become quarantined.
- Quarantine is raised for an unknown physical state, never for failure as such: a failure that proves it never reached the hardware (no toolchain process existed, OpenOCD stopped before
initcompleted, pyOCD refused the probe or target type before any connect, STM32CubeProgrammer's own report that no transport to the in-circuit debugger or programmer existed, a COM handle verifiably closed, a SocketCAN channel that never initialized, a read whose backend reports that no target answered) refuses withtarget_contacted: false,retry_safe: trueand writes no quarantine record; the proof obligation sits with the failure, and anything that cannot prove its abort point quarantines. The proof is the backend's positive claim and never this layer's inference from fields the backend did not write:probe_targetanddebugger_probes_listare read-only by construction and that is not itself proof, because a read on this bench is not passive; an SWD attach halts the core, and a backend killed at its deadline never ran theshutdownin its own command string. Those keepdebugger_readonly_target_state_unconfirmed, which the read-only predicate may not settle.PcanCanInitializationErroris the same rule on the CAN side: python-can raises it fromSetValuecalls that run afterPCANBasic.Initializehas put the channel on the bus, so the class alone carries no phase marker. Two answers from the installed library do carry one, and both refuse rather than quarantine: a PCAN-Basic API that will not load at all, asked by constructing thePCANBasicobjectPcanBus.__init__constructs first, means no driver object existed for a call to happen through (can_adapter_library_missing); and anInitializerefusal whose text is what that library's ownGetErrorTextanswers forPCAN_ERROR_ILLHANDLE, or a channel absent from a non-empty enumeration, means the handle named no channel (can_channel_not_available). An empty enumeration is a non-answer and is never used as one. An incident names itscleanup_reasonsand carriesquarantine_guidance(attempted / confirmed / unknown / physical_check per reason, from theagentic_hil.knowledgecatalogue), and only a closed set of reasons may be cleared without an operator. Machine recovery always requires: one single reason drawn from the permitted set, intact audit state, no other active lease, permission to read the probe, a successful reap of this owner's debugger processes, aprobe_targetre-read that detects the target, a durablerecovery: machine_attestedreport written before the state transition, and an attempt budget ofrecovery.max_attemptsper incident. An incident this live owner raised is read off the leases it still holds every resource of; an incident with no lease behind it, adopted from a process that died holding this project, is read off the durable record instead and cleared through the same marker rewriterecoverperforms, under the same three questions (this quarantine id, this project, these configuration bytes). A broken audit is excluded structurally and no policy can reach it. - A gate is only owed where the missing proof cannot come back on its own, so the standing, human-visible quarantine covers the
audit_brokenfamilies and nothing else. A target's state is proven by a reset into halt and an answering probe; a serial handle's and a CAN adapter's by their own next open, which the operating system refuses by itself when the handle is really still held; the peripheral cleanup reasons (com_open_cleanup_unconfirmed,com_cleanup_unconfirmed,can_open_cleanup_unconfirmed,can_adapter_cleanup_unconfirmed, the PCANSetValue-after-Initializefamily among them) therefore record their event under the same reason with the same guidance and open no incident at all. Everything else is an incident that is open for the length of the call that raised it: the recovery action runs, a confirmed one is attested withrecovery_action_verified, and whatever the recovery action did not settle stands down at the end of the call with ano_standing_stateline inrecovery.jsonlnaming every reason, the actor, and that nothing attested anything because nothing had to. Removing the gate removed no written line of evidence. What remains standing is the broken evidence chain, because a reset cannot write a report that was never written: it refuses the stimulus class withresource_quarantined, andagentic-hil recover --confirm-safe-stateandhardware_recover(gated bypermissions.allow_recover, carrying the operator's words verbatim asoperator_statement) are its only two exits. Both answernothing_to_recoverfor anything else, since there is nothing there to clear. - Which reasons are permitted is the bench's
recovery.auto_recoverpolicy:offpermits none;readonlypermitsRETRYABLE_CLEANUP_REASONS, verified by a probe re-read that connects without resetting (OpenOCDinit; targets, pyOCDstatus, STM32CubeProgrammer CLImode=HOTPLUG) and therefore cannot alter the state it attests to;reset_halt(the default) permitsRECOVERY_ACTION_REASONS, which is every reason except theaudit_brokenfamilies, verified by first driving a reset-into-halt and reading it back. That set is defined by its exclusion rather than enumerated on purpose: a target this host drove into a defined state and confirmed is a known state whatever the reason it was held for was called, while anaudit_brokenreason names a ledger that could not be written and no amount of driving the target writes it. The weakest predicate that can settle the open reason runs, so a read-only fault never causes a reset; recovery halts and never runs the target;reset_haltdegrades toreadonlywithoutallow_reset; and a config that never names the key gets the default plus a one-timewarningsentry in the report the first time it acts physically under it. - Process-bridge protocol v2 requires
ok: trueandsafe_state_confirmed: true; bridge process reap is verified separately. Both conditions plus intact audit state are required before ownership is released. A CAN bridge sentlisten_only: truemust additionally answerlisten_only: truefrom itsopenresult: a field on the response, not a version bump, so a bridge that is never asked for the mode is unaffected and one that is asked and stays silent is refused rather than believed. A CAN bridge that refusesopenafter its channel is already on the bus says so withchannel_open: trueon its own error response, and that refusal withholds the no-contact marker rather than claiming the bus was untouched (the same rule asPcanCanInitializationErrorabove, on a protocol whose single request and single response carry no phase of their own). Also a field and not a version bump, and read only as a literaltrue: a bridge that does not set it keeps the answer a cleanopenfailure has always had (retry_safe: trueand no incident), which is what the common case of a mistyped channel needs. - Canonical reports and lease records live under the user state root, namespaced by config and workspace identity. Workspace report files are untrusted write-only views. Detailed hardware-effect logs are likewise written canonically under the state root with a monotonic sequence and a tamper-evident SHA-256 hash chain; the workspace log is an untrusted mirror.
get_last_report/classify_last_errorexposecanonical_audit, whoseworkspace_log_verifiedconfirms every canonical effect record is still present, in order, in the workspace log (extra passive feedback lines are tolerated; deletion, alteration, or reordering of an effect record fails it). - Environment-derived absolute paths (the state root and config location) and any secret-named field are never emitted to an operator terminal, the CLI, or an MCP client; operator-facing results carry workspace-relative or basename references only. A key name cannot see a credential that a captured process stream prints inside its own value, and package managers print exactly that: pip and uv echo the index they were configured with, and a private index URL routinely carries
https://user:token@host/simple/. Values under a captured stream key (stdout,stderr, at any depth) therefore take a second, content pass that masks the credential shapes such output carries: URL userinfo (the account survives, the password does not), anAuthorization: BearerorBasicheader line, and a secret-named assignment or query parameter, under the same name vocabulary the key pattern uses. Both sinks share the function, so the machine document and the rendering are redacted alike. The pass is confined to those keys and replaces only the credential span, leaving every other byte of the stream intact: a captured stream is published so the operator can read the tool's own words, and a content pass over ordinary prose fields would eat the diagnosis rather than a secret. - Tool input schemas are enforced before dispatch, non-finite numbers are rejected, and composite success requires
ok: true;target_ok,audit_ok, andcleanup_oknot false;cleanup_requiredandquarantinednot true;lease_stateone ofnull,active, orreleased(any other value, includingstale, blocks success);side_effect_statusneitherunknownnorpartial; andhardware_statenotunknown. This predicate is the publicoverall_success()inagentic_hil.report. - Config, report/log, and artifact paths use nonblocking special-file-resistant opens. Artifact size, digest, and format checks use one verified descriptor before private staging.
Cryptography Scope¶
Agentic HIL does not implement authentication, password storage, encryption protocols, key agreement, or custom cryptographic primitives. It uses the Python standard library (hashlib) for SHA-256 artifact metadata. Release integrity is handled by PyPI delivery over HTTPS, GitHub Actions OIDC trusted publishing, and GitHub artifact attestations.
Secure Development Practices¶
The project uses type-annotated Python with schema-validated configuration, pytest end-to-end tests against fake backend fixtures, ruff linting in CI, a 3-OS × 4-Python-version CI matrix, and Dependabot for dependency monitoring. Major behavior changes should include or update automated tests and preserve the configured safety boundaries documented in CONTRIBUTING.md and SECURITY.md. Configuration bypasses are treated as vulnerabilities; see SECURITY.md for reporting.
Same-Identity Limitation¶
The external config and operator-controlled environment prevent repository edits from silently selecting another hardware configuration, but they are not an OS sandbox. An agent with arbitrary shell access as the same OS identity can modify that user's config or process environment. For that threat model, run Agentic HIL under a separate service account or isolated process and restrict the IPC boundary.