use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey};
use serde::{Deserialize, Serialize};
use crate::command::{classify_tier, CommandInvocation, CommandTier};
use crate::error::SdkError;
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct ActorId(pub String);
impl ActorId {
pub fn new(id: impl Into<String>) -> Self {
Self(id.into())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum EffectKind {
ReadFile,
SystemProbe,
DataRead,
ToolSearch,
ToolProgram,
Search,
List,
LspQuery,
WriteArtifact,
ApplyPatch,
MoveFile,
DeleteFile,
RunVerifier,
RunFormatter,
RunTest,
RunBuild,
MutateDependencies,
RunRepoScript,
RunShell,
GitRead,
GitWrite,
NetworkFetch,
AskUser,
SpawnAgent,
UpdateGraph,
UpdatePolicy,
}
impl EffectKind {
pub fn is_read_only(self) -> bool {
matches!(
self,
EffectKind::ReadFile
| EffectKind::SystemProbe
| EffectKind::DataRead
| EffectKind::ToolSearch
| EffectKind::ToolProgram
| EffectKind::Search
| EffectKind::List
| EffectKind::LspQuery
| EffectKind::GitRead
)
}
pub fn is_privileged(self) -> bool {
matches!(self, EffectKind::UpdateGraph | EffectKind::UpdatePolicy)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum RiskClass {
None,
Low,
Medium,
High,
Critical,
}
pub use crate::scope::{CommandPattern, NetworkPattern, PathPattern};
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct RiskBudget {
pub name: String,
pub limit: f64,
pub spent: f64,
}
impl RiskBudget {
pub fn new(name: impl Into<String>, limit: f64) -> Self {
Self {
name: name.into(),
limit,
spent: 0.0,
}
}
pub fn admits(&self, cost: f64) -> bool {
self.spent + cost <= self.limit
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ApprovalPolicy {
Auto,
Ask,
SessionApproved,
Deny,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum CapabilityRole {
Session,
ChatTool,
Explorer,
Worker,
Reviewer,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct GrantPolicy {
pub policy_id: String,
pub workspace_root: String,
pub effect_ceiling: Vec<EffectKind>,
pub path_ceiling: Vec<PathPattern>,
pub command_ceiling: Vec<CommandPattern>,
pub network_ceiling: Vec<NetworkPattern>,
pub approval_ceiling: ApprovalPolicy,
pub authority_epoch: u64,
pub persistent: bool,
pub integrity_binding: String,
}
fn intersect_scope(
requested: Vec<String>,
ceiling: &[String],
deny_when_empty_ceiling: bool,
) -> Vec<String> {
if ceiling.is_empty() {
return if deny_when_empty_ceiling {
Vec::new()
} else {
requested
};
}
if ceiling.iter().any(|pattern| pattern == "*") {
return requested;
}
if requested.is_empty() || requested.iter().any(|pattern| pattern == "*") {
return ceiling.to_vec();
}
requested
.into_iter()
.filter(|pattern| ceiling.iter().any(|allowed| allowed == pattern))
.collect()
}
impl GrantPolicy {
pub fn mint(&self, mut requested: Capability) -> crate::error::Result<Capability> {
requested
.effects
.retain(|effect| self.effect_ceiling.contains(effect));
if requested.effects.is_empty() {
return Err(SdkError::Domain(
"grant intersection authorizes no effects".into(),
));
}
let unwrap_paths = |patterns: Vec<PathPattern>| -> Vec<String> {
patterns.into_iter().map(|p| p.0).collect()
};
requested.path_scope = intersect_scope(
unwrap_paths(requested.path_scope),
&self
.path_ceiling
.iter()
.map(|p| p.0.clone())
.collect::<Vec<_>>(),
false,
)
.into_iter()
.map(PathPattern)
.collect();
requested.command_scope = intersect_scope(
requested.command_scope.into_iter().map(|p| p.0).collect(),
&self
.command_ceiling
.iter()
.map(|p| p.0.clone())
.collect::<Vec<_>>(),
false,
)
.into_iter()
.map(CommandPattern)
.collect();
requested.network_scope = intersect_scope(
requested.network_scope.into_iter().map(|p| p.0).collect(),
&self
.network_ceiling
.iter()
.map(|p| p.0.clone())
.collect::<Vec<_>>(),
true,
)
.into_iter()
.map(NetworkPattern)
.collect();
if requested.role != CapabilityRole::Worker
&& requested.approval_policy.strictness() < self.approval_ceiling.strictness()
{
requested.approval_policy = self.approval_ceiling;
}
requested.authority_epoch = self.authority_epoch;
Ok(requested)
}
pub fn canonical_bytes(&self) -> Vec<u8> {
fn field(out: &mut Vec<u8>, bytes: &[u8]) {
out.extend_from_slice(&(bytes.len() as u64).to_be_bytes());
out.extend_from_slice(bytes);
}
fn list(out: &mut Vec<u8>, entries: impl ExactSizeIterator<Item = String>) {
out.extend_from_slice(&(entries.len() as u64).to_be_bytes());
for entry in entries {
field(out, entry.as_bytes());
}
}
let mut out = Vec::new();
field(&mut out, b"perspt-grant-policy-v1");
field(&mut out, self.policy_id.as_bytes());
field(&mut out, self.workspace_root.as_bytes());
list(
&mut out,
self.effect_ceiling.iter().map(|e| format!("{e:?}")),
);
list(&mut out, self.path_ceiling.iter().map(|p| p.0.clone()));
list(&mut out, self.command_ceiling.iter().map(|p| p.0.clone()));
list(&mut out, self.network_ceiling.iter().map(|p| p.0.clone()));
field(&mut out, format!("{:?}", self.approval_ceiling).as_bytes());
out.extend_from_slice(&self.authority_epoch.to_be_bytes());
out.push(u8::from(self.persistent));
field(&mut out, self.integrity_binding.as_bytes());
out
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct SignedGrantPolicy {
pub policy: GrantPolicy,
pub public_key: String,
pub signature: String,
}
impl SignedGrantPolicy {
pub fn sign(policy: GrantPolicy, secret_key: &[u8; 32]) -> crate::error::Result<Self> {
let signing_key = SigningKey::from_bytes(secret_key);
let signature = signing_key.sign(&policy.canonical_bytes());
Ok(Self {
policy,
public_key: hex_encode(signing_key.verifying_key().as_bytes()),
signature: hex_encode(&signature.to_bytes()),
})
}
pub fn verify_against(&self, trusted_public_key: &[u8; 32]) -> crate::error::Result<()> {
let embedded: [u8; 32] = hex_decode(&self.public_key)?
.try_into()
.map_err(|_| SdkError::Signature("grant public key must be 32 bytes".into()))?;
if &embedded != trusted_public_key {
return Err(SdkError::Signature(
"grant signed by an untrusted key".into(),
));
}
let signature: [u8; 64] = hex_decode(&self.signature)?
.try_into()
.map_err(|_| SdkError::Signature("grant signature must be 64 bytes".into()))?;
let verifying_key = VerifyingKey::from_bytes(&embedded)
.map_err(|error| SdkError::Signature(error.to_string()))?;
verifying_key
.verify(
&self.policy.canonical_bytes(),
&Signature::from_bytes(&signature),
)
.map_err(|error| SdkError::Signature(error.to_string()))
}
}
pub fn grant_public_key(secret_key: &[u8; 32]) -> [u8; 32] {
SigningKey::from_bytes(secret_key)
.verifying_key()
.to_bytes()
}
pub fn hex_encode(bytes: &[u8]) -> String {
use std::fmt::Write;
let mut output = String::with_capacity(bytes.len() * 2);
for byte in bytes {
let _ = write!(&mut output, "{byte:02x}");
}
output
}
pub fn hex_decode(value: &str) -> crate::error::Result<Vec<u8>> {
if value.len() & 1 != 0 {
return Err(SdkError::Signature("hex value has odd length".into()));
}
(0..value.len())
.step_by(2)
.map(|index| {
u8::from_str_radix(&value[index..index + 2], 16)
.map_err(|error| SdkError::Signature(format!("invalid hex value: {error}")))
})
.collect()
}
impl ApprovalPolicy {
pub fn strictness(self) -> u8 {
match self {
ApprovalPolicy::Auto | ApprovalPolicy::SessionApproved => 0,
ApprovalPolicy::Ask => 1,
ApprovalPolicy::Deny => 2,
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct Capability {
pub capability_id: String,
pub holder: ActorId,
pub effects: Vec<EffectKind>,
pub path_scope: Vec<PathPattern>,
pub write_scope: Vec<PathPattern>,
pub command_scope: Vec<CommandPattern>,
pub network_scope: Vec<NetworkPattern>,
pub max_calls: Option<u32>,
pub expires_at: Option<i64>,
pub may_delegate: bool,
pub risk_budget: Option<RiskBudget>,
pub approval_policy: ApprovalPolicy,
pub session_id: String,
pub authority_epoch: u64,
pub graph_revision: String,
pub node_generation: u32,
pub role: CapabilityRole,
pub parent_capability_id: Option<String>,
}
impl Capability {
pub fn new(holder: ActorId, effects: Vec<EffectKind>) -> Self {
Self {
capability_id: uuid::Uuid::new_v4().to_string(),
holder,
effects,
path_scope: Vec::new(),
write_scope: Vec::new(),
command_scope: Vec::new(),
network_scope: Vec::new(),
max_calls: None,
expires_at: None,
may_delegate: false,
risk_budget: None,
approval_policy: ApprovalPolicy::Auto,
session_id: String::new(),
authority_epoch: 0,
graph_revision: String::new(),
node_generation: 0,
role: CapabilityRole::Session,
parent_capability_id: None,
}
}
pub fn with_paths(mut self, patterns: Vec<&str>) -> Self {
self.path_scope = patterns
.into_iter()
.map(|p| PathPattern(p.to_string()))
.collect();
self
}
pub fn delegable(mut self) -> Self {
self.may_delegate = true;
self
}
pub fn grants(&self, effect: EffectKind) -> bool {
self.effects.contains(&effect)
}
pub fn attenuates(&self, source: &Capability) -> bool {
if !self.effects.iter().all(|e| source.effects.contains(e)) {
return false;
}
fn scope_attenuates<T: PartialEq>(
child: &[T],
parent: &[T],
parent_has_wildcard: bool,
) -> bool {
if parent.is_empty() {
return true; }
if child.is_empty() {
return false; }
child
.iter()
.all(|c| parent_has_wildcard || parent.contains(c))
}
let path_wild = source.path_scope.iter().any(|sp| sp.0 == "*");
if !scope_attenuates(&self.path_scope, &source.path_scope, path_wild) {
return false;
}
let write_wild = source.write_scope.iter().any(|sp| sp.0 == "*");
if !scope_attenuates(&self.write_scope, &source.write_scope, write_wild) {
return false;
}
let cmd_wild = source.command_scope.iter().any(|sp| sp.0 == "*");
if !scope_attenuates(&self.command_scope, &source.command_scope, cmd_wild) {
return false;
}
if source.network_scope.is_empty() {
if !self.network_scope.is_empty() {
return false;
}
} else {
let net_wild = source.network_scope.iter().any(|sp| sp.0 == "*");
if !self
.network_scope
.iter()
.all(|c| net_wild || source.network_scope.contains(c))
{
return false;
}
}
if let (Some(child), Some(parent)) = (self.max_calls, source.max_calls) {
if child > parent {
return false;
}
}
if self.max_calls.is_none() && source.max_calls.is_some() {
return false; }
if let (Some(child), Some(parent)) = (self.expires_at, source.expires_at) {
if child > parent {
return false;
}
}
if self.expires_at.is_none() && source.expires_at.is_some() {
return false;
}
if self.may_delegate && !source.may_delegate {
return false;
}
match (&self.risk_budget, &source.risk_budget) {
(Some(child), Some(parent))
if child.limit - child.spent > parent.limit - parent.spent =>
{
return false;
}
(None, Some(_)) => return false, _ => {}
}
if self.approval_policy.strictness() < source.approval_policy.strictness() {
return false;
}
if self.session_id != source.session_id
|| self.authority_epoch != source.authority_epoch
|| self.graph_revision != source.graph_revision
|| self.node_generation != source.node_generation
{
return false;
}
if self.role == CapabilityRole::Session && source.role != CapabilityRole::Session {
return false;
}
if self.parent_capability_id.as_deref() != Some(source.capability_id.as_str()) {
return false;
}
true
}
pub fn delegate(&self, child: Capability) -> Option<Capability> {
if !self.may_delegate {
return None;
}
if child.attenuates(self) {
Some(child)
} else {
None
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct StateWitness {
pub resource: String,
pub content_hash: String,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct EffectProposal {
pub proposal_id: String,
pub actor: ActorId,
pub node_id: String,
pub generation: u32,
pub effect: EffectKind,
pub path: Option<String>,
#[serde(default)]
pub additional_paths: Vec<String>,
pub command: Option<CommandInvocation>,
pub network_target: Option<String>,
pub risk: RiskClass,
pub risk_cost: f64,
pub idempotency_key: String,
pub preconditions: Vec<StateWitness>,
}
impl EffectProposal {
pub fn new(actor: ActorId, node_id: impl Into<String>, effect: EffectKind) -> Self {
Self {
proposal_id: uuid::Uuid::new_v4().to_string(),
actor,
node_id: node_id.into(),
generation: 0,
effect,
path: None,
additional_paths: Vec::new(),
command: None,
network_target: None,
risk: RiskClass::Low,
risk_cost: 0.0,
idempotency_key: uuid::Uuid::new_v4().to_string(),
preconditions: Vec::new(),
}
}
pub fn with_path(mut self, path: impl Into<String>) -> Self {
self.path = Some(path.into());
self
}
pub fn with_additional_paths(mut self, paths: Vec<String>) -> Self {
self.additional_paths = paths;
self
}
pub fn with_command(mut self, command: CommandInvocation) -> Self {
self.command = Some(command);
self
}
pub fn with_generation(mut self, generation: u32) -> Self {
self.generation = generation;
self
}
pub fn with_risk_class(mut self, risk: RiskClass) -> Self {
self.risk = risk;
self
}
pub fn with_network_target(mut self, target: impl Into<String>) -> Self {
self.network_target = Some(target.into());
self
}
pub fn with_idempotency_key(mut self, key: impl Into<String>) -> Self {
self.idempotency_key = key.into();
self
}
pub fn with_preconditions(mut self, preconditions: Vec<StateWitness>) -> Self {
self.preconditions = preconditions;
self
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "decision", rename_all = "snake_case")]
pub enum AdmissibilityDecision {
Allow,
Deny { reason: DenyReason },
NeedsApproval,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum DenyReason {
NoCapability,
EffectOutOfScope,
PathOutOfScope,
CommandOutOfScope,
NetworkOutOfScope,
CallBudgetExhausted,
Expired,
RiskBudgetExhausted,
StateWitnessMismatch,
ShellNotPermitted,
MutationNotPermitted,
PolicyDenied,
PrivilegeEscalation,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum RecoveryClass {
Retryable,
NeedsApproval,
NeedsCapability,
Fatal,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct AdmissibilityWitness {
pub proposal_id: String,
pub actor: ActorId,
pub capability_id: Option<String>,
pub authority_ok: bool,
pub contract_ok: bool,
pub effect_ok: bool,
pub barrier_increment_ok: bool,
pub risk_budget_ok: bool,
pub decision: AdmissibilityDecision,
pub recovery_class: Option<RecoveryClass>,
}
#[derive(Debug, Clone, Default)]
pub struct KernelState {
pub witnesses: std::collections::HashMap<String, String>,
}
impl KernelState {
pub fn new() -> Self {
Self::default()
}
pub fn set_witness(&mut self, resource: impl Into<String>, hash: impl Into<String>) {
self.witnesses.insert(resource.into(), hash.into());
}
}
fn deny(
proposal: &EffectProposal,
cap: Option<&Capability>,
reason: DenyReason,
recovery: RecoveryClass,
) -> AdmissibilityWitness {
AdmissibilityWitness {
proposal_id: proposal.proposal_id.clone(),
actor: proposal.actor.clone(),
capability_id: cap.map(|c| c.capability_id.clone()),
authority_ok: cap.is_some(),
contract_ok: false,
effect_ok: false,
barrier_increment_ok: false,
risk_budget_ok: false,
decision: AdmissibilityDecision::Deny { reason },
recovery_class: Some(recovery),
}
}
pub fn check_admissibility(
proposal: &EffectProposal,
capabilities: &[Capability],
state: &KernelState,
) -> AdmissibilityWitness {
let cap = match find_authorized_capability(proposal, capabilities) {
Ok(cap) => cap,
Err(witness) => return *witness,
};
if let Some(denied) = check_binding(proposal, cap, state) {
return denied;
}
if let Some(denied) = check_lifetime(proposal, cap, state) {
return denied;
}
if let Some(denied) = check_scope(proposal, cap) {
return denied;
}
if let Some(denied) = check_state_witnesses(proposal, cap, state) {
return denied;
}
finalize_decision(proposal, cap)
}
fn find_authorized_capability<'a>(
proposal: &EffectProposal,
capabilities: &'a [Capability],
) -> Result<&'a Capability, Box<AdmissibilityWitness>> {
let cap = capabilities
.iter()
.find(|c| c.holder == proposal.actor && c.grants(proposal.effect))
.ok_or_else(|| {
Box::new(deny(
proposal,
None,
DenyReason::NoCapability,
RecoveryClass::NeedsCapability,
))
})?;
if proposal.effect.is_privileged()
&& (cap.role != CapabilityRole::Session || cap.parent_capability_id.is_some())
{
return Err(Box::new(deny(
proposal,
Some(cap),
DenyReason::PrivilegeEscalation,
RecoveryClass::Fatal,
)));
}
Ok(cap)
}
fn check_binding(
proposal: &EffectProposal,
cap: &Capability,
state: &KernelState,
) -> Option<AdmissibilityWitness> {
if !cap.graph_revision.is_empty()
&& state.witnesses.get("__graph_revision") != Some(&cap.graph_revision)
{
return Some(deny(
proposal,
Some(cap),
DenyReason::StateWitnessMismatch,
RecoveryClass::NeedsCapability,
));
}
if !cap.session_id.is_empty()
&& state
.witnesses
.get("__authority_epoch")
.and_then(|value| value.parse::<u64>().ok())
!= Some(cap.authority_epoch)
{
return Some(deny(
proposal,
Some(cap),
DenyReason::StateWitnessMismatch,
RecoveryClass::NeedsCapability,
));
}
if proposal.generation != cap.node_generation {
return Some(deny(
proposal,
Some(cap),
DenyReason::StateWitnessMismatch,
RecoveryClass::Retryable,
));
}
None
}
fn check_lifetime(
proposal: &EffectProposal,
cap: &Capability,
state: &KernelState,
) -> Option<AdmissibilityWitness> {
if let Some(expiry) = cap.expires_at {
match state
.witnesses
.get("__now")
.and_then(|s| s.parse::<i64>().ok())
{
Some(now) if now <= expiry => {}
_ => {
return Some(deny(
proposal,
Some(cap),
DenyReason::Expired,
RecoveryClass::NeedsCapability,
));
}
}
}
if cap.max_calls == Some(0) {
return Some(deny(
proposal,
Some(cap),
DenyReason::CallBudgetExhausted,
RecoveryClass::NeedsCapability,
));
}
None
}
fn check_scope(proposal: &EffectProposal, cap: &Capability) -> Option<AdmissibilityWitness> {
let mutating_proposal = matches!(
proposal.effect,
EffectKind::WriteArtifact
| EffectKind::ApplyPatch
| EffectKind::MoveFile
| EffectKind::DeleteFile
| EffectKind::MutateDependencies
);
for path in proposal.path.iter().chain(proposal.additional_paths.iter()) {
let out_of_path =
!cap.path_scope.is_empty() && !cap.path_scope.iter().any(|p| p.matches(path));
let out_of_write = mutating_proposal
&& !cap.write_scope.is_empty()
&& !cap.write_scope.iter().any(|p| p.matches(path));
if out_of_path || out_of_write {
return Some(deny(
proposal,
Some(cap),
DenyReason::PathOutOfScope,
RecoveryClass::NeedsApproval,
));
}
}
if let Some(command) = &proposal.command {
if command.requires_shell() && !cap.grants(EffectKind::RunShell) {
return Some(deny(
proposal,
Some(cap),
DenyReason::ShellNotPermitted,
RecoveryClass::NeedsApproval,
));
}
if classify_tier(command) == CommandTier::Mutation
&& !mutating_proposal
&& proposal.effect.is_read_only()
{
return Some(deny(
proposal,
Some(cap),
DenyReason::MutationNotPermitted,
RecoveryClass::NeedsApproval,
));
}
if !cap.command_scope.is_empty()
&& !cap
.command_scope
.iter()
.any(|p| p.matches(command.program_name()))
{
return Some(deny(
proposal,
Some(cap),
DenyReason::CommandOutOfScope,
RecoveryClass::NeedsApproval,
));
}
}
if let Some(target) = &proposal.network_target {
if !cap.network_scope.iter().any(|p| p.matches(target)) {
return Some(deny(
proposal,
Some(cap),
DenyReason::NetworkOutOfScope,
RecoveryClass::NeedsApproval,
));
}
}
None
}
fn check_state_witnesses(
proposal: &EffectProposal,
cap: &Capability,
state: &KernelState,
) -> Option<AdmissibilityWitness> {
for w in &proposal.preconditions {
match state.witnesses.get(&w.resource) {
Some(current) if current == &w.content_hash => {}
_ => {
return Some(deny(
proposal,
Some(cap),
DenyReason::StateWitnessMismatch,
RecoveryClass::Retryable,
));
}
}
}
None
}
fn finalize_decision(proposal: &EffectProposal, cap: &Capability) -> AdmissibilityWitness {
let risk_ok = cap
.risk_budget
.as_ref()
.map(|b| b.admits(proposal.risk_cost))
.unwrap_or(true);
if !risk_ok {
return deny(
proposal,
Some(cap),
DenyReason::RiskBudgetExhausted,
RecoveryClass::NeedsApproval,
);
}
let decision = match cap.approval_policy {
ApprovalPolicy::Deny => {
return deny(
proposal,
Some(cap),
DenyReason::PolicyDenied,
RecoveryClass::Fatal,
)
}
ApprovalPolicy::Ask => AdmissibilityDecision::NeedsApproval,
ApprovalPolicy::Auto | ApprovalPolicy::SessionApproved => AdmissibilityDecision::Allow,
};
let recovery = match decision {
AdmissibilityDecision::NeedsApproval => Some(RecoveryClass::NeedsApproval),
_ => None,
};
AdmissibilityWitness {
proposal_id: proposal.proposal_id.clone(),
actor: proposal.actor.clone(),
capability_id: Some(cap.capability_id.clone()),
authority_ok: true,
contract_ok: true,
effect_ok: true,
barrier_increment_ok: true,
risk_budget_ok: risk_ok,
decision,
recovery_class: recovery,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::command::canonicalize;
fn actor() -> ActorId {
ActorId::new("implementer")
}
#[test]
fn read_only_actor_cannot_write() {
let caps = vec![Capability::new(
actor(),
vec![EffectKind::ReadFile, EffectKind::Search],
)];
let proposal =
EffectProposal::new(actor(), "n1", EffectKind::WriteArtifact).with_path("src/x.rs");
let w = check_admissibility(&proposal, &caps, &KernelState::new());
assert!(matches!(
w.decision,
AdmissibilityDecision::Deny {
reason: DenyReason::NoCapability
}
));
}
#[test]
fn write_in_scope_is_allowed() {
let caps = vec![
Capability::new(actor(), vec![EffectKind::WriteArtifact]).with_paths(vec!["src/*"])
];
let proposal =
EffectProposal::new(actor(), "n1", EffectKind::WriteArtifact).with_path("src/x.rs");
let w = check_admissibility(&proposal, &caps, &KernelState::new());
assert_eq!(w.decision, AdmissibilityDecision::Allow);
}
#[test]
fn write_out_of_path_scope_is_denied() {
let caps = vec![
Capability::new(actor(), vec![EffectKind::WriteArtifact]).with_paths(vec!["src/*"])
];
let proposal =
EffectProposal::new(actor(), "n1", EffectKind::WriteArtifact).with_path("/etc/passwd");
let w = check_admissibility(&proposal, &caps, &KernelState::new());
assert!(matches!(
w.decision,
AdmissibilityDecision::Deny {
reason: DenyReason::PathOutOfScope
}
));
}
#[test]
fn shell_command_denied_without_shell_capability() {
let mut cap = Capability::new(actor(), vec![EffectKind::RunVerifier]);
cap.command_scope = vec![CommandPattern("*".into())];
let proposal = EffectProposal::new(actor(), "n1", EffectKind::RunVerifier)
.with_command(canonicalize("cat x | grep y", "/r"));
let w = check_admissibility(&proposal, &[cap], &KernelState::new());
assert!(matches!(
w.decision,
AdmissibilityDecision::Deny {
reason: DenyReason::ShellNotPermitted
}
));
}
#[test]
fn sed_in_place_denied_under_read_only_effect() {
let mut cap = Capability::new(actor(), vec![EffectKind::ReadFile]);
cap.command_scope = vec![CommandPattern("*".into())];
let proposal = EffectProposal::new(actor(), "n1", EffectKind::ReadFile)
.with_command(canonicalize("sed -i s/a/b/ f", "/r"));
let w = check_admissibility(&proposal, &[cap], &KernelState::new());
assert!(matches!(
w.decision,
AdmissibilityDecision::Deny {
reason: DenyReason::MutationNotPermitted
}
));
}
#[test]
fn stale_state_witness_is_denied() {
let caps =
vec![Capability::new(actor(), vec![EffectKind::ApplyPatch]).with_paths(vec!["*"])];
let mut proposal =
EffectProposal::new(actor(), "n1", EffectKind::ApplyPatch).with_path("src/x.rs");
proposal.preconditions = vec![StateWitness {
resource: "src/x.rs".into(),
content_hash: "old".into(),
}];
let mut state = KernelState::new();
state.set_witness("src/x.rs", "new"); let w = check_admissibility(&proposal, &caps, &state);
assert!(matches!(
w.decision,
AdmissibilityDecision::Deny {
reason: DenyReason::StateWitnessMismatch
}
));
}
#[test]
fn risk_budget_exhaustion_is_denied() {
let mut cap = Capability::new(actor(), vec![EffectKind::ApplyPatch]).with_paths(vec!["*"]);
cap.risk_budget = Some(RiskBudget {
name: "session".into(),
limit: 1.0,
spent: 0.8,
});
let mut proposal =
EffectProposal::new(actor(), "n1", EffectKind::ApplyPatch).with_path("x");
proposal.risk_cost = 0.5;
let w = check_admissibility(&proposal, &[cap], &KernelState::new());
assert!(matches!(
w.decision,
AdmissibilityDecision::Deny {
reason: DenyReason::RiskBudgetExhausted
}
));
}
#[test]
fn ask_policy_needs_approval() {
let mut cap = Capability::new(actor(), vec![EffectKind::RunShell]).with_paths(vec!["*"]);
cap.approval_policy = ApprovalPolicy::Ask;
cap.command_scope = vec![CommandPattern("*".into())];
let proposal = EffectProposal::new(actor(), "n1", EffectKind::RunShell)
.with_command(canonicalize("echo hi | tee x", "/r"));
let w = check_admissibility(&proposal, &[cap], &KernelState::new());
assert_eq!(w.decision, AdmissibilityDecision::NeedsApproval);
}
#[test]
fn attenuation_only_shrinks_authority() {
let parent = Capability::new(
actor(),
vec![EffectKind::ReadFile, EffectKind::WriteArtifact],
)
.with_paths(vec!["*"])
.delegable();
let mut child = Capability::new(ActorId::new("sub"), vec![EffectKind::ReadFile])
.with_paths(vec!["src/*"]);
child.max_calls = Some(3);
child.parent_capability_id = Some(parent.capability_id.clone());
assert!(child.attenuates(&parent));
assert!(parent.delegate(child).is_some());
let mut bad = Capability::new(ActorId::new("sub"), vec![EffectKind::UpdatePolicy]);
bad.parent_capability_id = Some(parent.capability_id.clone());
assert!(!bad.attenuates(&parent));
assert!(parent.delegate(bad).is_none());
}
#[test]
fn non_delegable_capability_cannot_delegate() {
let parent = Capability::new(actor(), vec![EffectKind::ReadFile]); let child = Capability::new(ActorId::new("sub"), vec![EffectKind::ReadFile]);
assert!(parent.delegate(child).is_none());
}
#[test]
fn payload_cannot_mint_authority() {
let proposal = EffectProposal::new(ActorId::new("ghost"), "n1", EffectKind::UpdatePolicy);
let w = check_admissibility(&proposal, &[], &KernelState::new());
assert!(matches!(
w.decision,
AdmissibilityDecision::Deny {
reason: DenyReason::NoCapability
}
));
}
#[test]
fn persistent_grant_signature_detects_policy_tampering() {
let policy = GrantPolicy {
policy_id: "p1".into(),
workspace_root: "/workspace".into(),
effect_ceiling: vec![EffectKind::ReadFile],
path_ceiling: vec![PathPattern("src/*".into())],
command_ceiling: vec![],
network_ceiling: vec![],
approval_ceiling: ApprovalPolicy::Ask,
authority_epoch: 3,
persistent: true,
integrity_binding: "ledger:abc".into(),
};
let mut signed = SignedGrantPolicy::sign(policy, &[7u8; 32]).unwrap();
let trusted: [u8; 32] = hex_decode(&signed.public_key).unwrap().try_into().unwrap();
signed.verify_against(&trusted).unwrap();
signed.policy.authority_epoch += 1;
assert!(signed.verify_against(&trusted).is_err());
}
#[test]
fn grant_signature_rejects_an_untrusted_signer() {
let policy = GrantPolicy {
policy_id: "p1".into(),
workspace_root: "/workspace".into(),
effect_ceiling: vec![EffectKind::ReadFile],
path_ceiling: vec![],
command_ceiling: vec![],
network_ceiling: vec![],
approval_ceiling: ApprovalPolicy::Ask,
authority_epoch: 0,
persistent: true,
integrity_binding: "ledger:abc".into(),
};
let trusted = SignedGrantPolicy::sign(policy.clone(), &[7u8; 32]).unwrap();
let trusted_key: [u8; 32] = hex_decode(&trusted.public_key).unwrap().try_into().unwrap();
let mut widened = policy;
widened.effect_ceiling.push(EffectKind::RunShell);
let attacker = SignedGrantPolicy::sign(widened, &[9u8; 32]).unwrap();
assert!(attacker.verify_against(&trusted_key).is_err());
}
#[test]
fn no_network_parent_cannot_delegate_network_authority() {
let parent = Capability::new(actor(), vec![EffectKind::NetworkFetch]).delegable();
let mut child = Capability::new(ActorId::new("child"), vec![EffectKind::NetworkFetch]);
child.parent_capability_id = Some(parent.capability_id.clone());
child.role = CapabilityRole::Worker;
child.network_scope = vec![NetworkPattern("*".into())];
assert!(parent.delegate(child).is_none());
}
#[test]
fn privileged_effect_requires_root_session_authority() {
let mut cap = Capability::new(actor(), vec![EffectKind::UpdatePolicy]);
cap.role = CapabilityRole::Worker;
cap.parent_capability_id = Some("parent".into());
let proposal = EffectProposal::new(actor(), "n1", EffectKind::UpdatePolicy);
let w = check_admissibility(&proposal, &[cap], &KernelState::new());
assert!(matches!(
w.decision,
AdmissibilityDecision::Deny {
reason: DenyReason::PrivilegeEscalation
}
));
}
#[test]
fn mint_intersects_the_grant_ceiling_and_fails_closed() {
let policy = GrantPolicy {
policy_id: "p1".into(),
workspace_root: "/workspace".into(),
effect_ceiling: vec![EffectKind::ReadFile, EffectKind::WriteArtifact],
path_ceiling: vec![PathPattern("src/*".into())],
command_ceiling: vec![CommandPattern("rg".into())],
network_ceiling: vec![],
approval_ceiling: ApprovalPolicy::Ask,
authority_epoch: 3,
persistent: false,
integrity_binding: "ledger:abc".into(),
};
let mut requested = Capability::new(
ActorId::new("worker"),
vec![EffectKind::ReadFile, EffectKind::RunShell],
);
requested.role = CapabilityRole::Worker;
requested.path_scope = vec![PathPattern("*".into())];
requested.network_scope = vec![NetworkPattern("*".into())];
let minted = policy.mint(requested).unwrap();
assert_eq!(minted.effects, vec![EffectKind::ReadFile]);
assert_eq!(minted.path_scope, vec![PathPattern("src/*".into())]);
assert!(minted.network_scope.is_empty(), "no-network ceiling holds");
assert_eq!(minted.authority_epoch, 3);
assert_eq!(minted.approval_policy, ApprovalPolicy::Auto);
let mut session = Capability::new(ActorId::new("session"), vec![EffectKind::ReadFile]);
session.role = CapabilityRole::Session;
assert_eq!(
policy.mint(session).unwrap().approval_policy,
ApprovalPolicy::Ask
);
let ghost = Capability::new(ActorId::new("worker"), vec![EffectKind::RunShell]);
assert!(policy.mint(ghost).is_err());
}
#[test]
fn expiry_without_a_now_witness_fails_closed() {
let mut cap = Capability::new(actor(), vec![EffectKind::ReadFile]);
cap.expires_at = Some(1_700_000_000);
let proposal = EffectProposal::new(actor(), "n1", EffectKind::ReadFile);
let w = check_admissibility(&proposal, std::slice::from_ref(&cap), &KernelState::new());
assert!(matches!(
w.decision,
AdmissibilityDecision::Deny {
reason: DenyReason::Expired
}
));
let mut state = KernelState::new();
state.set_witness("__now", "1699999999");
let w = check_admissibility(&proposal, &[cap], &state);
assert_eq!(w.decision, AdmissibilityDecision::Allow);
}
}