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, or a person's own seat: the holder is exactly that name either way.
102        .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
114/// Identity is an additional fence; passing it grants no subject right.
115///
116/// # Errors
117/// A foreign identity claim or a write to server-owned metadata.
118pub 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
153/// Decides the ordinary subject grant after the identity fence.
154///
155/// # Errors
156/// No admitted pass, a bad binding, or the pass's own named refusal.
157pub 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/// Closed host services always authenticate their payload claim in the handler.
175#[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}