diff --git a/CHANGELOG.md b/CHANGELOG.md
index d4d344c..fccb65b 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -46,8 +46,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
hoisted into a shared module so there is exactly one of them: relative paths
join the workspace, absolute paths must resolve inside it, symlinks are
followed, and a missing path or a non-directory fails the step instead of
- falling back to the workspace. A run with no workspace resolves nothing and
- passes the value to the harness verbatim, as before.
+ falling back to the workspace. An expression that resolves to `null` fails the
+ step too, rather than reading as "no directory declared" and falling through
+ to the agent definition's own `working_dir` or the harness default. A run with
+ no workspace resolves nothing and passes the value to the harness verbatim, as
+ before.
+
+- **`AgentRunner::resolve_workdir` + `caps::WorkdirCheck`** — the seam a harness
+ uses to answer for its **own** filesystem when a run does declare a workspace.
+ Deciding whether a directory exists, what it canonicalizes to, and whether it
+ is a directory is an outside-world effect, and on a harness whose agents run
+ in a container or a remote sandbox the answer is not on the engine's disk.
+ The shape of the value — absolute vs relative, `..` traversal — is string
+ arithmetic and stays with the engine, so a host cannot weaken the containment
+ check that matters most. The method has a default returning
+ `WorkdirCheck::Unmanaged`, which checks the engine's own filesystem exactly as
+ before, so every existing `AgentRunner` keeps compiling and behaving
+ identically.
### Changed
diff --git a/src/caps/agent.rs b/src/caps/agent.rs
index 377c04d..5afe2c9 100644
--- a/src/caps/agent.rs
+++ b/src/caps/agent.rs
@@ -433,7 +433,7 @@ impl AgentRunOutcome {
}
mod runner;
-pub use runner::AgentRunner;
+pub use runner::{AgentRunner, WorkdirCheck};
#[cfg(test)]
#[path = "agent_tests.rs"]
diff --git a/src/caps/agent/runner.rs b/src/caps/agent/runner.rs
index 7064fac..d1baee5 100644
--- a/src/caps/agent/runner.rs
+++ b/src/caps/agent/runner.rs
@@ -1,5 +1,22 @@
use super::*;
+/// A host's answer to "where does this declared working directory actually
+/// live?" — see [`AgentRunner::resolve_workdir`].
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub enum WorkdirCheck {
+ /// Not this harness's filesystem to judge. The engine falls back to
+ /// checking the directory on its own process filesystem, which is the
+ /// behavior every host had before this method existed.
+ Unmanaged,
+ /// The directory exists in that workspace, and this is its canonical path
+ /// on the filesystem the agent will actually run on. The engine passes it
+ /// to the harness verbatim.
+ Resolved(String),
+ /// The directory is refused, for this reason. The engine fails the step
+ /// with the message, prefixed with the node it came from.
+ Refused(String),
+}
+
/// Runs a host-registered, multi-turn **agent** — the harness seam.
///
/// Where [`LlmProvider::complete`](crate::caps::LlmProvider::complete) is a
@@ -181,4 +198,32 @@ pub trait AgentRunner: Send + Sync {
.map(|grant| ToolDescriptor::from_grant(grant, conn))
.collect())
}
+
+ /// Resolves a node's declared working directory (`config.cwd`, a
+ /// `sub_workflow` node's `config.workspace`) against `workspace`, on the
+ /// filesystem the agent will actually run on.
+ ///
+ /// The engine has no filesystem of its own. It can, and does, check the
+ /// *shape* of a declared directory — an absolute path, a `..` traversal —
+ /// because that is string arithmetic. Deciding whether the path **exists**,
+ /// what it canonicalizes to, and whether it is a directory is an
+ /// outside-world effect, and on a harness whose agents run in a remote
+ /// sandbox or a container the answer is not on the engine's disk at all.
+ /// This method is where such a host answers for its own filesystem.
+ ///
+ /// `workspace` is the run's declared boundary and `declared` the raw value
+ /// the author wrote (already expression-resolved). A host that resolves the
+ /// pair must also **contain** it: returning
+ /// [`Resolved`](WorkdirCheck::Resolved) asserts the path is inside
+ /// `workspace`, since only the host can compare two paths on its own
+ /// filesystem.
+ ///
+ /// The default returns [`Unmanaged`](WorkdirCheck::Unmanaged) for
+ /// everything, so the engine canonicalizes locally exactly as it did before
+ /// this method existed. A host whose agents share the engine's filesystem
+ /// wants precisely that and should leave it alone.
+ async fn resolve_workdir(&self, workspace: &str, declared: &str) -> WorkdirCheck {
+ let _ = (workspace, declared);
+ WorkdirCheck::Unmanaged
+ }
}
diff --git a/src/caps/mod.rs b/src/caps/mod.rs
index 4e4bb40..4f43215 100644
--- a/src/caps/mod.rs
+++ b/src/caps/mod.rs
@@ -24,7 +24,7 @@ use crate::error::Result;
pub use self::agent::{
AgentInput, AgentModelSelection, AgentRunIdentity, AgentRunOutcome, AgentRunRequest,
- AgentRunner, AgentUsage, ContextBlock, StopReason, ToolDescriptor,
+ AgentRunner, AgentUsage, ContextBlock, StopReason, ToolDescriptor, WorkdirCheck,
};
pub use self::approval::{
ApprovalDecision, ApprovalOutcome, ApprovalProvider, ApprovalRequest, ApprovalSubject,
diff --git a/src/nodes/integration/agent.rs b/src/nodes/integration/agent.rs
index 33386d1..94ce06e 100644
--- a/src/nodes/integration/agent.rs
+++ b/src/nodes/integration/agent.rs
@@ -189,7 +189,7 @@ async fn run_turn_indexed(
} else {
"working_dir"
};
- let resolved = super::agent_request::resolve_working_dir(ctx, &raw, key)?;
+ let resolved = super::agent_request::resolve_working_dir(ctx, &raw, key).await?;
if let Value::Object(map) = &mut request {
map.insert("cwd".to_string(), Value::from(resolved.clone()));
map.insert("working_dir".to_string(), Value::from(resolved));
diff --git a/src/nodes/integration/agent_request.rs b/src/nodes/integration/agent_request.rs
index 4df4d05..6f09aa7 100644
--- a/src/nodes/integration/agent_request.rs
+++ b/src/nodes/integration/agent_request.rs
@@ -191,14 +191,29 @@ fn narrow_tools(granted: &[ToolGrant], requested: &[ToolGrant], node_id: &str) -
/// both are set.
///
/// # Errors
-/// Refuses a non-string or a blank value, exactly as a `shell` node's `cwd`
-/// does. A number or an empty string here is an authoring slip, and the
-/// alternative is a step that silently runs somewhere else.
+/// Refuses a non-string, a blank value, **or a null**, exactly as a `shell`
+/// node's `cwd` does. A number or an empty string here is an authoring slip,
+/// and the alternative is a step that silently runs somewhere else.
+///
+/// Null is the one worth spelling out. `cfg` arrives already
+/// expression-resolved, so `"cwd": "=nodes.prepare.item.json.worktree"` becomes
+/// `null` whenever that path is missing — the upstream node failed, or the key
+/// moved. Treating that as "no `cwd` declared" would fall through to
+/// `working_dir`, then to the definition's own directory, then to whatever the
+/// harness defaults to: the step runs in a *different checkout* and says
+/// nothing. A directory an author named is never silently swapped for another,
+/// so a present-but-null value fails the node instead.
pub(crate) fn declared_working_dir(cfg: &Value, node_id: &str) -> Result> {
for key in ["cwd", "working_dir"] {
- let Some(value) = cfg.get(key).filter(|v| !v.is_null()) else {
+ let Some(value) = cfg.get(key) else {
continue;
};
+ if value.is_null() {
+ return Err(EngineError::Capability(format!(
+ "agent node {node_id}: `{key}` resolved to null; an expression that reads a \
+ missing path fails the step rather than falling back to another directory"
+ )));
+ }
let dir = value.as_str().ok_or_else(|| {
EngineError::Capability(format!("agent node {node_id}: `{key}` must be a string"))
})?;
@@ -225,13 +240,19 @@ pub(crate) fn declared_working_dir(cfg: &Value, node_id: &str) -> Result , raw: &str, key: &str) -> Result {
+pub(crate) async fn resolve_working_dir(
+ ctx: &NodeContext<'_>,
+ raw: &str,
+ key: &str,
+) -> Result {
crate::workdir::resolve_node_dir(
+ ctx.caps.agent.as_ref(),
ctx.run,
raw,
&format!("config.{key}"),
&format!("agent node {}", ctx.node.id),
)
+ .await
}
/// Resolves each declared [`ContextSource`] into a [`ContextBlock`], in
@@ -400,7 +421,7 @@ pub(crate) async fn assemble(
} else {
"working_dir"
};
- agent.working_dir = Some(resolve_working_dir(ctx, &raw, key)?);
+ agent.working_dir = Some(resolve_working_dir(ctx, &raw, key).await?);
}
let identity = identity_of(ctx, item_index);
let conn = cfg.get("connection_ref").and_then(Value::as_str);
diff --git a/src/nodes/integration/sub_workflow/execution.rs b/src/nodes/integration/sub_workflow/execution.rs
index 72cc5e5..605b891 100644
--- a/src/nodes/integration/sub_workflow/execution.rs
+++ b/src/nodes/integration/sub_workflow/execution.rs
@@ -126,7 +126,7 @@ fn pause_for_child_gates(node_id: &str, gates: Vec) -> NodeOutput {
/// (see [`crate::workdir`]). A parent run with no workspace has nothing to
/// contain the value against, so it is taken as written: that is how a graph
/// declares a workspace for a child when the run itself was never pinned to one.
-fn child_workspace(ctx: &NodeContext<'_>, scope: &Value) -> Result> {
+async fn child_workspace(ctx: &NodeContext<'_>, scope: &Value) -> Result > {
let Some(declared) = ctx.node.config.get("workspace").filter(|v| !v.is_null()) else {
return Ok(crate::workdir::run_workspace(ctx.run).map(str::to_string));
};
@@ -143,12 +143,16 @@ fn child_workspace(ctx: &NodeContext<'_>, scope: &Value) -> Result Vec {
}
}
- // Per-kind config checks. A `sub_workflow` node must reference its child
- // exactly one way: an inline `workflow` graph OR a `workflow_id` reference,
- // never both and never neither (the reference form is resolved at run time
- // via the host `WorkflowResolver`).
- for node in &graph.nodes {
- if node.kind == NodeKind::SubWorkflow {
- let has_inline = node.config.get("workflow").is_some();
- let has_ref = node
- .config
- .get("workflow_id")
- .and_then(serde_json::Value::as_str)
- .is_some_and(|s| !s.is_empty());
- if has_inline == has_ref {
- errors.push(ValidationError::InvalidNodeConfig {
- node: node.id.clone(),
- reason: "sub_workflow requires exactly one of `workflow` (inline) or \
- `workflow_id` (reference)"
- .to_string(),
- });
- }
- }
- }
-
- // Per-item fan-out config (`execution` / `concurrency` / `on_item_error`).
- // These select the execution strategy, so an unrecognized value cannot be
- // caught at run time without silently changing behaviour — a bad
- // `concurrency` would quietly stay sequential and a bad `on_item_error`
- // would quietly pick a default. Reject them here, where the message can name
- // the node.
- for node in &graph.nodes {
- let fans_out = matches!(
- node.kind,
- NodeKind::Agent
- | NodeKind::ToolCall
- | NodeKind::HttpRequest
- | NodeKind::Memory
- | NodeKind::SubWorkflow
- );
-
- if let Some(execution) = node.config.get("execution") {
- match execution.as_str() {
- Some("once" | "per_item") if fans_out => {}
- Some("once" | "per_item") => {
- errors.push(ValidationError::InvalidNodeConfig {
- node: node.id.clone(),
- reason: format!(
- "`execution` is not supported on a {} node (only agent, tool_call, \
- http_request, memory, and sub_workflow map over their input)",
- kind_name(&node.kind)
- ),
- });
- }
- _ => {
- errors.push(ValidationError::InvalidNodeConfig {
- node: node.id.clone(),
- reason: format!(
- "unknown `execution` value {execution} (expected \"once\" or \
- \"per_item\")"
- ),
- });
- }
- }
- }
-
- // Whether this node actually maps over its input, accounting for the
- // per-kind default: `tool_call` / `http_request` / `memory` are per-item
- // unless told otherwise; `agent` / `sub_workflow` are not.
- let per_item = match node.config.get("execution").and_then(Value::as_str) {
- Some("per_item") => true,
- Some("once") => false,
- _ => matches!(
- node.kind,
- NodeKind::ToolCall | NodeKind::HttpRequest | NodeKind::Memory
- ),
- };
-
- for key in ["concurrency", "on_item_error"] {
- let Some(value) = node.config.get(key) else {
- continue;
- };
- // A fan-out knob on a node that runs once is a no-op, and a silent
- // no-op reads as "I asked for parallelism and got none".
- if !per_item {
- errors.push(ValidationError::InvalidNodeConfig {
- node: node.id.clone(),
- reason: format!(
- "`{key}` has no effect without `execution: \"per_item\"` on a {} node",
- kind_name(&node.kind)
- ),
- });
- continue;
- }
- let ok = match key {
- "concurrency" => {
- matches!(
- value,
- Value::Number(n) if n.as_u64().is_some(),
- ) || value.as_str() == Some("all")
- }
- _ => matches!(value.as_str(), Some("collect" | "fail_fast" | "skip")),
- };
- if !ok {
- let expected = if key == "concurrency" {
- "a non-negative integer or \"all\""
- } else {
- "\"collect\", \"fail_fast\", or \"skip\""
- };
- errors.push(ValidationError::InvalidNodeConfig {
- node: node.id.clone(),
- reason: format!("`{key}` must be {expected}, got {value}"),
- });
- }
- }
- }
-
- // `memory` node config checks, including THE hard security invariant: a
- // `remember`/`forget` operation may never target `scope: "user"` — the
- // caller's durable, cross-flow memory. Rejecting this structurally, at the
- // door, means a workflow (or an LLM authoring one) can never plant or erase
- // durable facts about the user by way of a `remember`/`forget` node; the
- // only scope those two operations may write through is `"flow"`.
- for node in &graph.nodes {
- if node.kind != NodeKind::Memory {
- continue;
- }
-
- let operation = node.config.get("operation").and_then(Value::as_str);
- let Some(operation) = operation else {
- errors.push(ValidationError::InvalidNodeConfig {
- node: node.id.clone(),
- reason: "memory node requires `operation` (recall|search|flavour|people|\
- remember|forget)"
- .to_string(),
- });
- continue;
- };
- if !matches!(
- operation,
- "recall" | "search" | "flavour" | "people" | "remember" | "forget"
- ) {
- errors.push(ValidationError::InvalidNodeConfig {
- node: node.id.clone(),
- reason: format!(
- "memory node has unknown operation {operation:?} (expected one of \
- recall|search|flavour|people|remember|forget)"
- ),
- });
- continue;
- }
-
- let scope = node.config.get("scope").and_then(Value::as_str);
-
- // THE hard invariant (see the block comment above): reject before any
- // other config check, so it can never be masked by a different error.
- // remember/forget may write ONLY scope "flow". BOTH read-only scopes are
- // rejected here — "user" (the user's durable memory) and "flows"
- // (cross-flow read). This gate is unbypassable precisely because `scope`
- // is validated as a literal enum (below): an "=expr" binding resolves at
- // runtime and is never one of user|flow|flows, so it fails the enum
- // check and can never smuggle a write past this into
- // provider.remember/forget. If a future change makes `scope` bindable,
- // this invariant reopens — keep the enum check.
- if matches!(operation, "remember" | "forget")
- && matches!(scope, Some("user") | Some("flows"))
- {
- let bad = scope.unwrap_or_default();
- errors.push(ValidationError::InvalidNodeConfig {
- node: node.id.clone(),
- reason: format!(
- "memory node operation {operation:?} may not target scope {bad:?} — \
- remember/forget may only write scope \"flow\"; scopes \"user\" and \
- \"flows\" are read-only from a workflow"
- ),
- });
- }
-
- if let Some(scope) = scope {
- if !matches!(scope, "user" | "flow" | "flows") {
- errors.push(ValidationError::InvalidNodeConfig {
- node: node.id.clone(),
- reason: format!(
- "memory node has unknown scope {scope:?} (expected \
- user|flow|flows)"
- ),
- });
- }
- }
-
- // `scope` is required for recall/remember/forget (not search/flavour/
- // people — see the catalog contract for the exact per-operation table).
- if matches!(operation, "recall" | "remember" | "forget") && scope.is_none() {
- errors.push(ValidationError::InvalidNodeConfig {
- node: node.id.clone(),
- reason: format!("memory node operation {operation:?} requires `scope`"),
- });
- }
-
- if matches!(operation, "recall" | "search") {
- let has_query = node
- .config
- .get("query")
- .and_then(Value::as_str)
- .is_some_and(|s| !s.is_empty());
- if !has_query {
- errors.push(ValidationError::InvalidNodeConfig {
- node: node.id.clone(),
- reason: format!("memory node operation {operation:?} requires `query`"),
- });
- }
- }
-
- if operation == "flavour" {
- let has_flavour = node
- .config
- .get("flavour")
- .and_then(Value::as_str)
- .is_some_and(|s| !s.is_empty());
- if !has_flavour {
- errors.push(ValidationError::InvalidNodeConfig {
- node: node.id.clone(),
- reason: "memory node operation \"flavour\" requires `flavour` (slug)"
- .to_string(),
- });
- }
- }
-
- if matches!(operation, "remember" | "forget") {
- let has_key = node
- .config
- .get("key")
- .and_then(Value::as_str)
- .is_some_and(|s| !s.is_empty());
- if !has_key {
- errors.push(ValidationError::InvalidNodeConfig {
- node: node.id.clone(),
- reason: format!("memory node operation {operation:?} requires `key`"),
- });
- }
- }
-
- if operation == "remember" && node.config.get("value").is_none() {
- errors.push(ValidationError::InvalidNodeConfig {
- node: node.id.clone(),
- reason: "memory node operation \"remember\" requires `value`".to_string(),
- });
- }
- }
-
- // `dedup` node config checks: `key` (the per-item "=expr" dedup key) is the
- // only config field, and it is required — a dedup node with no `key` can
- // never resolve anything to compare, which is always an authoring mistake
- // (as opposed to a `key` that *resolves* to null at run time, which is the
- // intentional, per-item fail-open path the executor handles).
- for node in &graph.nodes {
- if node.kind != NodeKind::Dedup {
- continue;
- }
- let has_key = node
- .config
- .get("key")
- .and_then(Value::as_str)
- .is_some_and(|s| !s.is_empty());
- if !has_key {
- errors.push(ValidationError::InvalidNodeConfig {
- node: node.id.clone(),
- reason: "dedup node requires `key` (an \"=expr\" resolved per item, e.g. \
- \"=item.id\")"
- .to_string(),
- });
- }
- }
-
- // `approval` node config. These are all closed enums that SELECT BEHAVIOUR,
- // so a typo cannot be caught at run time without silently changing what the
- // node does: a misspelled `on_reject` would quietly route a rejection that
- // was meant to fail the run, and a misspelled `wait_mode` would quietly
- // suspend a review the author wanted polled. Refuse them at the door, where
- // the message can name the node and the alternatives.
- for node in &graph.nodes {
- if node.kind != NodeKind::Approval {
- continue;
- }
-
- for (key, allowed) in [
- ("wait_mode", &["suspend", "poll"][..]),
- ("on_reject", &["route", "error", "drop"][..]),
- ("on_timeout", &["error", "reject", "route"][..]),
- ] {
- let Some(value) = node.config.get(key) else {
- continue;
- };
- if !value.as_str().is_some_and(|v| allowed.contains(&v)) {
- errors.push(ValidationError::InvalidNodeConfig {
- node: node.id.clone(),
- reason: format!(
- "approval node has unknown `{key}` {value} (expected one of {})",
- allowed
- .iter()
- .map(|v| format!("{v:?}"))
- .collect::>()
- .join(", ")
- ),
- });
- }
- }
-
- // Reviewer handles are opaque to the crate, but their *shape* is not:
- // a bare string here (the natural mistake for a single reviewer) would
- // be read as "nobody", and the review would go to an empty audience
- // with no error anywhere. An empty array reaches the same audience of
- // nobody just as silently, so it is refused for the same reason.
- if let Some(assignees) = node.config.get("assignees") {
- if !assignees
- .as_array()
- .is_some_and(|values| !values.is_empty() && values.iter().all(Value::is_string))
- {
- errors.push(ValidationError::InvalidNodeConfig {
- node: node.id.clone(),
- reason: "approval node `assignees` must be a non-empty array of strings (a \
- single reviewer is a one-element array)"
- .to_string(),
- });
- }
- }
- }
+ validate_node_configs(graph, &mut errors);
// `void` node topology checks. The kind asserts exactly one thing — "the
// branch ends here, deliberately" — so the two ways to contradict it are
@@ -579,6 +255,9 @@ use agents::validate_agents;
mod loops;
use loops::validate_loops;
+mod node_config;
+use node_config::validate_node_configs;
+
mod scatter;
use scatter::{nodes_on_cycle, path_exists, validate_scatter_regions};
diff --git a/src/validate/node_config.rs b/src/validate/node_config.rs
new file mode 100644
index 0000000..6f0ac2d
--- /dev/null
+++ b/src/validate/node_config.rs
@@ -0,0 +1,344 @@
+//! Per-kind node **config** validation.
+//!
+//! The half of [`validate_all`](super::validate_all) that reads a node's
+//! `config` object rather than the graph's shape: the `sub_workflow` child
+//! reference, per-item fan-out selectors, `memory` scope rules, the `dedup`
+//! key, and the `approval` enums. Every check here is about one node in
+//! isolation — nothing in this module looks at an edge — which is what makes it
+//! a module of its own rather than an arbitrary cut through `validate_all`.
+
+use serde_json::Value;
+
+use crate::error::ValidationError;
+use crate::model::{NodeKind, WorkflowGraph};
+
+use super::kind_name;
+
+/// Appends every per-kind config error the graph carries, in node order.
+pub(super) fn validate_node_configs(graph: &WorkflowGraph, errors: &mut Vec) {
+ // Per-kind config checks. A `sub_workflow` node must reference its child
+ // exactly one way: an inline `workflow` graph OR a `workflow_id` reference,
+ // never both and never neither (the reference form is resolved at run time
+ // via the host `WorkflowResolver`).
+ for node in &graph.nodes {
+ if node.kind == NodeKind::SubWorkflow {
+ let has_inline = node.config.get("workflow").is_some();
+ let has_ref = node
+ .config
+ .get("workflow_id")
+ .and_then(serde_json::Value::as_str)
+ .is_some_and(|s| !s.is_empty());
+ if has_inline == has_ref {
+ errors.push(ValidationError::InvalidNodeConfig {
+ node: node.id.clone(),
+ reason: "sub_workflow requires exactly one of `workflow` (inline) or \
+ `workflow_id` (reference)"
+ .to_string(),
+ });
+ }
+ }
+ }
+
+ // Per-item fan-out config (`execution` / `concurrency` / `on_item_error`).
+ // These select the execution strategy, so an unrecognized value cannot be
+ // caught at run time without silently changing behaviour — a bad
+ // `concurrency` would quietly stay sequential and a bad `on_item_error`
+ // would quietly pick a default. Reject them here, where the message can name
+ // the node.
+ for node in &graph.nodes {
+ let fans_out = matches!(
+ node.kind,
+ NodeKind::Agent
+ | NodeKind::ToolCall
+ | NodeKind::HttpRequest
+ | NodeKind::Memory
+ | NodeKind::SubWorkflow
+ );
+
+ if let Some(execution) = node.config.get("execution") {
+ match execution.as_str() {
+ Some("once" | "per_item") if fans_out => {}
+ Some("once" | "per_item") => {
+ errors.push(ValidationError::InvalidNodeConfig {
+ node: node.id.clone(),
+ reason: format!(
+ "`execution` is not supported on a {} node (only agent, tool_call, \
+ http_request, memory, and sub_workflow map over their input)",
+ kind_name(&node.kind)
+ ),
+ });
+ }
+ _ => {
+ errors.push(ValidationError::InvalidNodeConfig {
+ node: node.id.clone(),
+ reason: format!(
+ "unknown `execution` value {execution} (expected \"once\" or \
+ \"per_item\")"
+ ),
+ });
+ }
+ }
+ }
+
+ // Whether this node actually maps over its input, accounting for the
+ // per-kind default: `tool_call` / `http_request` / `memory` are per-item
+ // unless told otherwise; `agent` / `sub_workflow` are not.
+ let per_item = match node.config.get("execution").and_then(Value::as_str) {
+ Some("per_item") => true,
+ Some("once") => false,
+ _ => matches!(
+ node.kind,
+ NodeKind::ToolCall | NodeKind::HttpRequest | NodeKind::Memory
+ ),
+ };
+
+ for key in ["concurrency", "on_item_error"] {
+ let Some(value) = node.config.get(key) else {
+ continue;
+ };
+ // A fan-out knob on a node that runs once is a no-op, and a silent
+ // no-op reads as "I asked for parallelism and got none".
+ if !per_item {
+ errors.push(ValidationError::InvalidNodeConfig {
+ node: node.id.clone(),
+ reason: format!(
+ "`{key}` has no effect without `execution: \"per_item\"` on a {} node",
+ kind_name(&node.kind)
+ ),
+ });
+ continue;
+ }
+ let ok = match key {
+ "concurrency" => {
+ matches!(
+ value,
+ Value::Number(n) if n.as_u64().is_some(),
+ ) || value.as_str() == Some("all")
+ }
+ _ => matches!(value.as_str(), Some("collect" | "fail_fast" | "skip")),
+ };
+ if !ok {
+ let expected = if key == "concurrency" {
+ "a non-negative integer or \"all\""
+ } else {
+ "\"collect\", \"fail_fast\", or \"skip\""
+ };
+ errors.push(ValidationError::InvalidNodeConfig {
+ node: node.id.clone(),
+ reason: format!("`{key}` must be {expected}, got {value}"),
+ });
+ }
+ }
+ }
+
+ // `memory` node config checks, including THE hard security invariant: a
+ // `remember`/`forget` operation may never target `scope: "user"` — the
+ // caller's durable, cross-flow memory. Rejecting this structurally, at the
+ // door, means a workflow (or an LLM authoring one) can never plant or erase
+ // durable facts about the user by way of a `remember`/`forget` node; the
+ // only scope those two operations may write through is `"flow"`.
+ for node in &graph.nodes {
+ if node.kind != NodeKind::Memory {
+ continue;
+ }
+
+ let operation = node.config.get("operation").and_then(Value::as_str);
+ let Some(operation) = operation else {
+ errors.push(ValidationError::InvalidNodeConfig {
+ node: node.id.clone(),
+ reason: "memory node requires `operation` (recall|search|flavour|people|\
+ remember|forget)"
+ .to_string(),
+ });
+ continue;
+ };
+ if !matches!(
+ operation,
+ "recall" | "search" | "flavour" | "people" | "remember" | "forget"
+ ) {
+ errors.push(ValidationError::InvalidNodeConfig {
+ node: node.id.clone(),
+ reason: format!(
+ "memory node has unknown operation {operation:?} (expected one of \
+ recall|search|flavour|people|remember|forget)"
+ ),
+ });
+ continue;
+ }
+
+ let scope = node.config.get("scope").and_then(Value::as_str);
+
+ // THE hard invariant (see the block comment above): reject before any
+ // other config check, so it can never be masked by a different error.
+ // remember/forget may write ONLY scope "flow". BOTH read-only scopes are
+ // rejected here — "user" (the user's durable memory) and "flows"
+ // (cross-flow read). This gate is unbypassable precisely because `scope`
+ // is validated as a literal enum (below): an "=expr" binding resolves at
+ // runtime and is never one of user|flow|flows, so it fails the enum
+ // check and can never smuggle a write past this into
+ // provider.remember/forget. If a future change makes `scope` bindable,
+ // this invariant reopens — keep the enum check.
+ if matches!(operation, "remember" | "forget")
+ && matches!(scope, Some("user") | Some("flows"))
+ {
+ let bad = scope.unwrap_or_default();
+ errors.push(ValidationError::InvalidNodeConfig {
+ node: node.id.clone(),
+ reason: format!(
+ "memory node operation {operation:?} may not target scope {bad:?} — \
+ remember/forget may only write scope \"flow\"; scopes \"user\" and \
+ \"flows\" are read-only from a workflow"
+ ),
+ });
+ }
+
+ if let Some(scope) = scope {
+ if !matches!(scope, "user" | "flow" | "flows") {
+ errors.push(ValidationError::InvalidNodeConfig {
+ node: node.id.clone(),
+ reason: format!(
+ "memory node has unknown scope {scope:?} (expected \
+ user|flow|flows)"
+ ),
+ });
+ }
+ }
+
+ // `scope` is required for recall/remember/forget (not search/flavour/
+ // people — see the catalog contract for the exact per-operation table).
+ if matches!(operation, "recall" | "remember" | "forget") && scope.is_none() {
+ errors.push(ValidationError::InvalidNodeConfig {
+ node: node.id.clone(),
+ reason: format!("memory node operation {operation:?} requires `scope`"),
+ });
+ }
+
+ if matches!(operation, "recall" | "search") {
+ let has_query = node
+ .config
+ .get("query")
+ .and_then(Value::as_str)
+ .is_some_and(|s| !s.is_empty());
+ if !has_query {
+ errors.push(ValidationError::InvalidNodeConfig {
+ node: node.id.clone(),
+ reason: format!("memory node operation {operation:?} requires `query`"),
+ });
+ }
+ }
+
+ if operation == "flavour" {
+ let has_flavour = node
+ .config
+ .get("flavour")
+ .and_then(Value::as_str)
+ .is_some_and(|s| !s.is_empty());
+ if !has_flavour {
+ errors.push(ValidationError::InvalidNodeConfig {
+ node: node.id.clone(),
+ reason: "memory node operation \"flavour\" requires `flavour` (slug)"
+ .to_string(),
+ });
+ }
+ }
+
+ if matches!(operation, "remember" | "forget") {
+ let has_key = node
+ .config
+ .get("key")
+ .and_then(Value::as_str)
+ .is_some_and(|s| !s.is_empty());
+ if !has_key {
+ errors.push(ValidationError::InvalidNodeConfig {
+ node: node.id.clone(),
+ reason: format!("memory node operation {operation:?} requires `key`"),
+ });
+ }
+ }
+
+ if operation == "remember" && node.config.get("value").is_none() {
+ errors.push(ValidationError::InvalidNodeConfig {
+ node: node.id.clone(),
+ reason: "memory node operation \"remember\" requires `value`".to_string(),
+ });
+ }
+ }
+
+ // `dedup` node config checks: `key` (the per-item "=expr" dedup key) is the
+ // only config field, and it is required — a dedup node with no `key` can
+ // never resolve anything to compare, which is always an authoring mistake
+ // (as opposed to a `key` that *resolves* to null at run time, which is the
+ // intentional, per-item fail-open path the executor handles).
+ for node in &graph.nodes {
+ if node.kind != NodeKind::Dedup {
+ continue;
+ }
+ let has_key = node
+ .config
+ .get("key")
+ .and_then(Value::as_str)
+ .is_some_and(|s| !s.is_empty());
+ if !has_key {
+ errors.push(ValidationError::InvalidNodeConfig {
+ node: node.id.clone(),
+ reason: "dedup node requires `key` (an \"=expr\" resolved per item, e.g. \
+ \"=item.id\")"
+ .to_string(),
+ });
+ }
+ }
+
+ // `approval` node config. These are all closed enums that SELECT BEHAVIOUR,
+ // so a typo cannot be caught at run time without silently changing what the
+ // node does: a misspelled `on_reject` would quietly route a rejection that
+ // was meant to fail the run, and a misspelled `wait_mode` would quietly
+ // suspend a review the author wanted polled. Refuse them at the door, where
+ // the message can name the node and the alternatives.
+ for node in &graph.nodes {
+ if node.kind != NodeKind::Approval {
+ continue;
+ }
+
+ for (key, allowed) in [
+ ("wait_mode", &["suspend", "poll"][..]),
+ ("on_reject", &["route", "error", "drop"][..]),
+ ("on_timeout", &["error", "reject", "route"][..]),
+ ] {
+ let Some(value) = node.config.get(key) else {
+ continue;
+ };
+ if !value.as_str().is_some_and(|v| allowed.contains(&v)) {
+ errors.push(ValidationError::InvalidNodeConfig {
+ node: node.id.clone(),
+ reason: format!(
+ "approval node has unknown `{key}` {value} (expected one of {})",
+ allowed
+ .iter()
+ .map(|v| format!("{v:?}"))
+ .collect::>()
+ .join(", ")
+ ),
+ });
+ }
+ }
+
+ // Reviewer handles are opaque to the crate, but their *shape* is not:
+ // a bare string here (the natural mistake for a single reviewer) would
+ // be read as "nobody", and the review would go to an empty audience
+ // with no error anywhere. An empty array reaches the same audience of
+ // nobody just as silently, so it is refused for the same reason.
+ if let Some(assignees) = node.config.get("assignees") {
+ if !assignees
+ .as_array()
+ .is_some_and(|values| !values.is_empty() && values.iter().all(Value::is_string))
+ {
+ errors.push(ValidationError::InvalidNodeConfig {
+ node: node.id.clone(),
+ reason: "approval node `assignees` must be a non-empty array of strings (a \
+ single reviewer is a one-element array)"
+ .to_string(),
+ });
+ }
+ }
+ }
+}
diff --git a/src/workdir.rs b/src/workdir.rs
index 50d564c..1925b0a 100644
--- a/src/workdir.rs
+++ b/src/workdir.rs
@@ -22,6 +22,27 @@
//! a host whose agents run in a remote sandbox names directories the engine's
//! own filesystem knows nothing about, and checking those against the local disk
//! would fail every one of them.
+//!
+//! # Whose filesystem
+//!
+//! A run that *does* declare a workspace still may not be running its agents on
+//! this process's disk. The checks therefore split in two:
+//!
+//! - **Shape** — absolute-vs-relative, `..` traversal — is string arithmetic
+//! with no filesystem in it, so the engine always does it, first, and no host
+//! can weaken it.
+//! - **Existence, canonical form, and directory-ness** are outside-world
+//! effects, and they route through
+//! [`AgentRunner::resolve_workdir`](crate::caps::AgentRunner::resolve_workdir).
+//! A harness that owns a remote or containerized workspace answers for it;
+//! the default answer is
+//! [`Unmanaged`](crate::caps::WorkdirCheck::Unmanaged), which falls back to
+//! the engine's own filesystem exactly as before.
+//!
+//! The shell node reached the same shape by a different route: it hands
+//! `args.cwd` to the [`ShellRunner`](crate::caps::ShellRunner) untouched and the
+//! host's [`ScriptPolicy`](crate::caps::host::ScriptPolicy) contains it. This
+//! module is the `agent`/`sub_workflow` equivalent.
use std::path::{Component, Path, PathBuf};
@@ -61,6 +82,40 @@ pub(crate) fn run_workspace(run: &Value) -> Option<&str> {
.filter(|w| !w.is_empty())
}
+/// The filesystem-free half of the rule: is this value even *shaped* like a
+/// path inside a workspace?
+///
+/// Absolute-vs-relative and `..` traversal are decided by reading the string,
+/// so the engine answers them itself on every host — including one whose agents
+/// run somewhere it cannot see. Keeping this here rather than behind
+/// [`AgentRunner::resolve_workdir`](crate::caps::AgentRunner::resolve_workdir)
+/// means a host implementation cannot accidentally drop the containment check
+/// that matters most.
+///
+/// # Errors
+/// Returns the refusal message when `raw` is absolute under
+/// [`Absolute::Refuse`], or when a relative path traverses upwards.
+pub(crate) fn check_shape(raw: &str, field: &str, absolute: Absolute) -> Result<(), String> {
+ let candidate = Path::new(raw);
+ if candidate.is_absolute() {
+ if absolute == Absolute::Refuse {
+ return Err(format!(
+ "`{field}` ('{raw}') must be relative to the workspace, not absolute"
+ ));
+ }
+ } else if candidate.components().any(|component| {
+ matches!(
+ component,
+ Component::ParentDir | Component::RootDir | Component::Prefix(_)
+ )
+ }) {
+ return Err(format!(
+ "`{field}` ('{raw}') must not traverse outside the workspace"
+ ));
+ }
+ Ok(())
+}
+
/// Resolves `raw` against `workspace`, refusing anything that escapes it.
///
/// Both halves are load-bearing, and both come from the shell step's policy.
@@ -83,23 +138,8 @@ pub(crate) fn resolve_in_workspace(
field: &str,
absolute: Absolute,
) -> Result {
+ check_shape(raw, field, absolute)?;
let candidate = Path::new(raw);
- if candidate.is_absolute() {
- if absolute == Absolute::Refuse {
- return Err(format!(
- "`{field}` ('{raw}') must be relative to the workspace, not absolute"
- ));
- }
- } else if candidate.components().any(|component| {
- matches!(
- component,
- Component::ParentDir | Component::RootDir | Component::Prefix(_)
- )
- }) {
- return Err(format!(
- "`{field}` ('{raw}') must not traverse outside the workspace"
- ));
- }
let workspace = workspace.canonicalize().map_err(|err| {
format!(
@@ -157,11 +197,17 @@ pub(crate) fn resolve_dir_in_workspace(
/// `raw` unchanged when it is not (see the module docs: the engine cannot check
/// a directory on a filesystem it does not have).
///
+/// The shape check runs first and always. Existence and directory-ness go to
+/// [`AgentRunner::resolve_workdir`](crate::caps::AgentRunner::resolve_workdir)
+/// when a harness is wired and claims the workspace, and to this process's
+/// filesystem otherwise.
+///
/// # Errors
/// Returns [`EngineError::Capability`](crate::error::EngineError::Capability),
/// prefixed with `surface`, when the directory escapes the workspace, does not
/// exist, or is not a directory.
-pub(crate) fn resolve_node_dir(
+pub(crate) async fn resolve_node_dir(
+ agent: Option<&std::sync::Arc>,
run: &Value,
raw: &str,
field: &str,
@@ -175,10 +221,27 @@ pub(crate) fn resolve_node_dir(
);
return Ok(raw.to_string());
};
+ let refuse =
+ |message: String| crate::error::EngineError::Capability(format!("{surface}: {message}"));
+
+ check_shape(raw, field, Absolute::AllowInside).map_err(refuse)?;
+
+ if let Some(runner) = agent {
+ match runner.resolve_workdir(workspace, raw).await {
+ crate::caps::WorkdirCheck::Resolved(path) => {
+ tracing::debug!(field, raw, %path, "workdir: resolved by the agent harness");
+ return Ok(path);
+ }
+ crate::caps::WorkdirCheck::Refused(message) => {
+ return Err(refuse(format!("`{field}` ('{raw}') {message}")));
+ }
+ crate::caps::WorkdirCheck::Unmanaged => {}
+ }
+ }
+
let resolved =
- resolve_dir_in_workspace(Path::new(workspace), raw, field, Absolute::AllowInside).map_err(
- |message| crate::error::EngineError::Capability(format!("{surface}: {message}")),
- )?;
+ resolve_dir_in_workspace(Path::new(workspace), raw, field, Absolute::AllowInside)
+ .map_err(refuse)?;
Ok(resolved.to_string_lossy().into_owned())
}
diff --git a/src/workdir_tests.rs b/src/workdir_tests.rs
index f03ab18..89420c2 100644
--- a/src/workdir_tests.rs
+++ b/src/workdir_tests.rs
@@ -9,6 +9,7 @@ use serde_json::json;
use super::{
Absolute, resolve_dir_in_workspace, resolve_in_workspace, resolve_node_dir, run_workspace,
};
+use crate::caps::WorkdirCheck;
/// A workspace with a `worktrees/issue-1` directory and a file in it.
fn workspace() -> tempfile::TempDir {
@@ -173,22 +174,141 @@ fn the_run_workspace_comes_from_the_run_slice_then_the_trigger() {
assert_eq!(run_workspace(&json!({})), None);
}
-#[test]
-fn a_run_with_no_workspace_passes_the_directory_through() {
+#[tokio::test]
+async fn a_run_with_no_workspace_passes_the_directory_through() {
// A harness whose agents run in a remote sandbox names directories this
// process has never heard of; checking them locally would fail every one.
- let resolved = resolve_node_dir(&json!({}), "/srv/checkout", "config.cwd", "agent node a")
- .expect("no workspace, no resolution");
+ let resolved = resolve_node_dir(
+ None,
+ &json!({}),
+ "/srv/checkout",
+ "config.cwd",
+ "agent node a",
+ )
+ .await
+ .expect("no workspace, no resolution");
assert_eq!(resolved, "/srv/checkout");
}
-#[test]
-fn a_resolved_directory_is_reported_with_the_node_surface() {
+#[tokio::test]
+async fn a_resolved_directory_is_reported_with_the_node_surface() {
let root = workspace();
let run = json!({ "workspace": root.path().to_string_lossy() });
- let error = resolve_node_dir(&run, "nope", "config.cwd", "agent node prepare")
+ let error = resolve_node_dir(None, &run, "nope", "config.cwd", "agent node prepare")
+ .await
.expect_err("a missing directory fails the step");
assert!(error.to_string().contains("agent node prepare:"), "{error}");
}
+
+/// An [`AgentRunner`] whose agents run on a filesystem this process cannot see:
+/// it answers for its own workspace and never touches the local disk.
+struct RemoteHarness {
+ answer: WorkdirCheck,
+}
+
+#[async_trait::async_trait]
+impl crate::caps::AgentRunner for RemoteHarness {
+ async fn run_agent(
+ &self,
+ _agent_ref: &str,
+ _request: serde_json::Value,
+ _conn: Option<&str>,
+ ) -> crate::error::Result {
+ Ok(json!({}))
+ }
+
+ async fn resolve_workdir(&self, _workspace: &str, _declared: &str) -> WorkdirCheck {
+ self.answer.clone()
+ }
+}
+
+fn harness(answer: WorkdirCheck) -> std::sync::Arc {
+ std::sync::Arc::new(RemoteHarness { answer })
+}
+
+#[tokio::test]
+async fn a_harness_that_owns_the_workspace_answers_for_it() {
+ // The workspace and the directory are both remote: nothing here exists on
+ // this process's disk, and the local check would have refused both.
+ let runner = harness(WorkdirCheck::Resolved("/remote/ws/worktrees/1".to_string()));
+ let run = json!({ "workspace": "/remote/ws" });
+
+ let resolved = resolve_node_dir(
+ Some(&runner),
+ &run,
+ "worktrees/1",
+ "config.cwd",
+ "agent node code",
+ )
+ .await
+ .expect("the harness resolved it");
+
+ assert_eq!(resolved, "/remote/ws/worktrees/1");
+}
+
+#[tokio::test]
+async fn a_harness_refusal_fails_the_step_with_its_reason() {
+ let runner = harness(WorkdirCheck::Refused("is not in the sandbox".to_string()));
+ let run = json!({ "workspace": "/remote/ws" });
+
+ let error = resolve_node_dir(
+ Some(&runner),
+ &run,
+ "worktrees/1",
+ "config.cwd",
+ "agent node code",
+ )
+ .await
+ .expect_err("a refusal fails the step");
+
+ let error = error.to_string();
+ assert!(error.contains("agent node code:"), "{error}");
+ assert!(error.contains("is not in the sandbox"), "{error}");
+ assert!(error.contains("worktrees/1"), "{error}");
+}
+
+#[tokio::test]
+async fn an_unmanaged_answer_falls_back_to_the_local_filesystem() {
+ // The default for every existing host: the engine checks its own disk,
+ // exactly as it did before the capability existed.
+ let runner = harness(WorkdirCheck::Unmanaged);
+ let root = workspace();
+ let run = json!({ "workspace": root.path().to_string_lossy() });
+
+ let resolved = resolve_node_dir(
+ Some(&runner),
+ &run,
+ "worktrees/issue-1",
+ "config.cwd",
+ "agent node code",
+ )
+ .await
+ .expect("the local filesystem answers");
+
+ assert!(resolved.ends_with("worktrees/issue-1"), "{resolved}");
+}
+
+#[tokio::test]
+async fn the_shape_check_runs_before_the_harness_is_consulted() {
+ // A host cannot be asked to bless a `..` escape: the syntactic half is the
+ // engine's, on every filesystem.
+ let runner = harness(WorkdirCheck::Resolved("/anywhere".to_string()));
+ let run = json!({ "workspace": "/remote/ws" });
+
+ let error = resolve_node_dir(
+ Some(&runner),
+ &run,
+ "../../etc",
+ "config.cwd",
+ "agent node code",
+ )
+ .await
+ .expect_err("traversal is refused before the harness sees it");
+
+ assert!(
+ error.to_string().contains("must not traverse outside"),
+ "{error}"
+ );
+}
diff --git a/tests/agent_workdir_e2e.rs b/tests/agent_workdir_e2e.rs
index 53a668d..6669908 100644
--- a/tests/agent_workdir_e2e.rs
+++ b/tests/agent_workdir_e2e.rs
@@ -146,6 +146,42 @@ async fn a_cwd_that_does_not_exist_fails_naming_the_path() {
);
}
+#[tokio::test]
+async fn a_cwd_expression_that_resolves_to_null_fails_the_step() {
+ // The upstream node did not publish the key the `cwd` expression reads, so
+ // the resolved config carries `null`. That must fail here rather than read
+ // as "no `cwd` declared" and let the harness pick its own directory.
+ let root = workspace();
+ let graph = parse(graph_json(
+ &canonical(&root),
+ json!("=nodes.prepare.item.missing_key"),
+ ));
+
+ let error = run_graph(&graph).await.expect_err("the step must fail");
+
+ assert!(error.contains("resolved to null"), "{error}");
+ assert!(error.contains("agent node code"), "{error}");
+}
+
+#[tokio::test]
+async fn a_null_cwd_does_not_fall_back_to_working_dir() {
+ // Both spellings present, `cwd` resolving to null: the older `working_dir`
+ // must not quietly win. Picking it up would run the step in a directory the
+ // author's `cwd` expression was meant to override.
+ let root = workspace();
+ let mut graph = graph_json(&canonical(&root), json!("=nodes.prepare.item.missing_key"));
+ graph["nodes"][2]["config"]["working_dir"] = json!("worktrees/issue-1");
+ let graph = parse(graph);
+
+ let error = run_graph(&graph).await.expect_err("the step must fail");
+
+ assert!(error.contains("resolved to null"), "{error}");
+ assert!(
+ !error.contains("worktrees/issue-1"),
+ "the fallback is never reached: {error}"
+ );
+}
+
#[tokio::test]
async fn a_run_with_no_workspace_passes_the_directory_through_unchanged() {
// Back-compat: a harness whose agents run in a sandbox names directories
diff --git a/wiki/Node-Catalog.md b/wiki/Node-Catalog.md
index 7ec1703..63571ec 100644
--- a/wiki/Node-Catalog.md
+++ b/wiki/Node-Catalog.md
@@ -97,10 +97,26 @@ falling back to the workspace. Every one of them is `=`-bindable, which is the
point — `"cwd": "=nodes.prepare.item.json.worktree"` runs the step in a
directory an earlier node created.
+An expression that resolves to `null` — the upstream node failed, or the key
+moved — **fails the step** too. It is not read as "no directory declared": that
+would fall back to the agent definition's own `working_dir`, then to whatever
+the harness defaults to, and the step would run in a different checkout without
+saying so.
+
A run with **no** workspace resolves nothing: the string reaches the harness
verbatim, as it always has, because a host whose agents run in a remote sandbox
names directories this process has never heard of.
+**Whose filesystem.** The shape of a declared directory — absolute vs relative,
+`..` traversal — is decided by reading the string, so the engine always checks
+it. Whether the path *exists*, what it canonicalizes to, and whether it is a
+directory are outside-world effects, and they route through
+`AgentRunner::resolve_workdir`. A harness whose agents run in a container or a
+remote sandbox implements it and answers for its own filesystem; the default is
+`WorkdirCheck::Unmanaged`, which checks the engine's own disk exactly as before.
+The `shell` node reaches the same place by a different road: it hands `cwd` to
+the `ShellRunner` untouched and the host's `ScriptPolicy` contains it.
+
A directory key a node does not read — `workdir` on an `agent` node, `cwd` on a
`tool_call` node — is a **validation error**, not a silent no-op. Being able to
write down where a step runs and have it ignored is the failure this whole seam