use super::{config::Seat, handshake::Version, tools::Identity};
use agent_client_protocol_schema::{v1, v2};
use lys_pass::{
Decision as Right, KeySet, Target, VerifiedPass,
drafts::{DraftCreated, DraftRequest},
};
use serde_json::{Value, json};
use std::{
collections::BTreeSet,
io::Read as _,
sync::Arc,
time::{Duration, SystemTime, UNIX_EPOCH},
};
const DRAFT_ID_BYTES: usize = 128;
pub(super) struct Request {
pub words: Value,
options: Vec<(String, String)>,
}
impl Request {
pub fn decode(
version: Version,
params: Value,
session: &str,
bound: usize,
) -> anyhow::Result<Self> {
let (id, title, tool, options) = match version {
Version::One => {
let request: v1::RequestPermissionRequest = serde_json::from_value(params)?;
(
request.session_id.to_string(),
request
.tool_call
.fields
.title
.clone()
.ok_or_else(|| anyhow::anyhow!("permission_title_missing"))?,
serde_json::to_value(request.tool_call)?,
serde_json::to_value(request.options)?,
)
}
Version::Two => {
let request: v2::RequestPermissionRequest = serde_json::from_value(params)?;
let tool = match request.subject {
Some(v2::RequestPermissionSubject::ToolCall(subject)) => {
serde_json::to_value(subject.tool_call)?
}
Some(subject) => {
let input = serde_json::to_value(subject)?;
json!({"kind":if input["type"]=="command" {"execute"}else{"other"},"rawInput":input})
}
None => Value::Null,
};
(
request.session_id.to_string(),
request.title,
tool,
serde_json::to_value(request.options)?,
)
}
};
anyhow::ensure!(
id == session && !title.trim().is_empty(),
"permission_session_or_title_invalid"
);
let offered = options
.as_array()
.filter(|offered| !offered.is_empty())
.ok_or_else(|| anyhow::anyhow!("permission_options_empty"))?;
let mut ids = BTreeSet::new();
let mut choices = Vec::new();
for option in offered {
let id = option["optionId"]
.as_str()
.filter(|id| !id.is_empty())
.ok_or_else(|| anyhow::anyhow!("permission_option_id_invalid"))?;
let kind = option["kind"]
.as_str()
.ok_or_else(|| anyhow::anyhow!("permission_option_kind_invalid"))?;
anyhow::ensure!(
ids.insert(id) && option["name"].as_str().is_some_and(|name| !name.is_empty()),
"permission_option_duplicate_or_unnamed"
);
choices.push((id.to_owned(), kind.to_owned()));
}
let mut words =
json!({"title":title,"options":options,"tool_kind":tool["kind"],"truncated":false});
if let Some(input) = tool.get("rawInput") {
words["input"] = input.clone();
}
fit_words(&mut words, bound)?;
Ok(Self {
words,
options: choices,
})
}
}
fn fit_words(words: &mut Value, bound: usize) -> anyhow::Result<()> {
if serde_json::to_vec(words)?.len() <= bound {
return Ok(());
}
words["truncated"] = json!(true);
if let Some(object) = words.as_object_mut() {
object.remove("input");
}
if serde_json::to_vec(words)?.len() <= bound {
return Ok(());
}
let title = words["title"]
.as_str()
.ok_or_else(|| anyhow::anyhow!("permission_title_missing"))?
.to_owned();
words["title"] = json!("");
anyhow::ensure!(
serde_json::to_vec(words)?.len() <= bound,
"permission_words_exceed_content_bytes"
);
let mut low = 0;
let mut high = title.chars().count();
while low < high {
let middle = low + (high - low).div_ceil(2);
words["title"] = json!(title.chars().take(middle).collect::<String>());
if serde_json::to_vec(words)?.len() <= bound {
low = middle;
} else {
high = middle - 1;
}
}
words["title"] = json!(title.chars().take(low).collect::<String>());
Ok(())
}
#[derive(Clone, Copy)]
pub(super) enum Decision {
Approve,
Refuse,
Cancelled,
}
pub(super) fn decide(request: &Request, decision: Decision) -> (Value, &'static str) {
let (kind, decided) = match decision {
Decision::Approve => (Some("allow_once"), "approve"),
Decision::Refuse => (Some("reject_once"), "refuse"),
Decision::Cancelled => (None, "cancelled"),
};
if let Some(kind) = kind
&& let Some((id, _)) = request.options.iter().find(|(_, offered)| offered == kind)
{
return (
json!({"outcome":{"outcome":"selected","optionId":id}}),
decided,
);
}
(json!({"outcome":{"outcome":"cancelled"}}), "cancelled")
}
pub(super) struct Held {
pub draft: String,
pub asked_at_ms: u64,
}
pub(super) struct Pending {
pub id: Value,
pub request: Request,
pub conversation: String,
pub draft: Option<String>,
pub cancelled: bool,
pub holding: bool,
pub answer_queued: bool,
}
#[derive(Clone)]
pub(super) struct Issuer {
pub(super) origin: String,
pub(super) agent: String,
launch: Arc<String>,
pub(super) store: String,
bound: usize,
pub(super) http: reqwest::Client,
pub(super) changes: reqwest::Client,
watch_pass: Arc<tokio::sync::Mutex<watch_pass::Cached>>,
slots: Arc<tokio::sync::Semaphore>,
}
impl Issuer {
pub fn new(seat: &Seat, identity: &Identity) -> anyhow::Result<Self> {
let http = reqwest::Client::builder()
.no_proxy()
.redirect(reqwest::redirect::Policy::none())
.connect_timeout(Duration::from_secs(5))
.timeout(Duration::from_secs(15))
.build()?;
let origin = super::tools::origin(&seat.issuer)?.to_owned();
Ok(Self {
origin,
agent: identity.agent.clone(),
launch: Arc::new(identity.launch.clone()),
store: seat.store.clone(),
bound: seat.harness.issuer_response_bytes,
changes: reqwest::Client::builder()
.no_proxy()
.redirect(reqwest::redirect::Policy::none())
.connect_timeout(Duration::from_secs(5))
.build()?,
watch_pass: Arc::new(tokio::sync::Mutex::new(watch_pass::Cached::new(
identity.pass.clone(),
)?)),
http,
slots: Arc::new(tokio::sync::Semaphore::new(seat.harness.stream_queue)),
})
}
pub fn words_budget(&self, conversation: &str, bound: usize) -> anyhow::Result<usize> {
let draft = "x".repeat(DRAFT_ID_BYTES);
let envelope = json!({"kind":"permission_ask","conversation":conversation,"seq":u64::MAX,"durable":false,"draft":draft,"url":self.url(&draft)?,"words":{},"asked_at_ms":u64::MAX,"truncated":false});
bound
.checked_sub(serde_json::to_vec(&envelope)?.len().saturating_sub(2))
.filter(|bound| *bound > 0)
.ok_or_else(|| anyhow::anyhow!("permission_envelope_exceeds_content_bytes"))
}
pub fn url(&self, draft: &str) -> anyhow::Result<String> {
let mut url = url::Url::parse(&self.origin)?;
let encoded: String = url::form_urlencoded::byte_serialize(draft.as_bytes()).collect();
url.set_fragment(Some(&format!("/access/drafts?draft={encoded}")));
Ok(url.into())
}
pub async fn hold(&self, operation: String, words: Value) -> anyhow::Result<Held> {
let permit = self.slots.acquire().await?;
let response = self
.http
.post(format!("{}/api/agents/{}/pass", self.origin, self.agent))
.headers(super::tools::proof_headers(&self.launch)?)
.json(&json!({"audience":"haematite"}))
.send()
.await
.map_err(|_| anyhow::anyhow!("permission_issuer_unreachable"))?;
let answer = self.json(response).await?;
anyhow::ensure!(
answer["audience"] == "haematite",
"permission_pass_audience_differs"
);
let token = answer["pass"]
.as_str()
.ok_or_else(|| anyhow::anyhow!("permission_pass_missing"))?;
let pass = self.verified(token).await?;
let now = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs();
let target = Target::new("haematite.database", &self.store, "agent_permission")?;
let Right::Held { grant, .. } =
pass.evaluate(&target.kind, &target.id, &target.action, now)?
else {
anyhow::bail!("permission_not_held");
};
let words = serde_json::to_string(&words)?;
let request = DraftRequest {
operation,
grant: grant.to_owned(),
target,
request_digest: lys_pass::drafts::request_digest(&words),
words,
};
let response = self
.http
.post(format!("{}/api/product-drafts", self.origin))
.bearer_auth(token)
.json(&request)
.send()
.await
.map_err(|_| anyhow::anyhow!("permission_issuer_unreachable"))?;
let answer = self.json(response).await?;
let asked_at_ms = u64::try_from(SystemTime::now().duration_since(UNIX_EPOCH)?.as_millis())?;
let created: DraftCreated = serde_json::from_value(answer)
.map_err(|_| anyhow::anyhow!("permission_draft_response_invalid"))?;
anyhow::ensure!(
!created.draft.is_empty()
&& created.draft.len() <= DRAFT_ID_BYTES
&& created
.draft
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_')),
"permission_draft_id_invalid"
);
drop(permit);
Ok(Held {
draft: created.draft,
asked_at_ms,
})
}
async fn verified(&self, token: &str) -> anyhow::Result<VerifiedPass> {
let response = self
.http
.get(format!("{}/oauth/jwks", self.origin))
.send()
.await
.map_err(|_| anyhow::anyhow!("permission_issuer_unreachable"))?;
let keys = KeySet::from_json(&serde_json::to_string(&self.json(response).await?)?)?;
let now = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs();
let pass = VerifiedPass::verify(token, &keys, &self.origin, "haematite", now)?;
anyhow::ensure!(
pass.claims().holder.id == self.agent
&& pass.claims().holder.kind == "agent"
&& pass.claims().holder.responsible.is_some(),
"permission_pass_holder_differs"
);
Ok(pass)
}
pub async fn responsible(&self, token: &str) -> anyhow::Result<String> {
let pass = self.verified(token).await?;
pass.claims()
.holder
.responsible
.clone()
.filter(|person| !person.is_empty() && person.len() <= 128 && person != &self.agent)
.ok_or_else(|| anyhow::anyhow!("permission_pass_responsible_invalid"))
}
pub(super) async fn json(&self, mut response: reqwest::Response) -> anyhow::Result<Value> {
anyhow::ensure!(
response.status().is_success(),
"permission_issuer_refused: HTTP {}",
response.status().as_u16()
);
let mut bytes = Vec::new();
while let Some(chunk) = response
.chunk()
.await
.map_err(|_| anyhow::anyhow!("permission_issuer_response_unreadable"))?
{
anyhow::ensure!(
chunk.len() <= self.bound.saturating_sub(bytes.len()),
"issuer_response_bytes_exceeded"
);
bytes.extend_from_slice(&chunk);
}
serde_json::from_slice(&bytes)
.map_err(|_| anyhow::anyhow!("permission_issuer_response_invalid"))
}
}
pub(super) fn operation() -> anyhow::Result<String> {
use std::fmt::Write as _;
let mut bytes = [0_u8; 16];
std::fs::File::open("/dev/urandom")?.read_exact(&mut bytes)?;
let mut operation = String::from("assistant-");
for byte in bytes {
write!(operation, "{byte:02x}")?;
}
Ok(operation)
}
#[path = "watch_pass.rs"]
mod watch_pass;