1use crate::{ResourceEntry, ResourceLifecycle, ResourceType, Sandbox, SandboxEgress};
2
3#[derive(Debug, Clone, Copy, PartialEq, Eq)]
4pub enum RemoteBindingKind {
5 Storage,
6 Key,
7 Ai,
8 Sandbox,
9}
10
11#[derive(Debug, Clone, Copy, PartialEq, Eq)]
13pub struct RemoteBindingDefinition {
14 pub resource_type: &'static str,
15 pub permission_set: &'static str,
16 pub kind: RemoteBindingKind,
17 pub description: &'static str,
18 pub setup_support_resource_types: &'static [&'static str],
21 pub revision: u32,
24}
25
26const DEFINITIONS: &[RemoteBindingDefinition] = &[
27 RemoteBindingDefinition {
28 resource_type: "storage",
29 permission_set: "storage/remote-data-write",
30 kind: RemoteBindingKind::Storage,
31 description: "Read and write objects in this storage resource",
32 setup_support_resource_types: &[
33 "azure_resource_group",
34 "azure_storage_account",
35 "service_activation",
36 ],
37 revision: 1,
38 },
39 RemoteBindingDefinition {
40 resource_type: "key",
41 permission_set: "key/remote-cryptography",
42 kind: RemoteBindingKind::Key,
43 description: "Encrypt and decrypt small values with this key",
44 setup_support_resource_types: &["azure_resource_group", "service_activation"],
45 revision: 1,
46 },
47 RemoteBindingDefinition {
48 resource_type: "ai",
49 permission_set: "ai/invoke",
50 kind: RemoteBindingKind::Ai,
51 description: "Invoke models through this AI resource",
52 setup_support_resource_types: &["azure_resource_group", "service_activation"],
53 revision: 1,
54 },
55 RemoteBindingDefinition {
56 resource_type: "sandbox",
57 permission_set: "sandbox/remote-execute",
58 kind: RemoteBindingKind::Sandbox,
59 description:
60 "Create and terminate sessions in this sandbox, and run arbitrary code inside them",
61 setup_support_resource_types: &[],
64 revision: 1,
65 },
66];
67
68pub fn remote_binding_definition(
69 resource_type: &ResourceType,
70) -> Option<&'static RemoteBindingDefinition> {
71 DEFINITIONS
72 .iter()
73 .find(|definition| definition.resource_type == resource_type.as_ref())
74}
75
76pub fn remote_binding_for_entry(entry: &ResourceEntry) -> Option<&'static RemoteBindingDefinition> {
77 (entry.remote_access && entry.lifecycle == ResourceLifecycle::Frozen)
78 .then(|| remote_binding_definition(&entry.config.resource_type()))
79 .flatten()
80}
81
82pub fn remote_binding_undeliverable_reason(entry: &ResourceEntry) -> Option<&'static str> {
92 remote_binding_for_entry(entry)?;
93 let sandbox = entry.config.downcast_ref::<Sandbox>()?;
94
95 if !matches!(sandbox.egress, SandboxEgress::Allow) {
96 return Some(
97 "a remotely published sandbox must declare egress 'allow'; a sandbox that routes its \
98 traffic through an egress connector cannot be reached remotely",
99 );
100 }
101
102 if !sandbox.preview_ports.is_empty() {
103 return Some(
104 "a remotely published sandbox must declare no previewPorts; the session token mint \
105 carries no port condition, so the list bounds a caller reaching the sandbox through \
106 its binding but not a holder of the remote credentials",
107 );
108 }
109
110 None
111}
112
113pub fn remote_binding_is_deliverable(entry: &ResourceEntry) -> bool {
115 remote_binding_undeliverable_reason(entry).is_none()
116}
117
118pub fn remote_binding_claims_management_set<'a>(
125 resources: impl IntoIterator<Item = &'a ResourceEntry>,
126 permission_set_id: &str,
127 reaches_a_session: impl Fn() -> bool,
128) -> bool {
129 resources.into_iter().any(|entry| {
130 remote_binding_for_entry(entry).is_some_and(|definition| {
131 permission_set_id == definition.permission_set
132 || (definition.kind == RemoteBindingKind::Sandbox && reaches_a_session())
133 })
134 })
135}
136
137pub fn remote_binding_definitions() -> &'static [RemoteBindingDefinition] {
138 DEFINITIONS
139}
140
141#[cfg(test)]
142mod tests {
143 use super::*;
144 use crate::{Sandbox, SandboxCode, SandboxLimits, SandboxSessionPolicy};
145
146 fn remote_sandbox(egress: SandboxEgress, preview_ports: Vec<u16>) -> ResourceEntry {
147 let sandbox = Sandbox::new("agent-sbx".to_string())
148 .code(SandboxCode::Image {
149 image: "ubuntu".to_string(),
150 })
151 .limits(SandboxLimits {
152 cpu: "1".to_string(),
153 memory: "2Gi".to_string(),
154 disk: "20Gi".to_string(),
155 max_processes: None,
156 })
157 .egress(egress)
158 .session(SandboxSessionPolicy {
159 max_lifetime_seconds: None,
160 idle_suspend_seconds: None,
161 })
162 .preview_ports(preview_ports)
163 .build();
164
165 ResourceEntry {
166 enabled_when: None,
167 config: crate::Resource::new(sandbox),
168 dependencies: Vec::new(),
169 lifecycle: ResourceLifecycle::Frozen,
170 remote_access: true,
171 }
172 }
173
174 #[test]
177 fn a_remote_sandbox_declaring_no_ports_is_deliverable() {
178 assert!(remote_binding_is_deliverable(&remote_sandbox(
179 SandboxEgress::Allow,
180 Vec::new()
181 )));
182 }
183
184 #[test]
187 fn a_remote_sandbox_declaring_ports_is_refused() {
188 let reason =
189 remote_binding_undeliverable_reason(&remote_sandbox(SandboxEgress::Allow, vec![8080]))
190 .expect("a declared port list is not deliverable to a remote caller");
191
192 assert!(
193 reason.contains("previewPorts"),
194 "the refusal must name the field the user declared"
195 );
196 }
197
198 #[test]
201 fn a_sandbox_with_no_remote_binding_may_declare_ports() {
202 let mut entry = remote_sandbox(SandboxEgress::Allow, vec![8080]);
203 entry.remote_access = false;
204
205 assert_eq!(remote_binding_undeliverable_reason(&entry), None);
206 assert!(remote_binding_is_deliverable(&entry));
207 }
208
209 #[test]
212 fn each_undeliverable_declaration_answers_in_its_own_terms() {
213 let egress =
214 remote_binding_undeliverable_reason(&remote_sandbox(SandboxEgress::Deny, Vec::new()))
215 .expect("a restricted egress is not deliverable");
216 let ports =
217 remote_binding_undeliverable_reason(&remote_sandbox(SandboxEgress::Allow, vec![8080]))
218 .expect("a declared port list is not deliverable");
219
220 assert_ne!(egress, ports, "one reason cannot stand in for the other");
221 assert!(egress.contains("egress"));
222 }
223}