diff --git a/10-core/app-spec.md b/10-core/app-spec.md index 26a5e05ad..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 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`. 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 332094a02..aae6d135f 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. 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) - Blocks until stopped (long-running app with stateful nodes) 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}`, diff --git a/cli/src/app_lock.rs b/cli/src/app_lock.rs index 718db8bb9..897a9ec81 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,21 +180,136 @@ impl CompileNote { } } -/// Compile a parsed app + the installed agent catalogue into a lockfile. +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 +/// source bytes. Missing, unreadable, malformed, or stale approval artifacts +/// 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 { + 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 + .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 = 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", + lock_path.display(), + lock.source_hash, + current_hash, + source_path.display() + ))); + } + 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 current = agents + .iter() + .find(|agent| agent.manifest.agent == agent_id) + .map(|agent| agent.manifest.version.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 {}, but the installed version is {}; run `aware app compile` again", + approved.unwrap_or("no version"), + current.unwrap_or("missing") + ))); + } + } + Ok(()) +} + +/// 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_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()); - // 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 @@ -830,12 +945,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) @@ -849,7 +973,7 @@ pub fn compile_to_disk(source: &Path, paths: &Paths) -> Result Result Result 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) } @@ -887,6 +1035,47 @@ pub fn compile_to_disk(source: &Path, paths: &Paths) -> Result.lock`, NEVER `.flo.lock`. @@ -1204,8 +1393,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")), @@ -1262,8 +1450,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, @@ -1369,8 +1556,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!( @@ -1471,8 +1657,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(); @@ -1554,8 +1739,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")), @@ -1623,8 +1807,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(); @@ -1696,8 +1879,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 @@ -1786,8 +1968,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 @@ -1881,8 +2062,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() @@ -1987,8 +2167,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() @@ -2058,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 sink = lock.nodes.iter().find(|n| n.id == "sink").unwrap(); assert!( !sink.notes.iter().any(|n| n.text.contains("nope")), @@ -2114,8 +2292,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!( @@ -2180,8 +2357,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!( @@ -2250,8 +2426,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!( @@ -2324,8 +2499,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!( @@ -2386,8 +2560,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 @@ -2511,8 +2684,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 { @@ -2658,10 +2830,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"), @@ -2747,8 +2918,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"); @@ -2886,8 +3056,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 8fc118f32..5b3fac508 100644 --- a/cli/src/commands/app.rs +++ b/cli/src/commands/app.rs @@ -234,7 +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)?; + // 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, 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 @@ -270,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 @@ -722,19 +725,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. @@ -894,7 +895,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 @@ -902,8 +903,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, } })?; @@ -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(()); } @@ -1730,10 +1732,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); diff --git a/cli/src/runtime/invoker.rs b/cli/src/runtime/invoker.rs index c56d17ade..0c9567803 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, 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" @@ -2942,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_expose.rs b/cli/tests/app_expose.rs index 0212c1842..49f9c4ecc 100644 --- a/cli/tests/app_expose.rs +++ b/cli/tests/app_expose.rs @@ -8,11 +8,24 @@ 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(); 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) @@ -123,6 +136,75 @@ 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 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(); diff --git a/cli/tests/app_requires_pin.rs b/cli/tests/app_requires_pin.rs index 143b4d475..ca46639a2 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 @@ -113,6 +117,70 @@ 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 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 @@ -242,6 +310,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")) 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..fd709c544 100644 --- a/cli/tests/common/mod.rs +++ b/cli/tests/common/mod.rs @@ -9,9 +9,11 @@ // `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; +use sha2::{Digest, Sha256}; use tempfile::TempDir; static FIXTURE: OnceLock = OnceLock::new(); @@ -100,3 +102,91 @@ 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 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: {pins_yaml}\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");