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| {
103 matches!(session.holder_kind(), Some("agent" | "person")) && session.holder() == name
104 })
105 {
106 return Ok(());
107 }
108 Err(AgentError::Identity {
109 holder: session.map_or("unverified", LysSession::holder).into(),
110 agent: name.into(),
111 })
112}
113
114pub fn check(
119 session: Option<&dyn LysSession>,
120 action: HotAction,
121 value: &str,
122) -> Result<(), AgentError> {
123 let Some((name, leaf)) = subject(value) else {
124 if value.starts_with("agents.")
125 && value.split('.').count() != 3
126 && !matches!(
127 value,
128 "agents.session"
129 | "agents.permission"
130 | "agents.refuse_prompt"
131 | "agents.session_close"
132 )
133 {
134 return Err(AgentError::Invalid("agent subject"));
135 }
136 return Ok(());
137 };
138 if (action == HotAction::Subscribe && leaf == "inbox")
139 || (matches!(action, HotAction::Publish | HotAction::Serve)
140 && !matches!(leaf, "inbox" | "cancel"))
141 {
142 claim(session, name)?;
143 }
144 if matches!(action, HotAction::Publish | HotAction::Serve) && server_owned(value) {
145 return Err(AgentError::ServerOwned(value.into()));
146 }
147 if session.is_none() {
148 return Err(AgentError::Invalid("agent subjects require a Lys pass"));
149 }
150 Ok(())
151}
152
153pub fn decide(
158 session: Option<&dyn LysSession>,
159 network: &str,
160 action: HotAction,
161 value: &str,
162 at_ms: u64,
163) -> Result<(), AgentError> {
164 check(session, action, value)?;
165 let session = session.ok_or(AgentError::Invalid("agent subjects require a Lys pass"))?;
166 let binding = SubjectBinding::new(network.into(), value.into(), value.into())
167 .map_err(|_| AgentError::Invalid("agent subject binding"))?;
168 session
169 .decide(&binding, action, at_ms / 1000)
170 .map_err(|error| AgentError::Store(error.to_string()))?;
171 Ok(())
172}
173
174#[must_use]
176pub fn is_host_route(subject: &str) -> bool {
177 matches!(
178 subject,
179 "agents.session" | "agents.permission" | "agents.refuse_prompt" | "agents.session_close"
180 )
181}