Skip to content

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_root exactly 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 init creates and reports the external config, and since 0.8.0 it grants every permission in it (including all three permissions.allow_config_*) except allow_raw_debugger_commands and allow_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 write false and nothing else. It reads the attached bench whether or not the workspace ships an agentic-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 place init widens 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 MCP project_config_create writes 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 named dut placeholder 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 default project_config_create uses fills the template. Only when nothing is found does it write the skeleton, whose debuggers entry names no toolchain and therefore drives nothing until somebody sets its executable; 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_id values 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 PATH either, so "not validated" and "drives nothing" stay the same set. From the moment an entry names an executable it is validated whole and doctor checks 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: true in 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 outside listen_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 with device_busy naming the holder if another owner has one, and is refused with undeclared_device if it reaches past its own declaration. A version 1 file: one with no version: key: keeps the old gates, where runtime debugger discovery/execution including debugger_probes_list still requires allow_probe, so an update never widens an existing bench. Target reset still requires allow_reset.
  • A com_ports entry 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 a serial_number, a resource_id or a /dev/serial/by-id/... device name, or it declares what identifies it instead with identity_source: vid_pid for an adapter that publishes USB ids but no serial number, device for one that publishes neither. An entry with none of those refuses the configuration at load, naming the entry and agentic-hil adopt-hardware, because a kernel name like COM7 or /dev/ttyACM0 is 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 warning doctor has always reported, so an update never adds a requirement to a bench that did not ask for one; init and adopt-hardware write 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: true is 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: peak sets PCAN's passive state, re-asserts it after the channel initializes (python-can applies the constructor's state before PCANBasic.Initialize and discards the SetValue result) and reads PCAN_LISTEN_ONLY back; socketcan reads the kernel's control mode and sets nothing, since the mode belongs to the CAN netdev; a process bridge must confirm the mode in its open response, because forwarding a flag to a bridge that ignores it is the same defect one process further out. The SocketCAN reading takes ip from /sbin, /usr/sbin, /bin or /usr/bin and never from PATH: 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 consulting permissions.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 under state_root: a lock kept per configuration is not a bench lock, and two sessions whose state_root differed 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 requires allow_reset.
  • Resuming a halted target inside a debug session requires allow_debug_execution on that probe, checked on debug_continue and nothing else: opening a session and inspecting one while it is halted (breakpoints, symbol reads, memory dumps, debug_halt) reach no further than allow_probe already 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_session and 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 own gdb-detach and gdb-end target 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_commands or allow_mass_erase is 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-stdio is reserved for JSON-RPC. Plain serial text uses the separate com-stdio path only when explicitly requested.
  • .agentic-hil/testconfig.yaml and --test-config select 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 the echo otherwise. Failure-worded lines printed on the way to a marker that did arrive come back verbatim on the successful result as backend_warnings, so the evidence stays in front of the reader instead of turning a reset the board performed into a reset_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, and log_path so failures can be audited without bypassing configured controls. Every written report additionally carries config_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 init completed, 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 with target_contacted: false, retry_safe: true and 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_target and debugger_probes_list are 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 the shutdown in its own command string. Those keep debugger_readonly_target_state_unconfirmed, which the read-only predicate may not settle. PcanCanInitializationError is the same rule on the CAN side: python-can raises it from SetValue calls that run after PCANBasic.Initialize has 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 the PCANBasic object PcanBus.__init__ constructs first, means no driver object existed for a call to happen through (can_adapter_library_missing); and an Initialize refusal whose text is what that library's own GetErrorText answers for PCAN_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 its cleanup_reasons and carries quarantine_guidance (attempted / confirmed / unknown / physical_check per reason, from the agentic_hil.knowledge catalogue), 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, a probe_target re-read that detects the target, a durable recovery: machine_attested report written before the state transition, and an attempt budget of recovery.max_attempts per 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 rewrite recover performs, 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_broken families 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 PCAN SetValue-after-Initialize family 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 with recovery_action_verified, and whatever the recovery action did not settle stands down at the end of the call with a no_standing_state line in recovery.jsonl naming 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 with resource_quarantined, and agentic-hil recover --confirm-safe-state and hardware_recover (gated by permissions.allow_recover, carrying the operator's words verbatim as operator_statement) are its only two exits. Both answer nothing_to_recover for anything else, since there is nothing there to clear.
  • Which reasons are permitted is the bench's recovery.auto_recover policy: off permits none; readonly permits RETRYABLE_CLEANUP_REASONS, verified by a probe re-read that connects without resetting (OpenOCD init; targets, pyOCD status, STM32CubeProgrammer CLI mode=HOTPLUG) and therefore cannot alter the state it attests to; reset_halt (the default) permits RECOVERY_ACTION_REASONS, which is every reason except the audit_broken families, 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 an audit_broken reason 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_halt degrades to readonly without allow_reset; and a config that never names the key gets the default plus a one-time warnings entry in the report the first time it acts physically under it.
  • Process-bridge protocol v2 requires ok: true and safe_state_confirmed: true; bridge process reap is verified separately. Both conditions plus intact audit state are required before ownership is released. A CAN bridge sent listen_only: true must additionally answer listen_only: true from its open result: 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 refuses open after its channel is already on the bus says so with channel_open: true on its own error response, and that refusal withholds the no-contact marker rather than claiming the bus was untouched (the same rule as PcanCanInitializationError above, 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 literal true: a bridge that does not set it keeps the answer a clean open failure has always had (retry_safe: true and 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_error expose canonical_audit, whose workspace_log_verified confirms 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), an Authorization: Bearer or Basic header 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, and cleanup_ok not false; cleanup_required and quarantined not true; lease_state one of null, active, or released (any other value, including stale, blocks success); side_effect_status neither unknown nor partial; and hardware_state not unknown. This predicate is the public overall_success() in agentic_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.