Skip to main content

liminal_server/agents/
binding.rs

1//! Identity claims are checked against the admitted Lys holder, never a seat file.
2
3use 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/// The name and leaf of an exact agent subject. Patterns are never identities.
11#[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/// Whether a subject has the server as its sole writer.
24#[must_use]
25pub fn server_owned(value: &str) -> bool {
26    subject(value).is_some_and(|(_, leaf)| matches!(leaf, "presence" | "state"))
27}
28
29/// The server's inner publish guard, including direct trait callers.
30///
31/// # Errors
32/// A connection attempted to write retained server metadata.
33pub 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
41/// Maps agent message/watch to the placed agent's right. Every other act uses
42///
43/// its existing subject right. The adapter's one evaluation site calls this;
44/// held rights keep the adapter's existing hot-mode refusal, never approval.
45///
46/// # Errors
47/// The verified pass's expiry, audience or rights error, unchanged.
48pub 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/// Names the actual grant used by the alias, preserving the consumer's cause.
61#[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
95/// Checks a tool's identity claim against the pass's own agent identity.
96///
97/// # Errors
98/// The holder is unverified, is not an agent, or names someone else.
99pub fn claim(session: Option<&dyn LysSession>, name: &str) -> Result<(), AgentError> {
100    if session
101        // An agent's station, a person's own seat, or an open seat's declared
102        // name (ACCESS-013 R2): the holder is exactly that name each way.
103        .is_some_and(|session| {
104            matches!(session.holder_kind(), Some("agent" | "person" | "open"))
105                && session.holder() == name
106        })
107    {
108        return Ok(());
109    }
110    // An open seat that declared no name claims no station (ACCESS-013 R2).
111    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
123/// Identity is an additional fence; passing it grants no subject right.
124///
125/// # Errors
126/// A foreign identity claim or a write to server-owned metadata.
127pub 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
162/// Decides the ordinary subject grant after the identity fence.
163///
164/// # Errors
165/// No admitted pass, a bad binding, or the pass's own named refusal.
166pub 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/// Closed host services always authenticate their payload claim in the handler.
184#[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}