liminal-cli 0.3.0

The liminal command: serve the network, maintain its stores, or serve its discovery MCP over stdio
Documentation
//! Only a server draft decision can select a one-shot ACP permission option.

use super::handshake::Version;
use agent_client_protocol_schema::{v1, v2};
use serde_json::{Value, json};
use std::collections::BTreeSet;

#[derive(Clone, Copy, PartialEq, Eq)]
pub(super) enum Decision {
    Executed,
    Refused,
    Cancelled,
}

pub(super) struct Request {
    pub words: Value,
    pub act: String,
    pub target: Option<String>,
    pub arguments: Option<Value>,
    options: Vec<Value>,
}

impl Request {
    pub fn discard_payload(&mut self) {
        // After submission the draft owns its words; the host needs only option ids and kinds.
        self.words = Value::Null;
        self.arguments = None;
        self.target = None;
        for option in &mut self.options {
            if let Some(option) = option.as_object_mut() {
                option.remove("name");
            }
        }
    }

    pub fn decode(
        version: Version,
        params: Value,
        session: &str,
        bound: usize,
    ) -> anyhow::Result<Self> {
        let Decoded {
            title,
            tool,
            options,
            id,
        } = decode_wire(version, params)?;
        anyhow::ensure!(id == session, "permission request names a foreign session");
        anyhow::ensure!(!title.trim().is_empty(), "permission title is empty");
        let options = options
            .as_array()
            .ok_or_else(|| anyhow::anyhow!("permission options is not an array"))?;
        anyhow::ensure!(!options.is_empty(), "permission options is empty");
        let mut ids = BTreeSet::new();
        let mut offered = Vec::new();
        for option in options {
            let id = option["optionId"]
                .as_str()
                .filter(|id| !id.is_empty())
                .ok_or_else(|| anyhow::anyhow!("permission option has no id"))?;
            anyhow::ensure!(ids.insert(id), "permission option ids are duplicated");
            let name = option["name"]
                .as_str()
                .filter(|name| !name.is_empty())
                .ok_or_else(|| anyhow::anyhow!("permission option has no name"))?;
            let kind = option["kind"]
                .as_str()
                .ok_or_else(|| anyhow::anyhow!("permission option has no kind"))?;
            offered.push(json!({"optionId":id,"name":name,"kind":kind}));
        }
        let mut words = json!({"title":title,"options":offered});
        anyhow::ensure!(
            serde_json::to_vec(&words)?.len() <= bound,
            "permission words exceed content_bytes {bound}"
        );
        let Act {
            name: act,
            target,
            arguments,
        } = prepare_act(&tool, bound)?;
        if act != "agent_permission" {
            words["act"] = json!(act);
            words["target"] = json!(target);
            words["arguments"] = json!(arguments);
        }
        anyhow::ensure!(
            serde_json::to_vec(&words)?.len() <= bound,
            "permission words exceed content_bytes {bound}"
        );
        Ok(Self {
            words,
            act,
            target,
            arguments,
            options: offered,
        })
    }
}

struct Decoded {
    title: String,
    tool: Value,
    options: Value,
    id: String,
}

fn decode_wire(version: Version, params: Value) -> anyhow::Result<Decoded> {
    let (title, tool, options, id) = match version {
        Version::One => {
            let request: v1::RequestPermissionRequest = serde_json::from_value(params)
                .map_err(|_| anyhow::anyhow!("ACP 1 permission params refused"))?;
            (
                request
                    .tool_call
                    .fields
                    .title
                    .clone()
                    .ok_or_else(|| anyhow::anyhow!("permission tool call has no title"))?,
                super::handshake::encoded(request.tool_call)?,
                super::handshake::encoded(request.options)?,
                request.session_id.to_string(),
            )
        }
        Version::Two => {
            let request: v2::RequestPermissionRequest = serde_json::from_value(params)
                .map_err(|_| anyhow::anyhow!("ACP 2 permission params refused"))?;
            let tool = match request.subject {
                Some(v2::RequestPermissionSubject::ToolCall(subject)) => {
                    super::handshake::encoded(subject.tool_call)?
                }
                _ => Value::Null,
            };
            (
                request.title,
                tool,
                super::handshake::encoded(request.options)?,
                request.session_id.to_string(),
            )
        }
    };
    Ok(Decoded {
        title,
        tool,
        options,
        id,
    })
}

struct Act {
    name: String,
    target: Option<String>,
    arguments: Option<Value>,
}

fn prepare_act(tool: &Value, bound: usize) -> anyhow::Result<Act> {
    // Only an explicit ticket tool descriptor names a network act; prose never does.
    let descriptor = tool.get("rawInput");
    let act = descriptor
        .and_then(|value| value.get("act"))
        .and_then(Value::as_str);
    let deliberate = act.filter(|act| {
        [
            "deploy_service",
            "create_agent",
            "start",
            "stop",
            "create_site",
            "bind_builder",
            "unbind",
            "propose_template",
            "complete",
            "grant",
        ]
        .contains(act)
    });
    let (act, target, arguments) = if let Some(act) = deliberate {
        let descriptor =
            descriptor.ok_or_else(|| anyhow::anyhow!("deliberate descriptor absent"))?;
        let target = descriptor["target"]
            .as_str()
            .filter(|target| !target.is_empty())
            .ok_or_else(|| anyhow::anyhow!("deliberate permission has no target"))?;
        let arguments = descriptor
            .get("arguments")
            .filter(|value| value.is_object())
            .ok_or_else(|| anyhow::anyhow!("deliberate permission has no arguments object"))?;
        anyhow::ensure!(
            serde_json::to_vec(descriptor)?.len() <= bound,
            "deliberate permission exceeds content_bytes {bound}"
        );
        (
            act.to_owned(),
            Some(target.to_owned()),
            Some(arguments.clone()),
        )
    } else {
        ("agent_permission".to_owned(), None, None)
    };
    Ok(Act {
        name: act,
        target,
        arguments,
    })
}

pub(super) fn decide(request: &Request, decision: Decision) -> Value {
    let kind = match decision {
        Decision::Executed => "allow_once",
        Decision::Refused => "reject_once",
        Decision::Cancelled => return json!({"outcome":{"outcome":"cancelled"}}),
    };
    request
        .options
        .iter()
        .find(|option| option["kind"] == kind)
        .map_or_else(
            || json!({"outcome":{"outcome":"cancelled"}}),
            |option| json!({"outcome":{"outcome":"selected","optionId":option["optionId"]}}),
        )
}

/// A held permission's operation id, as the server's draft keys it.
pub(super) fn operation() -> anyhow::Result<String> {
    use std::io::Read as _;
    // An operation's 128-bit identity is protocol entropy, not a configurable storage bound.
    let mut bytes = [0_u8; 16];
    std::fs::File::open("/dev/urandom")?.read_exact(&mut bytes)?;
    Ok(format!("acp-{}", hex::encode(bytes)))
}

/// The exchange's answer to a permission draft, refused unless it says the draft is held.
pub(super) fn held(answer: anyhow::Result<Value>) -> anyhow::Result<Value> {
    let answer = answer?;
    anyhow::ensure!(
        answer["state"] == "held",
        "agents.permission did not return a held draft"
    );
    Ok(answer)
}