Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion 10-core/app-spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -548,7 +548,7 @@ nodes:
| `aware app compile <app>` | Explicit compile. Emits `<app>.lock` next to the source file. Fails if validation fails. |
| `aware app validate <app>` | Now also writes `<app>.lock` as a side effect (was: silent pass) |
| `aware app inspect <app>` | Opens Glass Box — a single-file HTML viewer of the lockfile — in the user's default browser |
| `aware app run <app>` | 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 <app>` | 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

Expand Down
4 changes: 3 additions & 1 deletion 10-core/cli-spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -291,7 +291,9 @@ skills (31):

### `aware app run <app>`

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 `<app>.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)

Expand Down
2 changes: 2 additions & 0 deletions cli-connection-reader/model-windows-harness.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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}`,
Expand Down
291 changes: 230 additions & 61 deletions cli/src/app_lock.rs

Large diffs are not rendered by default.

33 changes: 16 additions & 17 deletions cli/src/commands/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)?;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Add compilation to the documented install-and-run flows

When an app is installed from an uncompiled source, this unconditional gate now rejects every run with E_APP_LOCK_MISSING. That is exactly how the main README, 30-apps/_examples/README.md, and the persona onboarding guides instruct users to proceed—install or edit a raw example and immediately run it—and the repository contains no example .lock files, so the advertised quickstart and scheduled-app flows now fail universally. Update those flows to compile the final installed source before running/scheduling it, or otherwise ensure installation carries an explicitly approved lock.

Useful? React with 👍 / 👎.


// Safety-contract pre-flight: refuse to run an app whose write-mode
// nodes are missing `safety:` blocks. Skipped in --dry-run (a dry-run
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -894,16 +895,16 @@ 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
);
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,
}
})?;
Expand Down Expand Up @@ -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(());
}
Expand Down Expand Up @@ -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);
Expand Down
5 changes: 4 additions & 1 deletion cli/src/runtime/invoker.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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
Expand Down
84 changes: 83 additions & 1 deletion cli/tests/app_expose.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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();
Expand Down
72 changes: 72 additions & 0 deletions cli/tests/app_requires_pin.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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"))
Expand Down
Loading
Loading