mirror of
https://github.com/ruvnet/RuView
synced 2026-06-12 10:43:19 +00:00
Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 48db9d37a6 | |||
| e7b1b66f74 | |||
| 3292bd2c5d | |||
| 0ca903b497 |
@@ -196,7 +196,8 @@ fields are **never read** for verification (only ever set to `None` in tests).
|
||||
re-doc'd **"(P4 — not yet enforced, ADR-161/B5)"** — parsed and round-tripped,
|
||||
but no integrity/signature check happens before a plugin runs. No verification
|
||||
code was added (that is P4); the doc now matches the code.
|
||||
**Grade: doc-honesty (no behavior change).**
|
||||
**Grade: doc-honesty (no behavior change).** *(Superseded by ADR-162 §P4:
|
||||
the hash/signature gate is now implemented and enforced.)*
|
||||
|
||||
## Negative Results (NO-ACTION positives — audited, found correct, cited not edited)
|
||||
|
||||
@@ -213,17 +214,23 @@ touched:
|
||||
|
||||
## Deferred Backlog (Nothing Dropped)
|
||||
|
||||
- **Plugin authority-isolation (P5)** — `homecore_permissions` claims are parsed
|
||||
but not enforced at the host-call boundary. **ACCEPTED-FUTURE.**
|
||||
- **Plugin signature/hash verification (P4)** — implement the
|
||||
- **Plugin authority-isolation (P5)** — ~~`homecore_permissions` claims are parsed
|
||||
but not enforced at the host-call boundary.~~ **DONE — ADR-162 §P5.**
|
||||
`hc_state_set` now consults a `PermissionSet` distilled from the manifest;
|
||||
an undeclared write returns a typed `-3` to the guest.
|
||||
- **Plugin signature/hash verification (P4)** — ~~implement the
|
||||
`wasm_module_hash`/`wasm_module_sig`/`publisher_key` gate that B5 now honestly
|
||||
says is absent. **ACCEPTED-FUTURE.**
|
||||
says is absent.~~ **DONE — ADR-162 §P4.** `WasmtimeRuntime::load_plugin` now
|
||||
SHA-256-checks the module, Ed25519-verifies the signature against
|
||||
`publisher_key`, and enforces a `PluginPolicy` trust allowlist
|
||||
(secure-default rejects unsigned/untrusted/tampered modules).
|
||||
- **HAP real pairing (P2)** — SRP/HKDF pairing + encrypted sessions; current
|
||||
bridge is an accessory-mapping surface. **ACCEPTED-FUTURE (honestly stubbed).**
|
||||
- **`RunMode::Queued`/`Restart`/`max` ordering** — `Single`/`Parallel` are
|
||||
- **`RunMode::Queued`/`Restart`/`max` ordering** — ~~`Single`/`Parallel` are
|
||||
honored; bounded queueing, restart-kill, and `max` concurrency are not yet
|
||||
wired (every non-Single mode is parallel). **ACCEPTED-FUTURE** — the
|
||||
`engine.rs` doc states exactly this, no over-claim.
|
||||
wired (every non-Single mode is parallel).~~ **DONE — ADR-162 §A5.** Restart
|
||||
aborts the in-flight task, Queued serializes via a per-automation async mutex,
|
||||
and `max: N` caps concurrency via a per-automation semaphore.
|
||||
- **Automation YAML load-at-boot** — the engine starts empty; a YAML loader is
|
||||
P-next. The bin log states "0 automations registered" honestly.
|
||||
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
# ADR-162: HOMECORE Plugin Security (Signature + Capability Isolation) & Bounded Automation RunModes — Making ADR-161's Deferred Claims TRUE
|
||||
|
||||
- **Status**: accepted
|
||||
- **Date**: 2026-06-12
|
||||
- **Deciders**: ruv
|
||||
- **Tags**: homecore, homecore-plugins, homecore-automation, plugin-security, wasm-signature-verification, ed25519, capability-isolation, runmode, prove-everything, soundness, honest-labeling
|
||||
- **Amends**: ADR-161 (relabelled P4/P5 + §A5 deferrals → now enforced), ADR-128 (plugin manifest), ADR-129 (automation engine)
|
||||
|
||||
## Context
|
||||
|
||||
Beyond-SOTA sweep **Milestone 8**, scoped to `homecore-plugins` and
|
||||
`homecore-automation` only, under the project's **prove-everything /
|
||||
anti-"AI-slop"** directive.
|
||||
|
||||
ADR-161 (Milestone 7) did the honest thing with three plugin/automation
|
||||
items it could not finish in that window: rather than fake them, it **relabelled
|
||||
them as deferred** —
|
||||
|
||||
- **P4** (plugin signature verification): the manifest's `wasm_module_hash` /
|
||||
`wasm_module_sig` / `publisher_key` were re-doc'd "(P4 — not yet enforced,
|
||||
ADR-161/B5)" — parsed and round-tripped, but **never checked** before a
|
||||
plugin runs.
|
||||
- **P5** (plugin authority isolation): `homecore_permissions` claims were
|
||||
parsed but **never consulted**; `hc_state_set` let any plugin write any
|
||||
entity, including `lock.*` / `alarm_control_panel.*`.
|
||||
- **§A5** (`RunMode`): `Single`/`Parallel` were honored; `Restart`/`Queued`/
|
||||
`max: N` were honestly documented as still **unbounded-parallel**.
|
||||
|
||||
### Headline — the deferred security items are now ENFORCED + TESTED
|
||||
|
||||
M8 turns those honest deferrals into real, tested behavior. The plugin trust
|
||||
boundary is now sound (a tampered module, an untrusted publisher, or an
|
||||
unsigned module is rejected by the secure default), an over-privileged plugin
|
||||
write is denied with a typed error, and the bounded run-modes actually bound.
|
||||
**Every fix is pinned by a test that FAILS on the pre-M8 code** — each of the
|
||||
three RunMode tests was additionally run against a simulated unbounded-parallel
|
||||
dispatch and confirmed to panic.
|
||||
|
||||
The Ed25519 crypto reuses the in-repo `cog-ha-matter::witness_signing` pattern
|
||||
(same `ed25519-dalek` 2.x API, same deterministic-test-key convention). SHA-256
|
||||
matches the `sha256:` prefix the manifest already declared and the
|
||||
`cog-ha-matter` cog manifest's `binary_sha256` hex convention. No new external
|
||||
dependency tree was introduced — `ed25519-dalek` / `sha2` / `hex` / `base64`
|
||||
were already in the workspace `Cargo.lock` (cog-ha-matter / bfld pull them in);
|
||||
only new dependency *edges* were added to `homecore-plugins`.
|
||||
|
||||
Grading vocabulary (ADR-152 / ADR-158 / ADR-160 / ADR-161):
|
||||
- **MEASURED** — reproduced in this worktree, command + failing-on-old test recorded.
|
||||
- **ACCEPTED-FUTURE** — deliberately deferred, nothing dropped.
|
||||
|
||||
## Decision — Fixes Landed
|
||||
|
||||
### §P4 — Plugin signature & integrity verification (SECURITY) — MEASURED
|
||||
|
||||
`homecore-plugins/src/manifest.rs` declared `wasm_module_hash` /
|
||||
`wasm_module_sig` / `publisher_key` but they were **never read** for
|
||||
verification; the load path (`wasmtime_runtime.rs`) instantiated any `.wasm`
|
||||
bytes handed to it.
|
||||
|
||||
**Real fix** (`src/verify.rs`, wired into `WasmtimeRuntime::load_plugin`):
|
||||
before instantiation the runtime now —
|
||||
|
||||
1. computes the **SHA-256** of the actual `.wasm` bytes and rejects if it ≠ the
|
||||
manifest's `wasm_module_hash` (`sha256:<hex>`) — tamper detection;
|
||||
2. verifies the **Ed25519** `wasm_module_sig` (`ed25519:<base64>`, 64-byte raw)
|
||||
over the 32-byte digest against `publisher_key` (`ed25519:<base64>`, 32-byte
|
||||
raw) and rejects on failure;
|
||||
3. enforces a configurable **trust policy** — `PluginPolicy::trusted(&[keys])`
|
||||
is an allowlist of publisher verifying keys; `PluginPolicy::AllowUnsigned`
|
||||
is an explicit dev escape hatch that LOGS a loud `warn` on every load it
|
||||
waves through. The **secure default rejects unsigned and unknown-publisher
|
||||
modules.** `PluginPolicy::deny_all()` trusts no publisher.
|
||||
|
||||
A typed `PluginError::SignatureRejected` is returned (no host panic). The
|
||||
legacy permission-free `load_wasm` is retained for first-party/trusted/test
|
||||
modules; production loading goes through `load_plugin`.
|
||||
|
||||
**Failing-on-old tests** (`tests/integration.rs`, `--features wasmtime`) — all
|
||||
drive `load_plugin`, which **did not exist** on the old code (so the gate is
|
||||
genuinely new):
|
||||
- `p4_tampered_module_is_rejected` — a byte-flipped `.wasm` → hash mismatch → rejected.
|
||||
- `p4_valid_sig_from_trusted_key_loads` — a valid sig from an allowlisted key loads.
|
||||
- `p4_valid_sig_from_untrusted_key_is_rejected` — a correctly-signed module from a key NOT on the allowlist is rejected.
|
||||
- `p4_unsigned_module_rejected_by_default_loads_only_under_allow_unsigned` — unsigned rejected under `deny_all`, loads (with warn) only under `AllowUnsigned`.
|
||||
- Unit (`src/verify.rs`): `valid_sig_from_trusted_key_passes`, `tampered_module_is_rejected`, `valid_sig_from_untrusted_key_is_rejected`, `forged_signature_is_rejected`, `unsigned_module_rejected_under_default_policy`.
|
||||
|
||||
A real deterministic keypair signs real `.wasm` bytes in the tests.
|
||||
The manifest doc now reads **"(P4 — ENFORCED, ADR-162)"**. **Grade: MEASURED. Milestone headline.**
|
||||
|
||||
### §P5 — Plugin authority / capability isolation (SECURITY) — MEASURED
|
||||
|
||||
`wasmtime_runtime.rs::hc_state_set` applied any write a plugin requested,
|
||||
ignoring the manifest's `homecore_permissions`.
|
||||
|
||||
**Real fix** (`src/permissions.rs` + `hc_state_set`): the manifest's
|
||||
`homecore_permissions` (the `state:write:<glob>` form, or a bare entity glob
|
||||
like `light.*`) are distilled into a `PermissionSet` installed in the plugin's
|
||||
Wasmtime store. The `hc_state_set` host import consults
|
||||
`permissions.may_write(entity_id)` before applying a write and returns a typed
|
||||
`-3` (permission denied) to the guest on a violation — **the host is not
|
||||
panicked.** Wasmtime already gives memory isolation; this adds **authority**
|
||||
isolation. A plugin with **no** write grants can write nothing (secure default).
|
||||
|
||||
**Failing-on-old tests** (`tests/integration.rs`, `--features wasmtime`):
|
||||
- `p5_declared_light_plugin_may_write_light_but_not_lock` — a `light.*` plugin writes `light.kitchen` (succeeds) but is REJECTED (`-3`, and the entity is not written) when it tries `lock.front_door`.
|
||||
- `p5_plugin_with_no_permissions_can_write_nothing` — a plugin with empty `homecore_permissions` cannot write `light.kitchen`.
|
||||
- Unit (`src/permissions.rs`): domain-glob, exact-grant, wildcard, read-grants-don't-confer-write, no-permissions, and explicit `state:write:` form.
|
||||
|
||||
The manifest doc now reads **"(P5 — ENFORCED, ADR-162)"**. **Grade: MEASURED.**
|
||||
|
||||
### §A5 — Bounded automation RunModes (Restart / Queued / max) — MEASURED
|
||||
|
||||
`homecore-automation/src/engine.rs` (per ADR-161) honored `Single`/`Parallel`
|
||||
but spawned an unbounded parallel task for `Restart`/`Queued`/`max`.
|
||||
|
||||
**Real fix** (`src/runmode.rs`, a per-automation `RunState` the engine owns and
|
||||
dispatches through at all three trigger sites — event loop, timer, test hook):
|
||||
- **Restart** — aborts the in-flight action task via `tokio::task::AbortHandle`, then starts a fresh one.
|
||||
- **Queued** — serializes runs in arrival order via a per-automation async `Mutex`: sequential, never concurrent, nothing dropped.
|
||||
- **max: N** — caps concurrency at N via a per-automation `Semaphore`; triggers beyond N **queue** (await a permit) rather than running concurrently. (HA bounded `parallel`/`queued` semantics — chosen and documented as *queue beyond N*, not drop.)
|
||||
- `Single`/`IgnoreFirst` re-entrancy guard and `Parallel` preserved.
|
||||
|
||||
`engine.rs` trimmed to **433 lines**; the run-mode machinery lives in the new
|
||||
`runmode.rs` (153 lines) to keep both under the 500-line guideline.
|
||||
|
||||
**Failing-on-old tests** (`tests/engine_behaviors.rs`) — each was run against a
|
||||
simulated unbounded-parallel dispatch and confirmed to panic:
|
||||
- `restart_mode_cancels_prior_run` — prior run is aborted: exactly **1** completion (old: both ran → 2).
|
||||
- `queued_mode_runs_sequentially_not_concurrently` — 3 rapid triggers all run, **max observed concurrency = 1** (old: 3).
|
||||
- `max_two_caps_concurrency_at_two` — 4 rapid triggers all run, **max observed concurrency ≤ 2** (old: 4).
|
||||
|
||||
**Grade: MEASURED. Restart, Queued, and `max: N` all implemented — no remaining RunMode deferral.**
|
||||
|
||||
## Threat model closed
|
||||
|
||||
| Threat | Before (ADR-161) | After (ADR-162) |
|
||||
|--------|------------------|-----------------|
|
||||
| **Tampered module** — attacker swaps `.wasm` bytes after signing | loaded unconditionally (hash never checked) | rejected: SHA-256 mismatch |
|
||||
| **Untrusted publisher** — valid sig from a key the host doesn't trust | loaded (sig/key never read) | rejected: publisher_key not on allowlist |
|
||||
| **Unsigned module** — no integrity material at all | loaded | rejected by secure default; loads only under explicit `AllowUnsigned` (loud warn) |
|
||||
| **Over-privileged plugin write** — a `light.*` plugin writes `lock.front_door` / `alarm_control_panel.*` | applied (permissions never consulted) | denied: typed `-3` to guest, write not applied |
|
||||
| **Run-mode resource exhaustion** — `max`/`Queued` spawn unbounded tasks | unbounded parallel | bounded: Restart cancels, Queued serializes, `max: N` caps at N |
|
||||
|
||||
## Remaining honest deferral (Nothing Dropped)
|
||||
|
||||
- **Plugin-key provisioning / rotation** — the host's trust allowlist
|
||||
(`PluginPolicy::trusted`) is supplied by the caller; sourcing it from the
|
||||
Cognitum control-plane key store (as `cog-ha-matter` does for Seed keys) and
|
||||
key rotation are **ACCEPTED-FUTURE** (out of M8 scope — same boundary
|
||||
`witness_signing` draws).
|
||||
- **`InProcessRuntime` (native first-party plugins)** — has no `.wasm` bytes to
|
||||
hash, so P4/P5 apply only to the WASM (`wasmtime`) path; native plugins remain
|
||||
trusted-by-compilation. Honestly noted, not over-claimed.
|
||||
- **HAP real pairing (P2)** — unchanged from ADR-161; out of M8 scope.
|
||||
|
||||
## Reproduction (MEASURED)
|
||||
|
||||
```bash
|
||||
cd v2
|
||||
# P4/P5 (wasmtime feature needs rustc 1.91+; workspace pins 1.89 for the rest):
|
||||
cargo +1.91.1 test -p homecore-plugins --features wasmtime
|
||||
# Bounded RunModes:
|
||||
cargo test -p homecore-automation --no-default-features
|
||||
# Full workspace still builds (1.89 toolchain, no wasmtime):
|
||||
cargo build --workspace --no-default-features
|
||||
```
|
||||
|
||||
Result at time of writing (all 0 failed):
|
||||
- **homecore-plugins** `--features wasmtime` — **32 passed** (lib 23; integration 9). (ADR-161 baseline was 15.)
|
||||
- **homecore-automation** `--no-default-features` — **45 passed** (lib 37; `engine_behaviors` 8). (ADR-161 baseline was 42.)
|
||||
- Full workspace `cargo build --workspace --no-default-features` succeeds.
|
||||
|
||||
## Consequences
|
||||
|
||||
- A HOMECORE WASM plugin can no longer be loaded with a tampered binary, an
|
||||
untrusted publisher, or (by default) no signature at all — the trust boundary
|
||||
ADR-161/B5 honestly said was absent is now real (P4).
|
||||
- A plugin can no longer write entities outside its declared
|
||||
`homecore_permissions`; the lock/alarm escalation path is closed (P5).
|
||||
- The automation engine's `Restart`, `Queued`, and `max: N` run-modes are now
|
||||
bounded as documented — no run-mode claims a capability the code lacks.
|
||||
- No new external dependency tree (reuses the cog-ha-matter Ed25519 stack
|
||||
already in the lock); source files kept under the 500-line guideline
|
||||
(`engine.rs` 433, `runmode.rs` 153, `verify.rs` 397, `permissions.rs` 168;
|
||||
`wasmtime_runtime.rs` non-test source < 500, inline WAT tests as ADR-161 left
|
||||
them).
|
||||
Generated
+4
@@ -3554,9 +3554,13 @@ name = "homecore-plugins"
|
||||
version = "0.1.0-alpha.0"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"base64 0.22.1",
|
||||
"ed25519-dalek",
|
||||
"hex",
|
||||
"homecore",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"thiserror 1.0.69",
|
||||
"tokio",
|
||||
"uuid",
|
||||
|
||||
@@ -3,14 +3,15 @@
|
||||
//!
|
||||
//! ADR-129 §2 design: one Tokio task per running automation instance.
|
||||
//!
|
||||
//! ## Run modes (ADR-161, HC-WS-05)
|
||||
//! ## Run modes (ADR-161 §A5 → completed in ADR-162)
|
||||
//!
|
||||
//! `RunMode::Single` is enforced via a per-automation `AtomicBool`
|
||||
//! guard: while an instance is executing, a second trigger is skipped.
|
||||
//! `Parallel` (and the as-yet-unbounded `Restart`/`Queued`) spawn a
|
||||
//! fresh instance on every trigger. (Before this fix the doc claimed
|
||||
//! AtomicBool enforcement but every trigger spawned unbounded parallel
|
||||
//! tasks regardless of `mode`.)
|
||||
//! Each registered automation owns a [`RunState`] that implements its
|
||||
//! `RunMode`: `Single`/`IgnoreFirst` skip re-entrant triggers, `Restart`
|
||||
//! aborts the in-flight run and starts a fresh one, `Queued` serializes
|
||||
//! runs in arrival order (nothing dropped), `Parallel` spawns on every
|
||||
//! trigger, and `max: N` caps concurrency via a per-automation semaphore.
|
||||
//! (ADR-161 only honored Single/Parallel; Restart/Queued/max were
|
||||
//! honestly documented as unbounded-parallel until ADR-162.)
|
||||
//!
|
||||
//! ## Time triggers (ADR-161, HC-WS-04)
|
||||
//!
|
||||
@@ -26,7 +27,6 @@
|
||||
//! `EvalContext::with_templates`), so `template:` conditions evaluate
|
||||
//! against live state instead of always returning false.
|
||||
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use chrono::{Local, Timelike};
|
||||
@@ -34,18 +34,18 @@ use tokio::sync::broadcast;
|
||||
|
||||
use homecore::HomeCore;
|
||||
|
||||
use crate::action::ExecutionContext;
|
||||
use crate::automation::{Automation, RunMode};
|
||||
use crate::automation::Automation;
|
||||
use crate::condition::EvalContext;
|
||||
use crate::runmode::RunState;
|
||||
use crate::template::TemplateEnvironment;
|
||||
use crate::trigger::{Trigger, TriggerContext};
|
||||
|
||||
/// An automation registered with the engine, plus its runtime run-state.
|
||||
struct Registered {
|
||||
auto: Arc<Automation>,
|
||||
/// `true` while a `Single`-mode instance is executing. Used to
|
||||
/// skip re-entrant triggers (HC-WS-05).
|
||||
running: Arc<AtomicBool>,
|
||||
/// Run-mode machinery (re-entrancy guard / restart abort handle /
|
||||
/// queue mutex / concurrency semaphore) for this automation.
|
||||
run_state: RunState,
|
||||
}
|
||||
|
||||
/// The automation engine. Holds a HOMECORE handle and a list of registered
|
||||
@@ -69,9 +69,10 @@ impl AutomationEngine {
|
||||
|
||||
/// Register an automation. Can be called before or after `start()`.
|
||||
pub fn register(&self, automation: Automation) {
|
||||
let run_state = RunState::new(&automation);
|
||||
self.automations.lock().unwrap().push(Registered {
|
||||
auto: Arc::new(automation),
|
||||
running: Arc::new(AtomicBool::new(false)),
|
||||
run_state,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -118,13 +119,13 @@ impl AutomationEngine {
|
||||
loop {
|
||||
match rx.recv().await {
|
||||
Ok(event) => {
|
||||
let snapshot: Vec<(Arc<Automation>, Arc<AtomicBool>)> = automations
|
||||
let snapshot: Vec<(Arc<Automation>, RunState)> = automations
|
||||
.lock()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|r| (Arc::clone(&r.auto), Arc::clone(&r.running)))
|
||||
.map(|r| (Arc::clone(&r.auto), r.run_state.clone()))
|
||||
.collect();
|
||||
for (automation, running) in snapshot {
|
||||
for (automation, run_state) in snapshot {
|
||||
if !automation.enabled {
|
||||
continue;
|
||||
}
|
||||
@@ -148,7 +149,7 @@ impl AutomationEngine {
|
||||
if !conditions_pass(&automation, &eval_ctx).await {
|
||||
continue;
|
||||
}
|
||||
spawn_run(&hc, automation, running);
|
||||
run_state.dispatch(&hc, automation);
|
||||
}
|
||||
}
|
||||
Err(broadcast::error::RecvError::Closed) => break,
|
||||
@@ -183,14 +184,14 @@ impl AutomationEngine {
|
||||
if last_fired_sec.as_deref() == Some(hhmmss.as_str()) {
|
||||
continue;
|
||||
}
|
||||
let snapshot: Vec<(Arc<Automation>, Arc<AtomicBool>)> = automations
|
||||
let snapshot: Vec<(Arc<Automation>, RunState)> = automations
|
||||
.lock()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|r| (Arc::clone(&r.auto), Arc::clone(&r.running)))
|
||||
.map(|r| (Arc::clone(&r.auto), r.run_state.clone()))
|
||||
.collect();
|
||||
let mut fired_any = false;
|
||||
for (automation, running) in snapshot {
|
||||
for (automation, run_state) in snapshot {
|
||||
if !automation.enabled {
|
||||
continue;
|
||||
}
|
||||
@@ -208,7 +209,7 @@ impl AutomationEngine {
|
||||
if !conditions_pass(&automation, &eval_ctx).await {
|
||||
continue;
|
||||
}
|
||||
spawn_run(&hc, automation, running);
|
||||
run_state.dispatch(&hc, automation);
|
||||
fired_any = true;
|
||||
}
|
||||
if fired_any {
|
||||
@@ -231,15 +232,15 @@ impl AutomationEngine {
|
||||
/// wall-clock second to roll over. Returns the number of automations
|
||||
/// that fired (passed conditions and were spawned).
|
||||
pub async fn fire_time_for_test(&self, hhmmss: &str) -> usize {
|
||||
let snapshot: Vec<(Arc<Automation>, Arc<AtomicBool>)> = self
|
||||
let snapshot: Vec<(Arc<Automation>, RunState)> = self
|
||||
.automations
|
||||
.lock()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|r| (Arc::clone(&r.auto), Arc::clone(&r.running)))
|
||||
.map(|r| (Arc::clone(&r.auto), r.run_state.clone()))
|
||||
.collect();
|
||||
let mut fired = 0usize;
|
||||
for (automation, running) in snapshot {
|
||||
for (automation, run_state) in snapshot {
|
||||
if !automation.enabled {
|
||||
continue;
|
||||
}
|
||||
@@ -254,7 +255,7 @@ impl AutomationEngine {
|
||||
if !conditions_pass(&automation, &eval_ctx).await {
|
||||
continue;
|
||||
}
|
||||
spawn_run(&self.hc, automation, running);
|
||||
run_state.dispatch(&self.hc, automation);
|
||||
fired += 1;
|
||||
}
|
||||
fired
|
||||
@@ -281,36 +282,6 @@ fn time_at_matches(at: &str, hhmmss: &str) -> bool {
|
||||
normalized == hhmmss
|
||||
}
|
||||
|
||||
/// Spawn an automation run, honoring `RunMode::Single` re-entrancy
|
||||
/// guard (HC-WS-05). For `Single`/`IgnoreFirst` modes a run already in
|
||||
/// flight causes the new trigger to be skipped; the `running` flag is
|
||||
/// cleared when the run finishes.
|
||||
fn spawn_run(hc: &HomeCore, automation: Arc<Automation>, running: Arc<AtomicBool>) {
|
||||
let single = matches!(automation.mode, RunMode::Single | RunMode::IgnoreFirst);
|
||||
if single {
|
||||
// Try to claim the running slot; if already running, skip.
|
||||
if running
|
||||
.compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst)
|
||||
.is_err()
|
||||
{
|
||||
return;
|
||||
}
|
||||
}
|
||||
let hc_clone = hc.clone();
|
||||
tokio::spawn(async move {
|
||||
let mut exec_ctx = ExecutionContext::new(hc_clone, automation.id.clone());
|
||||
for action in &automation.action {
|
||||
if let Err(e) = action.execute(&mut exec_ctx).await {
|
||||
eprintln!("[homecore-automation] action error in {}: {e}", automation.id);
|
||||
break;
|
||||
}
|
||||
}
|
||||
if single {
|
||||
running.store(false, Ordering::SeqCst);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -19,6 +19,7 @@ pub mod condition;
|
||||
pub mod action;
|
||||
pub mod template;
|
||||
pub mod engine;
|
||||
pub mod runmode;
|
||||
pub mod error;
|
||||
|
||||
pub use automation::{Automation, RunMode};
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
//! Per-automation run-mode machinery (ADR-162, completes ADR-161 §A5).
|
||||
//!
|
||||
//! ADR-161 implemented `RunMode::Single` (a per-automation `AtomicBool`
|
||||
//! re-entrancy guard) and `Parallel`, but honestly left `Restart`, `Queued`
|
||||
//! and `max: N` as "ACCEPTED-FUTURE / unbounded parallel" — every non-Single
|
||||
//! mode spawned an unbounded task. This module makes them real:
|
||||
//!
|
||||
//! | Mode | Semantics implemented |
|
||||
//! |------|-----------------------|
|
||||
//! | `Single` / `IgnoreFirst` | re-entrancy guard: skip while a run is in flight (ADR-161). |
|
||||
//! | `Restart` | **cancel** the in-flight run (`tokio::task::AbortHandle`) and start a fresh one. |
|
||||
//! | `Queued` | **serialize**: runs execute sequentially in arrival order via a per-automation async mutex — nothing is dropped. |
|
||||
//! | `Parallel` | spawn on every trigger (optionally capped, see below). |
|
||||
//! | `max: N` | cap concurrency at **N** via a per-automation semaphore; triggers beyond N **queue** (await a permit) rather than running concurrently — matching HA's bounded `parallel`/`queued`. |
|
||||
//!
|
||||
//! Each registered automation owns one [`RunState`]; the engine calls
|
||||
//! [`RunState::dispatch`] on every (trigger + conditions-passed) event.
|
||||
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use tokio::sync::{Mutex as AsyncMutex, Semaphore};
|
||||
|
||||
use homecore::HomeCore;
|
||||
|
||||
use crate::action::ExecutionContext;
|
||||
use crate::automation::{Automation, RunMode};
|
||||
|
||||
/// Per-automation runtime state backing the run-mode dispatch.
|
||||
///
|
||||
/// Cheap to clone (all fields are `Arc`); the engine clones it into each
|
||||
/// spawned run so the machinery (abort handle, queue mutex, semaphore) is
|
||||
/// shared across all triggers of the same automation.
|
||||
#[derive(Clone)]
|
||||
pub struct RunState {
|
||||
/// `Single`/`IgnoreFirst` re-entrancy guard (ADR-161 §A5).
|
||||
running: Arc<AtomicBool>,
|
||||
/// `Restart`: handle to the currently-running action task, so a new
|
||||
/// trigger can abort it before starting a fresh one.
|
||||
current: Arc<Mutex<Option<tokio::task::AbortHandle>>>,
|
||||
/// `Queued`: serializes runs in arrival order (one at a time, FIFO via
|
||||
/// fair async mutex acquisition).
|
||||
queue_lock: Arc<AsyncMutex<()>>,
|
||||
/// `max: N` (and bounded `Parallel`): caps concurrent runs at N.
|
||||
/// `None` when no cap applies.
|
||||
semaphore: Option<Arc<Semaphore>>,
|
||||
}
|
||||
|
||||
impl RunState {
|
||||
/// Build run-state for an automation, sizing the concurrency semaphore
|
||||
/// from its `max:` field (only meaningful for `Queued`/`Parallel`).
|
||||
pub fn new(automation: &Automation) -> Self {
|
||||
let semaphore = automation
|
||||
.max
|
||||
.filter(|n| *n > 0)
|
||||
.map(|n| Arc::new(Semaphore::new(n)));
|
||||
Self {
|
||||
running: Arc::new(AtomicBool::new(false)),
|
||||
current: Arc::new(Mutex::new(None)),
|
||||
queue_lock: Arc::new(AsyncMutex::new(())),
|
||||
semaphore,
|
||||
}
|
||||
}
|
||||
|
||||
/// Dispatch one trigger for `automation` according to its `RunMode`.
|
||||
/// Honors Single re-entrancy, Restart cancel-and-replace, Queued
|
||||
/// serialization, and `max:` concurrency capping.
|
||||
pub fn dispatch(&self, hc: &HomeCore, automation: Arc<Automation>) {
|
||||
match automation.mode {
|
||||
RunMode::Single | RunMode::IgnoreFirst => self.dispatch_single(hc, automation),
|
||||
RunMode::Restart => self.dispatch_restart(hc, automation),
|
||||
RunMode::Queued => self.dispatch_queued(hc, automation),
|
||||
RunMode::Parallel => self.dispatch_parallel(hc, automation),
|
||||
}
|
||||
}
|
||||
|
||||
/// `Single`: skip if a run is already in flight; clear the flag on done.
|
||||
fn dispatch_single(&self, hc: &HomeCore, automation: Arc<Automation>) {
|
||||
if self
|
||||
.running
|
||||
.compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst)
|
||||
.is_err()
|
||||
{
|
||||
return; // already running — skip re-entrant trigger.
|
||||
}
|
||||
let hc = hc.clone();
|
||||
let running = Arc::clone(&self.running);
|
||||
tokio::spawn(async move {
|
||||
run_actions(&hc, &automation).await;
|
||||
running.store(false, Ordering::SeqCst);
|
||||
});
|
||||
}
|
||||
|
||||
/// `Restart`: abort the in-flight run (if any), then start a fresh one
|
||||
/// and record its abort handle.
|
||||
fn dispatch_restart(&self, hc: &HomeCore, automation: Arc<Automation>) {
|
||||
// Abort any prior run before starting the new one.
|
||||
if let Some(prev) = self.current.lock().unwrap().take() {
|
||||
prev.abort();
|
||||
}
|
||||
let hc = hc.clone();
|
||||
let slot = Arc::clone(&self.current);
|
||||
let handle = tokio::spawn(async move {
|
||||
run_actions(&hc, &automation).await;
|
||||
});
|
||||
*slot.lock().unwrap() = Some(handle.abort_handle());
|
||||
}
|
||||
|
||||
/// `Queued`: serialize via the per-automation async mutex. Each trigger
|
||||
/// spawns a task that waits its turn, so all triggers run in arrival
|
||||
/// order, one at a time — nothing is dropped.
|
||||
fn dispatch_queued(&self, hc: &HomeCore, automation: Arc<Automation>) {
|
||||
let hc = hc.clone();
|
||||
let lock = Arc::clone(&self.queue_lock);
|
||||
let sem = self.semaphore.clone();
|
||||
tokio::spawn(async move {
|
||||
// Optional `max:` cap still applies on top of serialization.
|
||||
let _permit = match &sem {
|
||||
Some(s) => Some(s.acquire().await.expect("semaphore not closed")),
|
||||
None => None,
|
||||
};
|
||||
let _guard = lock.lock().await; // FIFO turn — sequential execution.
|
||||
run_actions(&hc, &automation).await;
|
||||
});
|
||||
}
|
||||
|
||||
/// `Parallel`: spawn on every trigger, capped at `max:` if set.
|
||||
fn dispatch_parallel(&self, hc: &HomeCore, automation: Arc<Automation>) {
|
||||
let hc = hc.clone();
|
||||
let sem = self.semaphore.clone();
|
||||
tokio::spawn(async move {
|
||||
let _permit = match &sem {
|
||||
Some(s) => Some(s.acquire().await.expect("semaphore not closed")),
|
||||
None => None,
|
||||
};
|
||||
run_actions(&hc, &automation).await;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Execute an automation's action sequence once.
|
||||
async fn run_actions(hc: &HomeCore, automation: &Automation) {
|
||||
let mut exec_ctx = ExecutionContext::new(hc.clone(), automation.id.clone());
|
||||
for action in &automation.action {
|
||||
if let Err(e) = action.execute(&mut exec_ctx).await {
|
||||
eprintln!(
|
||||
"[homecore-automation] action error in {}: {e}",
|
||||
automation.id
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -257,3 +257,162 @@ async fn template_condition_evaluates_false_blocks_action() {
|
||||
sleep(Duration::from_millis(50)).await;
|
||||
assert_eq!(log.lock().unwrap().len(), 0, "false template condition should block the action");
|
||||
}
|
||||
|
||||
// ── ADR-162 (completes ADR-161 §A5): bounded RunModes ───────────────
|
||||
//
|
||||
// ADR-161 honored only Single/Parallel; Restart/Queued/max were honestly
|
||||
// documented as unbounded-parallel. These tests drive the real
|
||||
// Restart/Queued/max machinery and FAIL on the old engine (where every
|
||||
// non-Single mode spawned an unbounded parallel task).
|
||||
|
||||
/// A service that increments a live concurrency gauge on entry, sleeps,
|
||||
/// then decrements — recording the maximum concurrency ever observed and
|
||||
/// the total number of completed runs. Returns `(max_concurrency, completed)`.
|
||||
async fn register_gauge(
|
||||
hc: &HomeCore,
|
||||
domain: &str,
|
||||
service: &str,
|
||||
work: Duration,
|
||||
) -> (Arc<AtomicUsize>, Arc<AtomicUsize>) {
|
||||
let live = Arc::new(AtomicUsize::new(0));
|
||||
let max_seen = Arc::new(AtomicUsize::new(0));
|
||||
let completed = Arc::new(AtomicUsize::new(0));
|
||||
let (l, m, c) = (Arc::clone(&live), Arc::clone(&max_seen), Arc::clone(&completed));
|
||||
hc.services()
|
||||
.register(
|
||||
ServiceName::new(domain, service),
|
||||
FnHandler(move |_call: ServiceCall| {
|
||||
let (l, m, c) = (Arc::clone(&l), Arc::clone(&m), Arc::clone(&c));
|
||||
async move {
|
||||
let now = l.fetch_add(1, Ordering::SeqCst) + 1;
|
||||
m.fetch_max(now, Ordering::SeqCst);
|
||||
sleep(work).await;
|
||||
l.fetch_sub(1, Ordering::SeqCst);
|
||||
c.fetch_add(1, Ordering::SeqCst);
|
||||
Ok(serde_json::Value::Null)
|
||||
}
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
(max_seen, completed)
|
||||
}
|
||||
|
||||
fn state_auto(id: &str, entity: &str, domain: &str, service: &str) -> Automation {
|
||||
Automation::new(
|
||||
id,
|
||||
vec![Trigger::State {
|
||||
entity_id: EntityId::parse(entity).unwrap(),
|
||||
from: None,
|
||||
to: None,
|
||||
}],
|
||||
vec![Action::ServiceCall {
|
||||
domain: domain.into(),
|
||||
service: service.into(),
|
||||
data: serde_json::json!({}),
|
||||
}],
|
||||
)
|
||||
}
|
||||
|
||||
// ── Restart: cancels the in-flight run ─────────────────────────────
|
||||
#[tokio::test]
|
||||
async fn restart_mode_cancels_prior_run() {
|
||||
let hc = HomeCore::new();
|
||||
// Each run sleeps 300ms before recording completion.
|
||||
let (_max, completed) =
|
||||
register_gauge(&hc, "light", "slow", Duration::from_millis(300)).await;
|
||||
|
||||
let engine = AutomationEngine::new(hc.clone());
|
||||
let mut auto = state_auto("restart_auto", "switch.r", "light", "slow");
|
||||
auto.mode = RunMode::Restart;
|
||||
engine.register(auto);
|
||||
let _handle = engine.start();
|
||||
|
||||
// Trigger 1 starts the slow run.
|
||||
hc.states().set(EntityId::parse("switch.r").unwrap(), "a", serde_json::json!({}), Context::new());
|
||||
sleep(Duration::from_millis(80)).await;
|
||||
// Trigger 2 arrives mid-run → must ABORT run 1 and start run 2.
|
||||
hc.states().set(EntityId::parse("switch.r").unwrap(), "b", serde_json::json!({}), Context::new());
|
||||
|
||||
// Wait long enough for run 2 (started ~80ms in) to finish, but run 1
|
||||
// (aborted at ~80ms, would have finished at ~300ms) must NOT complete.
|
||||
sleep(Duration::from_millis(400)).await;
|
||||
assert_eq!(
|
||||
completed.load(Ordering::SeqCst),
|
||||
1,
|
||||
"Restart must cancel the in-flight run: exactly the restarted run completes (not both). \
|
||||
On the old engine both ran to completion → 2."
|
||||
);
|
||||
}
|
||||
|
||||
// ── Queued: serialize N rapid triggers, all run, never concurrent ──
|
||||
#[tokio::test]
|
||||
async fn queued_mode_runs_sequentially_not_concurrently() {
|
||||
let hc = HomeCore::new();
|
||||
let (max_seen, completed) =
|
||||
register_gauge(&hc, "light", "slow", Duration::from_millis(120)).await;
|
||||
|
||||
let engine = AutomationEngine::new(hc.clone());
|
||||
let mut auto = state_auto("queued_auto", "switch.q", "light", "slow");
|
||||
auto.mode = RunMode::Queued;
|
||||
engine.register(auto);
|
||||
let _handle = engine.start();
|
||||
|
||||
// Three rapid triggers.
|
||||
for v in ["a", "b", "c"] {
|
||||
hc.states().set(EntityId::parse("switch.q").unwrap(), v, serde_json::json!({}), Context::new());
|
||||
sleep(Duration::from_millis(10)).await;
|
||||
}
|
||||
|
||||
// 3 runs × 120ms serialized ≈ 360ms; wait generously.
|
||||
sleep(Duration::from_millis(600)).await;
|
||||
assert_eq!(
|
||||
completed.load(Ordering::SeqCst),
|
||||
3,
|
||||
"Queued must run every trigger (nothing dropped)"
|
||||
);
|
||||
assert_eq!(
|
||||
max_seen.load(Ordering::SeqCst),
|
||||
1,
|
||||
"Queued must never run two instances concurrently. On the old engine all 3 ran in \
|
||||
parallel → max concurrency 3."
|
||||
);
|
||||
}
|
||||
|
||||
// ── max: 2 → never more than 2 concurrent ──────────────────────────
|
||||
#[tokio::test]
|
||||
async fn max_two_caps_concurrency_at_two() {
|
||||
let hc = HomeCore::new();
|
||||
let (max_seen, completed) =
|
||||
register_gauge(&hc, "light", "slow", Duration::from_millis(150)).await;
|
||||
|
||||
let engine = AutomationEngine::new(hc.clone());
|
||||
let mut auto = state_auto("max_auto", "switch.m", "light", "slow");
|
||||
auto.mode = RunMode::Parallel;
|
||||
auto.max = Some(2);
|
||||
engine.register(auto);
|
||||
let _handle = engine.start();
|
||||
|
||||
// Four rapid triggers — without the cap all 4 would run at once.
|
||||
for v in ["a", "b", "c", "d"] {
|
||||
hc.states().set(EntityId::parse("switch.m").unwrap(), v, serde_json::json!({}), Context::new());
|
||||
sleep(Duration::from_millis(10)).await;
|
||||
}
|
||||
|
||||
sleep(Duration::from_millis(600)).await;
|
||||
assert_eq!(
|
||||
completed.load(Ordering::SeqCst),
|
||||
4,
|
||||
"max:2 must still run all 4 triggers (queued beyond the cap, not dropped)"
|
||||
);
|
||||
assert!(
|
||||
max_seen.load(Ordering::SeqCst) <= 2,
|
||||
"max:2 must never exceed 2 concurrent runs (observed {}). On the old engine all 4 ran \
|
||||
concurrently → 4.",
|
||||
max_seen.load(Ordering::SeqCst)
|
||||
);
|
||||
assert!(
|
||||
max_seen.load(Ordering::SeqCst) >= 2,
|
||||
"max:2 should reach the cap of 2 with 4 rapid triggers (observed {})",
|
||||
max_seen.load(Ordering::SeqCst)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -50,6 +50,15 @@ serde_json = "1"
|
||||
# UUIDs for config entry IDs in host_abi.rs.
|
||||
uuid = { version = "1", features = ["v4"] }
|
||||
|
||||
# ── ADR-162 P4: plugin signature + integrity verification ──────────────────
|
||||
# Reuses the same in-repo crypto stack as cog-ha-matter (witness_signing.rs):
|
||||
# Ed25519 over a SHA-256 module digest. All four are already in the workspace
|
||||
# Cargo.lock (cog-ha-matter / bfld pull them in) — no new external dep tree.
|
||||
ed25519-dalek = "2.1"
|
||||
sha2 = { workspace = true }
|
||||
hex = "0.4"
|
||||
base64 = "0.22"
|
||||
|
||||
# Optional Wasmtime runtime (P2, default-off — 30 MB dep).
|
||||
# Bumped from 25.0.3 → 42 to remediate RUSTSEC-2026-0095 and RUSTSEC-2026-0096
|
||||
# (Cranelift/Winch sandbox-escape CVEs, CVSS 9.0 — iter-11 security sprint HC-03/04).
|
||||
|
||||
@@ -25,6 +25,18 @@ pub enum PluginError {
|
||||
#[error("plugin setup failed: {0}")]
|
||||
SetupFailed(String),
|
||||
|
||||
/// The plugin failed signature/integrity verification (ADR-162 P4):
|
||||
/// hash mismatch, bad signature, untrusted publisher, or unsigned
|
||||
/// module under a non-dev trust policy.
|
||||
#[error("plugin signature rejected: {0}")]
|
||||
SignatureRejected(String),
|
||||
|
||||
/// A plugin attempted a host call (e.g. `hc_state_set`) on an entity
|
||||
/// it did not declare in `homecore_permissions` (ADR-162 P5 authority
|
||||
/// isolation).
|
||||
#[error("plugin permission denied: {0}")]
|
||||
PermissionDenied(String),
|
||||
|
||||
/// The plugin's `unload` hook returned an error.
|
||||
#[error("plugin unload failed: {0}")]
|
||||
UnloadFailed(String),
|
||||
|
||||
@@ -22,8 +22,16 @@
|
||||
//! - Host ABI wiring: `hc_state_get`, `hc_state_set`, `hc_event_fire`, etc.
|
||||
//! (P2 — requires ADR-127 state machine API freeze first).
|
||||
//! - Config entry lifecycle + hot-load (P3).
|
||||
//! - Cog registry distribution + Ed25519 signature verification (P4).
|
||||
//! - Permission enforcement (P5).
|
||||
//!
|
||||
//! ## Now enforced (ADR-162)
|
||||
//!
|
||||
//! - **Ed25519 signature + SHA-256 integrity verification (P4)** — see
|
||||
//! [`verify`]: the plugin load path hashes the real `.wasm` bytes, checks
|
||||
//! the manifest `wasm_module_hash`, verifies `wasm_module_sig` against
|
||||
//! `publisher_key`, and enforces a [`verify::PluginPolicy`] allowlist.
|
||||
//! - **Permission / authority isolation (P5)** — see [`permissions`]: a
|
||||
//! plugin's `hc_state_set` writes are gated against the entity domains/
|
||||
//! globs it declared in `homecore_permissions`.
|
||||
//!
|
||||
//! ## Feature flags
|
||||
//!
|
||||
@@ -35,9 +43,11 @@
|
||||
pub mod error;
|
||||
pub mod host_abi;
|
||||
pub mod manifest;
|
||||
pub mod permissions;
|
||||
pub mod plugin;
|
||||
pub mod registry;
|
||||
pub mod runtime;
|
||||
pub mod verify;
|
||||
|
||||
#[cfg(feature = "wasmtime")]
|
||||
pub mod wasmtime_runtime;
|
||||
@@ -45,9 +55,11 @@ pub mod wasmtime_runtime;
|
||||
pub use error::PluginError;
|
||||
pub use host_abi::{ConfigEntryJson, StateChangedEventJson};
|
||||
pub use manifest::{IotClass, IntegrationType, PluginManifest};
|
||||
pub use permissions::PermissionSet;
|
||||
pub use plugin::{HomeCorePlugin, PluginId};
|
||||
pub use registry::PluginRegistry;
|
||||
pub use runtime::{InProcessRuntime, LoadedPlugin, PluginRuntime};
|
||||
pub use verify::{verify_module, PluginPolicy};
|
||||
|
||||
#[cfg(feature = "wasmtime")]
|
||||
pub use wasmtime_runtime::{WasmPlugin, WasmtimeRuntime};
|
||||
|
||||
@@ -85,24 +85,26 @@ pub struct PluginManifest {
|
||||
|
||||
/// [HOMECORE] `sha256:<hex>` hash of the wasm binary.
|
||||
///
|
||||
/// **(P4 — not yet enforced, ADR-161/B5):** this field is parsed and
|
||||
/// round-tripped but is NOT verified before execution. The hash/sig
|
||||
/// gate lands in P4; until then the presence of this field implies no
|
||||
/// integrity guarantee.
|
||||
/// **(P4 — ENFORCED, ADR-162):** `verify::verify_module` computes the
|
||||
/// SHA-256 of the real `.wasm` bytes on load and rejects the module if
|
||||
/// it does not equal this hash (tamper detection). See [`crate::verify`].
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub wasm_module_hash: Option<String>,
|
||||
|
||||
/// [HOMECORE] Ed25519 signature of the wasm binary hash (`ed25519:<base64>`).
|
||||
///
|
||||
/// **(P4 — not yet enforced, ADR-161/B5):** parsed but never checked.
|
||||
/// No signature verification happens before a plugin runs.
|
||||
/// **(P4 — ENFORCED, ADR-162):** verified against `publisher_key` over
|
||||
/// the SHA-256 module digest before instantiation. A bad/forged/absent
|
||||
/// signature is rejected under the secure trust policy (the
|
||||
/// `cog-ha-matter::witness_signing` Ed25519 pattern is reused).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub wasm_module_sig: Option<String>,
|
||||
|
||||
/// [HOMECORE] Ed25519 public key of the plugin publisher.
|
||||
///
|
||||
/// **(P4 — not yet enforced, ADR-161/B5):** parsed but never used to
|
||||
/// verify `wasm_module_sig`.
|
||||
/// **(P4 — ENFORCED, ADR-162):** used to verify `wasm_module_sig`, and
|
||||
/// checked against the host's [`crate::verify::PluginPolicy`] trust
|
||||
/// allowlist — an unknown publisher is rejected by the secure default.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub publisher_key: Option<String>,
|
||||
|
||||
@@ -115,6 +117,12 @@ pub struct PluginManifest {
|
||||
pub host_imports_required: Vec<String>,
|
||||
|
||||
/// [HOMECORE] Coarse-grained permission claims (glob patterns).
|
||||
///
|
||||
/// **(P5 — ENFORCED, ADR-162):** `state:write:<glob>` (or a bare entity
|
||||
/// glob like `light.*`) grants are parsed into a
|
||||
/// [`crate::permissions::PermissionSet` ] and consulted by the
|
||||
/// `hc_state_set` host import. A plugin can no longer write an entity it
|
||||
/// did not declare; a plugin with no write grants can write nothing.
|
||||
#[serde(default)]
|
||||
pub homecore_permissions: Vec<PermissionClaim>,
|
||||
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
//! Plugin authority / capability isolation (ADR-162, P5).
|
||||
//!
|
||||
//! Wasmtime already gives a plugin **memory** isolation — it cannot read
|
||||
//! another plugin's linear memory. It does NOT, by itself, stop a plugin
|
||||
//! from using a host import to write any entity it likes. Before this fix
|
||||
//! `hc_state_set` happily let any plugin write `lock.front_door` or
|
||||
//! `alarm_control_panel.*`, and the manifest's `homecore_permissions`
|
||||
//! claims were parsed but **never consulted** (ADR-161 deferred P5).
|
||||
//!
|
||||
//! This module adds **authority isolation**: a plugin may only write
|
||||
//! entities its manifest declared. The host import consults a
|
||||
//! [`PermissionSet`] before applying any state write and returns a typed
|
||||
//! error to the guest (it does **not** panic the host) on a violation.
|
||||
//!
|
||||
//! ## Permission grammar
|
||||
//!
|
||||
//! Each entry in `homecore_permissions` is one of:
|
||||
//!
|
||||
//! * a bare entity glob — `"light.*"`, `"light.kitchen"`, `"*"`;
|
||||
//! * the explicit capability form `"state:write:<glob>"` (the form the
|
||||
//! ADR-128 manifest doc shows), e.g. `"state:write:sensor.*"`.
|
||||
//!
|
||||
//! A glob supports a single trailing `*` (HA-style domain wildcards:
|
||||
//! `light.*` matches every `light` entity) and a leading-or-bare `*`
|
||||
//! (`*` = everything). Exact strings match exactly. A plugin with **no**
|
||||
//! `state:write` entries can write **nothing** — the secure default.
|
||||
|
||||
use crate::manifest::PluginManifest;
|
||||
|
||||
/// The set of entity-write permissions a plugin holds, distilled from its
|
||||
/// manifest `homecore_permissions` at load time.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct PermissionSet {
|
||||
/// Glob patterns the plugin may write (state:write authority). Empty =
|
||||
/// the plugin may write nothing.
|
||||
write_globs: Vec<String>,
|
||||
}
|
||||
|
||||
impl PermissionSet {
|
||||
/// Build a permission set from a manifest's `homecore_permissions`.
|
||||
///
|
||||
/// Only `state:write` authority is modelled here (the host import this
|
||||
/// gates is `hc_state_set`). A bare glob (`"light.*"`) is treated as a
|
||||
/// write grant; the explicit `"state:write:<glob>"` form is also
|
||||
/// accepted. Other capability strings (`state:read:*`, future verbs)
|
||||
/// are ignored for write-gating purposes.
|
||||
pub fn from_manifest(manifest: &PluginManifest) -> Self {
|
||||
let mut write_globs = Vec::new();
|
||||
for claim in &manifest.homecore_permissions {
|
||||
let claim = claim.trim();
|
||||
if let Some(glob) = claim.strip_prefix("state:write:") {
|
||||
write_globs.push(glob.trim().to_string());
|
||||
} else if claim.starts_with("state:read:") {
|
||||
// read authority — not relevant to write gating.
|
||||
} else if !claim.is_empty() {
|
||||
// Bare glob — treat as a write grant.
|
||||
write_globs.push(claim.to_string());
|
||||
}
|
||||
}
|
||||
Self { write_globs }
|
||||
}
|
||||
|
||||
/// An all-allowing set (equivalent to a `"*"` grant). Used by the
|
||||
/// legacy permission-free `WasmtimeRuntime::load_wasm` path so existing
|
||||
/// callers/tests that do not supply a manifest keep working; the
|
||||
/// permission-gated path uses [`Self::from_manifest`].
|
||||
pub fn allow_all() -> Self {
|
||||
Self {
|
||||
write_globs: vec!["*".to_string()],
|
||||
}
|
||||
}
|
||||
|
||||
/// May this plugin write the given entity id (e.g. `"light.kitchen"`)?
|
||||
pub fn may_write(&self, entity_id: &str) -> bool {
|
||||
self.write_globs.iter().any(|g| glob_matches(g, entity_id))
|
||||
}
|
||||
|
||||
/// Number of write-grant globs (0 = can write nothing).
|
||||
pub fn write_grant_count(&self) -> usize {
|
||||
self.write_globs.len()
|
||||
}
|
||||
}
|
||||
|
||||
/// Match `entity_id` against a single glob pattern.
|
||||
///
|
||||
/// Supported forms:
|
||||
/// * `"*"` → matches anything.
|
||||
/// * `"light.*"` → trailing wildcard: any id with the `light.` prefix.
|
||||
/// * `"light.kitchen"` → exact match.
|
||||
fn glob_matches(pattern: &str, entity_id: &str) -> bool {
|
||||
if pattern == "*" {
|
||||
return true;
|
||||
}
|
||||
if let Some(prefix) = pattern.strip_suffix('*') {
|
||||
return entity_id.starts_with(prefix);
|
||||
}
|
||||
pattern == entity_id
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn manifest_with(perms: &[&str]) -> PluginManifest {
|
||||
PluginManifest {
|
||||
domain: "p".into(),
|
||||
name: "P".into(),
|
||||
version: "1".into(),
|
||||
documentation: None,
|
||||
iot_class: None,
|
||||
config_flow: false,
|
||||
integration_type: None,
|
||||
dependencies: vec![],
|
||||
requirements: vec![],
|
||||
wasm_module: None,
|
||||
wasm_module_hash: None,
|
||||
wasm_module_sig: None,
|
||||
publisher_key: None,
|
||||
min_homecore_version: None,
|
||||
host_imports_required: vec![],
|
||||
homecore_permissions: perms.iter().map(|s| s.to_string()).collect(),
|
||||
cog_id: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn domain_glob_allows_same_domain_only() {
|
||||
let ps = PermissionSet::from_manifest(&manifest_with(&["light.*"]));
|
||||
assert!(ps.may_write("light.kitchen"));
|
||||
assert!(ps.may_write("light.bedroom"));
|
||||
assert!(!ps.may_write("lock.front_door"));
|
||||
assert!(!ps.may_write("alarm_control_panel.home"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_permissions_can_write_nothing() {
|
||||
let ps = PermissionSet::from_manifest(&manifest_with(&[]));
|
||||
assert_eq!(ps.write_grant_count(), 0);
|
||||
assert!(!ps.may_write("light.kitchen"));
|
||||
assert!(!ps.may_write("sensor.temp"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_state_write_form_is_honored() {
|
||||
let ps = PermissionSet::from_manifest(&manifest_with(&["state:write:sensor.*"]));
|
||||
assert!(ps.may_write("sensor.temp"));
|
||||
assert!(!ps.may_write("light.kitchen"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_grants_do_not_confer_write() {
|
||||
let ps = PermissionSet::from_manifest(&manifest_with(&["state:read:lock.*"]));
|
||||
assert!(!ps.may_write("lock.front_door"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exact_entity_grant_is_scoped() {
|
||||
let ps = PermissionSet::from_manifest(&manifest_with(&["light.kitchen"]));
|
||||
assert!(ps.may_write("light.kitchen"));
|
||||
assert!(!ps.may_write("light.bedroom"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wildcard_grants_everything() {
|
||||
let ps = PermissionSet::from_manifest(&manifest_with(&["*"]));
|
||||
assert!(ps.may_write("lock.front_door"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,397 @@
|
||||
//! Plugin signature & integrity verification (ADR-162, P4).
|
||||
//!
|
||||
//! ADR-161/B5 honestly relabelled the manifest's `wasm_module_hash` /
|
||||
//! `wasm_module_sig` / `publisher_key` fields as "(P4 — not yet enforced)":
|
||||
//! they were parsed and round-tripped but **never checked** before a plugin
|
||||
//! ran. This module makes that claim TRUE — it is the real verification gate
|
||||
//! the plugin load path runs before instantiating any `.wasm` module.
|
||||
//!
|
||||
//! ## What is verified, in order
|
||||
//!
|
||||
//! 1. **Module hash** — SHA-256 of the actual `.wasm` bytes must equal the
|
||||
//! manifest's `wasm_module_hash` (`sha256:<hex>`). A tampered module
|
||||
//! (one byte changed) fails here.
|
||||
//! 2. **Ed25519 signature** — `wasm_module_sig` (`ed25519:<base64>`, 64-byte
|
||||
//! raw signature) must verify over the **32-byte SHA-256 digest** under
|
||||
//! the `publisher_key` (`ed25519:<base64>`, 32-byte raw verifying key).
|
||||
//! 3. **Trust policy** — the `publisher_key` must be on the configured
|
||||
//! allowlist, unless [`PluginPolicy::AllowUnsigned`] is in force (a loud
|
||||
//! dev escape hatch).
|
||||
//!
|
||||
//! The crypto mirrors the in-repo Ed25519 pattern from
|
||||
//! `cog-ha-matter::witness_signing` (same `ed25519-dalek` 2.x API, same
|
||||
//! deterministic-test-key convention). SHA-256 matches the `sha256:` prefix
|
||||
//! the manifest doc already declared for `wasm_module_hash`, and the
|
||||
//! `cog-ha-matter` cog manifest's `binary_sha256` hex convention.
|
||||
//!
|
||||
//! ## Secure default
|
||||
//!
|
||||
//! [`PluginPolicy::trusted`] (the production constructor) **rejects**:
|
||||
//! * an unsigned module (no hash / sig / key),
|
||||
//! * a signature from a key not on the allowlist,
|
||||
//! * any hash or signature mismatch.
|
||||
//!
|
||||
//! Only [`PluginPolicy::AllowUnsigned`] loosens this, and every load it
|
||||
//! waves through emits a `warn`-level log line so it cannot pass silently.
|
||||
|
||||
use base64::Engine as _;
|
||||
use ed25519_dalek::{Signature, Verifier, VerifyingKey};
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
use crate::error::PluginError;
|
||||
use crate::manifest::PluginManifest;
|
||||
|
||||
/// Trust policy governing which plugins may load.
|
||||
///
|
||||
/// The production path uses [`PluginPolicy::trusted`] with an explicit
|
||||
/// allowlist of publisher verifying keys. [`PluginPolicy::AllowUnsigned`]
|
||||
/// is the dev escape hatch — it loads anything (even unsigned modules) but
|
||||
/// logs a loud warning per load.
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum PluginPolicy {
|
||||
/// Secure default: a plugin loads only if its module hash matches, its
|
||||
/// Ed25519 signature verifies, AND its publisher key is in this
|
||||
/// allowlist. Each entry is the 32-byte raw Ed25519 verifying key.
|
||||
Trusted { allowlist: Vec<[u8; 32]> },
|
||||
/// Dev-only: skip signature/allowlist enforcement. Hash is still
|
||||
/// checked when a `wasm_module_hash` is present (cheap integrity), but
|
||||
/// unsigned / unknown-publisher modules are allowed. Every load logs a
|
||||
/// loud `warn`.
|
||||
AllowUnsigned,
|
||||
}
|
||||
|
||||
impl PluginPolicy {
|
||||
/// Construct the secure (production) policy from a list of trusted
|
||||
/// publisher keys, each encoded as `ed25519:<base64>` (the same form
|
||||
/// the manifest `publisher_key` uses).
|
||||
pub fn trusted(publisher_keys: &[&str]) -> Result<Self, PluginError> {
|
||||
let mut allowlist = Vec::with_capacity(publisher_keys.len());
|
||||
for k in publisher_keys {
|
||||
allowlist.push(decode_verifying_key(k)?.to_bytes());
|
||||
}
|
||||
Ok(PluginPolicy::Trusted { allowlist })
|
||||
}
|
||||
|
||||
/// Secure policy that trusts no publisher at all — every signed or
|
||||
/// unsigned module is rejected. Useful as a strict default.
|
||||
pub fn deny_all() -> Self {
|
||||
PluginPolicy::Trusted { allowlist: vec![] }
|
||||
}
|
||||
|
||||
fn is_dev(&self) -> bool {
|
||||
matches!(self, PluginPolicy::AllowUnsigned)
|
||||
}
|
||||
|
||||
fn allows(&self, key: &VerifyingKey) -> bool {
|
||||
match self {
|
||||
PluginPolicy::AllowUnsigned => true,
|
||||
PluginPolicy::Trusted { allowlist } => {
|
||||
allowlist.iter().any(|k| k == &key.to_bytes())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Verify a `.wasm` module's integrity and signature against its manifest,
|
||||
/// under the given trust `policy`. Returns `Ok(())` only if the module may
|
||||
/// be instantiated.
|
||||
///
|
||||
/// On [`PluginPolicy::AllowUnsigned`] this still checks any present hash,
|
||||
/// but waves through missing/untrusted signatures with a loud `warn`.
|
||||
pub fn verify_module(
|
||||
manifest: &PluginManifest,
|
||||
wasm_bytes: &[u8],
|
||||
policy: &PluginPolicy,
|
||||
) -> Result<(), PluginError> {
|
||||
let signed = manifest.wasm_module_hash.is_some()
|
||||
|| manifest.wasm_module_sig.is_some()
|
||||
|| manifest.publisher_key.is_some();
|
||||
|
||||
if !signed {
|
||||
// No integrity material at all.
|
||||
if policy.is_dev() {
|
||||
eprintln!(
|
||||
"[PLUGIN WARN] loading UNSIGNED plugin `{}` — no wasm_module_hash/sig/publisher_key. \
|
||||
AllowUnsigned dev policy is active; this is INSECURE and must not be used in production.",
|
||||
manifest.domain
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
return Err(PluginError::SignatureRejected(format!(
|
||||
"plugin `{}` is unsigned (no wasm_module_hash/sig/publisher_key) and the trust policy \
|
||||
rejects unsigned modules; set PluginPolicy::AllowUnsigned to override in dev",
|
||||
manifest.domain
|
||||
)));
|
||||
}
|
||||
|
||||
// (1) Hash check — always enforced when a hash is declared.
|
||||
let digest = sha256_digest(wasm_bytes);
|
||||
if let Some(declared) = &manifest.wasm_module_hash {
|
||||
let expected = parse_sha256(declared)?;
|
||||
if expected != digest {
|
||||
return Err(PluginError::SignatureRejected(format!(
|
||||
"plugin `{}` wasm hash mismatch: module does not match manifest wasm_module_hash \
|
||||
(tampered or wrong binary)",
|
||||
manifest.domain
|
||||
)));
|
||||
}
|
||||
} else if !policy.is_dev() {
|
||||
return Err(PluginError::SignatureRejected(format!(
|
||||
"plugin `{}` carries a signature/publisher_key but no wasm_module_hash to bind it to",
|
||||
manifest.domain
|
||||
)));
|
||||
}
|
||||
|
||||
// (2) Signature check + (3) allowlist.
|
||||
match (&manifest.wasm_module_sig, &manifest.publisher_key) {
|
||||
(Some(sig_str), Some(key_str)) => {
|
||||
let key = decode_verifying_key(key_str)?;
|
||||
let sig = decode_signature(sig_str)?;
|
||||
key.verify(&digest, &sig).map_err(|_| {
|
||||
PluginError::SignatureRejected(format!(
|
||||
"plugin `{}` Ed25519 signature does not verify over the module hash under \
|
||||
publisher_key",
|
||||
manifest.domain
|
||||
))
|
||||
})?;
|
||||
if !policy.allows(&key) {
|
||||
if policy.is_dev() {
|
||||
eprintln!(
|
||||
"[PLUGIN WARN] plugin `{}` is validly signed but its publisher_key is NOT on \
|
||||
the trust allowlist; AllowUnsigned dev policy loads it anyway.",
|
||||
manifest.domain
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
return Err(PluginError::SignatureRejected(format!(
|
||||
"plugin `{}` is validly signed but its publisher_key is not on the trust \
|
||||
allowlist (untrusted publisher)",
|
||||
manifest.domain
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
_ => {
|
||||
// Hash present but signature/key incomplete.
|
||||
if policy.is_dev() {
|
||||
eprintln!(
|
||||
"[PLUGIN WARN] plugin `{}` has a hash but no complete Ed25519 signature; \
|
||||
AllowUnsigned dev policy loads it anyway.",
|
||||
manifest.domain
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
Err(PluginError::SignatureRejected(format!(
|
||||
"plugin `{}` is missing a complete wasm_module_sig + publisher_key pair; the trust \
|
||||
policy requires a valid signature",
|
||||
manifest.domain
|
||||
)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// SHA-256 of `bytes` as a 32-byte digest.
|
||||
fn sha256_digest(bytes: &[u8]) -> [u8; 32] {
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(bytes);
|
||||
hasher.finalize().into()
|
||||
}
|
||||
|
||||
/// Parse a `sha256:<hex>` manifest hash into a 32-byte digest.
|
||||
fn parse_sha256(s: &str) -> Result<[u8; 32], PluginError> {
|
||||
let hex_part = s.strip_prefix("sha256:").ok_or_else(|| {
|
||||
PluginError::InvalidManifest(format!(
|
||||
"wasm_module_hash must be `sha256:<hex>`, got {s:?}"
|
||||
))
|
||||
})?;
|
||||
let raw = hex::decode(hex_part).map_err(|e| {
|
||||
PluginError::InvalidManifest(format!("wasm_module_hash hex decode: {e}"))
|
||||
})?;
|
||||
raw.try_into().map_err(|v: Vec<u8>| {
|
||||
PluginError::InvalidManifest(format!(
|
||||
"wasm_module_hash must decode to 32 bytes, got {}",
|
||||
v.len()
|
||||
))
|
||||
})
|
||||
}
|
||||
|
||||
/// Decode an `ed25519:<base64>` 32-byte verifying key.
|
||||
fn decode_verifying_key(s: &str) -> Result<VerifyingKey, PluginError> {
|
||||
let b64 = s.strip_prefix("ed25519:").ok_or_else(|| {
|
||||
PluginError::InvalidManifest(format!(
|
||||
"publisher_key must be `ed25519:<base64>`, got {s:?}"
|
||||
))
|
||||
})?;
|
||||
let raw = base64::engine::general_purpose::STANDARD
|
||||
.decode(b64)
|
||||
.map_err(|e| PluginError::InvalidManifest(format!("publisher_key base64: {e}")))?;
|
||||
let bytes: [u8; 32] = raw.try_into().map_err(|v: Vec<u8>| {
|
||||
PluginError::InvalidManifest(format!(
|
||||
"publisher_key must decode to 32 bytes, got {}",
|
||||
v.len()
|
||||
))
|
||||
})?;
|
||||
VerifyingKey::from_bytes(&bytes)
|
||||
.map_err(|e| PluginError::InvalidManifest(format!("publisher_key not a valid Ed25519 point: {e}")))
|
||||
}
|
||||
|
||||
/// Decode an `ed25519:<base64>` 64-byte signature.
|
||||
fn decode_signature(s: &str) -> Result<Signature, PluginError> {
|
||||
let b64 = s.strip_prefix("ed25519:").ok_or_else(|| {
|
||||
PluginError::InvalidManifest(format!(
|
||||
"wasm_module_sig must be `ed25519:<base64>`, got {s:?}"
|
||||
))
|
||||
})?;
|
||||
let raw = base64::engine::general_purpose::STANDARD
|
||||
.decode(b64)
|
||||
.map_err(|e| PluginError::InvalidManifest(format!("wasm_module_sig base64: {e}")))?;
|
||||
let bytes: [u8; 64] = raw.try_into().map_err(|v: Vec<u8>| {
|
||||
PluginError::InvalidManifest(format!(
|
||||
"wasm_module_sig must decode to 64 bytes, got {}",
|
||||
v.len()
|
||||
))
|
||||
})?;
|
||||
Ok(Signature::from_bytes(&bytes))
|
||||
}
|
||||
|
||||
/// Encode a SHA-256 digest as the manifest `sha256:<hex>` form. Exposed so
|
||||
/// tooling (and tests) can produce a manifest hash for real `.wasm` bytes.
|
||||
pub fn encode_sha256(wasm_bytes: &[u8]) -> String {
|
||||
format!("sha256:{}", hex::encode(sha256_digest(wasm_bytes)))
|
||||
}
|
||||
|
||||
/// Encode an Ed25519 verifying key as the manifest `ed25519:<base64>` form.
|
||||
pub fn encode_verifying_key(key: &VerifyingKey) -> String {
|
||||
format!(
|
||||
"ed25519:{}",
|
||||
base64::engine::general_purpose::STANDARD.encode(key.to_bytes())
|
||||
)
|
||||
}
|
||||
|
||||
/// Encode an Ed25519 signature as the manifest `ed25519:<base64>` form.
|
||||
pub fn encode_signature(sig: &Signature) -> String {
|
||||
format!(
|
||||
"ed25519:{}",
|
||||
base64::engine::general_purpose::STANDARD.encode(sig.to_bytes())
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use ed25519_dalek::{Signer, SigningKey};
|
||||
|
||||
/// Deterministic publisher key (mirrors witness_signing's fixed-bytes
|
||||
/// seed convention — DO NOT use in production).
|
||||
fn publisher() -> SigningKey {
|
||||
SigningKey::from_bytes(b"homecore-plugins-pub-test-seed--")
|
||||
}
|
||||
|
||||
fn attacker() -> SigningKey {
|
||||
SigningKey::from_bytes(b"homecore-plugins-attacker-seed--")
|
||||
}
|
||||
|
||||
/// Sign `wasm_bytes` with `key` and produce a manifest carrying the real
|
||||
/// hash + signature + publisher key.
|
||||
fn signed_manifest(wasm_bytes: &[u8], key: &SigningKey) -> PluginManifest {
|
||||
let digest = sha256_digest(wasm_bytes);
|
||||
let sig = key.sign(&digest);
|
||||
PluginManifest {
|
||||
domain: "demo".into(),
|
||||
name: "Demo".into(),
|
||||
version: "1.0.0".into(),
|
||||
documentation: None,
|
||||
iot_class: None,
|
||||
config_flow: false,
|
||||
integration_type: None,
|
||||
dependencies: vec![],
|
||||
requirements: vec![],
|
||||
wasm_module: Some("demo.wasm".into()),
|
||||
wasm_module_hash: Some(encode_sha256(wasm_bytes)),
|
||||
wasm_module_sig: Some(encode_signature(&sig)),
|
||||
publisher_key: Some(encode_verifying_key(&key.verifying_key())),
|
||||
min_homecore_version: None,
|
||||
host_imports_required: vec![],
|
||||
homecore_permissions: vec![],
|
||||
cog_id: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn valid_sig_from_trusted_key_passes() {
|
||||
let wasm = b"\0asm\x01\0\0\0fake module bytes";
|
||||
let key = publisher();
|
||||
let manifest = signed_manifest(wasm, &key);
|
||||
let policy =
|
||||
PluginPolicy::trusted(&[&encode_verifying_key(&key.verifying_key())]).unwrap();
|
||||
verify_module(&manifest, wasm, &policy).expect("trusted signed module should load");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tampered_module_is_rejected() {
|
||||
let wasm = b"\0asm\x01\0\0\0fake module bytes";
|
||||
let key = publisher();
|
||||
let manifest = signed_manifest(wasm, &key);
|
||||
let policy =
|
||||
PluginPolicy::trusted(&[&encode_verifying_key(&key.verifying_key())]).unwrap();
|
||||
// Flip a byte: hash no longer matches.
|
||||
let tampered = b"\0asm\x01\0\0\0FAKE module bytes";
|
||||
let err = verify_module(&manifest, tampered, &policy).unwrap_err();
|
||||
assert!(matches!(err, PluginError::SignatureRejected(_)), "got {err:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn valid_sig_from_untrusted_key_is_rejected() {
|
||||
let wasm = b"\0asm\x01\0\0\0fake module bytes";
|
||||
// Signed correctly by the attacker, but the attacker is not trusted.
|
||||
let manifest = signed_manifest(wasm, &attacker());
|
||||
let policy =
|
||||
PluginPolicy::trusted(&[&encode_verifying_key(&publisher().verifying_key())]).unwrap();
|
||||
let err = verify_module(&manifest, wasm, &policy).unwrap_err();
|
||||
assert!(matches!(err, PluginError::SignatureRejected(_)), "got {err:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn forged_signature_is_rejected() {
|
||||
// Manifest claims the trusted publisher_key but the signature was
|
||||
// produced by the attacker (a forged sig under a trusted identity).
|
||||
let wasm = b"\0asm\x01\0\0\0fake module bytes";
|
||||
let digest = sha256_digest(wasm);
|
||||
let forged = attacker().sign(&digest);
|
||||
let mut manifest = signed_manifest(wasm, &publisher());
|
||||
manifest.wasm_module_sig = Some(encode_signature(&forged));
|
||||
let policy =
|
||||
PluginPolicy::trusted(&[&encode_verifying_key(&publisher().verifying_key())]).unwrap();
|
||||
let err = verify_module(&manifest, wasm, &policy).unwrap_err();
|
||||
assert!(matches!(err, PluginError::SignatureRejected(_)), "got {err:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unsigned_module_rejected_under_default_policy() {
|
||||
let wasm = b"\0asm\x01\0\0\0unsigned";
|
||||
let manifest = PluginManifest {
|
||||
domain: "u".into(),
|
||||
name: "U".into(),
|
||||
version: "1".into(),
|
||||
documentation: None,
|
||||
iot_class: None,
|
||||
config_flow: false,
|
||||
integration_type: None,
|
||||
dependencies: vec![],
|
||||
requirements: vec![],
|
||||
wasm_module: Some("u.wasm".into()),
|
||||
wasm_module_hash: None,
|
||||
wasm_module_sig: None,
|
||||
publisher_key: None,
|
||||
min_homecore_version: None,
|
||||
host_imports_required: vec![],
|
||||
homecore_permissions: vec![],
|
||||
cog_id: None,
|
||||
};
|
||||
let err = verify_module(&manifest, wasm, &PluginPolicy::deny_all()).unwrap_err();
|
||||
assert!(matches!(err, PluginError::SignatureRejected(_)), "got {err:?}");
|
||||
// ...but AllowUnsigned loads it (with a warn).
|
||||
verify_module(&manifest, wasm, &PluginPolicy::AllowUnsigned)
|
||||
.expect("AllowUnsigned should load an unsigned module");
|
||||
}
|
||||
}
|
||||
@@ -30,16 +30,27 @@ use wasmtime::{Engine, Linker, Module, Store};
|
||||
|
||||
use crate::error::PluginError;
|
||||
use crate::host_abi::{LogLevel, StateChangedEventJson, MAX_ABI_BUFFER_BYTES};
|
||||
use crate::manifest::PluginManifest;
|
||||
use crate::permissions::PermissionSet;
|
||||
use crate::verify::{verify_module, PluginPolicy};
|
||||
|
||||
// ── Store data ─────────────────────────────────────────────────────────────
|
||||
|
||||
/// Per-plugin state stored inside the Wasmtime [`Store`].
|
||||
///
|
||||
/// Wasmtime's `Store<T>` exposes `T` to host functions via `caller.data()`.
|
||||
/// We store the `HomeCore` handle and a list of subscribed entity IDs here.
|
||||
/// We store the `HomeCore` handle, a list of subscribed entity IDs, and the
|
||||
/// plugin's write-permission set (ADR-162 P5 authority isolation).
|
||||
pub struct PluginStoreData {
|
||||
pub hc: HomeCore,
|
||||
pub subscriptions: Vec<String>,
|
||||
/// Entity-write authority distilled from the manifest's
|
||||
/// `homecore_permissions`. Consulted by `hc_state_set`. The
|
||||
/// permission-free [`WasmtimeRuntime::load_wasm`] path installs an
|
||||
/// all-allowing set for backward compatibility; the
|
||||
/// [`WasmtimeRuntime::load_plugin`] path installs the manifest's
|
||||
/// declared set.
|
||||
pub permissions: PermissionSet,
|
||||
}
|
||||
|
||||
// ── WasmtimeRuntime ────────────────────────────────────────────────────────
|
||||
@@ -59,14 +70,53 @@ impl WasmtimeRuntime {
|
||||
Ok(Self { engine })
|
||||
}
|
||||
|
||||
/// Compile and instantiate a WASM plugin from raw bytes.
|
||||
/// Compile and instantiate a WASM plugin from raw bytes, **without**
|
||||
/// signature verification or permission gating (the plugin gets
|
||||
/// all-write authority).
|
||||
///
|
||||
/// Returns a [`WasmPlugin`] handle that owns the `Store` and the
|
||||
/// `Instance`. The handle can be used to call into the WASM module.
|
||||
/// Retained for the legacy/test path and first-party trusted modules.
|
||||
/// Production plugin loading should go through [`Self::load_plugin`],
|
||||
/// which verifies the module (ADR-162 P4) and scopes its write
|
||||
/// authority to the manifest (P5).
|
||||
pub fn load_wasm(
|
||||
&self,
|
||||
wasm_bytes: &[u8],
|
||||
hc: HomeCore,
|
||||
) -> Result<WasmPlugin, PluginError> {
|
||||
self.instantiate(wasm_bytes, hc, PermissionSet::allow_all())
|
||||
}
|
||||
|
||||
/// Verify and instantiate a WASM plugin from its manifest + raw bytes.
|
||||
///
|
||||
/// This is the secure load path (ADR-162):
|
||||
/// 1. **P4** — [`verify_module`] checks the SHA-256 module hash and
|
||||
/// Ed25519 signature against the manifest under `policy`. A
|
||||
/// tampered module, bad/forged signature, untrusted publisher, or
|
||||
/// (under the secure default) an unsigned module is rejected
|
||||
/// **before** any guest code runs.
|
||||
/// 2. **P5** — the plugin's `homecore_permissions` are distilled into
|
||||
/// a [`PermissionSet`] installed in the store, so `hc_state_set`
|
||||
/// can only write entities the plugin declared.
|
||||
pub fn load_plugin(
|
||||
&self,
|
||||
manifest: &PluginManifest,
|
||||
wasm_bytes: &[u8],
|
||||
hc: HomeCore,
|
||||
policy: &PluginPolicy,
|
||||
) -> Result<WasmPlugin, PluginError> {
|
||||
// P4: verify before instantiation.
|
||||
verify_module(manifest, wasm_bytes, policy)?;
|
||||
// P5: scope write authority to the manifest's declared permissions.
|
||||
let permissions = PermissionSet::from_manifest(manifest);
|
||||
self.instantiate(wasm_bytes, hc, permissions)
|
||||
}
|
||||
|
||||
/// Shared compile + instantiate, installing the given permission set.
|
||||
fn instantiate(
|
||||
&self,
|
||||
wasm_bytes: &[u8],
|
||||
hc: HomeCore,
|
||||
permissions: PermissionSet,
|
||||
) -> Result<WasmPlugin, PluginError> {
|
||||
let module = Module::new(&self.engine, wasm_bytes)
|
||||
.map_err(|e| PluginError::RuntimeError(format!("WASM compile: {e}")))?;
|
||||
@@ -77,6 +127,7 @@ impl WasmtimeRuntime {
|
||||
let store_data = PluginStoreData {
|
||||
hc,
|
||||
subscriptions: Vec::new(),
|
||||
permissions,
|
||||
};
|
||||
let mut store = Store::new(&self.engine, store_data);
|
||||
|
||||
@@ -183,7 +234,9 @@ fn register_hc_state_get(
|
||||
/// Sets the state for the entity whose UTF-8 ID is at `[eid_ptr,eid_ptr+eid_len)`.
|
||||
/// The new state string is at `[state_ptr,state_ptr+state_len)`.
|
||||
/// The attributes JSON is at `[attrs_ptr,attrs_ptr+attrs_len)`.
|
||||
/// Returns 0 on success, negative on error.
|
||||
/// Returns 0 on success, negative on error: -1 (bad memory/args), -2
|
||||
/// (invalid entity id), -3 (permission denied — entity not in the
|
||||
/// plugin's declared `homecore_permissions`, ADR-162 P5).
|
||||
fn register_hc_state_set(
|
||||
linker: &mut Linker<PluginStoreData>,
|
||||
) -> Result<(), PluginError> {
|
||||
@@ -224,6 +277,20 @@ fn register_hc_state_set(
|
||||
Ok(id) => id,
|
||||
Err(_) => return -2,
|
||||
};
|
||||
|
||||
// ── P5 authority isolation (ADR-162) ──────────────────────
|
||||
// Reject a write to an entity the plugin did not declare in
|
||||
// `homecore_permissions`. Return a typed error code to the
|
||||
// guest (-3); do NOT panic the host.
|
||||
if !caller.data().permissions.may_write(entity_id.as_str()) {
|
||||
eprintln!(
|
||||
"[PLUGIN WARN] denied hc_state_set on `{}` — not in plugin's declared \
|
||||
homecore_permissions (P5 authority isolation)",
|
||||
entity_id.as_str()
|
||||
);
|
||||
return -3;
|
||||
}
|
||||
|
||||
let attrs: serde_json::Value =
|
||||
serde_json::from_str(&attrs_str).unwrap_or(serde_json::json!({}));
|
||||
|
||||
|
||||
@@ -371,4 +371,259 @@ mod wasmtime_tests {
|
||||
let r = plugin.call_setup("{}").expect("setup");
|
||||
assert_eq!(r, 0);
|
||||
}
|
||||
|
||||
// ── ADR-162 P4: signature/integrity verification ────────────────────────
|
||||
//
|
||||
// Each of these FAILS on the pre-ADR-162 code, which had no
|
||||
// `load_plugin` / `verify_module` at all — the manifest hash/sig/key
|
||||
// were parsed and discarded. They drive the real verification gate.
|
||||
|
||||
use ed25519_dalek::{Signer, SigningKey};
|
||||
use homecore_plugins::manifest::PluginManifest;
|
||||
use homecore_plugins::verify::{encode_sha256, encode_signature, encode_verifying_key};
|
||||
use homecore_plugins::PluginPolicy;
|
||||
|
||||
/// Deterministic publisher key (fixed seed — never use in production;
|
||||
/// mirrors the cog-ha-matter witness_signing test-key convention).
|
||||
fn publisher_key() -> SigningKey {
|
||||
SigningKey::from_bytes(b"hc-plugins-integration-pub-seed-")
|
||||
}
|
||||
|
||||
fn untrusted_key() -> SigningKey {
|
||||
SigningKey::from_bytes(b"hc-plugins-integration-evil-seed")
|
||||
}
|
||||
|
||||
/// A minimal valid module that writes `light.kitchen` on setup, plus a
|
||||
/// `light.*` permission grant. Returns the WAT source.
|
||||
const WRITE_LIGHT_WAT: &str = r#"
|
||||
(module
|
||||
(import "env" "hc_state_get" (func $hc_state_get (param i32 i32 i32 i32) (result i32)))
|
||||
(import "env" "hc_state_set" (func $hc_state_set (param i32 i32 i32 i32 i32 i32) (result i32)))
|
||||
(import "env" "hc_state_subscribe" (func $hc_state_subscribe (param i32 i32) (result i32)))
|
||||
(import "env" "hc_log" (func $hc_log (param i32 i32 i32)))
|
||||
(memory (export "memory") 1)
|
||||
(global $bump (mut i32) (i32.const 512))
|
||||
(data (i32.const 0) "light.kitchen")
|
||||
(data (i32.const 64) "on")
|
||||
(data (i32.const 128) "{}")
|
||||
(func (export "alloc") (param i32) (result i32)
|
||||
(local $p i32)
|
||||
(local.set $p (global.get $bump))
|
||||
(global.set $bump (i32.add (global.get $bump) (local.get 0)))
|
||||
(local.get $p))
|
||||
(func (export "dealloc") (param i32 i32))
|
||||
(func (export "plugin_setup") (param i32 i32) (result i32)
|
||||
(call $hc_state_set
|
||||
(i32.const 0) (i32.const 13) ;; "light.kitchen"
|
||||
(i32.const 64) (i32.const 2) ;; "on"
|
||||
(i32.const 128) (i32.const 2)) ;; "{}"
|
||||
drop
|
||||
(i32.const 0))
|
||||
(func (export "plugin_handle_state_changed") (param i32 i32) (result i32) (i32.const 0))
|
||||
)
|
||||
"#;
|
||||
|
||||
/// Build a manifest signed by `key` over the SHA-256 of `wasm_bytes`,
|
||||
/// with the given write-permission grants.
|
||||
fn signed_manifest(
|
||||
wasm_bytes: &[u8],
|
||||
key: &SigningKey,
|
||||
perms: &[&str],
|
||||
) -> PluginManifest {
|
||||
use sha2::{Digest, Sha256};
|
||||
let digest: [u8; 32] = Sha256::digest(wasm_bytes).into();
|
||||
let sig = key.sign(&digest);
|
||||
let mut m = PluginManifest::parse_json(
|
||||
r#"{"domain":"demo","name":"Demo","version":"1.0.0"}"#,
|
||||
)
|
||||
.unwrap();
|
||||
m.wasm_module = Some("demo.wasm".into());
|
||||
m.wasm_module_hash = Some(encode_sha256(wasm_bytes));
|
||||
m.wasm_module_sig = Some(encode_signature(&sig));
|
||||
m.publisher_key = Some(encode_verifying_key(&key.verifying_key()));
|
||||
m.homecore_permissions = perms.iter().map(|s| s.to_string()).collect();
|
||||
m
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn p4_valid_sig_from_trusted_key_loads() {
|
||||
let wasm = wat::parse_str(WRITE_LIGHT_WAT).expect("WAT");
|
||||
let key = publisher_key();
|
||||
let manifest = signed_manifest(&wasm, &key, &["light.*"]);
|
||||
let policy =
|
||||
PluginPolicy::trusted(&[&encode_verifying_key(&key.verifying_key())]).unwrap();
|
||||
|
||||
let rt = WasmtimeRuntime::new().expect("rt");
|
||||
let hc = HomeCore::new();
|
||||
rt.load_plugin(&manifest, &wasm, hc, &policy)
|
||||
.expect("a validly-signed, trusted plugin must load");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn p4_tampered_module_is_rejected() {
|
||||
let wasm = wat::parse_str(WRITE_LIGHT_WAT).expect("WAT");
|
||||
let key = publisher_key();
|
||||
// Manifest signs the original bytes; we then load DIFFERENT bytes.
|
||||
let manifest = signed_manifest(&wasm, &key, &["light.*"]);
|
||||
let policy =
|
||||
PluginPolicy::trusted(&[&encode_verifying_key(&key.verifying_key())]).unwrap();
|
||||
|
||||
// Re-compile a byte-different module (writes "off" not "on").
|
||||
let tampered_src = WRITE_LIGHT_WAT.replace(r#""on""#, r#""of""#);
|
||||
let tampered = wat::parse_str(&tampered_src).expect("WAT");
|
||||
assert_ne!(wasm, tampered, "test bug: bytes must differ");
|
||||
|
||||
let rt = WasmtimeRuntime::new().expect("rt");
|
||||
let hc = HomeCore::new();
|
||||
match rt.load_plugin(&manifest, &tampered, hc, &policy) {
|
||||
Err(homecore_plugins::PluginError::SignatureRejected(_)) => {}
|
||||
Ok(_) => panic!("tampered module must be rejected (hash mismatch), but it loaded"),
|
||||
Err(e) => panic!("expected SignatureRejected, got {e:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn p4_valid_sig_from_untrusted_key_is_rejected() {
|
||||
let wasm = wat::parse_str(WRITE_LIGHT_WAT).expect("WAT");
|
||||
// Correctly signed by the untrusted key — but it is not on the allowlist.
|
||||
let manifest = signed_manifest(&wasm, &untrusted_key(), &["light.*"]);
|
||||
let policy =
|
||||
PluginPolicy::trusted(&[&encode_verifying_key(&publisher_key().verifying_key())])
|
||||
.unwrap();
|
||||
|
||||
let rt = WasmtimeRuntime::new().expect("rt");
|
||||
let hc = HomeCore::new();
|
||||
match rt.load_plugin(&manifest, &wasm, hc, &policy) {
|
||||
Err(homecore_plugins::PluginError::SignatureRejected(_)) => {}
|
||||
Ok(_) => panic!("untrusted publisher must be rejected, but it loaded"),
|
||||
Err(e) => panic!("expected SignatureRejected, got {e:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn p4_unsigned_module_rejected_by_default_loads_only_under_allow_unsigned() {
|
||||
let wasm = wat::parse_str(WRITE_LIGHT_WAT).expect("WAT");
|
||||
let mut manifest = PluginManifest::parse_json(
|
||||
r#"{"domain":"u","name":"U","version":"1"}"#,
|
||||
)
|
||||
.unwrap();
|
||||
manifest.wasm_module = Some("u.wasm".into());
|
||||
manifest.homecore_permissions = vec!["light.*".into()];
|
||||
// No hash/sig/key → unsigned.
|
||||
|
||||
let rt = WasmtimeRuntime::new().expect("rt");
|
||||
// Secure default: rejected.
|
||||
match rt.load_plugin(&manifest, &wasm, HomeCore::new(), &PluginPolicy::deny_all()) {
|
||||
Err(homecore_plugins::PluginError::SignatureRejected(_)) => {}
|
||||
Ok(_) => panic!("unsigned module must be rejected under the secure default"),
|
||||
Err(e) => panic!("expected SignatureRejected, got {e:?}"),
|
||||
}
|
||||
// Dev escape hatch: loads (with a loud warn).
|
||||
rt.load_plugin(
|
||||
&manifest,
|
||||
&wasm,
|
||||
HomeCore::new(),
|
||||
&PluginPolicy::AllowUnsigned,
|
||||
)
|
||||
.expect("AllowUnsigned dev policy must load an unsigned module");
|
||||
}
|
||||
|
||||
// ── ADR-162 P5: authority / capability isolation ────────────────────────
|
||||
//
|
||||
// FAILS on the pre-ADR-162 code, where `hc_state_set` ignored
|
||||
// `homecore_permissions` entirely and let any plugin write any entity.
|
||||
|
||||
/// Module that writes `lock.front_door` on setup (an over-privileged
|
||||
/// write a `light.*` plugin must NOT be allowed to perform).
|
||||
const WRITE_LOCK_WAT: &str = r#"
|
||||
(module
|
||||
(import "env" "hc_state_get" (func $hc_state_get (param i32 i32 i32 i32) (result i32)))
|
||||
(import "env" "hc_state_set" (func $hc_state_set (param i32 i32 i32 i32 i32 i32) (result i32)))
|
||||
(import "env" "hc_state_subscribe" (func $hc_state_subscribe (param i32 i32) (result i32)))
|
||||
(import "env" "hc_log" (func $hc_log (param i32 i32 i32)))
|
||||
(memory (export "memory") 1)
|
||||
(global $bump (mut i32) (i32.const 512))
|
||||
(data (i32.const 0) "lock.front_door")
|
||||
(data (i32.const 64) "unlocked")
|
||||
(data (i32.const 128) "{}")
|
||||
(func (export "alloc") (param i32) (result i32)
|
||||
(local $p i32)
|
||||
(local.set $p (global.get $bump))
|
||||
(global.set $bump (i32.add (global.get $bump) (local.get 0)))
|
||||
(local.get $p))
|
||||
(func (export "dealloc") (param i32 i32))
|
||||
;; plugin_setup returns the hc_state_set result code so the host test can
|
||||
;; assert the guest saw the typed permission-denied error (-3).
|
||||
(func (export "plugin_setup") (param i32 i32) (result i32)
|
||||
(call $hc_state_set
|
||||
(i32.const 0) (i32.const 15) ;; "lock.front_door"
|
||||
(i32.const 64) (i32.const 8) ;; "unlocked"
|
||||
(i32.const 128) (i32.const 2))) ;; "{}"
|
||||
(func (export "plugin_handle_state_changed") (param i32 i32) (result i32) (i32.const 0))
|
||||
)
|
||||
"#;
|
||||
|
||||
#[test]
|
||||
fn p5_declared_light_plugin_may_write_light_but_not_lock() {
|
||||
let key = publisher_key();
|
||||
let trusted = PluginPolicy::trusted(&[&encode_verifying_key(&key.verifying_key())]).unwrap();
|
||||
let rt = WasmtimeRuntime::new().expect("rt");
|
||||
|
||||
// (a) A `light.*` plugin writing `light.kitchen` → ALLOWED.
|
||||
let light_wasm = wat::parse_str(WRITE_LIGHT_WAT).expect("WAT");
|
||||
let light_manifest = signed_manifest(&light_wasm, &key, &["light.*"]);
|
||||
let hc_a = HomeCore::new();
|
||||
let plugin_a = rt
|
||||
.load_plugin(&light_manifest, &light_wasm, hc_a.clone(), &trusted)
|
||||
.expect("light plugin loads");
|
||||
let r = plugin_a.call_setup("{}").expect("setup");
|
||||
assert_eq!(r, 0, "write to declared light.kitchen should succeed");
|
||||
let kitchen = homecore::EntityId::parse("light.kitchen").unwrap();
|
||||
assert_eq!(
|
||||
hc_a.states().get(&kitchen).expect("light.kitchen written").state,
|
||||
"on"
|
||||
);
|
||||
|
||||
// (b) The SAME `light.*` plugin attempting to write `lock.front_door`
|
||||
// → REJECTED with the typed -3 code, and the lock is NOT written.
|
||||
let lock_wasm = wat::parse_str(WRITE_LOCK_WAT).expect("WAT");
|
||||
let lock_manifest = signed_manifest(&lock_wasm, &key, &["light.*"]);
|
||||
let hc_b = HomeCore::new();
|
||||
let plugin_b = rt
|
||||
.load_plugin(&lock_manifest, &lock_wasm, hc_b.clone(), &trusted)
|
||||
.expect("module loads (verification ok); the WRITE is what's gated");
|
||||
let denied = plugin_b.call_setup("{}").expect("setup runs without trapping host");
|
||||
assert_eq!(
|
||||
denied, -3,
|
||||
"over-privileged write to lock.front_door must return -3 (permission denied)"
|
||||
);
|
||||
let lock = homecore::EntityId::parse("lock.front_door").unwrap();
|
||||
assert!(
|
||||
hc_b.states().get(&lock).is_none(),
|
||||
"lock.front_door must NOT have been written by a light-only plugin"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn p5_plugin_with_no_permissions_can_write_nothing() {
|
||||
let key = publisher_key();
|
||||
let trusted = PluginPolicy::trusted(&[&encode_verifying_key(&key.verifying_key())]).unwrap();
|
||||
let rt = WasmtimeRuntime::new().expect("rt");
|
||||
|
||||
let wasm = wat::parse_str(WRITE_LIGHT_WAT).expect("WAT");
|
||||
// No permissions declared at all.
|
||||
let manifest = signed_manifest(&wasm, &key, &[]);
|
||||
let hc = HomeCore::new();
|
||||
let plugin = rt
|
||||
.load_plugin(&manifest, &wasm, hc.clone(), &trusted)
|
||||
.expect("module loads; the write is gated");
|
||||
// WRITE_LIGHT_WAT drops the host-import result and returns 0, so we
|
||||
// assert the denial via the side-effect: the write must NOT land.
|
||||
plugin.call_setup("{}").expect("setup runs without trapping host");
|
||||
let kitchen = homecore::EntityId::parse("light.kitchen").unwrap();
|
||||
assert!(
|
||||
hc.states().get(&kitchen).is_none(),
|
||||
"no-permission plugin must not write light.kitchen (P5 authority isolation)"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user