From 8982b0a0686117495d1a9d352f23eda152204770 Mon Sep 17 00:00:00 2001 From: Pawel Date: Tue, 8 Sep 2026 17:48:43 +0200 Subject: [PATCH 1/7] fix(app): enforce compiled lock before run (#501) --- 10-core/app-spec.md | 2 +- 10-core/cli-spec.md | 4 +- cli/src/app_lock.rs | 74 ++++++++++++-- cli/src/commands/app.rs | 5 + cli/tests/app_expose.rs | 6 +- cli/tests/app_requires_pin.rs | 4 + cli/tests/app_run.rs | 144 ++++++++++++++++++++++++++- cli/tests/common/mod.rs | 54 ++++++++++ cli/tests/dry_run_redacts_secrets.rs | 3 + 9 files changed, 281 insertions(+), 15 deletions(-) diff --git a/10-core/app-spec.md b/10-core/app-spec.md index 26a5e05ad..a2894b5bb 100644 --- a/10-core/app-spec.md +++ b/10-core/app-spec.md @@ -548,7 +548,7 @@ nodes: | `aware app compile ` | Explicit compile. Emits `.lock` next to the source file. Fails if validation fails. | | `aware app validate ` | Now also writes `.lock` as a side effect (was: silent pass) | | `aware app inspect ` | Opens Glass Box — a single-file HTML viewer of the lockfile — in the user's default browser | -| `aware app run ` | Refuses to execute unless a fresh `.lock` matches the source's `source-hash`. Prompts the user to run `aware app compile` first | +| `aware app run ` | Refuses before trace creation or node dispatch unless a present `.lock` matches the raw source bytes' `source-hash`. Missing lock: `E_APP_LOCK_MISSING`; unreadable/malformed lock: `E_APP_LOCK_INVALID`; hash mismatch: `E_APP_LOCK_STALE`. Each prompts the user to run `aware app compile` first. The gate applies to real, dry, and simulated runs. | ### Why this matters diff --git a/10-core/cli-spec.md b/10-core/cli-spec.md index 332094a02..42db8ee63 100644 --- a/10-core/cli-spec.md +++ b/10-core/cli-spec.md @@ -291,7 +291,9 @@ skills (31): ### `aware app run ` -The heaviest command. Loads the app file, resolves agent dependencies via the lockfile, starts any stateful agents, wires connections, and either: +The heaviest command. It first verifies the installed source against the engineer-approved `.lock`: the lock must be present, parseable, and carry the SHA-256 of the exact raw source bytes. A missing (`E_APP_LOCK_MISSING`), unreadable/malformed (`E_APP_LOCK_INVALID`), or mismatched (`E_APP_LOCK_STALE`) lock exits 3 before trace creation or node dispatch and tells the operator to run `aware app compile` again. This gate also applies to `--dry-run` and `--simulate`; those modes change dispatch behavior, not which source was approved. + +After that gate, it loads the app file, resolves agent dependencies via the lockfile, starts any stateful agents, wires connections, and either: - Returns immediately (one-shot app with only stateless nodes) - Blocks until stopped (long-running app with stateful nodes) diff --git a/cli/src/app_lock.rs b/cli/src/app_lock.rs index 718db8bb9..d123ffe57 100644 --- a/cli/src/app_lock.rs +++ b/cli/src/app_lock.rs @@ -13,7 +13,7 @@ use std::collections::{BTreeMap, BTreeSet}; use std::path::Path; -use serde::Serialize; +use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; use crate::error::AwareError; @@ -23,7 +23,7 @@ use crate::manifest::loader::{DiscoveredAgent, discover_agents}; use crate::paths::Paths; /// The lockfile schema. Serialized as YAML to `.lock`. -#[derive(Debug, Serialize)] +#[derive(Debug, Deserialize, Serialize)] pub struct LockFile { /// SHA-256 of the source app file (UTF-8 bytes). #[serde(rename = "source-hash")] @@ -62,7 +62,7 @@ pub struct LockFile { pub engineering: Option, } -#[derive(Debug, Serialize)] +#[derive(Debug, Deserialize, Serialize)] pub struct CompiledNode { pub id: String, @@ -101,7 +101,7 @@ pub struct CompiledNode { /// `kind` (info / warn / error) so consumers can render by severity /// without string-matching the prose (#170). Serialized as a list of /// `{ kind, text }` maps. - #[serde(skip_serializing_if = "Vec::is_empty")] + #[serde(default, skip_serializing_if = "Vec::is_empty")] pub notes: Vec, /// RFC #223: `true` when this node resolves to a curated `model-extraction` @@ -127,7 +127,7 @@ fn is_false(b: &bool) -> bool { /// Severity of a compile-time [`CompileNote`]. Consumers (the CLI, the lock /// audit, floless.app) render by `kind` — `info` quiet/collapsible, `warn` / /// `error` prominent — and stay correct across note-wording changes (#170). -#[derive(Debug, Serialize, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Deserialize, Serialize, Clone, Copy, PartialEq, Eq)] #[serde(rename_all = "lowercase")] pub enum NoteKind { /// Benign provenance / FYI — e.g. "the compiler trusted the node-level @@ -146,7 +146,7 @@ pub enum NoteKind { } /// A single compile-time note: a severity [`kind`](NoteKind) plus its prose. -#[derive(Debug, Serialize, Clone, PartialEq, Eq)] +#[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Eq)] pub struct CompileNote { pub kind: NoteKind, pub text: String, @@ -180,6 +180,62 @@ impl CompileNote { } } +fn hash_source(source_path: &Path) -> Result { + let source_bytes = std::fs::read(source_path) + .map_err(|e| AwareError::Internal(format!("read {}: {e}", source_path.display())))?; + let mut hasher = Sha256::new(); + hasher.update(&source_bytes); + Ok(format!("sha256:{:x}", hasher.finalize())) +} + +/// Enforce the compiled-approval gate before an installed app can run. +/// +/// The lock is named by the source app id and its `source-hash` covers the raw +/// source bytes. Missing, unreadable, malformed, or stale approval artifacts +/// are validation failures: none may reach provenance setup or node dispatch. +pub fn verify_run_lock(app: &App, source_path: &Path) -> Result<(), AwareError> { + let source_dir = source_path + .parent() + .ok_or_else(|| AwareError::Internal("source path has no parent".into()))?; + let lock_path = source_dir.join(format!("{}.lock", app.app)); + let lock_text = match std::fs::read_to_string(&lock_path) { + Ok(text) => text, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + return Err(AwareError::Validation(format!( + "[E_APP_LOCK_MISSING] app {} has no compiled approval at {}; run `aware app compile {}` first", + app.app, + lock_path.display(), + source_path.display() + ))); + } + Err(error) => { + return Err(AwareError::Validation(format!( + "[E_APP_LOCK_INVALID] cannot read compiled approval {}: {error}; run `aware app compile {}` again", + lock_path.display(), + source_path.display() + ))); + } + }; + let lock: LockFile = serde_yaml::from_str(&lock_text).map_err(|error| { + AwareError::Validation(format!( + "[E_APP_LOCK_INVALID] compiled approval {} is invalid: {error}; run `aware app compile {}` again", + lock_path.display(), + source_path.display() + )) + })?; + let current_hash = hash_source(source_path)?; + if lock.source_hash != current_hash { + return Err(AwareError::Validation(format!( + "[E_APP_LOCK_STALE] compiled approval {} does not match the installed source (approved {}, current {}); run `aware app compile {}` again", + lock_path.display(), + lock.source_hash, + current_hash, + source_path.display() + ))); + } + Ok(()) +} + /// Compile a parsed app + the installed agent catalogue into a lockfile. /// /// The lockfile is *not* written to disk here — callers (typically @@ -189,11 +245,7 @@ pub fn compile( agents: &[DiscoveredAgent], source_path: &Path, ) -> Result { - let source_bytes = std::fs::read(source_path) - .map_err(|e| AwareError::Internal(format!("read {}: {e}", source_path.display())))?; - let mut hasher = Sha256::new(); - hasher.update(&source_bytes); - let source_hash = format!("sha256:{:x}", hasher.finalize()); + let source_hash = hash_source(source_path)?; // Flatten the node tree: top-level nodes plus the bodies of `do:`-bearing // primitives (for-each / sweep), so inner nodes are pinned, compiled, and diff --git a/cli/src/commands/app.rs b/cli/src/commands/app.rs index 8fc118f32..e322e6ae2 100644 --- a/cli/src/commands/app.rs +++ b/cli/src/commands/app.rs @@ -236,6 +236,11 @@ async fn run( .ok_or_else(|| AwareError::Validation(format!("app {app_id} has no .flo/.app file")))?; let app = crate::manifest::loader::load_app(&manifest_path)?; + // The compiled sidecar is the approval artifact for these exact source + // bytes. Gate every run mode before provenance setup or node dispatch: a + // missing or stale lock means the installed source has not been approved. + crate::app_lock::verify_run_lock(&app, &manifest_path)?; + // Safety-contract pre-flight: refuse to run an app whose write-mode // nodes are missing `safety:` blocks. Skipped in --dry-run (a dry-run // is precisely how you'd test an app's safety contract before adding diff --git a/cli/tests/app_expose.rs b/cli/tests/app_expose.rs index 0212c1842..75a6b3c58 100644 --- a/cli/tests/app_expose.rs +++ b/cli/tests/app_expose.rs @@ -1,6 +1,8 @@ //! End-to-end tests for `exposes-as-agent`: an app installed as a callable //! agent, invoked from another app's `nodes:` block (issue #178). +mod common; + use assert_cmd::Command; use predicates::prelude::*; @@ -8,7 +10,9 @@ use predicates::prelude::*; fn write_app(src_root: &std::path::Path, name: &str, flo: &str) -> std::path::PathBuf { let dir = src_root.join(name); std::fs::create_dir_all(&dir).unwrap(); - std::fs::write(dir.join(format!("{name}.flo")), flo).unwrap(); + let source = dir.join(format!("{name}.flo")); + std::fs::write(&source, flo).unwrap(); + common::approve_app_source(&source); dir } diff --git a/cli/tests/app_requires_pin.rs b/cli/tests/app_requires_pin.rs index 143b4d475..c69f6c10d 100644 --- a/cli/tests/app_requires_pin.rs +++ b/cli/tests/app_requires_pin.rs @@ -49,6 +49,10 @@ fn fixture(version: &str, pin: &str) -> (tempfile::TempDir, std::path::PathBuf) } fn aware(home: &std::path::Path) -> Command { + // Pin-focused tests construct installed apps directly or through install; + // give those fixtures a current compiled approval so they reach the pin + // gate they are intended to exercise. + common::approve_installed_apps(home); let mut c = Command::cargo_bin("aware").unwrap(); c.env("AWARE_HOME", home); c diff --git a/cli/tests/app_run.rs b/cli/tests/app_run.rs index 9b1cbedfd..54bf13db9 100644 --- a/cli/tests/app_run.rs +++ b/cli/tests/app_run.rs @@ -31,6 +31,7 @@ fn run_one_shot_app_with_no_installed_agents_fails_clearly() { .success(); // Now `app run` — should fail with a clear error (binary not found or network) + common::approve_installed_apps(&aware); Command::cargo_bin("aware") .unwrap() .env("AWARE_HOME", &aware) @@ -91,6 +92,7 @@ requires: [] ) .unwrap(); + common::approve_installed_apps(&aware); Command::cargo_bin("aware") .unwrap() .env("AWARE_HOME", &aware) @@ -163,6 +165,7 @@ requires: [] .failure(); // `--simulate` stubs the read node — no host contact — and succeeds. + common::approve_installed_apps(&aware); Command::cargo_bin("aware") .unwrap() .env("AWARE_HOME", &aware) @@ -228,6 +231,7 @@ requires: [] // A real run takes the long-running path and fails: invoke_stream tries to // spawn `this-watch-binary-does-not-exist`, which isn't on PATH (#172). + common::approve_installed_apps(&aware); Command::cargo_bin("aware") .unwrap() .env("AWARE_HOME", &aware) @@ -238,6 +242,7 @@ requires: [] // `--simulate` stubs the stream source (one placeholder event, then the // source closes) and the run completes — no invoke_stream. + common::approve_installed_apps(&aware); Command::cargo_bin("aware") .unwrap() .env("AWARE_HOME", &aware) @@ -330,6 +335,7 @@ requires: [] let existing_pidfile = b"pid: 4242\nstartedAt: preserved\n"; std::fs::write(&pidfile, existing_pidfile).unwrap(); + common::approve_installed_apps(&aware); Command::cargo_bin("aware") .unwrap() .env("AWARE_HOME", &aware) @@ -395,6 +401,7 @@ requires: [] // `--simulate` renders the write node's config (for the would-write event), // exercising the hyphenated dot-path. Must succeed. + common::approve_installed_apps(&aware); Command::cargo_bin("aware") .unwrap() .env("AWARE_HOME", &aware) @@ -480,6 +487,7 @@ requires: [] // Try to run — will fail because the binary doesn't exist; // we just verify the pidfile is cleaned up afterwards. + common::approve_installed_apps(&aware); let _ = Command::cargo_bin("aware") .unwrap() .env("AWARE_HOME", &aware) @@ -564,6 +572,7 @@ requires: [] ) .unwrap(); + common::approve_installed_apps(&aware); Command::cargo_bin("aware") .unwrap() .env("AWARE_HOME", &aware) @@ -650,6 +659,7 @@ requires: [] ) .unwrap(); + common::approve_installed_apps(&aware); Command::cargo_bin("aware") .unwrap() .env("AWARE_HOME", &aware) @@ -781,6 +791,7 @@ requires: [] ) .unwrap(); + common::approve_installed_apps(&aware); let exe = assert_cmd::cargo::cargo_bin("aware"); let mut run = std::process::Command::new(&exe) .env("AWARE_HOME", &aware) @@ -920,6 +931,7 @@ fn main() { let aware = tmp.path().join("aware"); install_watcher_app(&aware, &bin); + common::approve_installed_apps(&aware); // A real (non-simulated) run: the long-running path consumes the stream and // completes when the source closes. @@ -971,7 +983,7 @@ fn main() { let aware = tmp.path().join("aware"); install_watcher_app(&aware, &bin); - + common::approve_installed_apps(&aware); Command::cargo_bin("aware") .unwrap() .env("AWARE_HOME", &aware) @@ -1120,6 +1132,7 @@ requires: [] ) .unwrap(); + common::approve_installed_apps(&aware); Command::cargo_bin("aware") .unwrap() .env("AWARE_HOME", &aware) @@ -1255,6 +1268,7 @@ fn run_refuses_app_whose_agent_is_not_installed() { // but must no longer be silent about the gap. .stderr(predicate::str::contains("W_APP_AGENT_NOT_INSTALLED")); + common::approve_installed_apps(&home); Command::cargo_bin("aware") .unwrap() .env("AWARE_HOME", &home) @@ -1297,6 +1311,7 @@ fn simulate_still_runs_an_app_whose_agent_is_not_installed() { .assert() .success(); + common::approve_installed_apps(&home); Command::cargo_bin("aware") .unwrap() .env("AWARE_HOME", &home) @@ -1329,6 +1344,7 @@ fn run_allows_a_frozen_node_whose_agent_is_not_installed() { .assert() .success(); + common::approve_installed_apps(&home); Command::cargo_bin("aware") .unwrap() .env("AWARE_HOME", &home) @@ -1336,3 +1352,129 @@ fn run_allows_a_frozen_node_whose_agent_is_not_installed() { .assert() .success(); } + +fn install_ui_agent(home: &std::path::Path) { + let ui = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .parent() + .unwrap() + .join("20-agents/_core/ui"); + Command::cargo_bin("aware") + .unwrap() + .env("AWARE_HOME", home) + .args(["agent", "install"]) + .arg(ui) + .assert() + .success(); +} + +fn write_lock_gate_probe(source_dir: &std::path::Path) -> std::path::PathBuf { + std::fs::create_dir_all(source_dir).unwrap(); + let source = source_dir.join("chat-storage-probe.flo"); + std::fs::write( + &source, + r#"app: chat-storage-probe +version: 0.1.0 +description: A harmless local catalogue probe for storage verification. +requires: + - ui@1.0.0 +nodes: + - id: catalog + agent: ui + command: catalog + mode: read + description: Read the installed built-in UI catalogue without external services. +"#, + ) + .unwrap(); + source +} + +#[test] +fn run_requires_a_present_lock_matching_the_installed_source() { + let tmp = tempfile::tempdir().unwrap(); + let home = tmp.path().join("aware"); + let source_dir = tmp.path().join("source"); + let source = write_lock_gate_probe(&source_dir); + install_ui_agent(&home); + + // Missing is a defined rejection, not an implicit approval. This is the + // state produced by installing an authoring directory that was never + // compiled. + Command::cargo_bin("aware") + .unwrap() + .env("AWARE_HOME", &home) + .args(["app", "install"]) + .arg(&source_dir) + .assert() + .success(); + Command::cargo_bin("aware") + .unwrap() + .env("AWARE_HOME", &home) + .args(["app", "run", "chat-storage-probe"]) + .assert() + .failure() + .code(3) + .stderr(predicate::str::contains("E_APP_LOCK_MISSING")) + .stderr(predicate::str::contains("aware app compile")); + assert!( + !home.join("logs/chat-storage-probe").exists(), + "a missing approval lock must stop before a run trace or node dispatch" + ); + + // Reinstall the same app with a real compiled approval artifact. The fresh + // lock is the positive control: a gate that rejected every run would pass + // both negative assertions below. + std::fs::remove_dir_all(home.join("apps/chat-storage-probe")).unwrap(); + Command::cargo_bin("aware") + .unwrap() + .env("AWARE_HOME", &home) + .args(["app", "compile"]) + .arg(&source) + .assert() + .success(); + Command::cargo_bin("aware") + .unwrap() + .env("AWARE_HOME", &home) + .args(["app", "install"]) + .arg(&source_dir) + .assert() + .success(); + Command::cargo_bin("aware") + .unwrap() + .env("AWARE_HOME", &home) + .args(["app", "run", "chat-storage-probe"]) + .assert() + .success(); + + // Change the installed bytes without touching the compiled lock. The run + // must reject the stale approval before it dispatches the changed command. + let installed = home.join("apps/chat-storage-probe/chat-storage-probe.flo"); + let edited = std::fs::read_to_string(&installed).unwrap().replace( + "command: catalog", + "command: validate\n config:\n descriptor: {}", + ); + std::fs::write(&installed, edited).unwrap(); + let trace_count_before = std::fs::read_dir(home.join("logs/chat-storage-probe/default")) + .unwrap() + .flatten() + .filter(|entry| entry.path().extension().is_some_and(|ext| ext == "jsonl")) + .count(); + Command::cargo_bin("aware") + .unwrap() + .env("AWARE_HOME", &home) + .args(["app", "run", "chat-storage-probe"]) + .assert() + .failure() + .code(3) + .stderr(predicate::str::contains("E_APP_LOCK_STALE")) + .stderr(predicate::str::contains("aware app compile")); + let trace_count_after = std::fs::read_dir(home.join("logs/chat-storage-probe/default")) + .unwrap() + .flatten() + .filter(|entry| entry.path().extension().is_some_and(|ext| ext == "jsonl")) + .count(); + assert_eq!( + trace_count_after, trace_count_before, + "a stale approval lock must stop before a run trace or node dispatch" + ); +} diff --git a/cli/tests/common/mod.rs b/cli/tests/common/mod.rs index 066c11058..0f4e03beb 100644 --- a/cli/tests/common/mod.rs +++ b/cli/tests/common/mod.rs @@ -12,6 +12,7 @@ use std::path::{Path, PathBuf}; use std::sync::OnceLock; +use sha2::{Digest, Sha256}; use tempfile::TempDir; static FIXTURE: OnceLock = OnceLock::new(); @@ -100,3 +101,56 @@ fn copy_dir_recursive(src: &Path, dst: &Path) -> std::io::Result<()> { } Ok(()) } + +/// Write the smallest structurally valid compiled approval needed by runtime +/// integration fixtures. Tests that exercise compilation itself use the real +/// `aware app compile` command instead. +pub fn approve_app_source(source: &Path) { + let bytes = std::fs::read(source).expect("read app source for approval"); + let parsed: serde_yaml::Value = + serde_yaml::from_slice(&bytes).expect("parse app source for approval"); + let app = parsed["app"].as_str().expect("app id in fixture"); + let version = parsed["version"].as_str().expect("app version in fixture"); + let hash = format!("sha256:{:x}", Sha256::digest(&bytes)); + let lock = format!( + "source-hash: {hash}\ncompiled-at: test\ncompiler-version: test\napp: {app}\nversion: {version}\nagent-pins: {{}}\nnodes: []\n" + ); + std::fs::write(source.with_file_name(format!("{app}.lock")), lock) + .expect("write app approval fixture"); +} + +/// Approve every installed app source under `/apps/`. +pub fn approve_installed_apps(home: &Path) { + let apps = home.join("apps"); + let Ok(entries) = std::fs::read_dir(apps) else { + return; + }; + for entry in entries.flatten() { + let dir = entry.path(); + let Ok(files) = std::fs::read_dir(&dir) else { + continue; + }; + for file in files.flatten() { + let source = file.path(); + if source + .extension() + .and_then(|ext| ext.to_str()) + .is_some_and(|ext| matches!(ext, "flo" | "app" | "flow" | "aware")) + { + // Some negative-path tests deliberately make a nested source + // unreadable or malformed. Leave those untouched so the + // production command reports the intended fault; approve only + // ordinary fixtures that can actually represent an app. + let approvable = std::fs::read(&source) + .ok() + .and_then(|bytes| serde_yaml::from_slice::(&bytes).ok()) + .is_some_and(|value| { + value["app"].as_str().is_some() && value["version"].as_str().is_some() + }); + if approvable { + approve_app_source(&source); + } + } + } + } +} diff --git a/cli/tests/dry_run_redacts_secrets.rs b/cli/tests/dry_run_redacts_secrets.rs index 2fc7aa340..8f53d3faf 100644 --- a/cli/tests/dry_run_redacts_secrets.rs +++ b/cli/tests/dry_run_redacts_secrets.rs @@ -20,6 +20,8 @@ //! developer's real login keyring (the OS keychain is process-global and not //! scoped by `AWARE_HOME`). +mod common; + use assert_cmd::Command; /// Long and distinctive so a substring search for it cannot match anything the @@ -27,6 +29,7 @@ use assert_cmd::Command; const SECRET: &str = "sk-live-must-never-reach-a-trace-9f13a7"; fn aware(home: &std::path::Path) -> Command { + common::approve_installed_apps(home); let mut cmd = Command::cargo_bin("aware").unwrap(); cmd.env("AWARE_HOME", home) .env("AWARE_DISABLE_KEYRING", "1"); From 225205b5dac9e6e4cef4b9ce95f2c71569f14deb Mon Sep 17 00:00:00 2001 From: Pawel Date: Tue, 8 Sep 2026 18:49:08 +0200 Subject: [PATCH 2/7] fix(app): bind approval to every executed source (#501) --- 10-core/app-spec.md | 2 +- 10-core/cli-spec.md | 2 +- cli/src/app_lock.rs | 25 ++++++++++++++++++------- cli/src/commands/app.rs | 9 +++------ cli/src/runtime/invoker.rs | 4 +++- cli/tests/app_expose.rs | 35 +++++++++++++++++++++++++++++++++++ 6 files changed, 61 insertions(+), 16 deletions(-) diff --git a/10-core/app-spec.md b/10-core/app-spec.md index a2894b5bb..c200c62ae 100644 --- a/10-core/app-spec.md +++ b/10-core/app-spec.md @@ -548,7 +548,7 @@ nodes: | `aware app compile ` | Explicit compile. Emits `.lock` next to the source file. Fails if validation fails. | | `aware app validate ` | Now also writes `.lock` as a side effect (was: silent pass) | | `aware app inspect ` | Opens Glass Box — a single-file HTML viewer of the lockfile — in the user's default browser | -| `aware app run ` | Refuses before trace creation or node dispatch unless a present `.lock` matches the raw source bytes' `source-hash`. Missing lock: `E_APP_LOCK_MISSING`; unreadable/malformed lock: `E_APP_LOCK_INVALID`; hash mismatch: `E_APP_LOCK_STALE`. Each prompts the user to run `aware app compile` first. The gate applies to real, dry, and simulated runs. | +| `aware app run ` | Refuses before trace creation or node dispatch unless a present `.lock` matches the raw source bytes' `source-hash`. The runtime parses and hashes one source read, and applies the same independent gate before dispatching an app-backed agent. Missing lock: `E_APP_LOCK_MISSING`; unreadable/malformed lock: `E_APP_LOCK_INVALID`; hash mismatch: `E_APP_LOCK_STALE`. Each prompts the user to run `aware app compile` first. The gate applies to real, dry, and simulated runs. | ### Why this matters diff --git a/10-core/cli-spec.md b/10-core/cli-spec.md index 42db8ee63..263f84af6 100644 --- a/10-core/cli-spec.md +++ b/10-core/cli-spec.md @@ -291,7 +291,7 @@ skills (31): ### `aware app run ` -The heaviest command. It first verifies the installed source against the engineer-approved `.lock`: the lock must be present, parseable, and carry the SHA-256 of the exact raw source bytes. A missing (`E_APP_LOCK_MISSING`), unreadable/malformed (`E_APP_LOCK_INVALID`), or mismatched (`E_APP_LOCK_STALE`) lock exits 3 before trace creation or node dispatch and tells the operator to run `aware app compile` again. This gate also applies to `--dry-run` and `--simulate`; those modes change dispatch behavior, not which source was approved. +The heaviest command. It first verifies the installed source against the engineer-approved `.lock`: the lock must be present, parseable, and carry the SHA-256 of the exact raw source bytes. Parsing and hashing use the same source read, so the approved bytes are the bytes executed. A missing (`E_APP_LOCK_MISSING`), unreadable/malformed (`E_APP_LOCK_INVALID`), or mismatched (`E_APP_LOCK_STALE`) lock exits 3 before trace creation or node dispatch and tells the operator to run `aware app compile` again. This gate applies independently to the top-level app and every app-backed agent it invokes, as well as to `--dry-run` and `--simulate`; those modes change dispatch behavior, not which source was approved. After that gate, it loads the app file, resolves agent dependencies via the lockfile, starts any stateful agents, wires connections, and either: - Returns immediately (one-shot app with only stateless nodes) diff --git a/cli/src/app_lock.rs b/cli/src/app_lock.rs index d123ffe57..e443c0735 100644 --- a/cli/src/app_lock.rs +++ b/cli/src/app_lock.rs @@ -183,17 +183,28 @@ impl CompileNote { fn hash_source(source_path: &Path) -> Result { let source_bytes = std::fs::read(source_path) .map_err(|e| AwareError::Internal(format!("read {}: {e}", source_path.display())))?; + Ok(hash_source_bytes(&source_bytes)) +} + +fn hash_source_bytes(source_bytes: &[u8]) -> String { let mut hasher = Sha256::new(); - hasher.update(&source_bytes); - Ok(format!("sha256:{:x}", hasher.finalize())) + hasher.update(source_bytes); + format!("sha256:{:x}", hasher.finalize()) } -/// Enforce the compiled-approval gate before an installed app can run. +/// Load an installed app and enforce its compiled approval over the same bytes. /// /// The lock is named by the source app id and its `source-hash` covers the raw /// source bytes. Missing, unreadable, malformed, or stale approval artifacts -/// are validation failures: none may reach provenance setup or node dispatch. -pub fn verify_run_lock(app: &App, source_path: &Path) -> Result<(), AwareError> { +/// are validation failures. Reading, parsing, and hashing one buffer ensures the +/// parsed app is exactly the artifact the lock approves even if the file is +/// replaced concurrently. +pub fn load_approved_app(source_path: &Path) -> Result { + let source_text = std::fs::read_to_string(source_path).map_err(|error| { + std::io::Error::new(error.kind(), format!("{}: {error}", source_path.display())) + })?; + let app: App = serde_yaml::from_str(&source_text) + .map_err(|error| AwareError::Validation(format!("{}: {error}", source_path.display())))?; let source_dir = source_path .parent() .ok_or_else(|| AwareError::Internal("source path has no parent".into()))?; @@ -223,7 +234,7 @@ pub fn verify_run_lock(app: &App, source_path: &Path) -> Result<(), AwareError> source_path.display() )) })?; - let current_hash = hash_source(source_path)?; + let current_hash = hash_source_bytes(source_text.as_bytes()); if lock.source_hash != current_hash { return Err(AwareError::Validation(format!( "[E_APP_LOCK_STALE] compiled approval {} does not match the installed source (approved {}, current {}); run `aware app compile {}` again", @@ -233,7 +244,7 @@ pub fn verify_run_lock(app: &App, source_path: &Path) -> Result<(), AwareError> source_path.display() ))); } - Ok(()) + Ok(app) } /// Compile a parsed app + the installed agent catalogue into a lockfile. diff --git a/cli/src/commands/app.rs b/cli/src/commands/app.rs index e322e6ae2..ac76a9875 100644 --- a/cli/src/commands/app.rs +++ b/cli/src/commands/app.rs @@ -234,12 +234,9 @@ async fn run( .unwrap_or(app_id); let manifest_path = crate::manifest::loader::find_app_manifest(&app_dir) .ok_or_else(|| AwareError::Validation(format!("app {app_id} has no .flo/.app file")))?; - let app = crate::manifest::loader::load_app(&manifest_path)?; - - // The compiled sidecar is the approval artifact for these exact source - // bytes. Gate every run mode before provenance setup or node dispatch: a - // missing or stale lock means the installed source has not been approved. - crate::app_lock::verify_run_lock(&app, &manifest_path)?; + // Parse and hash one source buffer so the compiled sidecar approves the + // exact app we execute. Gate every run mode before provenance or dispatch. + let app = crate::app_lock::load_approved_app(&manifest_path)?; // Safety-contract pre-flight: refuse to run an app whose write-mode // nodes are missing `safety:` blocks. Skipped in --dry-run (a dry-run diff --git a/cli/src/runtime/invoker.rs b/cli/src/runtime/invoker.rs index c56d17ade..45097e0f9 100644 --- a/cli/src/runtime/invoker.rs +++ b/cli/src/runtime/invoker.rs @@ -2922,7 +2922,9 @@ impl DispatchInvoker { crate::manifest::loader::find_app_manifest(&app_dir).ok_or_else(|| { AwareError::Validation(format!("backing app {backed_by} has no .flo/.app file")) })?; - let app = crate::manifest::loader::load_app(&manifest_path)?; + // Nested app-backed dispatch is still app execution: require its own + // compiled approval and bind parsing to the exact bytes that were hashed. + let app = crate::app_lock::load_approved_app(&manifest_path)?; if !app.exposes_as_agent { return Err(AwareError::Validation(format!( "app {backed_by} is not declared exposes-as-agent" diff --git a/cli/tests/app_expose.rs b/cli/tests/app_expose.rs index 75a6b3c58..76bd13b26 100644 --- a/cli/tests/app_expose.rs +++ b/cli/tests/app_expose.rs @@ -127,6 +127,41 @@ fn outer_app_invokes_inner_app_as_agent() { ); } +#[test] +fn outer_app_refuses_a_stale_inner_app_approval() { + let tmp = tempfile::tempdir().unwrap(); + let aware = tmp.path().join("aware"); + let src = tmp.path().join("src"); + + install_app(&aware, &write_app(&src, "inner", INNER_FLO)).success(); + install_app(&aware, &write_app(&src, "outer", OUTER_FLO)).success(); + + // The outer app remains approved, but its app-backed agent has changed + // since compilation. Nested dispatch must enforce the backing app's own + // approval before it opens a nested provenance trace or runs any node. + let installed_inner = aware.join("apps/inner/inner.flo"); + let source = std::fs::read_to_string(&installed_inner).unwrap(); + std::fs::write( + &installed_inner, + source.replace("always pass", "changed after approval"), + ) + .unwrap(); + + Command::cargo_bin("aware") + .unwrap() + .env("AWARE_HOME", &aware) + .args(["app", "run", "outer"]) + .assert() + .failure() + .code(3) + .stderr(predicate::str::contains("E_APP_LOCK_STALE")); + + assert!( + !aware.join("logs/inner/nested").exists(), + "a stale backing app must fail before nested provenance or dispatch" + ); +} + #[test] fn wrong_typed_exposed_input_is_rejected() { let tmp = tempfile::tempdir().unwrap(); From 7aba347bb924182c7593294df9ac82ad0ed23fe1 Mon Sep 17 00:00:00 2001 From: Pawel Date: Tue, 8 Sep 2026 19:19:36 +0200 Subject: [PATCH 3/7] fix(app): preflight nested approvals in previews (#501) --- cli/src/commands/app.rs | 22 ++++++++++------------ cli/tests/app_expose.rs | 34 ++++++++++++++++++++++++++++++++++ 2 files changed, 44 insertions(+), 12 deletions(-) diff --git a/cli/src/commands/app.rs b/cli/src/commands/app.rs index ac76a9875..41e442545 100644 --- a/cli/src/commands/app.rs +++ b/cli/src/commands/app.rs @@ -724,19 +724,17 @@ mod model_reader_control_tests { } } -/// Unreadable `requires:` pins in the apps behind this app's app-backed agents. +/// File-level preflight for apps behind this app's app-backed agents. /// -/// Whether a constraint can be *read* is a fact about a file — true on every -/// machine, needing no binary — so the `--simulate` exemption, which is about -/// the environment, must not swallow it one level down any more than it does at -/// the top level. Under a real run the nested pins are read at dispatch by -/// [`crate::runtime::invoker::DispatchInvoker::resolve_exposed`]; under -/// `--simulate` the orchestrator short-circuits with a synthesized output before -/// the app transport, so nothing ever loaded the backing app to look. +/// Approval and constraint readability are facts about files — true on every +/// machine, needing no binary — so preview-mode transport short-circuits must +/// not swallow them one level down. Real dispatch repeats the approval gate at +/// [`crate::runtime::invoker::DispatchInvoker::resolve_exposed`]. /// /// Deliberately narrow, and the narrowness is the point: /// -/// - It reads a **file**, and only for the `requires:` *syntax*. It does not +/// - It reads the backing **source and approval**, then checks only `requires:` +/// *syntax*. It does not /// dispatch to the nested app, run it, or apply the catalogue checks /// (installed / version-satisfied) that `--simulate` is legitimately excused /// from because it contacts no binary. @@ -896,7 +894,7 @@ fn nested_malformed_requires( // which yields `Io` for the same file on a real run. `cli-spec.md` keeps 1 // ("general failure") and 3 ("validation failed") distinct: a file that // cannot be read is not an invalid one. - let backing = crate::manifest::loader::load_app(&manifest_path).map_err(|e| { + let backing = crate::app_lock::load_approved_app(&manifest_path).map_err(|e| { let hop = format!( "app-backed agent {:?} (backing app {:?})", agent_id, app_transport.backed_by @@ -904,8 +902,8 @@ fn nested_malformed_requires( match e { AwareError::Validation(m) => AwareError::Validation(format!("{hop}: {m}")), AwareError::Io(io) => std::io::Error::new(io.kind(), format!("{hop}: {io}")).into(), - // `load_app` yields only those two; anything else keeps its own - // class and loses only the hop, which fails safe. + // Loading/approval can also produce other classes; those keep + // their own class and lose only the hop, which fails safe. other => other, } })?; diff --git a/cli/tests/app_expose.rs b/cli/tests/app_expose.rs index 76bd13b26..d0df4ae8d 100644 --- a/cli/tests/app_expose.rs +++ b/cli/tests/app_expose.rs @@ -162,6 +162,40 @@ fn outer_app_refuses_a_stale_inner_app_approval() { ); } +#[test] +fn preview_modes_refuse_a_stale_inner_app_approval() { + for mode in ["--dry-run", "--simulate"] { + let tmp = tempfile::tempdir().unwrap(); + let aware = tmp.path().join("aware"); + let src = tmp.path().join("src"); + + install_app(&aware, &write_app(&src, "inner", INNER_FLO)).success(); + install_app(&aware, &write_app(&src, "outer", OUTER_FLO)).success(); + + let installed_inner = aware.join("apps/inner/inner.flo"); + let source = std::fs::read_to_string(&installed_inner).unwrap(); + std::fs::write( + &installed_inner, + source.replace("always pass", "changed before preview"), + ) + .unwrap(); + + Command::cargo_bin("aware") + .unwrap() + .env("AWARE_HOME", &aware) + .args(["app", "run", "outer", mode]) + .assert() + .failure() + .code(3) + .stderr(predicate::str::contains("E_APP_LOCK_STALE")); + + assert!( + !aware.join("logs/inner/nested").exists(), + "{mode} must check nested approval before preview short-circuits" + ); + } +} + #[test] fn wrong_typed_exposed_input_is_rejected() { let tmp = tempfile::tempdir().unwrap(); From 5a1acdfcab6a7dfb5879b0910fd7b7308bf24781 Mon Sep 17 00:00:00 2001 From: Pawel Date: Tue, 8 Sep 2026 20:30:51 +0200 Subject: [PATCH 4/7] fix(app): unify approval source snapshots (#501) --- 10-core/app-spec.md | 2 +- 10-core/cli-spec.md | 2 +- cli/src/app_lock.rs | 179 ++++++++++++++++++++++++++-------------- cli/src/commands/app.rs | 5 +- 4 files changed, 118 insertions(+), 70 deletions(-) diff --git a/10-core/app-spec.md b/10-core/app-spec.md index c200c62ae..241ecec82 100644 --- a/10-core/app-spec.md +++ b/10-core/app-spec.md @@ -548,7 +548,7 @@ nodes: | `aware app compile ` | Explicit compile. Emits `.lock` next to the source file. Fails if validation fails. | | `aware app validate ` | Now also writes `.lock` as a side effect (was: silent pass) | | `aware app inspect ` | Opens Glass Box — a single-file HTML viewer of the lockfile — in the user's default browser | -| `aware app run ` | Refuses before trace creation or node dispatch unless a present `.lock` matches the raw source bytes' `source-hash`. The runtime parses and hashes one source read, and applies the same independent gate before dispatching an app-backed agent. Missing lock: `E_APP_LOCK_MISSING`; unreadable/malformed lock: `E_APP_LOCK_INVALID`; hash mismatch: `E_APP_LOCK_STALE`. Each prompts the user to run `aware app compile` first. The gate applies to real, dry, and simulated runs. | +| `aware app run ` | Refuses before trace creation or node dispatch unless a present `.lock` matches the raw source bytes' `source-hash`. Compile and run each bind parsing and hashing to one source snapshot; unsafe `app:` ids are rejected before lock lookup. The same independent gate applies before dispatching an app-backed agent. Missing lock: `E_APP_LOCK_MISSING`; unreadable/malformed lock: `E_APP_LOCK_INVALID`; hash mismatch: `E_APP_LOCK_STALE`. Each prompts the user to run `aware app compile` first. The gate applies to real, dry, and simulated runs. | ### Why this matters diff --git a/10-core/cli-spec.md b/10-core/cli-spec.md index 263f84af6..1d75172e7 100644 --- a/10-core/cli-spec.md +++ b/10-core/cli-spec.md @@ -291,7 +291,7 @@ skills (31): ### `aware app run ` -The heaviest command. It first verifies the installed source against the engineer-approved `.lock`: the lock must be present, parseable, and carry the SHA-256 of the exact raw source bytes. Parsing and hashing use the same source read, so the approved bytes are the bytes executed. A missing (`E_APP_LOCK_MISSING`), unreadable/malformed (`E_APP_LOCK_INVALID`), or mismatched (`E_APP_LOCK_STALE`) lock exits 3 before trace creation or node dispatch and tells the operator to run `aware app compile` again. This gate applies independently to the top-level app and every app-backed agent it invokes, as well as to `--dry-run` and `--simulate`; those modes change dispatch behavior, not which source was approved. +The heaviest command. It first verifies the installed source against the engineer-approved `.lock`: the lock must be present, parseable, and carry the SHA-256 of the exact raw source bytes. Compilation and runtime each parse and hash one source snapshot, so the compiled plan, approved bytes, and executed app cannot drift between reads. An unsafe `app:` id is rejected before it can become a lock path. A missing (`E_APP_LOCK_MISSING`), unreadable/malformed (`E_APP_LOCK_INVALID`), or mismatched (`E_APP_LOCK_STALE`) lock exits 3 before trace creation or node dispatch and tells the operator to run `aware app compile` again. This gate applies independently to the top-level app and every app-backed agent it invokes, as well as to `--dry-run` and `--simulate`; those modes change dispatch behavior, not which source was approved. After that gate, it loads the app file, resolves agent dependencies via the lockfile, starts any stateful agents, wires connections, and either: - Returns immediately (one-shot app with only stateless nodes) diff --git a/cli/src/app_lock.rs b/cli/src/app_lock.rs index e443c0735..882c2e8e6 100644 --- a/cli/src/app_lock.rs +++ b/cli/src/app_lock.rs @@ -180,18 +180,37 @@ impl CompileNote { } } -fn hash_source(source_path: &Path) -> Result { - let source_bytes = std::fs::read(source_path) - .map_err(|e| AwareError::Internal(format!("read {}: {e}", source_path.display())))?; - Ok(hash_source_bytes(&source_bytes)) -} - fn hash_source_bytes(source_bytes: &[u8]) -> String { let mut hasher = Sha256::new(); hasher.update(source_bytes); format!("sha256:{:x}", hasher.finalize()) } +struct AppSourceSnapshot { + app: App, + source_hash: String, +} + +/// Read, parse, validate the path-bearing id, and hash one immutable buffer. +/// Every approval producer and consumer goes through this snapshot boundary. +fn read_source_snapshot(source_path: &Path) -> Result { + let source_text = std::fs::read_to_string(source_path).map_err(|error| { + std::io::Error::new(error.kind(), format!("{}: {error}", source_path.display())) + })?; + let app: App = serde_yaml::from_str(&source_text) + .map_err(|error| AwareError::Validation(format!("{}: {error}", source_path.display())))?; + if !crate::manifest::loader::is_safe_segment(&app.app) { + return Err(AwareError::Validation(format!( + "[E_APP_ID_NOT_A_SEGMENT] app id {:?} is not a plain name", + app.app + ))); + } + Ok(AppSourceSnapshot { + app, + source_hash: hash_source_bytes(source_text.as_bytes()), + }) +} + /// Load an installed app and enforce its compiled approval over the same bytes. /// /// The lock is named by the source app id and its `source-hash` covers the raw @@ -200,11 +219,8 @@ fn hash_source_bytes(source_bytes: &[u8]) -> String { /// parsed app is exactly the artifact the lock approves even if the file is /// replaced concurrently. pub fn load_approved_app(source_path: &Path) -> Result { - let source_text = std::fs::read_to_string(source_path).map_err(|error| { - std::io::Error::new(error.kind(), format!("{}: {error}", source_path.display())) - })?; - let app: App = serde_yaml::from_str(&source_text) - .map_err(|error| AwareError::Validation(format!("{}: {error}", source_path.display())))?; + let snapshot = read_source_snapshot(source_path)?; + let app = snapshot.app; let source_dir = source_path .parent() .ok_or_else(|| AwareError::Internal("source path has no parent".into()))?; @@ -234,7 +250,7 @@ pub fn load_approved_app(source_path: &Path) -> Result { source_path.display() )) })?; - let current_hash = hash_source_bytes(source_text.as_bytes()); + let current_hash = snapshot.source_hash; if lock.source_hash != current_hash { return Err(AwareError::Validation(format!( "[E_APP_LOCK_STALE] compiled approval {} does not match the installed source (approved {}, current {}); run `aware app compile {}` again", @@ -247,17 +263,21 @@ pub fn load_approved_app(source_path: &Path) -> Result { Ok(app) } -/// Compile a parsed app + the installed agent catalogue into a lockfile. +/// Compile a source snapshot + the installed agent catalogue into a lockfile. /// /// The lockfile is *not* written to disk here — callers (typically /// `aware app compile`) handle the write. -pub fn compile( +#[cfg(test)] +fn compile(source_path: &Path, agents: &[DiscoveredAgent]) -> Result { + let snapshot = read_source_snapshot(source_path)?; + compile_snapshot(&snapshot.app, agents, snapshot.source_hash) +} + +fn compile_snapshot( app: &App, agents: &[DiscoveredAgent], - source_path: &Path, + source_hash: String, ) -> Result { - let source_hash = hash_source(source_path)?; - // Flatten the node tree: top-level nodes plus the bodies of `do:`-bearing // primitives (for-each / sweep), so inner nodes are pinned, compiled, and // ref-checked rather than silently ignored (#117 finding #3). Body nodes @@ -893,12 +913,21 @@ pub fn find_app_source(path: &Path) -> Option { /// End-to-end: load + compile + write. Called by `aware app compile`. pub fn compile_to_disk(source: &Path, paths: &Paths) -> Result { - let app = crate::manifest::loader::load_app(source)?; + compile_to_disk_with_lock(source, paths).map(|(path, _)| path) +} + +/// Compile and persist one source snapshot, returning the exact plan written. +pub fn compile_to_disk_with_lock( + source: &Path, + paths: &Paths, +) -> Result<(std::path::PathBuf, LockFile), AwareError> { + let snapshot = read_source_snapshot(source)?; + let app = &snapshot.app; // Refuse to produce a lock for an app the runtime can't execute (e.g. an // inline kind the orchestrator rejects). Gating here covers every // lock-producing path — `app compile`, `app inspect`, … — so an unrunnable // construct fails before locking, not at run (#160). - let issues = crate::validate::validate_app(&app); + let issues = crate::validate::validate_app(app); if let Some(err) = issues .iter() .find(|i| i.severity == crate::validate::Severity::Error) @@ -912,7 +941,7 @@ pub fn compile_to_disk(source: &Path, paths: &Paths) -> Result Result Result.lock`, NEVER `.flo.lock`. @@ -1267,8 +1338,7 @@ requires: [] ) .unwrap(); - let app = crate::manifest::loader::load_app(&src).unwrap(); - let lock = compile(&app, &agents, &src).unwrap(); + let lock = compile(&src, &agents).unwrap(); let sink = lock.nodes.iter().find(|n| n.id == "sink").unwrap(); assert!( sink.notes.iter().any(|n| n.text.contains("src.nope")), @@ -1325,8 +1395,7 @@ requires: [] "#, ) .unwrap(); - let app = crate::manifest::loader::load_app(&src).unwrap(); - let lock = compile(&app, &agents, &src).unwrap(); + let lock = compile(&src, &agents).unwrap(); let n = lock.nodes.iter().find(|n| n.id == "extract").unwrap(); assert!( n.runtime_model, @@ -1432,8 +1501,7 @@ requires: [] ) .unwrap(); - let app = crate::manifest::loader::load_app(&src).unwrap(); - let lock = compile(&app, &agents, &src).unwrap(); + let lock = compile(&src, &agents).unwrap(); // Inner do: agent is pinned. assert_eq!( @@ -1534,8 +1602,7 @@ requires: [] "#, ) .unwrap(); - let app = crate::manifest::loader::load_app(&src).unwrap(); - let lock = compile(&app, &agents, &src).unwrap(); + let lock = compile(&src, &agents).unwrap(); // `{{ dup.rows }}` (on `loop`) must validate against the TOP-LEVEL dup, // which has `rows` — not the body dup, which doesn't. So: no note. let lp = lock.nodes.iter().find(|n| n.id == "loop").unwrap(); @@ -1617,8 +1684,7 @@ requires: [] "#, ) .unwrap(); - let app = crate::manifest::loader::load_app(&src).unwrap(); - let lock = compile(&app, &agents, &src).unwrap(); + let lock = compile(&src, &agents).unwrap(); let consumer = lock.nodes.iter().find(|n| n.id == "loop.consumer").unwrap(); assert!( !consumer.notes.iter().any(|n| n.text.contains("rfis")), @@ -1686,8 +1752,7 @@ requires: [] "#, ) .unwrap(); - let app = crate::manifest::loader::load_app(&src).unwrap(); - let lock = compile(&app, &agents, &src).unwrap(); + let lock = compile(&src, &agents).unwrap(); // The body `{{ item.foo }}` is the per-iteration var — no note. The // top-level `{{ item.bar }}` on `loop` is a real ref that resolves. let consumer = lock.nodes.iter().find(|n| n.id == "loop.consumer").unwrap(); @@ -1759,8 +1824,7 @@ requires: [] "#, ) .unwrap(); - let app = crate::manifest::loader::load_app(&src).unwrap(); - let lock = compile(&app, &agents, &src).unwrap(); + let lock = compile(&src, &agents).unwrap(); let worker = lock.nodes.iter().find(|n| n.id == "loop.worker").unwrap(); let inputs = worker .inputs @@ -1849,8 +1913,7 @@ requires: [] "#, ) .unwrap(); - let app = crate::manifest::loader::load_app(&src).unwrap(); - let lock = compile(&app, &agents, &src).unwrap(); + let lock = compile(&src, &agents).unwrap(); let worker = lock.nodes.iter().find(|n| n.id == "study.worker").unwrap(); assert!( worker @@ -1944,8 +2007,7 @@ requires: [] "#, ) .unwrap(); - let app = crate::manifest::loader::load_app(&src).unwrap(); - let lock = compile(&app, &agents, &src).unwrap(); + let lock = compile(&src, &agents).unwrap(); let worker = lock .nodes .iter() @@ -2050,8 +2112,7 @@ requires: [] "#, ) .unwrap(); - let app = crate::manifest::loader::load_app(&src).unwrap(); - let lock = compile(&app, &agents, &src).unwrap(); + let lock = compile(&src, &agents).unwrap(); let worker = lock .nodes .iter() @@ -2121,8 +2182,7 @@ requires: [] "#, ) .unwrap(); - let app = crate::manifest::loader::load_app(&src).unwrap(); - let lock = compile(&app, &agents, &src).unwrap(); + let lock = compile(&src, &agents).unwrap(); let sink = lock.nodes.iter().find(|n| n.id == "sink").unwrap(); assert!( !sink.notes.iter().any(|n| n.text.contains("nope")), @@ -2177,8 +2237,7 @@ requires: [] ) .unwrap(); - let app = crate::manifest::loader::load_app(&src).unwrap(); - let lock = compile(&app, &agents, &src).unwrap(); + let lock = compile(&src, &agents).unwrap(); let probe = lock.nodes.iter().find(|n| n.id == "probe").unwrap(); assert_eq!( @@ -2243,8 +2302,7 @@ requires: [] ) .unwrap(); - let app = crate::manifest::loader::load_app(&src).unwrap(); - let lock = compile(&app, &agents, &src).unwrap(); + let lock = compile(&src, &agents).unwrap(); let probe = lock.nodes.iter().find(|n| n.id == "probe").unwrap(); assert_eq!( @@ -2313,8 +2371,7 @@ requires: [] ) .unwrap(); - let app = crate::manifest::loader::load_app(&src).unwrap(); - let lock = compile(&app, &agents, &src).unwrap(); + let lock = compile(&src, &agents).unwrap(); let probe = lock.nodes.iter().find(|n| n.id == "probe").unwrap(); assert_eq!( @@ -2387,8 +2444,7 @@ requires: [] ) .unwrap(); - let app = crate::manifest::loader::load_app(&src).unwrap(); - let lock = compile(&app, &agents, &src).unwrap(); + let lock = compile(&src, &agents).unwrap(); let probe = lock.nodes.iter().find(|n| n.id == "probe").unwrap(); assert_eq!( @@ -2449,8 +2505,7 @@ requires: [] ) .unwrap(); - let app = crate::manifest::loader::load_app(&src).unwrap(); - let lock = compile(&app, &agents, &src).unwrap(); + let lock = compile(&src, &agents).unwrap(); let yaml = serde_yaml::to_string(&lock).unwrap(); // The note must serialize as a `{ kind, text }` map with a lowercase @@ -2574,8 +2629,7 @@ requires: [] "#, ) .unwrap(); - let app = crate::manifest::loader::load_app(&src).unwrap(); - compile(&app, &mode_axis_agents(), &src).unwrap() + compile(&src, &mode_axis_agents()).unwrap() } fn compiled<'a>(lock: &'a LockFile, id: &str) -> &'a CompiledNode { @@ -2721,10 +2775,9 @@ requires: [] "#, ) .unwrap(); - let app = crate::manifest::loader::load_app(&src).unwrap(); // Deliberately compiled against an EMPTY agent set — that is the // "not installed" condition. - let lock = compile(&app, &[], &src).unwrap(); + let lock = compile(&src, &[]).unwrap(); for (id, want_mode) in [ ("silent", "write"), @@ -2810,8 +2863,7 @@ requires: [] "#, ) .unwrap(); - let app = crate::manifest::loader::load_app(&src).unwrap(); - let lock = compile(&app, &agents, &src).unwrap(); + let lock = compile(&src, &agents).unwrap(); let declared = compiled(&lock, "declared"); assert_eq!(declared.mode, "write"); @@ -2949,8 +3001,7 @@ requires: [] "#, ) .unwrap(); - let app = crate::manifest::loader::load_app(&src).unwrap(); - let lock = compile(&app, &agents, &src).unwrap(); + let lock = compile(&src, &agents).unwrap(); let sink = compiled(&lock, "sink"); assert!( sink.notes.iter().any(|n| n.text.contains("src.nope")), diff --git a/cli/src/commands/app.rs b/cli/src/commands/app.rs index 41e442545..03f38632a 100644 --- a/cli/src/commands/app.rs +++ b/cli/src/commands/app.rs @@ -1730,10 +1730,7 @@ fn inspect_cmd(ctx: &Context, path: &std::path::Path) -> Result<(), AwareError> )) })?; // Compile first so the viewer renders the freshly-resolved lockfile. - let lock_path = crate::app_lock::compile_to_disk(&source, &ctx.paths)?; - let app = crate::manifest::loader::load_app(&source)?; - let agents = crate::manifest::loader::discover_agents(&ctx.paths)?; - let lock = crate::app_lock::compile(&app, &agents, &source)?; + let (lock_path, lock) = crate::app_lock::compile_to_disk_with_lock(&source, &ctx.paths)?; let html_path = glass_box_html_path(&lock_path); let html = render_glass_box_html(&lock); From 4d3bd2f85bf12a46cb4adda2fd55fe94147e408b Mon Sep 17 00:00:00 2001 From: Pawel Date: Tue, 8 Sep 2026 21:06:39 +0200 Subject: [PATCH 5/7] fix(app): enforce full compiled approval (#501) --- 10-core/app-spec.md | 2 +- 10-core/cli-spec.md | 2 +- cli/src/app_lock.rs | 55 ++++++++++++++++++++++++++++++++++- cli/src/commands/app.rs | 4 ++- cli/src/runtime/invoker.rs | 3 +- cli/tests/app_requires_pin.rs | 37 +++++++++++++++++++++++ 6 files changed, 98 insertions(+), 5 deletions(-) diff --git a/10-core/app-spec.md b/10-core/app-spec.md index 241ecec82..57066199b 100644 --- a/10-core/app-spec.md +++ b/10-core/app-spec.md @@ -548,7 +548,7 @@ nodes: | `aware app compile ` | Explicit compile. Emits `.lock` next to the source file. Fails if validation fails. | | `aware app validate ` | Now also writes `.lock` as a side effect (was: silent pass) | | `aware app inspect ` | Opens Glass Box — a single-file HTML viewer of the lockfile — in the user's default browser | -| `aware app run ` | Refuses before trace creation or node dispatch unless a present `.lock` matches the raw source bytes' `source-hash`. Compile and run each bind parsing and hashing to one source snapshot; unsafe `app:` ids are rejected before lock lookup. The same independent gate applies before dispatching an app-backed agent. Missing lock: `E_APP_LOCK_MISSING`; unreadable/malformed lock: `E_APP_LOCK_INVALID`; hash mismatch: `E_APP_LOCK_STALE`. Each prompts the user to run `aware app compile` first. The gate applies to real, dry, and simulated runs. | +| `aware app run ` | Refuses before trace creation or node dispatch unless a present `.lock` matches the raw source bytes' `source-hash`. Compile and run each bind parsing and hashing to one source snapshot; unsafe `app:` ids are rejected before lock lookup. Real dispatch also requires every reachable installed agent to match the exact version in `agent-pins` (`E_APP_LOCK_AGENT_PIN_MISMATCH`). The same independent gate applies before dispatching an app-backed agent. Missing lock: `E_APP_LOCK_MISSING`; unreadable/malformed lock: `E_APP_LOCK_INVALID`; hash mismatch: `E_APP_LOCK_STALE`. Each prompts the user to run `aware app compile` first. Source approval applies to real, dry, and simulated runs; simulation continues to ignore ambient agent availability and versions because it dispatches no agent. | ### Why this matters diff --git a/10-core/cli-spec.md b/10-core/cli-spec.md index 1d75172e7..aae6d135f 100644 --- a/10-core/cli-spec.md +++ b/10-core/cli-spec.md @@ -291,7 +291,7 @@ skills (31): ### `aware app run ` -The heaviest command. It first verifies the installed source against the engineer-approved `.lock`: the lock must be present, parseable, and carry the SHA-256 of the exact raw source bytes. Compilation and runtime each parse and hash one source snapshot, so the compiled plan, approved bytes, and executed app cannot drift between reads. An unsafe `app:` id is rejected before it can become a lock path. A missing (`E_APP_LOCK_MISSING`), unreadable/malformed (`E_APP_LOCK_INVALID`), or mismatched (`E_APP_LOCK_STALE`) lock exits 3 before trace creation or node dispatch and tells the operator to run `aware app compile` again. This gate applies independently to the top-level app and every app-backed agent it invokes, as well as to `--dry-run` and `--simulate`; those modes change dispatch behavior, not which source was approved. +The heaviest command. It first verifies the installed source against the engineer-approved `.lock`: the lock must be present, parseable, and carry the SHA-256 of the exact raw source bytes. Compilation and runtime each parse and hash one source snapshot, so the compiled plan, approved bytes, and executed app cannot drift between reads. An unsafe `app:` id is rejected before it can become a lock path. A missing (`E_APP_LOCK_MISSING`), unreadable/malformed (`E_APP_LOCK_INVALID`), or mismatched (`E_APP_LOCK_STALE`) lock exits 3 before trace creation or node dispatch and tells the operator to run `aware app compile` again. Before real dispatch, every reachable agent must also match the exact compiled `agent-pins` version (`E_APP_LOCK_AGENT_PIN_MISMATCH`); simulation remains independent of ambient agent versions because it contacts no binary. Source approval applies independently to the top-level app and every app-backed agent it invokes, including `--dry-run` and `--simulate`. After that gate, it loads the app file, resolves agent dependencies via the lockfile, starts any stateful agents, wires connections, and either: - Returns immediately (one-shot app with only stateless nodes) diff --git a/cli/src/app_lock.rs b/cli/src/app_lock.rs index 882c2e8e6..c16733a2f 100644 --- a/cli/src/app_lock.rs +++ b/cli/src/app_lock.rs @@ -219,6 +219,11 @@ fn read_source_snapshot(source_path: &Path) -> Result Result { + load_approved_app_with_lock(source_path).map(|(app, _)| app) +} + +/// Load the approved source together with the exact compiled plan it matched. +pub fn load_approved_app_with_lock(source_path: &Path) -> Result<(App, LockFile), AwareError> { let snapshot = read_source_snapshot(source_path)?; let app = snapshot.app; let source_dir = source_path @@ -260,7 +265,32 @@ pub fn load_approved_app(source_path: &Path) -> Result { source_path.display() ))); } - Ok(app) + Ok((app, lock)) +} + +/// Refuse execution when a dispatchable agent no longer matches the exact +/// version captured in the engineer-approved plan. +pub fn verify_agent_pins( + app: &App, + lock: &LockFile, + agents: &[DiscoveredAgent], +) -> Result<(), AwareError> { + for agent_id in crate::validate::dispatchable_agents(app) { + let Some(approved) = lock.agent_pins.get(agent_id) else { + continue; + }; + let current = agents + .iter() + .find(|agent| agent.manifest.agent == agent_id) + .map(|agent| agent.manifest.version.as_str()); + if current != Some(approved.as_str()) { + return Err(AwareError::Validation(format!( + "[E_APP_LOCK_AGENT_PIN_MISMATCH] compiled approval pins agent {agent_id} at {approved}, but the installed version is {}; run `aware app compile` again", + current.unwrap_or("missing") + ))); + } + } + Ok(()) } /// Compile a source snapshot + the installed agent catalogue into a lockfile. @@ -976,6 +1006,29 @@ pub fn compile_to_disk_with_lock( Ok((path, lock)) } +/// Validate one source snapshot using `app validate` semantics, then persist +/// the plan and hash derived from that same snapshot. Ambient missing or +/// unsatisfied agent versions remain outside validation's file-only verdict. +pub fn validate_to_disk(source: &Path, paths: &Paths) -> Result { + let snapshot = read_source_snapshot(source)?; + let app = &snapshot.app; + let mut issues = crate::validate::validate_app(app); + let agents = crate::manifest::loader::discover_agents(paths).unwrap_or_default(); + issues.extend(crate::validate::validate_app_safety(app, &agents)); + issues.extend(crate::validate::validate_app_agents(app, &agents)); + if let Some(error) = issues + .iter() + .find(|issue| issue.severity == crate::validate::Severity::Error) + { + return Err(AwareError::Validation(format!( + "app failed validation: [{}] {}", + error.code, error.message + ))); + } + let lock = compile_snapshot(app, &agents, snapshot.source_hash)?; + write_lockfile(&lock, source) +} + #[cfg(test)] mod tests { use super::*; diff --git a/cli/src/commands/app.rs b/cli/src/commands/app.rs index 03f38632a..5b3fac508 100644 --- a/cli/src/commands/app.rs +++ b/cli/src/commands/app.rs @@ -236,7 +236,7 @@ async fn run( .ok_or_else(|| AwareError::Validation(format!("app {app_id} has no .flo/.app file")))?; // Parse and hash one source buffer so the compiled sidecar approves the // exact app we execute. Gate every run mode before provenance or dispatch. - let app = crate::app_lock::load_approved_app(&manifest_path)?; + let (app, approved_lock) = crate::app_lock::load_approved_app_with_lock(&manifest_path)?; // Safety-contract pre-flight: refuse to run an app whose write-mode // nodes are missing `safety:` blocks. Skipped in --dry-run (a dry-run @@ -272,6 +272,7 @@ async fn run( if !simulate { let agents = crate::manifest::loader::discover_agents(&ctx.paths)?; + crate::app_lock::verify_agent_pins(&app, &approved_lock, &agents)?; // Planned-agent check: a plain `--dry-run` still dispatches to live read-mode // binaries (only `--simulate`, excluded above, stubs everything), so refuse a @@ -1551,6 +1552,7 @@ fn validate_cmd(ctx: &Context, path: &std::path::Path) -> Result<(), AwareError> } if issues.is_empty() { + crate::app_lock::validate_to_disk(&manifest_path, &ctx.paths)?; println!("\u{2713} {} is valid", manifest_path.display()); return Ok(()); } diff --git a/cli/src/runtime/invoker.rs b/cli/src/runtime/invoker.rs index 45097e0f9..0c9567803 100644 --- a/cli/src/runtime/invoker.rs +++ b/cli/src/runtime/invoker.rs @@ -2924,7 +2924,7 @@ impl DispatchInvoker { })?; // Nested app-backed dispatch is still app execution: require its own // compiled approval and bind parsing to the exact bytes that were hashed. - let app = crate::app_lock::load_approved_app(&manifest_path)?; + let (app, approved_lock) = crate::app_lock::load_approved_app_with_lock(&manifest_path)?; if !app.exposes_as_agent { return Err(AwareError::Validation(format!( "app {backed_by} is not declared exposes-as-agent" @@ -2944,6 +2944,7 @@ impl DispatchInvoker { // skips it: every node is stubbed and no binary is contacted. if !app_ctx.simulate { let agents = crate::manifest::loader::discover_agents_in(&self.agents_dir)?; + crate::app_lock::verify_agent_pins(&app, &approved_lock, &agents)?; // The nested app gets the same two catalogue pre-flights `aware app run` // applies to the app the operator named — it never had either, because // the command-level pre-flight only ever sees the top-level app. Missing diff --git a/cli/tests/app_requires_pin.rs b/cli/tests/app_requires_pin.rs index c69f6c10d..d4055e1ea 100644 --- a/cli/tests/app_requires_pin.rs +++ b/cli/tests/app_requires_pin.rs @@ -117,6 +117,39 @@ fn run_refuses_an_app_whose_pin_the_installed_agent_does_not_satisfy() { .stderr(predicate::str::contains("E_APP_AGENT_PIN_UNSATISFIED")); } +#[test] +fn run_refuses_an_agent_version_that_drifted_from_the_compiled_plan() { + let (tmp, src) = fixture("1.3.0", "1.x"); + let home = tmp.path().join("home"); + aware(&home) + .args(["app", "compile"]) + .arg(src.join("pin-test.flo")) + .assert() + .success(); + aware(&home) + .args(["app", "install"]) + .arg(&src) + .assert() + .success(); + + let manifest = home.join("agents/probe-agent/manifest.yaml"); + let changed = std::fs::read_to_string(&manifest) + .unwrap() + .replace("version: 1.3.0", "version: 1.4.0"); + std::fs::write(manifest, changed).unwrap(); + + Command::cargo_bin("aware") + .unwrap() + .env("AWARE_HOME", &home) + .args(["app", "run", "pin-test", "--dry-run"]) + .assert() + .failure() + .code(3) + .stderr(predicate::str::contains("E_APP_LOCK_AGENT_PIN_MISMATCH")) + .stderr(predicate::str::contains("1.3.0")) + .stderr(predicate::str::contains("1.4.0")); +} + #[test] fn install_warns_but_still_installs() { // Installing an app before the agent it pins is legitimate (#170), and the @@ -246,6 +279,10 @@ fn validate_judges_the_file_not_the_machine() { .assert() .success() .stdout(predicate::str::contains("is valid")); + assert!( + src.join("pin-test.lock").is_file(), + "successful validation must emit the approval required by app run" + ); let (tmp2, src2) = fixture("1.3.0", "not-a-version"); aware(&tmp2.path().join("home")) From 30408c353c910e15d542e366ec1562aa7c00e222 Mon Sep 17 00:00:00 2001 From: Pawel Date: Tue, 8 Sep 2026 21:15:04 +0200 Subject: [PATCH 6/7] fix(app): reject unapproved agent installs (#501) --- cli/src/app_lock.rs | 12 ++++++----- cli/tests/app_expose.rs | 15 +++++++++++--- cli/tests/app_requires_pin.rs | 31 ++++++++++++++++++++++++++++ cli/tests/common/mod.rs | 38 ++++++++++++++++++++++++++++++++++- 4 files changed, 87 insertions(+), 9 deletions(-) diff --git a/cli/src/app_lock.rs b/cli/src/app_lock.rs index c16733a2f..897a9ec81 100644 --- a/cli/src/app_lock.rs +++ b/cli/src/app_lock.rs @@ -276,16 +276,18 @@ pub fn verify_agent_pins( agents: &[DiscoveredAgent], ) -> Result<(), AwareError> { for agent_id in crate::validate::dispatchable_agents(app) { - let Some(approved) = lock.agent_pins.get(agent_id) else { - continue; - }; let current = agents .iter() .find(|agent| agent.manifest.agent == agent_id) .map(|agent| agent.manifest.version.as_str()); - if current != Some(approved.as_str()) { + let approved = lock.agent_pins.get(agent_id).map(String::as_str); + // A missing agent is reported by the existing missing-agent preflight. + // An installed agent absent from the lock was never approved and must + // not become executable merely because it appeared after compilation. + if current.is_some() && current != approved { return Err(AwareError::Validation(format!( - "[E_APP_LOCK_AGENT_PIN_MISMATCH] compiled approval pins agent {agent_id} at {approved}, but the installed version is {}; run `aware app compile` again", + "[E_APP_LOCK_AGENT_PIN_MISMATCH] compiled approval pins agent {agent_id} at {}, but the installed version is {}; run `aware app compile` again", + approved.unwrap_or("no version"), current.unwrap_or("missing") ))); } diff --git a/cli/tests/app_expose.rs b/cli/tests/app_expose.rs index d0df4ae8d..49f9c4ecc 100644 --- a/cli/tests/app_expose.rs +++ b/cli/tests/app_expose.rs @@ -1,8 +1,6 @@ //! End-to-end tests for `exposes-as-agent`: an app installed as a callable //! agent, invoked from another app's `nodes:` block (issue #178). -mod common; - use assert_cmd::Command; use predicates::prelude::*; @@ -12,11 +10,22 @@ fn write_app(src_root: &std::path::Path, name: &str, flo: &str) -> std::path::Pa std::fs::create_dir_all(&dir).unwrap(); let source = dir.join(format!("{name}.flo")); std::fs::write(&source, flo).unwrap(); - common::approve_app_source(&source); dir } fn install_app(aware: &std::path::Path, src_dir: &std::path::Path) -> assert_cmd::assert::Assert { + let source = std::fs::read_dir(src_dir) + .unwrap() + .flatten() + .map(|entry| entry.path()) + .find(|path| path.extension().is_some_and(|extension| extension == "flo")) + .unwrap(); + let _ = Command::cargo_bin("aware") + .unwrap() + .env("AWARE_HOME", aware) + .args(["app", "compile"]) + .arg(source) + .output(); Command::cargo_bin("aware") .unwrap() .env("AWARE_HOME", aware) diff --git a/cli/tests/app_requires_pin.rs b/cli/tests/app_requires_pin.rs index d4055e1ea..ca46639a2 100644 --- a/cli/tests/app_requires_pin.rs +++ b/cli/tests/app_requires_pin.rs @@ -150,6 +150,37 @@ fn run_refuses_an_agent_version_that_drifted_from_the_compiled_plan() { .stderr(predicate::str::contains("1.4.0")); } +#[test] +fn run_refuses_an_installed_agent_that_was_absent_from_the_compiled_plan() { + let (tmp, src) = fixture("1.3.0", "1.x"); + let home = tmp.path().join("home"); + let agent = home.join("agents/probe-agent"); + let parked = home.join("probe-agent-parked"); + std::fs::rename(&agent, &parked).unwrap(); + aware(&home) + .args(["app", "compile"]) + .arg(src.join("pin-test.flo")) + .assert() + .success(); + std::fs::rename(&parked, &agent).unwrap(); + aware(&home) + .args(["app", "install"]) + .arg(&src) + .assert() + .success(); + + Command::cargo_bin("aware") + .unwrap() + .env("AWARE_HOME", &home) + .args(["app", "run", "pin-test", "--dry-run"]) + .assert() + .failure() + .code(3) + .stderr(predicate::str::contains("E_APP_LOCK_AGENT_PIN_MISMATCH")) + .stderr(predicate::str::contains("no version")) + .stderr(predicate::str::contains("1.3.0")); +} + #[test] fn install_warns_but_still_installs() { // Installing an app before the agent it pins is legitimate (#170), and the diff --git a/cli/tests/common/mod.rs b/cli/tests/common/mod.rs index 0f4e03beb..fd709c544 100644 --- a/cli/tests/common/mod.rs +++ b/cli/tests/common/mod.rs @@ -9,6 +9,7 @@ // `common` is compiled once per test binary; not every binary uses every item. #![allow(dead_code)] +use std::collections::BTreeMap; use std::path::{Path, PathBuf}; use std::sync::OnceLock; @@ -112,8 +113,43 @@ pub fn approve_app_source(source: &Path) { let app = parsed["app"].as_str().expect("app id in fixture"); let version = parsed["version"].as_str().expect("app version in fixture"); let hash = format!("sha256:{:x}", Sha256::digest(&bytes)); + let home = source + .parent() + .and_then(Path::parent) + .and_then(Path::parent); + let mut pins = BTreeMap::new(); + if let Some(agents_dir) = home.map(|path| path.join("agents")) + && let Ok(entries) = std::fs::read_dir(agents_dir) + { + for entry in entries.flatten() { + let manifest = entry.path().join("manifest.yaml"); + let Some(value) = std::fs::read(&manifest) + .ok() + .and_then(|body| serde_yaml::from_slice::(&body).ok()) + else { + continue; + }; + if let (Some(id), Some(agent_version)) = + (value["agent"].as_str(), value["version"].as_str()) + { + pins.insert(id.to_string(), agent_version.to_string()); + } + } + } + let pins_yaml = if pins.is_empty() { + "{}".to_string() + } else { + let yaml = serde_yaml::to_string(&pins).expect("serialize fixture agent pins"); + format!( + "\n{}", + yaml.lines() + .map(|line| format!(" {line}")) + .collect::>() + .join("\n") + ) + }; let lock = format!( - "source-hash: {hash}\ncompiled-at: test\ncompiler-version: test\napp: {app}\nversion: {version}\nagent-pins: {{}}\nnodes: []\n" + "source-hash: {hash}\ncompiled-at: test\ncompiler-version: test\napp: {app}\nversion: {version}\nagent-pins: {pins_yaml}\nnodes: []\n" ); std::fs::write(source.with_file_name(format!("{app}.lock")), lock) .expect("write app approval fixture"); From 378a52092209ee21d98bab94cf19c9ccc3ee15a5 Mon Sep 17 00:00:00 2001 From: Pawel Date: Tue, 8 Sep 2026 22:02:17 +0200 Subject: [PATCH 7/7] test(connection-reader): compile harness app approval (#501) --- cli-connection-reader/model-windows-harness.mjs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/cli-connection-reader/model-windows-harness.mjs b/cli-connection-reader/model-windows-harness.mjs index f9529320c..c50555bee 100644 --- a/cli-connection-reader/model-windows-harness.mjs +++ b/cli-connection-reader/model-windows-harness.mjs @@ -171,6 +171,8 @@ nodes: expected-signer-sha256: '{{ inputs.expected-signer-sha256 }}' `); execFileSync(aware, ['app', 'install', appDirectory], { env: environment, stdio: 'pipe', windowsHide: true }); + const installedAppSource = path.join(home, 'apps', 'rvt-reader-e2e', 'rvt-reader-e2e.flo'); + execFileSync(aware, ['app', 'compile', installedAppSource], { env: environment, stdio: 'pipe', windowsHide: true }); const appStdout = execFileSync(aware, [ 'app', 'run', 'rvt-reader-e2e', '--input', `rvt-path=${source}`,