agentmux_srv\drone/
data_flow.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! `{{var}}` interpolation — Mustache-style. Resolves
5//! `{{block_id.path}}`, `{{var.name}}`, `{{env.NAME}}` against the
6//! execution context (RFC #753 §2 Q5).
7//!
8//! The Phase 1 resolver is intentionally simple: regex find-replace
9//! over the input string. Loop scope (`{{loop.index}}`, `{{loop.item}}`)
10//! is added in Phase 2 alongside the Loop block.
11
12use std::collections::HashMap;
13
14use serde_json::Value;
15
16/// Only env vars starting with this prefix are exposed to drone
17/// templates via `{{env.NAME}}`. See `ExecutionScope::lookup` for
18/// the security rationale.
19const DRONE_ENV_PREFIX: &str = "AGENTMUX_DR_";
20
21/// Holds the per-run scope. Maps:
22///   * `outputs[block_id]` — the JSON output of a completed block
23///   * `vars[name]` — drone-scope variables (set by Variables block)
24#[derive(Debug, Default)]
25pub struct ExecutionScope {
26    pub outputs: HashMap<String, Value>,
27    pub vars: HashMap<String, Value>,
28}
29
30impl ExecutionScope {
31    pub fn new() -> Self {
32        Self::default()
33    }
34
35    /// Resolve `{{...}}` tokens in `input` against this scope. Unknown
36    /// tokens are left as-is (Phase 1 — Phase 2 will surface them as
37    /// validation errors).
38    pub fn resolve(&self, input: &str) -> String {
39        let mut out = String::with_capacity(input.len());
40        let bytes = input.as_bytes();
41        let mut i = 0;
42        while i < bytes.len() {
43            // `{` is ASCII so the byte-level scan for `{{` is safe to
44            // run at any byte offset — UTF-8 continuation bytes are
45            // always >= 0x80 and never equal `{` (0x7B). The non-token
46            // emit path below walks one full Unicode scalar at a time
47            // to avoid splitting multi-byte sequences into separate
48            // Latin-1 `char`s (mojibake).
49            if i + 1 < bytes.len() && bytes[i] == b'{' && bytes[i + 1] == b'{' {
50                // Find the matching `}}`.
51                if let Some(end_rel) = find_close(&input[i + 2..]) {
52                    let token = &input[i + 2..i + 2 + end_rel];
53                    let resolved = self.lookup(token.trim());
54                    match resolved {
55                        Some(v) => out.push_str(&value_to_string(&v)),
56                        None => {
57                            // Leave unresolved tokens visible — easier to debug.
58                            out.push_str("{{");
59                            out.push_str(token);
60                            out.push_str("}}");
61                        }
62                    }
63                    i = i + 2 + end_rel + 2;
64                    continue;
65                }
66            }
67            // Copy one full Unicode scalar from `input` starting at
68            // byte i. `&str` guarantees a valid char starts here.
69            let ch_len = input[i..]
70                .chars()
71                .next()
72                .expect("byte index inside &str must start a valid char")
73                .len_utf8();
74            out.push_str(&input[i..i + ch_len]);
75            i += ch_len;
76        }
77        out
78    }
79
80    fn lookup(&self, path: &str) -> Option<Value> {
81        // Splits like `block1.response.text` → ["block1", "response", "text"].
82        let mut parts = path.split('.');
83        let head = parts.next()?;
84        let rest: Vec<&str> = parts.collect();
85
86        let root = if head == "var" || head == "vars" {
87            // `{{var.name.path}}` — name is the next part.
88            let name = rest.first()?;
89            let v = self.vars.get(*name)?;
90            return Some(walk(v.clone(), &rest[1..]));
91        } else if head == "env" {
92            // Restrict `{{env.NAME}}` to vars under the drone
93            // namespace prefix. Without this guard a Response template
94            // could read AWS_*, GITHUB_TOKEN, CLAUDE_API_KEY, etc. and
95            // surface them via the persisted run output, exfiltrating
96            // server-side secrets to any caller with drone access.
97            // (reagent P1 on PR #755.) Phase 2 introduces a per-drone
98            // configured allowlist; the prefix is the Phase 1 stopgap.
99            let name = rest.first()?;
100            if !name.starts_with(DRONE_ENV_PREFIX) {
101                return None;
102            }
103            return std::env::var(name).ok().map(Value::String);
104        } else {
105            // Treat head as a block id; rest is dot-path inside its output.
106            self.outputs.get(head)?
107        };
108        Some(walk(root.clone(), &rest))
109    }
110}
111
112fn find_close(s: &str) -> Option<usize> {
113    let bytes = s.as_bytes();
114    let mut i = 0;
115    while i + 1 < bytes.len() {
116        if bytes[i] == b'}' && bytes[i + 1] == b'}' {
117            return Some(i);
118        }
119        i += 1;
120    }
121    None
122}
123
124fn walk(mut v: Value, path: &[&str]) -> Value {
125    for key in path {
126        match v {
127            Value::Object(mut m) => {
128                v = m.remove(*key).unwrap_or(Value::Null);
129            }
130            _ => return Value::Null,
131        }
132    }
133    v
134}
135
136fn value_to_string(v: &Value) -> String {
137    match v {
138        Value::String(s) => s.clone(),
139        Value::Null => String::new(),
140        other => other.to_string(),
141    }
142}
143
144#[cfg(test)]
145mod tests {
146    use super::*;
147    use serde_json::json;
148
149    #[test]
150    fn resolves_block_output_path() {
151        let mut scope = ExecutionScope::new();
152        scope
153            .outputs
154            .insert("agent_1".to_string(), json!({ "response": "hello" }));
155        assert_eq!(scope.resolve("Got: {{agent_1.response}}"), "Got: hello");
156    }
157
158    #[test]
159    fn resolves_drone_var() {
160        let mut scope = ExecutionScope::new();
161        scope.vars.insert("name".to_string(), json!("world"));
162        assert_eq!(scope.resolve("hi {{var.name}}"), "hi world");
163    }
164
165    #[test]
166    fn leaves_unknown_tokens_intact() {
167        let scope = ExecutionScope::new();
168        assert_eq!(scope.resolve("{{ghost.x}}"), "{{ghost.x}}");
169    }
170
171    #[test]
172    fn deep_path_into_object() {
173        let mut scope = ExecutionScope::new();
174        scope
175            .outputs
176            .insert("api1".to_string(), json!({ "body": { "id": 42 } }));
177        assert_eq!(scope.resolve("got id={{api1.body.id}}"), "got id=42");
178    }
179
180    #[test]
181    fn preserves_text_around_tokens() {
182        let mut scope = ExecutionScope::new();
183        scope.vars.insert("x".to_string(), json!("X"));
184        assert_eq!(scope.resolve("a {{var.x}} b {{var.x}} c"), "a X b X c");
185    }
186
187    #[test]
188    fn empty_input_is_empty() {
189        let scope = ExecutionScope::new();
190        assert_eq!(scope.resolve(""), "");
191    }
192
193    // ────────────────────────────────────────────────────────────────
194    // UTF-8 preservation (reagent P1 + codex P2 on PR #755)
195    // ────────────────────────────────────────────────────────────────
196
197    #[test]
198    fn preserves_multibyte_chars_around_tokens() {
199        let mut scope = ExecutionScope::new();
200        scope.vars.insert("name".to_string(), json!("world"));
201        // 'Olá' has a 2-byte codepoint (á = 0xC3 0xA1).
202        assert_eq!(scope.resolve("Olá {{var.name}}"), "Olá world");
203    }
204
205    #[test]
206    fn preserves_multibyte_token_value() {
207        let mut scope = ExecutionScope::new();
208        scope.vars.insert("greeting".to_string(), json!("こんにちは"));
209        assert_eq!(scope.resolve("hi: {{var.greeting}}"), "hi: こんにちは");
210    }
211
212    #[test]
213    fn preserves_emoji_passthrough() {
214        let scope = ExecutionScope::new();
215        // No tokens — pure passthrough. The pre-fix byte-wise copy
216        // would turn each UTF-8 byte of 🚀 (0xF0 0x9F 0x9A 0x80) into
217        // four separate Latin-1 chars; this assertion catches that.
218        assert_eq!(scope.resolve("ship it 🚀"), "ship it 🚀");
219    }
220
221    #[test]
222    fn preserves_multibyte_in_unresolved_token_passthrough() {
223        let scope = ExecutionScope::new();
224        // Unresolved tokens echo their surrounding context — make
225        // sure that path doesn't corrupt the surrounding bytes either.
226        assert_eq!(scope.resolve("café {{ghost}} ☕"), "café {{ghost}} ☕");
227    }
228
229    // ────────────────────────────────────────────────────────────────
230    // Env-var allowlist (reagent P1 on PR #755 v0.33.841)
231    // ────────────────────────────────────────────────────────────────
232
233    #[test]
234    fn env_var_allowed_under_drone_prefix() {
235        let key = "AGENTMUX_DR_TEST_PASS_KEY";
236        std::env::set_var(key, "hello");
237        let scope = ExecutionScope::new();
238        assert_eq!(
239            scope.resolve(&format!("got: {{{{env.{key}}}}}")),
240            "got: hello"
241        );
242        std::env::remove_var(key);
243    }
244
245    #[test]
246    fn env_var_outside_prefix_is_blocked() {
247        // Setting common secret-shaped names would let drones
248        // exfiltrate them via Response output if the lookup wasn't
249        // namespaced.
250        let secret_key = "AWS_TEST_SECRET_DO_NOT_LEAK";
251        std::env::set_var(secret_key, "TOPSECRET");
252        let scope = ExecutionScope::new();
253        // Without the AGENTMUX_DR_ prefix, lookup returns None —
254        // the template emits the unresolved token (passthrough).
255        let out = scope.resolve(&format!("leak: {{{{env.{secret_key}}}}}"));
256        assert!(!out.contains("TOPSECRET"), "secret value leaked: {out}");
257        std::env::remove_var(secret_key);
258    }
259
260    #[test]
261    fn env_var_path_blocked() {
262        // PATH is universally set; if it leaked, attackers could
263        // confirm a target's directory layout. Plain `env.PATH`
264        // without the prefix is rejected.
265        let scope = ExecutionScope::new();
266        assert_eq!(scope.resolve("{{env.PATH}}"), "{{env.PATH}}");
267    }
268}