From 845bbb7627c7033e40866057ed5f9d752bfd81f7 Mon Sep 17 00:00:00 2001 From: Pawel Lisowski Date: Mon, 12 Oct 2026 09:28:09 +0200 Subject: [PATCH 01/17] feat(manifest): literal inputs refuse templates at validate, compile and run (#692) An exec command's `code` is a program, and the approved `.lock` hashes the app source, not what a template resolves to later. `code: '{{ inputs.code }}'` would run Python supplied after a person approved the lock. A manifest input may now declare `literal: true`. A node giving one any template syntax ({{ }}, {% %}, {# #}) is refused with E_APP_INPUT_MUST_BE_LITERAL naming the node and the input: by `app validate` and the lock compiler (both through check_node_agents), and by the orchestrator, which re-checks the node's merged UN-rendered value ahead of every render_config, so a lock from an older compiler cannot smuggle one in either. Templated non-literal inputs (exec `args`, i.e. data) still pass. Only blender's exec.code adopts it here; tekla/rhino-8/revit-2026/ sketchup-2026 exec are #693. --- 10-core/app-spec.md | 14 +++++ cli/src/manifest/agent.rs | 108 ++++++++++++++++++++++++++++++++ cli/src/runtime/orchestrator.rs | 92 +++++++++++++++++++++++++++ cli/src/validate.rs | 67 ++++++++++++++++++++ 4 files changed, 281 insertions(+) diff --git a/10-core/app-spec.md b/10-core/app-spec.md index 00d517cd9..b4da37435 100644 --- a/10-core/app-spec.md +++ b/10-core/app-spec.md @@ -1061,6 +1061,20 @@ One class of command is different: those that run **caller-supplied logic** whos The author taking responsibility for an `exec` script's mode is the only authority that exists — no manifest can inspect an arbitrary script. This resolves issue #165: a read-only host-`exec` app is no longer mislabeled write-mode or forced to declare a bogus `safety:` block. +Not every `exec` is overridable. `blender`'s `exec` (#692) is fixed `mode: write` with no override, because a read-mode node runs for real under `--dry-run` and a preview must never render an animation. + +#### Literal inputs (`literal: true`) + +An input a command runs as a program — an `exec` command's `code` — must be pinned by the approval, and the `.lock` hashes the app source, not what a template later resolves to. A `code: '{{ inputs.code }}'` would run Python supplied after a person approved the lock. So an agent manifest may declare an input **`literal: true`**: + +```yaml +inputs: + code: { type: string, required: true, literal: true } + args: { type: object } # data: still templatable +``` + +A node that gives a literal input any template syntax (`{{ }}`, `{% %}`, `{# #}`) is refused with **`E_APP_INPUT_MUST_BE_LITERAL`**, naming the node and the input, by `aware app validate`, by the lock compiler, and by the runtime. The runtime check reads the node's merged, **un-rendered** value before any template is rendered, so a lock produced by an older compiler cannot smuggle one through either. Pass run-time data through a templatable input (`args`) instead. Today only `blender`'s `exec.code` is literal; the other host `exec` commands adopting it is tracked separately (#693). + ### The block ```yaml diff --git a/cli/src/manifest/agent.rs b/cli/src/manifest/agent.rs index df3a4f43e..0db41f2cc 100644 --- a/cli/src/manifest/agent.rs +++ b/cli/src/manifest/agent.rs @@ -668,10 +668,118 @@ fn is_write_by_convention(name: &str) -> bool { || name == "save-attributes" } +/// The validation code for a `literal: true` input that carries template syntax (#692). +pub const E_APP_INPUT_MUST_BE_LITERAL: &str = "E_APP_INPUT_MUST_BE_LITERAL"; + +impl Command { + /// The inputs this command declares `literal: true`, in manifest order. + /// + /// A literal input is one whose value must be pinned in the app source itself and so covered + /// by the `.lock` source hash — above all an `exec` command's `code`, which is run as a + /// program. A template there (`code: '{{ inputs.code }}'`) would execute text supplied AFTER a + /// person approved the lock, so validate, compile and the runtime all refuse one (#692). Data + /// the program reads (`args`) stays templatable; only the program is pinned. + pub fn literal_inputs(&self) -> Vec<&str> { + let Some(map) = self.inputs.as_mapping() else { + return Vec::new(); + }; + map.iter() + .filter(|(_, decl)| { + decl.get("literal") + .and_then(Value::as_bool) + .unwrap_or(false) + }) + .filter_map(|(name, _)| name.as_str()) + .collect() + } + + /// The `literal: true` inputs whose value in `params` (a node's merged, UN-rendered + /// `config:` + `inputs:`) carries template syntax. Empty when every literal input is literal. + /// + /// Checked on the raw value, never after rendering: once a template has been rendered its + /// origin is unknowable, so a check placed after `render_config` would wave through exactly + /// the substitution it exists to stop. + pub fn templated_literal_inputs(&self, params: Option<&Value>) -> Vec { + let Some(params) = params else { + return Vec::new(); + }; + self.literal_inputs() + .into_iter() + .filter(|name| params.get(*name).is_some_and(contains_template_syntax)) + .map(str::to_string) + .collect() + } +} + +/// Whether a value carries template syntax anywhere: `{{ … }}`, `{% … %}` or `{# … #}`. +/// +/// Deliberately the whole minijinja delimiter set, not only `{{`: the runtime renders every string +/// it hands a transport, so a `{%` block in a literal input would be executed by the template +/// engine just as surely as an expression — and a literal input must reach the agent as written. +pub fn contains_template_syntax(value: &Value) -> bool { + match value { + Value::String(s) => ["{{", "{%", "{#"].iter().any(|d| s.contains(d)), + Value::Sequence(items) => items.iter().any(contains_template_syntax), + Value::Mapping(map) => map + .iter() + .any(|(k, v)| contains_template_syntax(k) || contains_template_syntax(v)), + Value::Tagged(tagged) => contains_template_syntax(&tagged.value), + _ => false, + } +} + +/// The human message for [`E_APP_INPUT_MUST_BE_LITERAL`], shared by validate, compile and the +/// runtime so the three surfaces say the same thing. +pub fn literal_input_message(node: &str, agent: &str, command: &str, input: &str) -> String { + format!( + "node {node:?} passes a templated value to `{input}` of {agent}.{command}, which is \ + declared `literal: true`: it must be written out in the app source so the approved \ + `.lock` pins exactly what runs. A template there would run text supplied after \ + approval. Put the value inline, and pass run-time data through a templatable input \ + (e.g. `args`) instead. Template delimiters (`{{{{`, `{{%`, `{{#`) are not allowed \ + anywhere in a literal input." + ) +} + #[cfg(test)] mod tests { use super::*; + #[test] + fn literal_inputs_are_read_from_the_input_declarations() { + let cmd: Command = serde_yaml::from_str( + "lifecycle: single\ndescription: d\ninputs:\n code: { type: string, literal: true }\n args: { type: object }\n flag: string\n", + ) + .unwrap(); + assert_eq!(cmd.literal_inputs(), vec!["code"]); + + let params: Value = + serde_yaml::from_str("code: '{{ inputs.code }}'\nargs: '{{ inputs.args }}'").unwrap(); + assert_eq!(cmd.templated_literal_inputs(Some(¶ms)), vec!["code"]); + + // Templated `args` (data, not code) is allowed; a literal `code` passes. + let ok: Value = + serde_yaml::from_str("code: 'def run(ctx): return 1'\nargs: '{{ inputs.args }}'") + .unwrap(); + assert!(cmd.templated_literal_inputs(Some(&ok)).is_empty()); + assert!(cmd.templated_literal_inputs(None).is_empty()); + } + + #[test] + fn every_template_delimiter_counts_as_templated() { + for text in ["{{ x }}", "a {% if x %}b{% endif %}", "{# note #}"] { + assert!( + contains_template_syntax(&Value::String(text.into())), + "{text}" + ); + } + assert!(!contains_template_syntax(&Value::String( + "def run(ctx):\n return {'a': {1: 2}}".into() + ))); + let nested: Value = serde_yaml::from_str("[1, {k: '{{ x }}'}]").unwrap(); + assert!(contains_template_syntax(&nested)); + } + /// Parse a shipped manifest relative to the repository root, with its path in errors. fn real_manifest(rel: &str) -> Agent { let path = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")) diff --git a/cli/src/runtime/orchestrator.rs b/cli/src/runtime/orchestrator.rs index eee0e389b..6be573eeb 100644 --- a/cli/src/runtime/orchestrator.rs +++ b/cli/src/runtime/orchestrator.rs @@ -268,6 +268,7 @@ impl Orchestrator { AwareError::Validation(format!("node `{source_id}` declares no agent")) })?; let command = node.command.as_deref().unwrap_or(""); + self.ensure_literal_inputs(&node, agent, command)?; let params = yaml_to_json(node.merged_params().unwrap_or(serde_yaml::Value::Null))?; let args = render_config(¶ms, &self.ctx)?; let record_args = render_for_record(¶ms, &self.record_render_context(¶ms)); @@ -665,6 +666,7 @@ impl Orchestrator { } self.ctx.record_output(&node.id, current_event.clone()); + self.ensure_literal_inputs(node, agent_id, command)?; let params = yaml_to_json(node.merged_params().unwrap_or(serde_yaml::Value::Null))?; let args = render_config(¶ms, &self.ctx)?; let record_args = render_for_record(¶ms, &self.record_render_context(¶ms)); @@ -868,6 +870,40 @@ impl Orchestrator { outcome } + /// Refuse a node whose `literal: true` input (an `exec` command's `code`) carries template + /// syntax, BEFORE anything is rendered (#692). + /// + /// Validate and the lock compiler already refuse this; the runtime repeats it because a lock + /// produced by an older compiler predates the rule, and because the check is only meaningful + /// on the un-rendered value — after `render_config` the text no longer says where it came + /// from. So this reads the node's merged `config:` + `inputs:` straight from the app the lock + /// approved, and the orchestrator calls it ahead of every `render_config` on an agent node. + fn ensure_literal_inputs( + &self, + node: &Node, + agent_id: &str, + command: &str, + ) -> Result<(), AwareError> { + let Ok(agent) = self.catalogue.manifest(agent_id) else { + return Ok(()); + }; + let Some(cmd) = agent.commands.get(command) else { + return Ok(()); + }; + if let Some(input) = cmd + .templated_literal_inputs(node.merged_params().as_ref()) + .into_iter() + .next() + { + return Err(AwareError::Validation(format!( + "[{}] {}", + crate::manifest::agent::E_APP_INPUT_MUST_BE_LITERAL, + crate::manifest::agent::literal_input_message(&node.id, agent_id, command, &input) + ))); + } + Ok(()) + } + async fn emit_progress(&mut self, node_id: &str, data: Value) -> Result<(), AwareError> { self.emit(RunEvent::NodeProgress { ts: now_iso(), @@ -969,6 +1005,7 @@ impl Orchestrator { let command = node.command.as_deref().unwrap_or(""); // Source params via the shared config:+inputs: merge rule, then // convert serde_yaml::Value → serde_json::Value and render templates. + self.ensure_literal_inputs(node, agent_id, command)?; let config_json = yaml_to_json(node.merged_params().unwrap_or(serde_yaml::Value::Null))?; let args = render_config(&config_json, &self.ctx)?; @@ -2603,6 +2640,61 @@ commands: } } + /// #692: the runtime refuses a templated `literal: true` input on the UN-rendered value, so a + /// lock compiled before the rule existed still cannot run code supplied after approval. The + /// mock invoker HAS a canned output for the call, so without the check the run would succeed + /// — the refusal is what makes it fail. Templated `args` beside literal `code` still runs. + #[tokio::test] + async fn the_runtime_refuses_a_templated_literal_input_before_rendering() { + let app_yaml = |code: &str| { + format!( + "app: lit\nversion: 0.1.0\ndescription: x\nnodes:\n - id: animate\n \ + agent: runner\n command: exec\n inputs:\n code: {code}\n \ + args: '{{{{ inputs.args }}}}'\nconnections: []\nrequires: []\n" + ) + }; + let install = |tmp: &tempfile::TempDir| { + let agent_dir = tmp.path().join("aware").join("agents").join("runner"); + std::fs::create_dir_all(&agent_dir).unwrap(); + std::fs::write( + agent_dir.join("manifest.yaml"), + "agent: runner\nversion: 0.0.1\ndescription: x\nstateful: false\nlicense: MIT\n\ + transport: { builtin: {} }\ncommands:\n exec:\n lifecycle: single\n \ + mode: write\n description: x\n inputs:\n code: { type: string, literal: true }\n \ + args: { type: object }\n", + ) + .unwrap(); + }; + let invoker = || -> Arc { + Arc::new(MockInvoker::new().with_single( + "runner", + "exec", + serde_json::json!({ "ok": true }), + )) + }; + + let templated: App = serde_yaml::from_str(&app_yaml("'{{ inputs.code }}'")).unwrap(); + let (mut orch, tmp, _log) = make_orchestrator(templated, invoker()).await; + install(&tmp); + orch.ctx.inputs = serde_json::json!({ "code": "def run(ctx): return 1", "args": {} }); + let err = orch + .run_one_shot() + .await + .expect_err("templated literal code must not run"); + let text = err.to_string(); + assert!(text.contains("E_APP_INPUT_MUST_BE_LITERAL"), "{text}"); + assert!(text.contains("\"animate\""), "{text}"); + assert!(text.contains("`code`"), "{text}"); + + let literal: App = serde_yaml::from_str(&app_yaml("'def run(ctx): return 1'")).unwrap(); + let (mut orch, tmp, _log) = make_orchestrator(literal, invoker()).await; + install(&tmp); + orch.ctx.inputs = serde_json::json!({ "args": { "storeys": 3 } }); + orch.run_one_shot() + .await + .expect("literal code with templated args must run"); + } + #[tokio::test] async fn frozen_node_emits_pinned_output_and_skips_the_agent() { // A frozen node must NOT call its agent (the empty invoker would error if it did) and must diff --git a/cli/src/validate.rs b/cli/src/validate.rs index e1fae2e60..7cb507383 100644 --- a/cli/src/validate.rs +++ b/cli/src/validate.rs @@ -1519,6 +1519,24 @@ fn check_node_agents( )); } + // #692: a `literal: true` input (an `exec` command's `code`) must be written out in + // the app source, so the `.lock` source hash pins exactly what runs. Checked here — + // the agent-aware validator every lock-producing and run path goes through — so + // `app validate` and the lock compiler refuse it identically; the orchestrator + // re-checks the un-rendered value at run time for a lock made before this rule. + if let Some(cmd_name) = &n.command + && let Some(cmd) = d.manifest.commands.get(cmd_name.as_str()) + { + for input in cmd.templated_literal_inputs(n.merged_params().as_ref()) { + out.push(ValidationIssue::error( + crate::manifest::agent::E_APP_INPUT_MUST_BE_LITERAL, + crate::manifest::agent::literal_input_message( + &n.id, agent_id, cmd_name, &input, + ), + )); + } + } + // RFC #223 — the fenced runtime model-extraction carve-out. A command that // calls a model at run time (`model-extraction: true`) is admitted into the // deterministic run path ONLY when it is `category: curated` AND its agent @@ -3385,6 +3403,55 @@ app: after-everything ); } + /// #692: a `literal: true` input carrying a template is refused with a code naming the node + /// and the input; the same input written out passes, and a templated NON-literal input + /// (`args`, data rather than code) is still allowed. + #[test] + fn a_templated_literal_input_is_refused_by_name() { + let agents = vec![crate::manifest::loader::DiscoveredAgent { + manifest: serde_yaml::from_str( + "agent: runner\nversion: 0.1.0\ndescription: x\nstateful: false\nlicense: MIT\n\ + transport:\n builtin: {}\ncommands:\n exec:\n lifecycle: single\n \ + mode: write\n description: x\n inputs:\n code: { type: string, literal: true }\n \ + args: { type: object }\n", + ) + .unwrap(), + root: std::path::PathBuf::from("."), + }]; + let app = |code: &str| -> App { + serde_yaml::from_str(&format!( + "app: lit\nversion: 0.0.1\ndescription: x\nrequires: []\n\ + nodes:\n - id: animate\n agent: runner\n command: exec\n \ + inputs:\n code: {code}\n args: '{{{{ inputs.args }}}}'\n" + )) + .unwrap() + }; + + let issues = validate_app_agents(&app("'{{ inputs.code }}'"), &agents); + let hit = issues + .iter() + .find(|i| i.code == "E_APP_INPUT_MUST_BE_LITERAL") + .unwrap_or_else(|| panic!("templated code must be refused: {issues:?}")); + assert!(hit.message.contains("\"animate\""), "{}", hit.message); + assert!(hit.message.contains("`code`"), "{}", hit.message); + assert!(hit.message.contains("runner.exec"), "{}", hit.message); + + // `{% %}` is template syntax too — the engine would execute it. + let block = validate_app_agents(&app("'{% if true %}x{% endif %}'"), &agents); + assert!( + block + .iter() + .any(|i| i.code == "E_APP_INPUT_MUST_BE_LITERAL"), + "{block:?}" + ); + + let ok = validate_app_agents(&app("'def run(ctx): return 1'"), &agents); + assert!( + !ok.iter().any(|i| i.code == "E_APP_INPUT_MUST_BE_LITERAL"), + "literal code with templated args must pass: {ok:?}" + ); + } + #[test] fn rejects_node_referencing_planned_command() { // The agent is available, but one command is `status: planned` (#199) — an From edbd51bc72c879c42d7360f967b831d9015ff65f Mon Sep 17 00:00:00 2001 From: Pawel Lisowski Date: Mon, 12 Oct 2026 09:28:18 +0200 Subject: [PATCH 02/17] feat(blender): exec dispatch and per-frame progress on the builtin transport (#692) Long Blender renders were silent until they finished: node-progress (#405) was relayed only for CLI transports, so a 40-minute animation looked exactly like a hang. The bounded Relay pump (8 KiB/record, 10,000 records, 16 MiB read, progress-suppressed) moves from invoker.rs into runtime::progress as a shared component. The CLI transport drives it beside wait_with_output as before; render::blender drives it from INSIDE its own select loop, next to the concurrent pipe drains, the deadline and the taskkill /T tree kill, with a final drain after exit and after a timeout kill. The Blender runner is not replaced by relay_progress, which would have lost both. On a run, DispatchInvoker hands blender a run-owned channel (AWARE_PROGRESS_FILE) and the records become node-progress events exactly as for a CLI bridge. `aware agent invoke` passes a caller-set AWARE_PROGRESS_FILE through to Blender (FloLess's Render card tails it); any other launch removes the variable so an inherited one is never written to. `exec` joins the command table (exec.py, 1800 s default), with pre-spawn validation (non-empty string code, ifc-path/blend-path exclusive, output-dir present, object args) failing as invalid-inputs, exit 3, and a dry-run that echoes the inputs with the code's size, never the code. --- cli/src/commands/agent.rs | 3 + cli/src/render/blender.rs | 540 +++++++++++++++++++++++++++++++++--- cli/src/runtime/invoker.rs | 262 +++-------------- cli/src/runtime/progress.rs | 223 +++++++++++++++ 4 files changed, 775 insertions(+), 253 deletions(-) diff --git a/cli/src/commands/agent.rs b/cli/src/commands/agent.rs index c3d5667b1..ad35fa23a 100644 --- a/cli/src/commands/agent.rs +++ b/cli/src/commands/agent.rs @@ -995,6 +995,9 @@ async fn invoke_cmd( let result = BuiltinInvoker { dry_run: false, blender_root: None, + // A caller that exported AWARE_PROGRESS_FILE (FloLess's Render card) tails it itself, so a + // producer this builtin spawns writes there (#692). + inherit_progress_env: true, } .invoke_single(agent_id, command, args) .await?; diff --git a/cli/src/render/blender.rs b/cli/src/render/blender.rs index 685a2feb0..fe06cbca9 100644 --- a/cli/src/render/blender.rs +++ b/cli/src/render/blender.rs @@ -1,8 +1,9 @@ //! `blender.*` — the builtin transport for the headless Blender visualization agent. //! //! The `blender` agent is IFC in, rendered PNG / MP4 out, fully unattended. The work itself lives in -//! five `bpy` scripts shipped with the agent (`/agents/blender/scripts/`); this module is -//! the launcher that runs them: +//! the `bpy` scripts shipped with the agent (`/agents/blender/scripts/`) — five fixed +//! recipes plus `exec.py`, which runs author-time code pinned in the approved lock (#692); this +//! module is the launcher that runs them: //! //! ```text //! blender -b -P /