[Feature and Refactor] Hardware Context Protocol - #45
Merged
Conversation
Adds leapflow.hardware, a governed path for operating physical devices, and fixes a pre-existing security defect that let MCP tools execute ungated. Hardware Context Protocol (hc.v0) --------------------------------- Splits device integration along what can be known. HardwareContext describes what an agent must know to command a device safely -- quantities, units, operating envelopes, interlocks, settling time, reversibility -- and is fixed by physics rather than by any wire protocol. HardwareTransport is a six-method contract for how a command reaches the device, and can be swapped freely. Two kind->factory seams (providers/, transports/) make supporting another southbound standard a module plus a table row; an architecture contract test keeps upstream standard concepts out of the domain model so that stays true. - Eight generic tools, count independent of device count (progressive disclosure). Writes split by effect class so each reaches its own ActionKind instead of a generic fallback. - Envelope-derived hardline denials: out of range, unsatisfied or unevaluable interlock, undeclared envelope, unverified context, effect-class mismatch. Rate limiting is a retryable refusal, not a hardline: the same command becomes valid after waiting. - Physical writes declare effect_scope=external, so a failed command is not replayed and its side-effect verdict reaches the next turn. Re-running an aspirate dispenses twice; an error is not proof that nothing happened. - Grant identity is device:channel@envelope-band. One consent covers the band, and widening the declared envelope invalidates the narrower grant it was given under. - Sampling keeps raw readings out of SignalBuffer (capacity 50); only envelope-derived events cross into the interaction signal pipeline. - Raw samples persist as session-scoped, sensitive, non-syncable, TTL-bounded cache artifacts; downsampled windows go to instrument.duckdb. - Physical outcomes compute a numeric, envelope-normalised delta with no model call and land in ExperienceStore, so an optimisation performed once becomes a starting point rather than an experiment to repeat. Off by default. With hardware disabled the plugin exposes no tools and the risk classifier is the unmodified default, leaving the tool index byte-identical. MCP approval bypass (security) ------------------------------ MCP tool schemas carried no x_leapflow metadata and the handler was a bare passthrough, so third-party server code ran with this agent's privileges with no risk classification, no consent, and no audit record -- the only sensitive capability in the process reachable without the orchestrator. - ActionKind.MCP_TOOL, assessed on provenance rather than on the description the server wrote about itself. - The handler routes through ApprovalOrchestrator, resolved per call so the daemon-side gate applies. Fails closed on absence and on exception. - readOnlyHint is honoured where present; its absence is not a safety claim, so unannotated servers stay gated. - Descriptions matching prompt-injection patterns are refused registration rather than logged, because they are injected verbatim into the tool index. - mcp.approval_mode: mutating_only (default) | always | off. Reviewer note: security/actions.py, security/risk.py, cli/context.py and the two config modules carry hunks from both concerns, so splitting at hunk granularity risked a non-building intermediate commit. The security-module surface worth reviewing on its own is the six new ActionKind values, ActionDescriptor.device() and .mcp_tool(), and the two _normalize_detail branches -- together they define the grant contract for both domains. Config: mcp.approval_mode plus 11 hardware.* keys, all discoverable through leap config and all restart-required. Tests: 2708 passed, 2 skipped, 1 xfailed (+142); journeys 7 passed with cassette fingerprints unchanged; ruff clean on new and modified files.
Continues leapflow.hardware from a governed command path into an observable one, adds the second southbound transport, and fixes several defects that were green in the suite because nothing asserted the connection they broke. Clock domains and sampling -------------------------- Reading carried one timestamp taken from time.monotonic() and persisted it as a wall-clock instant, so stored history was unreadable across restarts and could not be compared with any other subsystem. Split into observed_at (wall) and monotonic_at (per-boot), mirroring the convention SystemEvent already declared. - Sampling schedules against a deadline instead of sleeping a fixed interval, so a slow read no longer accumulates drift; missed slots are counted, not hidden. - Persistence moved off the sampling path via asyncio.to_thread. - Per-kind event pacing, so a paced rate_exceeded cannot hide a first-time threshold_exceeded behind it. - Threshold hysteresis derived from the declared envelope quantization rather than a new knob: a value hovering on a bound no longer emits a breach per sample. Breach uses the human's limit exactly; recovery uses the margin. - Per-device I/O mutex. An interleaved read and write on one bus lands a command on the wrong channel, and that outcome is indistinguishable from success. Reachability precedes consent ----------------------------- registry.transport() ran after approval, so commanding a device that was never reachable produced a prompt, a consent, and only then the failure. Asking somebody to authorise an undeliverable command is how people learn to click through prompts -- and the prompt they learn to dismiss guards the commands that can be delivered. The check probes rather than only opens, because transport() caches: a session that died is handed back without open() running again, which is the common failure for a server-backed device. A dead transport is dropped so the refusal cannot outlive the outage. hw_estop is deliberately exempt: refusing to attempt a stop is worse than attempting one that fails. MCP transport ------------- transports/mcp.py is the second southbound implementation and therefore the first real test of the seam's claim. Measured: one new module, one lookup row, and sixteen lines in cli/context.py to install the client resolver -- with zero lines changed in the domain model. The third file is specific to MCP being already present in the process as a control plane; a self-contained standard would not need it. Everything device-specific is declared: tool names, argument names, the response key holding the value. No name matching and no fallback chain, because a guess that lands on the wrong tool is a physical action nobody authorised and nothing downstream can detect it. A failed write reports UNKNOWN unless the server says otherwise -- the MCP client turns a timeout into an ordinary error reply, so a failure genuinely cannot distinguish "never sent" from "sent, no answer". The conformance suite hardcoded _TRANSPORT_CASES[0] for its two most consequential cases -- an error is not proof that nothing happened, and "cannot stop" must be declared -- so every transport after the first was covered by fourteen cases and silently exempt from those two. Now parameterised per case. Storage governance ------------------ reading_windows had no retention, no index, and no way to remove the rows whose timebase was unusable. Retention runs on the write path, so the only process that grows the table is the one that trims it. Raw files roll at a byte cap and are re-indexed per append: CacheManager records the size it finds at registration, so a file indexed once and appended to for hours was accounted at its first few kilobytes with a TTL counting down from the first sample. LeapBoard --------- New hardware lens: device inventory, channel traces, envelope conformance, sampling health, learned command outcomes, storage state. Two panels deliberately expose problems rather than hiding them -- observed-versus-declared rate makes sampling drift visible, and unpersisted-window count makes a locked database visible, since a database that cannot be opened otherwise looks exactly like an idle bench. - capability lens rendered nothing: its producer set only evidence, never payload, which is what the template binds to. Four tables also used a mapping repeat and a node-level bind, both of which render_node ignores. Correct headings over zero rows, with nothing reporting a fault. - Board i18n covered only the two original lenses. Five of seven shipped English in every language -- capability 0 of 31 strings, hardware 14 of 44 -- while the i18n test checked signal keys and stayed green. 100 strings x 5 locales added, with coverage now asserted per template and per locale. - /board gained completion for its second token. It accepts a reserved verb or a lens name in the same position and offered neither, so both were reachable only by reading the source. Physical learning ----------------- predicted_effect was always "reach <commanded>", so the residual measured the device and could never improve: a valve that always ran eight percent low reported the same error on its thousandth command as its first. Outcomes now carry a second residual measured against a prediction derived from prior observations, which is the only one a learning loop can drive down. No LLM call -- the physical predictor is the command plus a learned bias, clamped to a quarter of the declared span. The calibration is per session and says so. Concurrency ----------- Seven stores captured holder.connection in __init__, permanently binding the root connection and defeating the per-thread cursor mechanism entirely. Two threads then reached DuckDB concurrently and the daemon hung before answering its first status call -- 37% of runs, reproduced against HEAD. Now resolved per call. daemon.status also read the watch store inline on the event loop; that read is now serialized off-loop. Execution policy ---------------- x_leapflow.risk_level carries seven values (a disclosure grading) while ToolSpec.RiskLevel has three (a side-effect classification). plugin_generate declared medium and was parsed as read_only, so the ledger deduplicated it, it ran freely in parallel, and no side-effect gate applied. An explicit no-effect claim now outranks a substring guess about the name; exactly one built-in tool changes behaviour. Also: PluginHealthProducer registered (its docstring claimed it already was), interval watch dedup keys fixed, sync_fixtures --check no longer fails on corpus growth, AGENTS.md gains the plugin and extension rules. Verified: 2799 passed, 55 regression, 7 journeys, i18n coverage complete at 7 templates x 5 locales. Static analysis unchanged from baseline.
…on and trust Folds the first real findings from the physical experiment workspace back into the domain model, and adds the calibration, trust, replay and audit surfaces the experiments showed were missing. Envelope revisions, both driven by a falsified prediction --------------------------------------------------------- Two gaps were confirmed by experiment rather than by reasoning, and both changed what a stored delta means: - G-1 (E3-T0, ratio 100 where ~1 was predicted): bias is an absolute quantity and does not scale with the declared range, so span-normalisation made a tight-tolerance channel report a misleadingly small error. Envelope gains `tolerance`, and normalized_delta divides by it when declared. - G-2 (E2-T0, 95% convergence at 15s against a 2-tau budget of 10s, 12.3% residual): a single settling instant cannot describe an asymptotic approach. Envelope gains `settling_model` and `settling_tau_s`, with `effective_settling_s` giving 5*tau for first-order channels; record_command now waits on that rather than on a scalar. Both fields default to prior behaviour (`tolerance=0.0`, `settling_model="step"`), so hc.v0 declarations are unaffected and HC_VERSION does not move. The promotion threshold for hc.v1 is now met on evidence (four confirmed gaps across host, bench and driver classes), but bumping the version would force every existing declaration to be re-examined for an additive, optional change -- so the schema was extended instead. That call is worth a second look on review. New capability -------------- - Calibration: per-channel state, freshness and residual correction, with a store, a board section, and `leap hw` commands - Trust and readiness: a device that cannot be commanded is refused before a human is asked for consent, via `blocks_approval` -- prompting for permission to an action that will be denied teaches users to click through prompts - Replay and audit: device sessions can be replayed from recorded readings, and commands leave an audit trail - Transport discovery, an in-repo simulated transport, and a testing helper - r8_hardware journey with cassettes Security surface ---------------- `allow_permanent=False` is now enforced rather than advisory: the gate withholds session-wide bypass for actions whose risk forbids a permanent grant. Previously the flag was a suggestion no code checked. i18n ---- The calibration board section shipped eight English literals with no translation in any of the five non-English locales. `test_dashboard_i18n_static` caught it -- that test exists because five of seven boards once rendered English in every language while the older signal-key check stayed green. Tests: 3160 passed, 2 skipped, 1 xfailed (was 2708); journeys green including the new r8_hardware; ruff clean on new and modified files.
…vices in LeapBoard
Every Board view returned HTTP 503 "could not be assembled". The hardware digest carried a per-window `conformance` grid that no renderer draws and `_fit()` never decimated, growing one finding to ~980 KB. A `watch.findings` batch then reached 8.33 MiB and overran the 4 MiB JSON-RPC frame, so the generic, signals, hardware and capability lenses all failed together. Fixed in depth, at each layer that let it through: - series/digest: ship only the rendered `conformance_mix` distribution, classify conformance after series are clamped, and warn when a payload still exceeds the ceiling instead of returning oversize silently. Worst-case payload drops from ~980 KB to ~160 KB. Schema version -> 2. - monitor_coordinator: bound a `watch.findings` reply to one RPC frame, keeping the newest findings and dropping the oldest tail. - daemon client: translate an over-limit frame from a bare ValueError into a typed DaemonUnavailableError naming the limit and the responsible handler. - dashboard service: fetch findings scoped to a domain's own watch, so a burst of large hardware findings cannot crowd out a current session or capability finding. Also lands the MHS hardware preview work in progress: device probe and preview across the Board, viewer-owned preview leases with explicit release, an atomic Board content revision so templates, static assets and Python move as one generation, and preview profile ceilings. Verified in a real browser: all four lenses render, hardware shows its device cards, channel traces, envelope conformance, sampling and calibration panels. Adds regression coverage for the payload bound, the frame budget, the typed transport error and domain-scoped fetching. Signed-off-by: 班扬 <xingjun.wxj@alibaba-inc.com>
An audit asked whether the LeapBoard hardware plane routes scan, connect and preview through the Hardware Context Protocol. The data paths do. Seven deviations sat in lifecycle governance, concurrency and vocabulary instead, and the suite was green through all of them. Device occupation could be left running with no owner: - MediaTransport.read() on a frame channel started a capture only close/halt could stop, so a single hw_read left a camera claimed for the life of the daemon -- no lease to sweep it, no release to call. read() now captures transiently and restores the claim it found; read_frame() stays the preview path whose capture the PreviewBroker lease owns. The ownership test, the capture and the stop share one lock hold, so a preview starting concurrently cannot have its grabber stopped by a metadata read. - `leap hw` builds its own registry, which has no EffectScope and was never closed. The read plane now releases every transport in a finally, on the failure path too. Concurrency and observability: - The frame read runs inside the registry's per-device I/O lock, exactly as scalar reads, writes and the sampling loop do. MediaTransport's internal lock hid the gap in tree; a third-party FrameTransport sharing a bus with a scalar channel had no such cover. - Preview rows carry the live lease (active, viewers, frame_age_ms). The Connection stat cannot report capture -- a media transport reports connected once its declaration is bound, deliberately before any capture -- so a camera with its light on and one merely resolved looked identical. Read through registry.active_previews(), which answers "nothing" without building a broker and with it a sweeper task. Contract vocabulary: - The render mode is the declared representation, passed through rather than translated into "camera"/"microphone": those name device classes the protocol deliberately never branches on, so a level source that was not a microphone arrived at the browser labelled one. - Dropped the autostart binding the builder never supplied. Opening a camera follows a person asking, not a page loading. - probe() and halt() now state their lock-free contract on the Protocol itself. A diagnostic must not queue behind the operation it diagnoses, and taking a lock in order to stop a moving machine delays the halt by exactly as long as the runaway operation takes. docs/plugins/hardware_peripherals_board.md is a third-party specification and had gone materially stale: it documented the MJPEG endpoint the binary WebSocket relay replaced, a 12fps Detail profile, and silence as the only release path. Updated to what the code does today. Each new test was verified to fail against the pre-fix code. The first version of the lock test passed regardless -- it counted lock acquisitions, and the release path already takes that lock -- so it was rewritten to assert the lock was held at the moment read_frame ran. Full suite: 3273 passed, 4 skipped, 1 xfailed. Signed-off-by: 班扬 <xingjun.wxj@alibaba-inc.com>
MILESTONE. The LeapBoard hardware plane now routes scan, connect and preview through the Hardware Context Protocol with no ungoverned device claim, no unserialised frame read, and no device-class vocabulary in the render contract. All seven deviations from the HCP consistency audit are closed: D1 frame read serialised on the registry's per-device I/O lock D2 an unleased read releases the capture it started D3 `leap hw` releases every transport through HCP close before it exits D4 the board reports the live preview lease, not a bound declaration D5 render mode is the declared representation, not a device class D6 the autostart binding the builder never supplied is gone D7 probe/halt state their lock-free contract on the Protocol itself Verified on real hardware: the indicator light follows the lease, an explicit Stop releases the device at once rather than waiting for the idle sweep, and a one-shot read leaves nothing claimed. Full suite: 3273 passed, 4 skipped, 1 xfailed. No tag is cut; this commit is the milestone marker. __version__ moves to 0.2.1+main so the runtime self-report matches it. Signed-off-by: 班扬 <xingjun.wxj@alibaba-inc.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Adds a governed path for operating physical devices, an observable hardware plane in LeapBoard, and low-latency device preview.
Feature
HardwareContextdeclares what an agent must know to command a device safely (quantities, units, operating envelopes, interlocks, settling time, reversibility);HardwareTransportis a six-method southbound contract that can be swapped freely. Twokind → factoryseams make supporting another standard a module plus a table row.configure/actuate/dispenseeach reach their ownActionKind.leap hwcommands.leap hwCLI — list, describe, read, status, estop, plus daemon-routed pause/resume.Enhance
device:channel@envelope-band— one consent covers the band; widening the declared envelope invalidates the narrower grant.effect_scope=external— a failed command is not replayed, and its side-effect verdict reaches the next turn.SignalBuffer; only envelope-derived events enter the signal pipeline. Raw samples persist session-scoped, sensitive, non-syncable and TTL-bounded.Envelopegainstoleranceandsettling_model/settling_tau_s, both driven by falsified experiment predictions and both defaulting to prior behaviour, so hc.v0 declarations are unaffected.active,viewers,frame_age_ms), which is the only signal that distinguishes a bound declaration from a device actually capturing.Fix
watch.findingsreply, typed the transport error, and scoped fetching per domain.Readingpersisted a monotonic timestamp as wall-clock, making stored history unreadable across restarts and incomparable with any other subsystem. Split intoobserved_atandmonotonic_at.leap hwnever closed the registry it built; the frame read bypassed the per-device I/O lock; the board could not report real capture; the render contract carried device-class vocabulary; a template bound a field the builder never supplied;probe/haltleft their lock-free contract unstated.Documentation
AGENTS.md, third-party plugin development guide.Tests