use polyc_llm::ToolSpec;
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
#[repr(u8)]
pub enum Capability {
LocalRead = 1,
LocalWrite = 1 << 1,
FixedConnectorRead = 1 << 2,
ArbitraryEgress = 1 << 3,
MutateExternal = 1 << 4,
GrantAccess = 1 << 5,
RevokeAccess = 1 << 6,
ManageAdmin = 1 << 7,
}
impl Capability {
pub const ALL: [Self; 5] = [
Self::LocalRead,
Self::LocalWrite,
Self::FixedConnectorRead,
Self::ArbitraryEgress,
Self::MutateExternal,
];
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::LocalRead => "local-read",
Self::LocalWrite => "local-write",
Self::FixedConnectorRead => "fixed-connector-read",
Self::ArbitraryEgress => "arbitrary-egress",
Self::MutateExternal => "mutate-external",
Self::GrantAccess => "grant-access",
Self::RevokeAccess => "revoke-access",
Self::ManageAdmin => "manage-admin",
}
}
#[must_use]
pub fn from_name(name: &str) -> Option<Self> {
Self::ALL.into_iter().find(|c| c.as_str() == name)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Hash)]
pub struct CapabilitySet(u8);
pub const NATIVE_SEARCH_GROUNDING: &str = "web_search_grounding";
impl CapabilitySet {
pub const EMPTY: Self = Self(0);
#[must_use]
pub const fn native_search_grounding_requirements() -> Self {
Self::of(Capability::ArbitraryEgress)
}
#[must_use]
pub const fn all() -> Self {
let mut bits = 0u8;
let mut i = 0;
while i < Capability::ALL.len() {
bits |= Capability::ALL[i] as u8;
i += 1;
}
Self(bits)
}
#[must_use]
pub const fn of(capability: Capability) -> Self {
Self(capability as u8)
}
#[must_use]
pub const fn with(self, capability: Capability) -> Self {
Self(self.0 | capability as u8)
}
#[must_use]
pub const fn contains(self, capability: Capability) -> bool {
self.0 & capability as u8 != 0
}
#[must_use]
pub const fn is_empty(self) -> bool {
self.0 == 0
}
#[must_use]
pub const fn is_subset_of(self, other: Self) -> bool {
self.0 & !other.0 == 0
}
#[must_use]
pub const fn union(self, other: Self) -> Self {
Self(self.0 | other.0)
}
#[must_use]
pub const fn intersection(self, other: Self) -> Self {
Self(self.0 & other.0)
}
#[must_use]
pub const fn difference(self, other: Self) -> Self {
Self(self.0 & !other.0)
}
pub fn iter(self) -> impl Iterator<Item = Capability> {
Capability::ALL
.into_iter()
.filter(move |c| self.contains(*c))
}
pub fn from_names<'a, I: IntoIterator<Item = &'a str>>(names: I) -> (Self, Vec<String>) {
let mut set = Self::EMPTY;
let mut unknown = Vec::new();
for name in names {
match Capability::from_name(name) {
Some(c) => set = set.with(c),
None => unknown.push(name.to_owned()),
}
}
(set, unknown)
}
#[must_use]
pub fn names(self) -> Vec<&'static str> {
self.iter().map(Capability::as_str).collect()
}
}
impl FromIterator<Capability> for CapabilitySet {
fn from_iter<I: IntoIterator<Item = Capability>>(iter: I) -> Self {
iter.into_iter().fold(Self::EMPTY, Self::with)
}
}
pub const TAINT_REVOKED: CapabilitySet =
CapabilitySet::of(Capability::ArbitraryEgress).with(Capability::MutateExternal);
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ToolOrigin {
LocalSandbox,
Fetcher,
FirstParty,
AccessGrant,
AccessRevoke,
AdminManage,
RegisteredConnector,
Unknown,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ToolProfile {
pub origin: ToolOrigin,
pub read_only: bool,
pub destructive: bool,
pub open_world: bool,
}
impl ToolProfile {
#[must_use]
pub const fn for_spec(spec: &ToolSpec, origin: ToolOrigin) -> Self {
Self {
origin,
read_only: spec.read_only,
destructive: spec.destructive,
open_world: spec.open_world,
}
}
}
#[must_use]
pub const fn required_capabilities(profile: ToolProfile) -> CapabilitySet {
match profile.origin {
ToolOrigin::LocalSandbox => {
if profile.read_only {
CapabilitySet::of(Capability::LocalRead)
} else {
CapabilitySet::of(Capability::LocalRead).with(Capability::LocalWrite)
}
}
ToolOrigin::Fetcher => {
if profile.destructive {
CapabilitySet::of(Capability::ArbitraryEgress).with(Capability::MutateExternal)
} else {
CapabilitySet::of(Capability::ArbitraryEgress)
}
}
ToolOrigin::FirstParty | ToolOrigin::RegisteredConnector => {
if profile.read_only && !profile.destructive {
CapabilitySet::of(Capability::FixedConnectorRead)
} else {
CapabilitySet::of(Capability::FixedConnectorRead).with(Capability::MutateExternal)
}
}
ToolOrigin::AccessGrant => CapabilitySet::of(Capability::GrantAccess),
ToolOrigin::AccessRevoke => CapabilitySet::of(Capability::RevokeAccess),
ToolOrigin::AdminManage => CapabilitySet::of(Capability::ManageAdmin),
ToolOrigin::Unknown => CapabilitySet::all(),
}
}
#[must_use]
pub const fn monotonic_redeclaration(old: ToolProfile, new: ToolProfile) -> ToolProfile {
ToolProfile {
origin: old.origin,
read_only: old.read_only && new.read_only,
destructive: old.destructive || new.destructive,
open_world: old.open_world || new.open_world,
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Ceremony {
WalletLink,
EmailMagicLink,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Requirement {
MailRelay,
CeremonyPage(Ceremony),
PaymentsProxy,
MandateIssuer,
}
impl Requirement {
pub const ALL: [Self; 5] = [
Self::MailRelay,
Self::CeremonyPage(Ceremony::WalletLink),
Self::CeremonyPage(Ceremony::EmailMagicLink),
Self::PaymentsProxy,
Self::MandateIssuer,
];
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::MailRelay => "mail-relay",
Self::CeremonyPage(Ceremony::WalletLink) => "ceremony-page:wallet-link",
Self::CeremonyPage(Ceremony::EmailMagicLink) => "ceremony-page:email-magic-link",
Self::PaymentsProxy => "payments-proxy",
Self::MandateIssuer => "mandate-issuer",
}
}
#[must_use]
pub fn from_name(name: &str) -> Option<Self> {
Self::ALL.into_iter().find(|r| r.as_str() == name)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
#[allow(clippy::struct_excessive_bools)] pub struct DeploymentCapabilities {
pub mail_relay: bool,
pub wallet_link_ceremony: bool,
pub email_magic_link_ceremony: bool,
pub payments_proxy: bool,
pub mandate_issuer: bool,
}
impl DeploymentCapabilities {
#[must_use]
pub const fn all() -> Self {
Self {
mail_relay: true,
wallet_link_ceremony: true,
email_magic_link_ceremony: true,
payments_proxy: true,
mandate_issuer: true,
}
}
#[must_use]
pub const fn is_viable(self, requirement: Requirement) -> bool {
match requirement {
Requirement::MailRelay => self.mail_relay,
Requirement::CeremonyPage(Ceremony::WalletLink) => self.wallet_link_ceremony,
Requirement::CeremonyPage(Ceremony::EmailMagicLink) => self.email_magic_link_ceremony,
Requirement::PaymentsProxy => self.payments_proxy,
Requirement::MandateIssuer => self.mandate_issuer,
}
}
#[must_use]
pub fn all_viable(self, requirements: &[Requirement]) -> bool {
requirements.iter().all(|r| self.is_viable(*r))
}
#[must_use]
pub fn viable_names(self) -> Vec<&'static str> {
Requirement::ALL
.into_iter()
.filter(|r| self.is_viable(*r))
.map(Requirement::as_str)
.collect()
}
#[must_use]
pub fn from_names<'a, I: IntoIterator<Item = &'a str>>(names: I) -> Self {
let mut caps = Self::default();
for name in names {
match Requirement::from_name(name) {
Some(Requirement::MailRelay) => caps.mail_relay = true,
Some(Requirement::CeremonyPage(Ceremony::WalletLink)) => {
caps.wallet_link_ceremony = true;
}
Some(Requirement::CeremonyPage(Ceremony::EmailMagicLink)) => {
caps.email_magic_link_ceremony = true;
}
Some(Requirement::PaymentsProxy) => caps.payments_proxy = true,
Some(Requirement::MandateIssuer) => caps.mandate_issuer = true,
None => {}
}
}
caps
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum TaintState {
#[default]
Clean,
Tainted,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct GrantPolicy {
pub base: CapabilitySet,
pub taint_resilient: CapabilitySet,
}
impl Default for GrantPolicy {
fn default() -> Self {
Self {
base: CapabilitySet::all(),
taint_resilient: CapabilitySet::EMPTY,
}
}
}
#[must_use]
pub const fn granted_capabilities(policy: GrantPolicy, taint: TaintState) -> CapabilitySet {
match taint {
TaintState::Clean => policy.base,
TaintState::Tainted => {
let survivors = policy.taint_resilient.intersection(TAINT_REVOKED);
policy
.base
.difference(TAINT_REVOKED)
.union(policy.base.intersection(survivors))
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub enum ArgTransform {
#[default]
None,
Rewrite(String),
InjectContext(String),
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct CallPolicy {
pub veto: Option<String>,
pub requires_human: bool,
pub sandbox_escalation: bool,
pub transform: ArgTransform,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum GateOutcome {
Allow,
Modify(
String,
),
InjectContext(
String,
),
Escalate {
reason: String,
missing: CapabilitySet,
},
Deny(
String,
),
}
impl GateOutcome {
#[must_use]
pub const fn label(&self) -> &'static str {
match self {
Self::Allow => "allow",
Self::Modify(_) => "modify",
Self::InjectContext(_) => "inject_context",
Self::Escalate { .. } => "escalate",
Self::Deny(_) => "deny",
}
}
}
#[must_use]
pub fn decide(
required: CapabilitySet,
granted: CapabilitySet,
policy: &CallPolicy,
tool_name: &str,
) -> GateOutcome {
if let Some(reason) = &policy.veto {
return GateOutcome::Deny(reason.clone());
}
let missing = required.difference(granted);
if !missing.is_empty() {
return GateOutcome::Escalate {
reason: escalation_reason(tool_name, missing),
missing,
};
}
if policy.requires_human || policy.sandbox_escalation {
return GateOutcome::Escalate {
reason: String::new(),
missing: CapabilitySet::EMPTY,
};
}
match &policy.transform {
ArgTransform::None => GateOutcome::Allow,
ArgTransform::Rewrite(args) => GateOutcome::Modify(args.clone()),
ArgTransform::InjectContext(note) => GateOutcome::InjectContext(note.clone()),
}
}
#[must_use]
pub fn escalation_reason(tool_name: &str, missing: CapabilitySet) -> String {
if missing.contains(Capability::GrantAccess) {
return format!(
"`{tool_name}` would give someone access to Polychrome, so a person needs to \
confirm exactly who's being invited before it goes ahead"
);
}
if missing.contains(Capability::RevokeAccess) {
return format!(
"`{tool_name}` would remove someone's access to Polychrome, so a person needs to \
confirm exactly who's being removed before it goes ahead"
);
}
if missing.contains(Capability::ManageAdmin) {
return format!(
"`{tool_name}` would take away someone's admin role, so a person needs to confirm \
exactly whose role is being removed before it goes ahead"
);
}
let reaches_out = missing.contains(Capability::ArbitraryEgress);
let mutates = missing.contains(Capability::MutateExternal);
match (reaches_out, mutates) {
(true, true) => format!(
"this conversation has taken in content from outside sources, so `{tool_name}` \
needs a quick human check before it could send anything out or change anything \
beyond this conversation"
),
(true, false) => format!(
"this conversation has taken in content from outside sources, so `{tool_name}` \
needs a quick human check before it reaches an outside address"
),
(false, true) => format!(
"this conversation has taken in content from outside sources, so `{tool_name}` \
needs a quick human check before it could change anything beyond this conversation"
),
(false, false) => format!(
"`{tool_name}` needs more access than this conversation currently has, so a \
human check is needed first"
),
}
}
#[cfg(test)]
mod tests;