Skip to main content

codetether_agent/a2a/
task_scope.rs

1//! Scope gating for incoming A2A tasks and bus events.
2//!
3//! Agents must only act on work that falls within their declared scope.
4//! This module centralises the "is this task/event for me?" check so
5//! every intake path (SSE, polling, bus broadcast) applies the same rules.
6//!
7//! # Scope dimensions
8//!
9//! | Dimension       | Where it comes from                    | Reject if                         |
10//! |-----------------|----------------------------------------|-----------------------------------|
11//! | `target_agent`  | task metadata `target_agent_name`      | non-empty AND ≠ worker agent name |
12//! | `target_worker` | task metadata `target_worker_id`        | non-empty AND ≠ worker ID         |
13//! | `workspace`     | task `workspace_id` / metadata         | not in worker's registered set*   |
14//!
15//! \* An empty/absent workspace ID is treated as a global broadcast
16//!   (virtual task) and is **always** accepted.
17
18/// Whether a task is within scope for a given worker.
19///
20/// Returns `Ok(())` if the task should be processed, or `Err(reason)`
21/// with a human-readable rejection reason if it should be skipped.
22pub fn check_task_scope(
23    task: &serde_json::Value,
24    worker_id: &str,
25    agent_name: &str,
26    workspace_ids: &[String],
27) -> Result<(), String> {
28    let metadata = task_metadata(task);
29
30    // ── Agent targeting ──────────────────────────────────────────────
31    let target_agent = metadata
32        .get("target_agent_name")
33        .and_then(|v| v.as_str())
34        .or_else(|| task.get("target_agent_name").and_then(|v| v.as_str()))
35        .or_else(|| {
36            task.get("task")
37                .and_then(|t| t.get("target_agent_name"))
38                .and_then(|v| v.as_str())
39        });
40    if let Some(target) = target_agent {
41        if !target.is_empty() && target != agent_name {
42            return Err(format!("target_agent_name={target} ≠ agent={agent_name}"));
43        }
44    }
45
46    // ── Worker targeting ─────────────────────────────────────────────
47    let target_worker = metadata
48        .get("target_worker_id")
49        .and_then(|v| v.as_str())
50        .or_else(|| task.get("target_worker_id").and_then(|v| v.as_str()));
51    if let Some(target) = target_worker {
52        if !target.is_empty() && target != worker_id {
53            return Err(format!("target_worker_id={target} ≠ worker={worker_id}"));
54        }
55    }
56
57    // ── Workspace scoping ────────────────────────────────────────────
58    // Empty / "global" workspace IDs are virtual tasks — always accept.
59    let ws_id = task
60        .get("workspace_id")
61        .and_then(|v| v.as_str())
62        .or_else(|| metadata.get("workspace_id").and_then(|v| v.as_str()))
63        .unwrap_or("");
64    if !ws_id.is_empty() && ws_id != "global" && !workspace_ids.is_empty() {
65        if !workspace_ids.iter().any(|id| id == ws_id) {
66            return Err(format!("workspace_id={ws_id} not in worker scope"));
67        }
68    }
69
70    Ok(())
71}
72
73/// Extract the metadata map from a task JSON envelope.
74fn task_metadata(task: &serde_json::Value) -> serde_json::Map<String, serde_json::Value> {
75    task.get("task")
76        .and_then(|t| t.get("metadata"))
77        .or_else(|| task.get("metadata"))
78        .and_then(|m| m.as_object())
79        .cloned()
80        .unwrap_or_default()
81}
82
83#[cfg(test)]
84mod tests {
85    use super::*;
86    use serde_json::json;
87
88    #[test]
89    fn accepts_unscoped_task() {
90        let task = json!({"id": "t1", "prompt": "hello"});
91        assert!(check_task_scope(&task, "w1", "builder", &[]).is_ok());
92    }
93
94    #[test]
95    fn rejects_wrong_agent() {
96        let task = json!({"id": "t1", "metadata": {"target_agent_name": "planner"}});
97        assert!(check_task_scope(&task, "w1", "builder", &[]).is_err());
98    }
99
100    #[test]
101    fn accepts_matching_agent() {
102        let task = json!({"id": "t1", "metadata": {"target_agent_name": "builder"}});
103        assert!(check_task_scope(&task, "w1", "builder", &[]).is_ok());
104    }
105
106    #[test]
107    fn rejects_wrong_worker() {
108        let task = json!({"id": "t1", "metadata": {"target_worker_id": "other-worker"}});
109        assert!(check_task_scope(&task, "w1", "builder", &[]).is_err());
110    }
111
112    #[test]
113    fn accepts_matching_worker() {
114        let task = json!({"id": "t1", "metadata": {"target_worker_id": "w1"}});
115        assert!(check_task_scope(&task, "w1", "builder", &[]).is_ok());
116    }
117
118    #[test]
119    fn rejects_unknown_workspace() {
120        let task = json!({"id": "t1", "workspace_id": "ws-999"});
121        let workspaces = vec!["ws-1".into(), "ws-2".into()];
122        assert!(check_task_scope(&task, "w1", "builder", &workspaces).is_err());
123    }
124
125    #[test]
126    fn accepts_known_workspace() {
127        let task = json!({"id": "t1", "workspace_id": "ws-1"});
128        let workspaces = vec!["ws-1".into(), "ws-2".into()];
129        assert!(check_task_scope(&task, "w1", "builder", &workspaces).is_ok());
130    }
131
132    #[test]
133    fn accepts_global_workspace() {
134        let task = json!({"id": "t1", "workspace_id": "global"});
135        let workspaces = vec!["ws-1".into()];
136        assert!(check_task_scope(&task, "w1", "builder", &workspaces).is_ok());
137    }
138
139    #[test]
140    fn accepts_empty_workspace_when_worker_has_none() {
141        let task = json!({"id": "t1"});
142        assert!(check_task_scope(&task, "w1", "builder", &[]).is_ok());
143    }
144
145    #[test]
146    fn nested_task_metadata_agent() {
147        let task = json!({"task": {"id": "t1", "metadata": {"target_agent_name": "builder"}}});
148        assert!(check_task_scope(&task, "w1", "builder", &[]).is_ok());
149    }
150
151    #[test]
152    fn nested_task_metadata_wrong_agent() {
153        let task = json!({"task": {"id": "t1", "metadata": {"target_agent_name": "planner"}}});
154        assert!(check_task_scope(&task, "w1", "builder", &[]).is_err());
155    }
156}