liminal_server/agents/
binding.rs1use crate::auth_lys::session::LysSession;
4use crate::auth_lys::{AccessAction, AccessResource, ResourceKind};
5use crate::auth_lys::{HotAction, SubjectBinding};
6use crate::config::templates::is_segment;
7
8use super::error::AgentError;
9
10#[must_use]
12pub fn subject(subject: &str) -> Option<(&str, &str)> {
13 let rest = subject.strip_prefix("agents.")?;
14 let (name, leaf) = rest.split_once('.')?;
15 (is_segment(name)
16 && matches!(
17 leaf,
18 "inbox" | "presence" | "state" | "stream" | "plan" | "cancel"
19 ))
20 .then_some((name, leaf))
21}
22
23#[must_use]
25pub fn server_owned(value: &str) -> bool {
26 subject(value).is_some_and(|(_, leaf)| matches!(leaf, "presence" | "state"))
27}
28
29pub fn refuse_server_publish(value: &str) -> Result<(), AgentError> {
34 if server_owned(value) {
35 Err(AgentError::ServerOwned(value.into()))
36 } else {
37 Ok(())
38 }
39}
40
41pub fn evaluate_subject<'a>(
49 pass: &'a lys_pass::VerifiedPass,
50 resource: &AccessResource,
51 action: AccessAction,
52 now: u64,
53) -> Result<lys_pass::Decision<'a>, lys_pass::Error> {
54 if let Some((name, action)) = agent_target(resource, action) {
55 return pass.evaluate("liminal.agent", name, action, now);
56 }
57 pass.evaluate(resource.kind.qualified(), &resource.id, action.name(), now)
58}
59
60#[must_use]
62pub fn refusal(
63 resource: &AccessResource,
64 action: AccessAction,
65 request: &str,
66 cause: crate::auth_lys::RefusalCause,
67) -> crate::auth_lys::AccessDecisionError {
68 let refusal = if let Some((name, action)) = agent_target(resource, action) {
69 crate::auth_lys::AccessRefusal::for_target(
70 "liminal",
71 "liminal.agent",
72 name,
73 action,
74 request.into(),
75 )
76 } else {
77 crate::auth_lys::AccessRefusal::new(resource.clone(), action, request.into())
78 };
79 crate::auth_lys::AccessDecisionError { cause, refusal }
80}
81
82fn agent_target(resource: &AccessResource, action: AccessAction) -> Option<(&str, &'static str)> {
83 if resource.kind != ResourceKind::Subject {
84 return None;
85 }
86 let (name, leaf) = subject(&resource.id)?;
87 let action = match (action, leaf) {
88 (AccessAction::Publish, "inbox" | "cancel") => "message",
89 (AccessAction::Subscribe, "presence" | "state" | "stream") => "watch",
90 _ => return None,
91 };
92 Some((name, action))
93}
94
95pub fn claim(session: Option<&dyn LysSession>, name: &str) -> Result<(), AgentError> {
100 if session
101 .is_some_and(|session| {
104 matches!(session.holder_kind(), Some("agent" | "person" | "open"))
105 && session.holder() == name
106 })
107 {
108 return Ok(());
109 }
110 if session.is_some_and(|session| {
112 session.holder_kind() == Some(super::open_session::OPEN_KIND)
113 && session.holder() == crate::server::connection::declared_holder::ANONYMOUS
114 }) {
115 return Err(AgentError::Invalid(super::error::ANONYMOUS_SEAT));
116 }
117 Err(AgentError::Identity {
118 holder: session.map_or("unverified", LysSession::holder).into(),
119 agent: name.into(),
120 })
121}
122
123pub fn check(
128 session: Option<&dyn LysSession>,
129 action: HotAction,
130 value: &str,
131) -> Result<(), AgentError> {
132 let Some((name, leaf)) = subject(value) else {
133 if value.starts_with("agents.")
134 && value.split('.').count() != 3
135 && !matches!(
136 value,
137 "agents.session"
138 | "agents.permission"
139 | "agents.refuse_prompt"
140 | "agents.session_close"
141 )
142 {
143 return Err(AgentError::Invalid("agent subject"));
144 }
145 return Ok(());
146 };
147 if (action == HotAction::Subscribe && leaf == "inbox")
148 || (matches!(action, HotAction::Publish | HotAction::Serve)
149 && !matches!(leaf, "inbox" | "cancel"))
150 {
151 claim(session, name)?;
152 }
153 if matches!(action, HotAction::Publish | HotAction::Serve) && server_owned(value) {
154 return Err(AgentError::ServerOwned(value.into()));
155 }
156 if session.is_none() {
157 return Err(AgentError::Invalid("agent subjects require a Lys pass"));
158 }
159 Ok(())
160}
161
162pub fn decide(
167 session: Option<&dyn LysSession>,
168 network: &str,
169 action: HotAction,
170 value: &str,
171 at_ms: u64,
172) -> Result<(), AgentError> {
173 check(session, action, value)?;
174 let session = session.ok_or(AgentError::Invalid("agent subjects require a Lys pass"))?;
175 let binding = SubjectBinding::new(network.into(), value.into(), value.into())
176 .map_err(|_| AgentError::Invalid("agent subject binding"))?;
177 session
178 .decide(&binding, action, at_ms / 1000)
179 .map_err(|error| AgentError::Store(error.to_string()))?;
180 Ok(())
181}
182
183#[must_use]
185pub fn is_host_route(subject: &str) -> bool {
186 matches!(
187 subject,
188 "agents.session" | "agents.permission" | "agents.refuse_prompt" | "agents.session_close"
189 )
190}