diff --git a/Cargo.lock b/Cargo.lock index 5bda36e..291bc05 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1724,6 +1724,7 @@ dependencies = [ "async-trait", "base64 0.21.7", "chrono", + "ed25519-dalek", "hmac 0.12.1", "mtp", "regex", diff --git a/Cargo.toml b/Cargo.toml index bcee5e3..d2ce2c0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,6 +15,7 @@ mtp = { git = "https://git@git.methanium.net/methanium/mtp.git", features = [ ] } tokio = "1" async-trait = "0.1" +ed25519-dalek = "2" url = "2" regex = "1" hmac = "0.12" diff --git a/src/compute.rs b/src/compute.rs new file mode 100644 index 0000000..1104203 --- /dev/null +++ b/src/compute.rs @@ -0,0 +1,219 @@ +use serde::{Deserialize, Serialize}; + +/// Administrator-defined weighted compute admission cost. +/// +/// Units are deliberately not tied to a physical quantity such as CPU cores +/// or GPU count. They are a relative scheduling weight configured for a +/// worker and its workloads. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] +#[serde(transparent)] +pub struct Units(pub u32); + +impl Units { + pub const ZERO: Self = Self(0); + + pub const fn new(value: u32) -> Self { + Self(value) + } + + pub const fn get(self) -> u32 { + self.0 + } + + /// Tokio's weighted semaphore API accepts a `u32` permit count. Keeping + /// this conversion explicit prevents callers from silently truncating a + /// wider configuration value before admission. + pub const fn semaphore_permits(self) -> u32 { + self.0 + } + + pub fn checked_add(self, other: Self) -> Option { + self.0.checked_add(other.0).map(Self) + } + + pub fn checked_sub(self, other: Self) -> Option { + self.0.checked_sub(other.0).map(Self) + } +} + +impl From for u32 { + fn from(units: Units) -> Self { + units.0 + } +} + +impl TryFrom for Units { + type Error = std::num::TryFromIntError; + + fn try_from(value: u64) -> Result { + u32::try_from(value).map(Self) + } +} + +/// A worker's current weighted capacity and work-slot snapshot. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct CapacitySnapshot { + pub total_units: Units, + pub available_units: Units, + pub active_work: u32, + pub max_work: u32, +} + +impl CapacitySnapshot { + pub const fn new(total_units: Units, max_work: u32) -> Self { + Self { + total_units, + available_units: total_units, + active_work: 0, + max_work, + } + } + + /// Returns whether both independent admission guards have room for work. + pub fn can_admit(&self, required_units: Units) -> bool { + self.available_units >= required_units && self.active_work < self.max_work + } + + /// Capacity snapshots are external observations, so reject impossible + /// values before they reach scheduling code. + pub fn validate(&self) -> Result<(), CapacitySnapshotError> { + if self.available_units > self.total_units { + return Err(CapacitySnapshotError::AvailableExceedsTotal); + } + if self.active_work > self.max_work { + return Err(CapacitySnapshotError::ActiveWorkExceedsMaximum); + } + Ok(()) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)] +pub enum CapacitySnapshotError { + #[error("available Units exceed total Units")] + AvailableExceedsTotal, + #[error("active work exceeds the configured maximum")] + ActiveWorkExceedsMaximum, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct PodHardwareSnapshot { + pub gpu_count: u32, + pub gpu_memory_total_mb: Option, + pub gpu_memory_free_mb: Option, + pub system_memory_total_mb: Option, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct QuayHardwareSnapshot { + pub logical_cpu_count: Option, + pub system_memory_total_mb: Option, + pub scratch_total_bytes: Option, + pub scratch_free_bytes: Option, +} + +/// Hard scheduling requirements. This is separate from task execution +/// containment limits such as timeout and memory rlimits. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct ComputeRequirement { + pub units: Units, + pub min_memory_mb: Option, + pub min_gpu_memory_mb: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub struct PodCapacityConfig { + pub total_units: Units, + pub max_agent_sessions: u32, +} + +impl Default for PodCapacityConfig { + fn default() -> Self { + Self { + total_units: Units::ZERO, + max_agent_sessions: 0, + } + } +} + +impl PodCapacityConfig { + pub const fn new(total_units: Units, max_agent_sessions: u32) -> Self { + Self { + total_units, + max_agent_sessions, + } + } + + pub const fn snapshot(self) -> CapacitySnapshot { + CapacitySnapshot::new(self.total_units, self.max_agent_sessions) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub struct QuayCapacityConfig { + pub total_units: Units, + pub max_executions: u32, +} + +impl Default for QuayCapacityConfig { + fn default() -> Self { + Self { + total_units: Units::ZERO, + max_executions: 0, + } + } +} + +impl QuayCapacityConfig { + pub const fn new(total_units: Units, max_executions: u32) -> Self { + Self { + total_units, + max_executions, + } + } + + pub const fn snapshot(self) -> CapacitySnapshot { + CapacitySnapshot::new(self.total_units, self.max_executions) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn units_are_transparent_and_checked() { + let units = Units::new(7); + let encoded = serde_json::to_value(units).expect("Units should serialize"); + assert_eq!(encoded, serde_json::json!(7)); + assert_eq!(Units::try_from(7_u64), Ok(units)); + assert!(Units::try_from(u64::from(u32::MAX) + 1).is_err()); + assert_eq!(units.checked_add(Units::new(2)), Some(Units::new(9))); + assert_eq!(Units::new(2).checked_sub(units), None); + } + + #[test] + fn capacity_uses_units_and_work_as_separate_guards() { + let mut snapshot = CapacitySnapshot::new(Units::new(4), 2); + assert!(snapshot.can_admit(Units::new(4))); + snapshot.active_work = 1; + snapshot.available_units = Units::new(0); + assert!(!snapshot.can_admit(Units::new(1))); + snapshot.available_units = Units::new(4); + snapshot.active_work = 2; + assert!(!snapshot.can_admit(Units::ZERO)); + } + + #[test] + fn capacity_snapshot_rejects_inconsistent_observations() { + let snapshot = CapacitySnapshot { + total_units: Units::new(1), + available_units: Units::new(2), + active_work: 0, + max_work: 1, + }; + assert_eq!( + snapshot.validate(), + Err(CapacitySnapshotError::AvailableExceedsTotal) + ); + } +} diff --git a/src/enums.rs b/src/enums.rs index 48ddcf9..d5b0e7b 100644 --- a/src/enums.rs +++ b/src/enums.rs @@ -1,6 +1,25 @@ use serde::{Deserialize, Serialize}; use std::fmt; +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum InfrastructureKind { + Pod, + Quay, + Reservoir, +} + +impl fmt::Display for InfrastructureKind { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let value = match self { + Self::Pod => "pod", + Self::Quay => "quay", + Self::Reservoir => "reservoir", + }; + f.write_str(value) + } +} + #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum ToDoMode { diff --git a/src/krill.rs b/src/krill.rs index 9fefb7f..0e3988e 100644 --- a/src/krill.rs +++ b/src/krill.rs @@ -2,6 +2,7 @@ use chrono::{DateTime, Utc}; use uuid::Uuid; use crate::enums::ModelType; +use crate::Units; #[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)] pub enum PodProtocolVersion { @@ -16,6 +17,40 @@ impl Default for PodProtocolVersion { pub type KrillId = Uuid; +/// Trusted scheduling metadata for one Krill runtime. +/// +/// This profile is administrator/registry data. It is intentionally separate +/// from `KrillConfig::settings`, which may contain model-specific runtime +/// options and is not a scheduling authority. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub struct KrillRuntimeProfile { + pub compute_units: Units, + pub min_memory_mb: Option, + pub min_gpu_memory_mb: Option, +} + +impl KrillRuntimeProfile { + pub const fn remote() -> Self { + Self { + compute_units: Units::ZERO, + min_memory_mb: None, + min_gpu_memory_mb: None, + } + } + + pub const fn local( + compute_units: Units, + min_memory_mb: Option, + min_gpu_memory_mb: Option, + ) -> Self { + Self { + compute_units, + min_memory_mb, + min_gpu_memory_mb, + } + } +} + #[derive(Debug, Clone)] pub struct KrillDescriptor { pub id: KrillId, @@ -24,6 +59,7 @@ pub struct KrillDescriptor { pub model_type: ModelType, pub model_path: Option, pub config: KrillConfig, + pub runtime_profile: KrillRuntimeProfile, pub version: String, pub created_at: DateTime, } @@ -37,6 +73,7 @@ impl Default for KrillDescriptor { model_type: ModelType::Llm, model_path: None, config: KrillConfig::default(), + runtime_profile: KrillRuntimeProfile::default(), version: "0.1.0".to_string(), created_at: Utc::now(), } @@ -52,6 +89,7 @@ impl KrillDescriptor { model_type, model_path: None, config: KrillConfig::default(), + runtime_profile: KrillRuntimeProfile::default(), version: "0.1.0".to_string(), created_at: Utc::now(), } @@ -72,6 +110,11 @@ impl KrillDescriptor { self } + pub fn with_runtime_profile(mut self, runtime_profile: KrillRuntimeProfile) -> Self { + self.runtime_profile = runtime_profile; + self + } + pub fn with_version(mut self, version: String) -> Self { self.version = version; self @@ -143,4 +186,9 @@ mod tests { assert_eq!(config.get("temperature").unwrap().as_f64(), Some(0.7)); assert_eq!(config.get("max_tokens").unwrap().as_u64(), Some(2048)); } + + #[test] + fn remote_profile_does_not_consume_pod_units() { + assert_eq!(KrillRuntimeProfile::remote().compute_units, Units::ZERO); + } } diff --git a/src/lib.rs b/src/lib.rs index 0e14a5b..1092dd6 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -5,6 +5,7 @@ pub use uuid::Uuid; pub mod agents; pub mod ai_response; +pub mod compute; pub mod conclusion; pub mod coral; pub mod data_retention; @@ -14,6 +15,7 @@ pub mod krill; pub mod mutations; pub mod principal; pub mod project; +pub mod rollback; pub mod sandbox; pub mod sync; pub mod task; @@ -24,6 +26,10 @@ pub use agents::{ AgentPolicy, AgentPromptConfig, AgentType, ApprovalMode, PlannedAction, SimulationResult, }; pub use ai_response::{Conversation, Message}; +pub use compute::{ + CapacitySnapshot, CapacitySnapshotError, ComputeRequirement, PodCapacityConfig, + PodHardwareSnapshot, QuayCapacityConfig, QuayHardwareSnapshot, Units, +}; pub use conclusion::{ Artifact, ArtifactType, ConclusionCard, ConclusionMetrics, ConclusionTrigger, }; @@ -33,29 +39,35 @@ pub use data_retention::{ }; pub use enums::*; pub use errors::{AuditOutcome, AuditRecord, FieldError, PublicError, ShoalError, ValidationError}; -pub use krill::{KrillConfig, KrillDescriptor, KrillId, PodProtocolVersion}; +pub use krill::{KrillConfig, KrillDescriptor, KrillId, KrillRuntimeProfile, PodProtocolVersion}; pub use mutations::{ merged_affected_files, normalize_repository_path, FileMutation, PathValidationError, }; pub use principal::{ - AuthError, AuthenticatedPrincipal, AuthorizationService, DispatchAuthError, - DiscussionCredentialClaims, DiscussionExecutionPrincipal, ExecutionPrincipal, GlobalRole, - ModelGatewayClaims, ModelGatewayPrincipal, PodPrincipal, ProjectAction, ProjectRole, - RequestContext, ReservoirConnectionContext, ReservoirPrincipal, ReservoirServiceRole, - TokenValidationResult, + sign_quay_execution_credential, sign_service_credential, verify_quay_execution_credential, + verify_service_credential, AuthError, AuthenticatedPrincipal, AuthorizationService, + DiscussionCredentialClaims, DiscussionExecutionPrincipal, DispatchAuthError, + ExecutionPrincipal, GlobalRole, ModelGatewayClaims, ModelGatewayPrincipal, PodPrincipal, + ProjectAction, ProjectRole, QuayExecutionClaims, RequestContext, ReservoirConnectionContext, + ReservoirPrincipal, ReservoirServiceRole, ServiceCredentialClaims, ServiceCredentialError, + TokenValidationResult, QUAY_EXECUTION_AUDIENCE, }; -pub use project::{Project, ProjectFile, ProjectId, ProjectSettings}; +pub use project::{Project, ProjectFile, ProjectId, ProjectSettings, ValidationCommand}; +pub use rollback::RollbackReference; +pub use sync::{ChangeSetDescriptor, ChangeSetFile, FileOperationKind}; pub use task::{Task, TaskResult}; pub use todo::{Dependency, ToDo}; pub use tools::{ - compute_tools_hash, format_tool_error, format_tool_result, format_tools_json, is_global_tool, - is_reef_proxy_tool, parse_tool_call_blocks, parse_tool_call_stream, tool_definitions, - tool_ids_for_agent_type, apply_verified_scope, normalize_tool_call, resolve_effective_project, - ApprovalRequirement, CompositionStep, ContextVisibility, EffectiveProject, ExecutionContext, - MarketplaceSearchResults, ParsedToolCall, TestAction, ToolComposition, ToolDefinition, - ToolDependency, ToolDocumentation, ToolExample, ToolExecutor, ToolListing, ToolParser, - ToolPlugin, ToolRegistry, ToolState, ToolTest, ToolTestResult, ToolTestSuite, - ToolTestSuiteResult, ToolVersion, VerifiedToolScope, GLOBAL_TOOLS, REEF_PROXY_TOOLS, + apply_verified_scope, compute_tools_hash, format_tool_error, format_tool_result, + format_tools_json, is_global_tool, normalize_tool_call, parse_tool_call_blocks, + parse_tool_call_stream, resolve_effective_project, tool_definitions, tool_ids_for_agent_type, + validate_tool_definition, validate_tool_definitions, ApprovalRequirement, CompositionStep, + ContextVisibility, EffectiveProject, ExecutionContext, MarketplaceSearchResults, + ParsedToolCall, SandboxAccess, TestAction, ToolComposition, ToolDefinition, + ToolDefinitionError, ToolDependency, ToolDocumentation, ToolExample, ToolExecutionLocation, + ToolExecutor, ToolListing, ToolParser, ToolPlugin, ToolRegistry, ToolState, ToolTest, + ToolTestResult, ToolTestSuite, ToolTestSuiteResult, ToolVersion, VerifiedToolScope, + GLOBAL_TOOLS, }; #[cfg(test)] diff --git a/src/principal.rs b/src/principal.rs index 60064e5..9444eca 100644 --- a/src/principal.rs +++ b/src/principal.rs @@ -190,6 +190,14 @@ pub enum ReservoirPrincipal { service_id: Uuid, role: ReservoirServiceRole, }, + /// A short-lived Reef-issued service capability. Unlike the legacy + /// `Service` variant this principal carries the operation and project + /// scope that Reservoir must enforce for every request. + ScopedService { + service_id: Uuid, + role: ReservoirServiceRole, + claims: ServiceCredentialClaims, + }, } /// Explicit infrastructure roles accepted by Reservoir. Adding a new service @@ -198,6 +206,16 @@ pub enum ReservoirPrincipal { #[serde(rename_all = "snake_case")] pub enum ReservoirServiceRole { ReefStorage, + QuayWorkspace, +} + +impl ReservoirServiceRole { + pub const fn as_str(self) -> &'static str { + match self { + Self::ReefStorage => "reef_storage", + Self::QuayWorkspace => "quay_workspace", + } + } } /// Authoritative result returned when Reef validates a user session token. @@ -373,6 +391,20 @@ pub enum CredentialError { AttemptInactive, /// The requested tool is not in the credential's allow-list. ToolNotAllowed, + /// The credential was issued for another service or control-plane. + AudienceMismatch, + /// The credential was issued by an unexpected authority. + IssuerMismatch, + /// The credential does not grant the requested operation or project. + ScopeDenied, + /// A Quay authorization does not match the durable execution request. + ExecutionMismatch, + /// A Quay authorization does not match the leased workspace. + WorkspaceMismatch, + /// A Quay authorization does not match the trusted tool metadata. + ToolMismatch, + /// A Quay authorization does not match the trusted Unit cost. + UnitsMismatch, } impl std::fmt::Display for CredentialError { @@ -385,12 +417,241 @@ impl std::fmt::Display for CredentialError { Self::PodMismatch => write!(f, "credential not valid for this pod"), Self::AttemptInactive => write!(f, "attempt inactive"), Self::ToolNotAllowed => write!(f, "tool not in credential allow-list"), + Self::AudienceMismatch => write!(f, "credential audience mismatch"), + Self::IssuerMismatch => write!(f, "credential issuer mismatch"), + Self::ScopeDenied => write!(f, "credential scope denied"), + Self::ExecutionMismatch => write!(f, "credential execution mismatch"), + Self::WorkspaceMismatch => write!(f, "credential workspace mismatch"), + Self::ToolMismatch => write!(f, "credential tool mismatch"), + Self::UnitsMismatch => write!(f, "credential Unit requirement mismatch"), } } } impl std::error::Error for CredentialError {} +/// The audience used by Reef-issued execution authorizations delivered to a +/// Quay. Keeping this value in the shared contract prevents a token minted +/// for one service from being accepted by another worker. +pub const QUAY_EXECUTION_AUDIENCE: &str = "quay"; + +/// Claims for exactly one authorized Quay execution. +/// +/// These claims are deliberately narrower than the Pod execution credential. +/// Reef performs user/agent authorization first, then mints this capability +/// for the selected Quay. The Quay must not derive any of these values from +/// model-supplied arguments. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct QuayExecutionClaims { + pub execution_id: Uuid, + pub workspace_lease_id: Uuid, + pub project_id: Uuid, + pub quay_id: Uuid, + pub workspace_id: Uuid, + pub tool_id: String, + pub compute_units: crate::Units, + pub credential_id: Uuid, + pub expires_at: DateTime, +} + +impl QuayExecutionClaims { + /// Validate the parts of a request that are known by the Quay at + /// execution time. The signature, audience, issuer, and expiry are + /// checked by `verify_quay_execution_credential`. + pub fn validate_request( + &self, + execution_id: Uuid, + workspace_lease_id: Uuid, + project_id: Uuid, + quay_id: Uuid, + workspace_id: Uuid, + tool_id: &str, + compute_units: crate::Units, + ) -> Result<(), CredentialError> { + if self.execution_id != execution_id { + return Err(CredentialError::ExecutionMismatch); + } + if self.workspace_lease_id != workspace_lease_id || self.workspace_id != workspace_id { + return Err(CredentialError::WorkspaceMismatch); + } + if self.project_id != project_id || self.quay_id != quay_id { + return Err(CredentialError::ScopeDenied); + } + if self.tool_id != tool_id { + return Err(CredentialError::ToolMismatch); + } + if self.compute_units != compute_units { + return Err(CredentialError::UnitsMismatch); + } + Ok(()) + } +} + +/// A Reef-to-service capability. `operations` contains canonical operation +/// names, never free-form request text. `project_id == None` is reserved for +/// explicitly project-independent service operations. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct ServiceCredentialClaims { + pub issuer: String, + pub audience: String, + pub service_role: String, + pub project_id: Option, + pub operations: Vec, + pub issued_at: DateTime, + pub expires_at: DateTime, + pub credential_id: Uuid, +} + +impl ServiceCredentialClaims { + pub fn allows(&self, operation: &str, project_id: Option, now: DateTime) -> bool { + now >= self.issued_at + && now <= self.expires_at + && self.operations.iter().any(|allowed| allowed == operation) + && (self.project_id.is_none() || self.project_id == project_id) + } +} + +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +pub enum ServiceCredentialError { + #[error("service credential payload could not be serialized")] + Serialization, + #[error("service credential is malformed")] + Malformed, + #[error("service credential signature is invalid")] + InvalidSignature, + #[error("service credential is expired")] + Expired, + #[error("service credential issuer mismatch")] + IssuerMismatch, + #[error("service credential audience mismatch")] + AudienceMismatch, + #[error("service credential scope denied")] + ScopeDenied, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct QuayExecutionEnvelope { + issuer: String, + audience: String, + claims: QuayExecutionClaims, + #[serde(default)] + signature: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct ServiceCredentialEnvelope { + claims: ServiceCredentialClaims, + signature: String, +} + +/// Sign a Quay execution capability with Reef's control-plane Ed25519 key. +/// The returned value is an opaque `base64url(payload).base64url(signature)` +/// token suitable for transport in an MTP response. +pub fn sign_quay_execution_credential( + claims: &QuayExecutionClaims, + issuer: &str, + signing_key: &[u8; 32], +) -> Result { + let payload = serde_json::json!({ + "issuer": issuer, + "audience": QUAY_EXECUTION_AUDIENCE, + "claims": claims, + }); + sign_json_payload(&payload, signing_key) +} + +/// Verify a Quay execution capability and its service audience/issuer. +pub fn verify_quay_execution_credential( + credential: &str, + verifying_key: &[u8; 32], + expected_issuer: &str, +) -> Result { + let payload: QuayExecutionEnvelope = verify_json_payload(credential, verifying_key)?; + if payload.issuer != expected_issuer { + return Err(ServiceCredentialError::IssuerMismatch); + } + if payload.audience != QUAY_EXECUTION_AUDIENCE { + return Err(ServiceCredentialError::AudienceMismatch); + } + if Utc::now() > payload.claims.expires_at { + return Err(ServiceCredentialError::Expired); + } + Ok(payload.claims) +} + +/// Sign a scoped Reef service credential for Reservoir or another service. +pub fn sign_service_credential( + claims: &ServiceCredentialClaims, + signing_key: &[u8; 32], +) -> Result { + let envelope = ServiceCredentialEnvelope { + claims: claims.clone(), + signature: String::new(), + }; + let payload = + serde_json::to_value(envelope).map_err(|_| ServiceCredentialError::Serialization)?; + sign_json_payload(&payload, signing_key) +} + +/// Verify a scoped service credential and its intended service audience. +pub fn verify_service_credential( + credential: &str, + verifying_key: &[u8; 32], + expected_issuer: &str, + expected_audience: &str, +) -> Result { + let envelope: ServiceCredentialEnvelope = verify_json_payload(credential, verifying_key)?; + if envelope.claims.issuer != expected_issuer { + return Err(ServiceCredentialError::IssuerMismatch); + } + if envelope.claims.audience != expected_audience { + return Err(ServiceCredentialError::AudienceMismatch); + } + if Utc::now() > envelope.claims.expires_at { + return Err(ServiceCredentialError::Expired); + } + Ok(envelope.claims) +} + +fn sign_json_payload( + value: &serde_json::Value, + signing_key: &[u8; 32], +) -> Result { + use base64::Engine; + use ed25519_dalek::{Signer, SigningKey}; + + let bytes = serde_json::to_vec(value).map_err(|_| ServiceCredentialError::Serialization)?; + let payload = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes); + let signature = SigningKey::from_bytes(signing_key).sign(payload.as_bytes()); + let signature = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(signature.to_bytes()); + Ok(format!("{payload}.{signature}")) +} + +fn verify_json_payload Deserialize<'de>>( + credential: &str, + verifying_key: &[u8; 32], +) -> Result { + use base64::Engine; + use ed25519_dalek::{Signature, Verifier, VerifyingKey}; + + let (payload, encoded_signature) = credential + .split_once('.') + .ok_or(ServiceCredentialError::Malformed)?; + let signature_bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD + .decode(encoded_signature) + .map_err(|_| ServiceCredentialError::Malformed)?; + let signature = + Signature::from_slice(&signature_bytes).map_err(|_| ServiceCredentialError::Malformed)?; + let key = + VerifyingKey::from_bytes(verifying_key).map_err(|_| ServiceCredentialError::Malformed)?; + key.verify(payload.as_bytes(), &signature) + .map_err(|_| ServiceCredentialError::InvalidSignature)?; + let bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD + .decode(payload) + .map_err(|_| ServiceCredentialError::Malformed)?; + serde_json::from_slice(&bytes).map_err(|_| ServiceCredentialError::Malformed) +} + /// The verifiable claims inside a signed execution credential. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct CredentialClaims { @@ -1520,4 +1781,83 @@ mod tests { let claims = verify_model_gateway_signature(&credential, &secret).unwrap(); assert!(claims.allowed_models.is_empty()); } + + #[test] + fn quay_execution_credential_is_narrow_and_signed() { + let key = [7_u8; 32]; + let verifying_key = ed25519_dalek::SigningKey::from_bytes(&key) + .verifying_key() + .to_bytes(); + let claims = QuayExecutionClaims { + execution_id: Uuid::new_v4(), + workspace_lease_id: Uuid::new_v4(), + project_id: Uuid::new_v4(), + quay_id: Uuid::new_v4(), + workspace_id: Uuid::new_v4(), + tool_id: "bash".to_string(), + compute_units: crate::Units::new(2), + credential_id: Uuid::new_v4(), + expires_at: Utc::now() + chrono::Duration::minutes(5), + }; + let credential = sign_quay_execution_credential(&claims, "reef-test", &key) + .expect("Quay credential should sign"); + let verified = verify_quay_execution_credential(&credential, &verifying_key, "reef-test") + .expect("Quay credential should verify"); + assert_eq!(verified, claims); + claims + .validate_request( + claims.execution_id, + claims.workspace_lease_id, + claims.project_id, + claims.quay_id, + claims.workspace_id, + "bash", + crate::Units::new(2), + ) + .expect("matching request should be accepted"); + assert_eq!( + claims.validate_request( + claims.execution_id, + claims.workspace_lease_id, + claims.project_id, + claims.quay_id, + claims.workspace_id, + "read_file", + crate::Units::new(2), + ), + Err(CredentialError::ToolMismatch) + ); + } + + #[test] + fn service_credential_enforces_audience_and_operation_scope() { + let key = [9_u8; 32]; + let verifying_key = ed25519_dalek::SigningKey::from_bytes(&key) + .verifying_key() + .to_bytes(); + let project_id = Uuid::new_v4(); + let claims = ServiceCredentialClaims { + issuer: "reef-test".to_string(), + audience: "reservoir".to_string(), + service_role: "quay_workspace".to_string(), + project_id: Some(project_id), + operations: vec!["get_project_head".to_string()], + issued_at: Utc::now(), + expires_at: Utc::now() + chrono::Duration::minutes(5), + credential_id: Uuid::new_v4(), + }; + let credential = + sign_service_credential(&claims, &key).expect("service credential should sign"); + let verified = + verify_service_credential(&credential, &verifying_key, "reef-test", "reservoir") + .expect("service credential should verify"); + assert!(verified.allows("get_project_head", Some(project_id), Utc::now())); + assert!(!verified.allows("create_candidate", Some(project_id), Utc::now())); + assert!(!verified.allows("get_project_head", Some(Uuid::new_v4()), Utc::now())); + assert_eq!( + verify_service_credential(&credential, &verifying_key, "reef-test", "other-service",) + .unwrap_err(), + ServiceCredentialError::AudienceMismatch + ); + } } diff --git a/src/project.rs b/src/project.rs index bcb6c6b..83b009d 100644 --- a/src/project.rs +++ b/src/project.rs @@ -2,6 +2,8 @@ use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use uuid::Uuid; +use crate::enums::CompletionPolicy; + pub type ProjectId = Uuid; #[derive(Debug, Clone, Serialize, Deserialize)] @@ -112,10 +114,25 @@ pub struct ProjectSettings { pub run_command: Option, /// Conventional subject/body template used for generated git commits. pub git_commit_template: Option, + /// Publication policy for candidate changesets. + #[serde(default)] + pub completion_policy: CompletionPolicy, + /// Whether a candidate must run the configured deterministic checks. + #[serde(default)] + pub validation_required: bool, + /// Commands run against the exact candidate version before publication. + #[serde(default)] + pub validation_commands: Vec, #[serde(flatten)] pub extra: serde_json::Value, } +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct ValidationCommand { + pub name: String, + pub command: String, +} + impl ProjectSettings { pub fn new() -> Self { Self { @@ -123,6 +140,9 @@ impl ProjectSettings { build_command: None, run_command: None, git_commit_template: None, + completion_policy: CompletionPolicy::default(), + validation_required: false, + validation_commands: Vec::new(), extra: serde_json::json!({}), } } @@ -147,11 +167,63 @@ impl ProjectSettings { self } + pub fn with_completion_policy(mut self, policy: CompletionPolicy) -> Self { + self.completion_policy = policy; + self + } + + pub fn with_validation(mut self, required: bool, commands: Vec) -> Self { + self.validation_required = required; + self.validation_commands = commands; + self + } + pub fn set_extra(&mut self, key: &str, value: serde_json::Value) { + if !self.extra.is_object() { + self.extra = serde_json::json!({}); + } if let Some(obj) = self.extra.as_object_mut() { obj.insert(key.to_string(), value); } } + + pub fn review_state_for(&self, has_mutations: bool) -> &'static str { + if !has_mutations { + return "not_required"; + } + match self.completion_policy { + CompletionPolicy::AutoApply | CompletionPolicy::DocumentationOnly => "not_required", + CompletionPolicy::ApprovalRequired | CompletionPolicy::DryRun => "pending", + } + } + + pub fn validation_state(&self, has_mutations: bool) -> &'static str { + if !has_mutations || !self.validation_required { + return "not_required"; + } + if self.validation_commands.is_empty() + || self + .validation_commands + .iter() + .any(|command| command.name.trim().is_empty() || command.command.trim().is_empty()) + { + "configuration_error" + } else { + "pending" + } + } + + pub fn is_publishable( + &self, + review_state: &str, + validation_state: &str, + publication_state: &str, + ) -> bool { + self.completion_policy != CompletionPolicy::DryRun + && matches!(review_state, "approved" | "not_required") + && matches!(validation_state, "passed" | "not_required") + && matches!(publication_state, "candidate" | "ready") + } } #[cfg(test)] @@ -200,4 +272,49 @@ mod tests { assert_eq!(settings.build_command, Some("cargo build".to_string())); assert_eq!(settings.run_command, Some("cargo run".to_string())); } + + #[test] + fn test_project_publication_settings_roundtrip() { + let settings = ProjectSettings::new() + .with_completion_policy(CompletionPolicy::AutoApply) + .with_validation( + true, + vec![ValidationCommand { + name: "build".to_string(), + command: "cargo check".to_string(), + }], + ); + let encoded = serde_json::to_string(&settings).expect("settings serialize"); + let decoded: ProjectSettings = + serde_json::from_str(&encoded).expect("settings deserialize"); + assert_eq!(decoded.completion_policy, CompletionPolicy::AutoApply); + assert!(decoded.validation_required); + assert_eq!(decoded.validation_commands[0].command, "cargo check"); + } + + #[test] + fn required_validation_without_commands_is_configuration_error() { + let settings = ProjectSettings::new().with_validation(true, Vec::new()); + assert_eq!(settings.validation_state(true), "configuration_error"); + assert!(!settings.is_publishable("not_required", "configuration_error", "candidate")); + } + + #[test] + fn dry_run_never_becomes_publishable() { + let settings = ProjectSettings::new().with_completion_policy(CompletionPolicy::DryRun); + assert_eq!(settings.review_state_for(true), "pending"); + assert!(!settings.is_publishable("approved", "not_required", "candidate")); + } + + #[test] + fn blank_required_validation_command_is_configuration_error() { + let settings = ProjectSettings::new().with_validation( + true, + vec![ValidationCommand { + name: "check".to_string(), + command: " ".to_string(), + }], + ); + assert_eq!(settings.validation_state(true), "configuration_error"); + } } diff --git a/src/rollback.rs b/src/rollback.rs new file mode 100644 index 0000000..dbbbe42 --- /dev/null +++ b/src/rollback.rs @@ -0,0 +1,50 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +/// Opaque reference to a pre-mutation snapshot owned by the workspace +/// executor. Reef stores this reference as workflow metadata; it must never +/// contain a host path or the snapshot bytes. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct RollbackReference { + pub reference_id: Uuid, + pub execution_id: Uuid, + pub workspace_lease_id: Uuid, + pub workspace_id: Uuid, + pub project_id: Uuid, + pub expires_at: DateTime, +} + +impl RollbackReference { + pub fn is_expired(&self, now: DateTime) -> bool { + self.expires_at <= now + } + + pub fn validates_execution(&self, execution_id: Uuid) -> bool { + self.execution_id == execution_id && self.reference_id != Uuid::nil() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn reference() -> RollbackReference { + RollbackReference { + reference_id: Uuid::new_v4(), + execution_id: Uuid::new_v4(), + workspace_lease_id: Uuid::new_v4(), + workspace_id: Uuid::new_v4(), + project_id: Uuid::new_v4(), + expires_at: Utc::now() + chrono::Duration::minutes(1), + } + } + + #[test] + fn reference_is_opaque_and_execution_bound() { + let reference = reference(); + assert!(reference.validates_execution(reference.execution_id)); + assert!(!reference.validates_execution(Uuid::new_v4())); + assert!(!reference.is_expired(Utc::now())); + } +} diff --git a/src/sync.rs b/src/sync.rs index 2083fea..a8845e4 100644 --- a/src/sync.rs +++ b/src/sync.rs @@ -1,4 +1,48 @@ use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +/// The operation applied to one repository-relative regular file. +/// +/// This is deliberately shared by Pod, Reservoir, Reef, and Dock. Git object +/// IDs identify stored objects; the hashes on this protocol model identify +/// file contents and are always SHA-256. +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +pub enum FileOperationKind { + Create, + Modify, + Delete, +} + +impl FileOperationKind { + pub fn as_str(self) -> &'static str { + match self { + Self::Create => "create", + Self::Modify => "modify", + Self::Delete => "delete", + } + } +} + +/// One file in an immutable candidate changeset. Content is fetched from +/// Reservoir at the candidate version instead of being embedded here. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct ChangeSetFile { + pub path: String, + pub operation: FileOperationKind, + pub base_sha256: Option, + pub result_sha256: Option, +} + +/// Metadata shared by candidate registration, review, and synchronization. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct ChangeSetDescriptor { + pub id: Uuid, + pub project_id: Uuid, + pub base_version: Option, + pub candidate_version: String, + pub files: Vec, +} #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] pub enum ConflictResolution { @@ -31,6 +75,43 @@ impl ResolutionStrategy { } } +#[cfg(test)] +mod changeset_tests { + use super::*; + + #[test] + fn changeset_file_uses_wire_operation_names() { + let file = ChangeSetFile { + path: "src/lib.rs".to_string(), + operation: FileOperationKind::Modify, + base_sha256: Some("base".to_string()), + result_sha256: Some("result".to_string()), + }; + let json = serde_json::to_value(file).expect("changeset file serializes"); + assert_eq!(json["operation"], "modify"); + } + + #[test] + fn changeset_descriptor_round_trips() { + let descriptor = ChangeSetDescriptor { + id: Uuid::new_v4(), + project_id: Uuid::new_v4(), + base_version: None, + candidate_version: "candidate".to_string(), + files: vec![ChangeSetFile { + path: "new.txt".to_string(), + operation: FileOperationKind::Create, + base_sha256: None, + result_sha256: Some("sha256".to_string()), + }], + }; + let encoded = serde_json::to_string(&descriptor).expect("descriptor serializes"); + let decoded: ChangeSetDescriptor = + serde_json::from_str(&encoded).expect("descriptor deserializes"); + assert_eq!(decoded, descriptor); + } +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct FileConflict { pub file: String, diff --git a/src/tools.rs b/src/tools.rs index 0fee8a7..720e780 100644 --- a/src/tools.rs +++ b/src/tools.rs @@ -2,7 +2,7 @@ use async_trait::async_trait; use serde::{Deserialize, Serialize}; use uuid::Uuid; -use crate::principal::ProjectAction; +use crate::{principal::ProjectAction, Units}; /// Authoritative execution ownership for every tool. /// Each tool declares exactly one execution location. Connector-krill uses this @@ -10,19 +10,78 @@ use crate::principal::ProjectAction; #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum ToolExecutionLocation { + /// The tool has not been resolved against the canonical registry. + /// This is intentionally the deserialization default: callers must reject + /// it instead of silently executing a legacy or incomplete definition. + Unknown, /// Tool executes exclusively on Reef (the authorization boundary). /// Connector must not locally execute after Reef rejection/failure. Reef, + /// Tool executes on Quay inside the leased project workspace. + Quay, /// Tool executes locally on the connector (e.g. web_search, web_fetch). Local, } impl Default for ToolExecutionLocation { fn default() -> Self { - Self::Local + Self::Unknown } } +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +pub enum ToolDefinitionError { + #[error("tool '{tool_id}' has an unknown execution location")] + UnknownExecutionLocation { tool_id: String }, + #[error("sandbox tool '{tool_id}' must run on Quay")] + SandboxToolMustRunOnQuay { tool_id: String }, + #[error("Quay tool '{tool_id}' must declare sandbox access")] + QuayToolRequiresSandbox { tool_id: String }, +} + +/// Validate the independent ownership and workspace axes of one canonical +/// tool definition. Callers should run this over the complete registry at +/// startup and fail closed if any definition is invalid. +pub fn validate_tool_definition(tool: &ToolDefinition) -> Result<(), ToolDefinitionError> { + if tool.execution_location == ToolExecutionLocation::Unknown { + return Err(ToolDefinitionError::UnknownExecutionLocation { + tool_id: tool.id.clone(), + }); + } + + if tool.sandbox_access != SandboxAccess::None + && tool.execution_location != ToolExecutionLocation::Quay + { + return Err(ToolDefinitionError::SandboxToolMustRunOnQuay { + tool_id: tool.id.clone(), + }); + } + + if tool.execution_location == ToolExecutionLocation::Quay + && tool.sandbox_access == SandboxAccess::None + { + return Err(ToolDefinitionError::QuayToolRequiresSandbox { + tool_id: tool.id.clone(), + }); + } + + Ok(()) +} + +pub fn validate_tool_definitions(tools: &[ToolDefinition]) -> Result<(), ToolDefinitionError> { + tools.iter().try_for_each(validate_tool_definition) +} + +/* Whether a tool needs a leased Quay workspace, and whether it may mutate it. */ +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "snake_case")] +pub enum SandboxAccess { + #[default] + None, + ReadOnly, + ReadWrite, +} + /* Context in which a tool should be available */ #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] @@ -154,17 +213,11 @@ pub fn apply_verified_scope( } if let Some(todo_id) = scope.todo_id { - args.insert( - "todo_id".into(), - serde_json::json!(todo_id.to_string()), - ); + args.insert("todo_id".into(), serde_json::json!(todo_id.to_string())); } if let Some(pod_id) = scope.pod_id { - args.insert( - "pod_id".into(), - serde_json::json!(pod_id.to_string()), - ); + args.insert("pod_id".into(), serde_json::json!(pod_id.to_string())); } } @@ -247,10 +300,7 @@ pub enum EffectiveProject { /// No project source supplied — valid for non-project-scoped tools. None, /// Two or more distinct project UUIDs were supplied and conflict. - Conflicting { - first: Uuid, - second: Uuid, - }, + Conflicting { first: Uuid, second: Uuid }, } /// Resolve a single effective project from the three main sources available @@ -389,12 +439,16 @@ pub struct ToolDefinition { 1+ = only visible to agents with max_depth >= this value. */ #[serde(default)] pub required_depth: u32, - /* Authoritative execution ownership. Every privileged or project-changing - tool declares Reef; tools that execute only locally declare Local. - Connector-krill must never locally execute a Reef-owned tool after - any Reef failure (rejection, auth failure, timeout, connection error). */ + /* Authoritative execution ownership. Project workspace tools declare Quay; + orchestration tools declare Reef; connector-global tools declare Local. */ #[serde(default)] pub execution_location: ToolExecutionLocation, + /// Whether this tool requires and may mutate a Quay workspace. + #[serde(default)] + pub sandbox_access: SandboxAccess, + /// Trusted Quay admission weight for the canonical operation. + #[serde(default)] + pub compute_units: Units, } const fn default_timeout_ms() -> u64 { @@ -420,6 +474,8 @@ impl Default for ToolDefinition { context_visibility: ContextVisibility::default(), required_depth: 0, execution_location: ToolExecutionLocation::default(), + sandbox_access: SandboxAccess::default(), + compute_units: Units::ZERO, } } } @@ -974,6 +1030,8 @@ macro_rules! tool { context_visibility: ContextVisibility::default(), required_depth: 0, execution_location: ToolExecutionLocation::default(), + sandbox_access: SandboxAccess::default(), + compute_units: Units::ZERO, } }; ($name:expr, $desc:expr, $cat:expr, $dangerous:expr, $approval:expr, $scoped:expr, $params:expr, $timeout:expr) => { @@ -994,6 +1052,8 @@ macro_rules! tool { context_visibility: ContextVisibility::default(), required_depth: 0, execution_location: ToolExecutionLocation::default(), + sandbox_access: SandboxAccess::default(), + compute_units: Units::ZERO, } }; } @@ -1198,34 +1258,6 @@ pub const AGENT_ONLY_TOOLS: &[&str] = &[ "kanban_remove_task", ]; -/* Tools that proxy to the Reef API when a `REEF_URL` / `REEF_API_URL` is -configured. These tools have both a local implementation and a Reef-backed -implementation (via Reservoir). When Reef is available the Reef path is -preferred so that file operations go through the Reservoir-backed sandbox; -when Reef is unreachable the local fallback executes instead. - -This is the Krill → Reef proxy set, not the full set of Reef-only tools. -Tools like kanban_*, document_*, and strategic items are always executed on -Reef via MTP and are NOT in this list — they have no local implementation. - -DEPRECATED: Use `ToolDefinition::execution_location` instead. This list is -retained only for backward compatibility and will be removed in a future release. */ -#[deprecated(note = "Use ToolDefinition::execution_location instead")] -pub const REEF_PROXY_TOOLS: &[&str] = &[ - "read_file", - "file_read", - "write_file", - "file_write", - "edit_file", - "list_directory", - "search_files", - "grep", - "bash", - "execute", - "delete_workspace", - "list_files", -]; - /* Tools that operate without a project scope (global). These are always executed locally on the Krill and never need project_id injection. */ pub const GLOBAL_TOOLS: &[&str] = &["web_search", "web_fetch", "web_api"]; @@ -1235,14 +1267,6 @@ pub fn is_global_tool(name: &str) -> bool { GLOBAL_TOOLS.contains(&name) } -/* Returns true if the tool name should proxy to Reef when Reef is available. - -DEPRECATED: Use `ToolDefinition::execution_location` instead. */ -#[deprecated(note = "Use ToolDefinition::execution_location instead")] -pub fn is_reef_proxy_tool(name: &str) -> bool { - REEF_PROXY_TOOLS.contains(&name) -} - /* Returns all built-in tool definitions across all categories. */ pub fn tool_definitions() -> Vec { let mut tools = vec![ @@ -2243,6 +2267,33 @@ pub fn tool_definitions() -> Vec { per-tool metadata. A project-scoped tool without an action causes authorization to fail closed. */ let action_map: std::collections::HashMap<&str, ProjectAction> = [ + // Project filesystem, execution, git and build tools. These must not + // inherit the non-project Read fallback: the project membership check + // is part of their security boundary. + ("read_file", ProjectAction::Read), + ("file_read", ProjectAction::Read), + ("list_directory", ProjectAction::Read), + ("search_files", ProjectAction::Read), + ("grep", ProjectAction::Read), + ("list_files", ProjectAction::Read), + ("git_status", ProjectAction::Read), + ("git_diff", ProjectAction::Read), + ("git_log", ProjectAction::Read), + ("git_branch", ProjectAction::Read), + ("workspace_info", ProjectAction::Read), + ("write_file", ProjectAction::ModifyFiles), + ("file_write", ProjectAction::ModifyFiles), + ("edit_file", ProjectAction::ModifyFiles), + ("bash", ProjectAction::ModifyFiles), + ("execute", ProjectAction::ModifyFiles), + ("delete_workspace", ProjectAction::ModifyFiles), + ("cargo_check", ProjectAction::ModifyFiles), + ("npm_build", ProjectAction::ModifyFiles), + ("python_check", ProjectAction::ModifyFiles), + ("test_runner", ProjectAction::ModifyFiles), + ("create_workspace", ProjectAction::ModifyFiles), + ("create_venv", ProjectAction::ModifyFiles), + ("install_dependencies", ProjectAction::ModifyFiles), // Kanban read ("kanban_list_board", ProjectAction::Read), // Kanban todo mutations @@ -2270,7 +2321,6 @@ pub fn tool_definitions() -> Vec { // Documentation write ("store_file_doc", ProjectAction::ModifyFiles), // File tools - ("list_files", ProjectAction::Read), // Strategic tools (read-only analysis) ("propose_strategic_item", ProjectAction::CreateTodo), ("split_task", ProjectAction::ModifyFiles), @@ -2286,6 +2336,16 @@ pub fn tool_definitions() -> Vec { .cloned() .collect(); + // Keep the scope decision beside the action decision. A tool with a + // project action is necessarily project-scoped; this prevents a newly + // added filesystem/build tool from accidentally being authorized as a + // global read operation. + for tool in tools.iter_mut() { + if action_map.contains_key(tool.id.as_str()) { + tool.project_scoped = true; + } + } + for tool in tools.iter_mut() { if tool.project_scoped { if let Some(&action) = action_map.get(tool.id.as_str()) { @@ -2372,11 +2432,9 @@ pub fn tool_definitions() -> Vec { } } - /* Assign execution_location for every tool. - Reef-owned tools must never fall back to local execution. This metadata - replaces the old REEF_PROXY_TOOLS list and name-prefix routing. */ - let reef_tools: std::collections::HashSet<&str> = [ - // Filesystem (Reef-backed via Reservoir) + /* Assign execution ownership and the independent workspace/capacity + metadata for every canonical tool. */ + let quay_tools: std::collections::HashSet<&str> = [ "read_file", "file_read", "write_file", @@ -2386,25 +2444,27 @@ pub fn tool_definitions() -> Vec { "search_files", "grep", "list_files", - // Execution (Reef-backed) "bash", "execute", - "delete_workspace", - // Git (Reef-backed) "git_status", "git_diff", "git_log", "git_branch", - // Build (Reef-backed) "cargo_check", "npm_build", "python_check", "test_runner", - // Workspace (Reef-backed) "create_workspace", + "delete_workspace", "create_venv", "install_dependencies", "workspace_info", + ] + .iter() + .copied() + .collect(); + + let reef_tools: std::collections::HashSet<&str> = [ // Kanban (always Reef — no valid local fallback) "kanban_list_board", "kanban_create_todo", @@ -2446,13 +2506,34 @@ pub fn tool_definitions() -> Vec { .collect(); for tool in tools.iter_mut() { - if reef_tools.contains(tool.id.as_str()) { + if quay_tools.contains(tool.id.as_str()) { + tool.execution_location = ToolExecutionLocation::Quay; + tool.sandbox_access = match tool.id.as_str() { + "read_file" | "file_read" | "list_directory" | "search_files" | "grep" + | "list_files" | "git_status" | "git_diff" | "git_log" | "git_branch" + | "workspace_info" => SandboxAccess::ReadOnly, + _ => SandboxAccess::ReadWrite, + }; + // The cost is trusted registry metadata, never a model argument. + tool.compute_units = match tool.id.as_str() { + "cargo_check" | "npm_build" | "python_check" | "test_runner" => Units::new(2), + _ => Units::new(1), + }; + } else if reef_tools.contains(tool.id.as_str()) { tool.execution_location = ToolExecutionLocation::Reef; + tool.sandbox_access = SandboxAccess::None; + tool.compute_units = Units::ZERO; } else { tool.execution_location = ToolExecutionLocation::Local; + tool.sandbox_access = SandboxAccess::None; + tool.compute_units = Units::ZERO; } } + if let Err(error) = validate_tool_definitions(&tools) { + panic!("invalid canonical tool definition: {error}"); + } + tools } @@ -3334,10 +3415,11 @@ Done with tools. fn every_tool_has_declared_execution_location() { let tools = tool_definitions(); for tool in &tools { - // Every tool must have either Reef or Local — the enum guarantees this, - // but we verify the field is present and meaningful. + // Unknown is reserved for incomplete/deserialized definitions and + // must never be present in the canonical registry. assert!( tool.execution_location == ToolExecutionLocation::Reef + || tool.execution_location == ToolExecutionLocation::Quay || tool.execution_location == ToolExecutionLocation::Local, "tool '{}' has unclassified execution_location", tool.id @@ -3346,18 +3428,18 @@ Done with tools. } #[test] - fn reef_owned_mutation_tools_are_classified() { + fn quay_owned_workspace_tools_are_classified() { let tools = tool_definitions(); - let reef_tools: Vec<&str> = tools + let quay_tools: Vec<&str> = tools .iter() - .filter(|t| t.execution_location == ToolExecutionLocation::Reef) + .filter(|t| t.execution_location == ToolExecutionLocation::Quay) .map(|t| t.id.as_str()) .collect(); - // Privileged mutation tools must be Reef-owned + // Workspace mutation tools must be Quay-owned. for name in &["write_file", "edit_file", "bash", "delete_workspace"] { assert!( - reef_tools.contains(name), - "privileged tool '{}' must be Reef-owned", + quay_tools.contains(name), + "workspace tool '{}' must be Quay-owned", name ); } @@ -3377,6 +3459,33 @@ Done with tools. } } + #[test] + fn project_execution_tools_have_explicit_actions() { + let tools = tool_definitions(); + for name in [ + "read_file", + "write_file", + "edit_file", + "bash", + "git_status", + "cargo_check", + "npm_build", + "python_check", + "test_runner", + "create_workspace", + "create_venv", + "install_dependencies", + "workspace_info", + ] { + let tool = tools.iter().find(|tool| tool.id == name).unwrap(); + assert!(tool.project_scoped, "{name} must be project-scoped"); + assert!( + tool.required_project_action.is_some(), + "{name} must declare a project action" + ); + } + } + #[test] fn no_tool_is_both_reef_and_local() { let tools = tool_definitions(); @@ -3385,6 +3494,7 @@ Done with tools. // the field is consistently set. assert!( tool.execution_location == ToolExecutionLocation::Reef + || tool.execution_location == ToolExecutionLocation::Quay || tool.execution_location == ToolExecutionLocation::Local, "tool '{}' has invalid execution_location", tool.id @@ -3437,6 +3547,36 @@ Done with tools. } } + #[test] + fn canonical_tool_sandbox_metadata_is_valid() { + let tools = tool_definitions(); + validate_tool_definitions(&tools).expect("canonical tool metadata must be valid"); + + for tool in tools { + if tool.execution_location == ToolExecutionLocation::Quay { + assert_ne!(tool.sandbox_access, SandboxAccess::None); + assert!(tool.compute_units > Units::ZERO); + } else { + assert_eq!(tool.sandbox_access, SandboxAccess::None); + assert_eq!(tool.compute_units, Units::ZERO); + } + } + } + + #[test] + fn sandbox_validation_rejects_wrong_owner() { + let tool = ToolDefinition { + id: "unsafe_reef_tool".to_string(), + execution_location: ToolExecutionLocation::Reef, + sandbox_access: SandboxAccess::ReadOnly, + ..Default::default() + }; + assert!(matches!( + validate_tool_definition(&tool), + Err(ToolDefinitionError::SandboxToolMustRunOnQuay { .. }) + )); + } + #[test] fn apply_verified_scope_normalizes_agent_run_id() { let run_id = Uuid::new_v4(); @@ -3446,14 +3586,8 @@ Done with tools. }; let mut args = serde_json::Map::new(); // Client supplies a conflicting agent_run_id - args.insert( - "agent_run_id".into(), - serde_json::json!("spoofed-value"), - ); - args.insert( - "run_id".into(), - serde_json::json!("spoofed-run"), - ); + args.insert("agent_run_id".into(), serde_json::json!("spoofed-value")); + args.insert("run_id".into(), serde_json::json!("spoofed-run")); apply_verified_scope(&mut args, &scope); @@ -3476,10 +3610,7 @@ Done with tools. ..Default::default() }; let mut args = serde_json::Map::new(); - args.insert( - "project_id".into(), - serde_json::json!("spoofed-project"), - ); + args.insert("project_id".into(), serde_json::json!("spoofed-project")); apply_verified_scope(&mut args, &scope);