Hollidays

This commit is contained in:
Alex 2026-08-12 14:21:20 +02:00
commit 7a139d855f
Signed by: alex
SSH key fingerprint: SHA256:D1+Ub8o0v4K5y1JNivW8IxEOelqLSvPmUzBbDIoZkRQ
5 changed files with 1653 additions and 19 deletions

View file

@ -143,13 +143,9 @@ impl RetentionPolicy {
/// Returns the retention period in days for a given data class.
pub fn retention_days(&self, class: DataClass) -> u32 {
match class {
DataClass::SourceCode | DataClass::Documentation => {
self.documentation_retention_days
}
DataClass::SourceCode | DataClass::Documentation => self.documentation_retention_days,
DataClass::Prompt | DataClass::ModelOutput => 7,
DataClass::ToolArguments | DataClass::ToolResult => {
self.execution_log_retention_days
}
DataClass::ToolArguments | DataClass::ToolResult => self.execution_log_retention_days,
DataClass::ApprovalData | DataClass::AgentRun => self.run_retention_days,
DataClass::Credentials => 0, // never retained
DataClass::MutationContent => self.run_retention_days,
@ -258,8 +254,17 @@ fn redact_json_secrets(value: &serde_json::Value) -> serde_json::Value {
fn is_sensitive_json_field(name: &str) -> bool {
matches!(
name.to_lowercase().as_str(),
"password" | "secret" | "api_key" | "apikey" | "token" | "authorization"
| "private_key" | "credentials" | "env" | "environment" | "bearer"
"password"
| "secret"
| "api_key"
| "apikey"
| "token"
| "authorization"
| "private_key"
| "credentials"
| "env"
| "environment"
| "bearer"
)
}

View file

@ -1,4 +1,74 @@
use serde::Serialize;
use thiserror::Error;
use uuid::Uuid;
// ---------------------------------------------------------------------------
// Public error envelope (Section 21)
// ---------------------------------------------------------------------------
/// A structured error returned to API callers. Internal details are never
/// exposed; they are logged on the server with the same `correlation_id`.
#[derive(Debug, Clone, Serialize)]
pub struct PublicError {
/// Stable machine-readable code (e.g. `"unauthorized"`, `"not_found"`).
pub code: &'static str,
/// Human-safe message. No internal paths, SQL, tokens, or stack traces.
pub message: String,
/// Correlation ID that links client-facing error to server logs.
pub correlation_id: Uuid,
/// Whether retrying the same request may succeed.
pub retryable: bool,
/// Optional per-field validation errors for 400-class responses.
pub field_errors: Vec<FieldError>,
}
/// A per-field validation error within a `PublicError`.
#[derive(Debug, Clone, Serialize)]
pub struct FieldError {
pub field: String,
pub code: &'static str,
pub message: String,
}
/// A durable audit record that captures who did what to which resource.
#[derive(Debug, Clone, Serialize)]
pub struct AuditRecord {
pub id: Uuid,
pub correlation_id: Uuid,
pub principal_id: Uuid,
pub project_id: Option<Uuid>,
pub action: String,
pub resource: String,
pub outcome: AuditOutcome,
pub details: serde_json::Value,
pub created_at: chrono::DateTime<chrono::Utc>,
}
/// The outcome of an audited operation.
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum AuditOutcome {
Success,
Denied,
Failure,
}
/// Errors that can arise from the authorization and audit layer.
#[derive(Debug, Clone, Error)]
pub enum AuthorizationError {
#[error("authentication required")]
AuthenticationRequired,
#[error("not authorized: {0}")]
Denied(String),
#[error("authorization database error: {0}")]
DatabaseError(String),
#[error("project not found")]
ProjectNotFound,
}
// ---------------------------------------------------------------------------
// Existing types (unchanged)
// ---------------------------------------------------------------------------
#[derive(Debug, Clone, Error)]
pub enum ValidationError {

View file

@ -32,21 +32,30 @@ pub use data_retention::{
redact_secrets, truncate_bytes, DataClass, DataRetentionService, RetentionPolicy,
};
pub use enums::*;
pub use errors::{ShoalError, ValidationError};
pub use errors::{AuditOutcome, AuditRecord, FieldError, PublicError, ShoalError, ValidationError};
pub use krill::{KrillConfig, KrillDescriptor, KrillId, PodProtocolVersion};
pub use mutations::{FileMutation, PathValidationError, merged_affected_files, normalize_repository_path};
pub use principal::ExecutionPrincipal;
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,
};
pub use project::{Project, ProjectFile, ProjectId, ProjectSettings};
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, ApprovalRequirement, CompositionStep, ContextVisibility,
ExecutionContext, MarketplaceSearchResults, ParsedToolCall, TestAction, ToolComposition,
ToolDefinition, ToolDependency, ToolDocumentation, ToolExample, ToolExecutor, ToolListing,
ToolParser, ToolPlugin, ToolRegistry, ToolState, ToolTest, ToolTestResult, ToolTestSuite,
ToolTestSuiteResult, ToolVersion, GLOBAL_TOOLS, REEF_PROXY_TOOLS,
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,
};
#[cfg(test)]

View file

@ -1,9 +1,361 @@
use std::collections::HashSet;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use crate::agents::AgentType;
// ---------------------------------------------------------------------------
// Unified request principal and project RBAC (Section 9)
// ---------------------------------------------------------------------------
/// Global user roles that apply across all projects.
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)]
#[serde(rename_all = "snake_case")]
pub enum GlobalRole {
User,
Admin,
}
impl GlobalRole {
pub fn is_admin(&self) -> bool {
matches!(self, GlobalRole::Admin)
}
}
/// Project-level roles that determine what a member can do within a project.
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash, PartialOrd, Ord)]
#[serde(rename_all = "snake_case")]
pub enum ProjectRole {
Viewer,
Contributor,
Reviewer,
Operator,
Owner,
}
impl ProjectRole {
/// Returns the minimum role required for the given action.
pub fn required_for(action: ProjectAction) -> Self {
match action {
ProjectAction::Read => ProjectRole::Viewer,
ProjectAction::CreateTodo => ProjectRole::Contributor,
ProjectAction::ModifyFiles => ProjectRole::Contributor,
ProjectAction::ReviewChanges => ProjectRole::Reviewer,
ProjectAction::OperateAgents => ProjectRole::Operator,
ProjectAction::ManageMembers => ProjectRole::Owner,
ProjectAction::DeleteProject => ProjectRole::Owner,
}
}
}
/// Actions that can be performed on a project.
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum ProjectAction {
Read,
CreateTodo,
ModifyFiles,
ReviewChanges,
OperateAgents,
ManageMembers,
DeleteProject,
}
/// An authenticated principal that has proven identity at the transport boundary.
///
/// Every transport adapter (HTTP, MTP) must construct one of these variants
/// from authenticated connection state. Handlers and application services
/// receive this type instead of raw UUIDs.
#[derive(Debug, Clone)]
pub enum AuthenticatedPrincipal {
User {
user_id: Uuid,
global_role: GlobalRole,
},
Pod(PodPrincipal),
Execution(ExecutionPrincipal),
Service {
service_id: Uuid,
},
System {
component: &'static str,
},
}
impl AuthenticatedPrincipal {
pub fn user_id(&self) -> Option<Uuid> {
match self {
AuthenticatedPrincipal::User { user_id, .. } => Some(*user_id),
_ => None,
}
}
pub fn pod_id(&self) -> Option<Uuid> {
match self {
AuthenticatedPrincipal::Pod(pod) => Some(pod.pod_id),
_ => None,
}
}
pub fn is_admin(&self) -> bool {
match self {
AuthenticatedPrincipal::User { global_role, .. } => global_role.is_admin(),
_ => false,
}
}
pub fn is_internal_service(&self) -> bool {
matches!(
self,
AuthenticatedPrincipal::Service { .. } | AuthenticatedPrincipal::System { .. }
)
}
pub fn id(&self) -> Uuid {
match self {
AuthenticatedPrincipal::User { user_id, .. } => *user_id,
AuthenticatedPrincipal::Pod(pod) => pod.pod_id,
AuthenticatedPrincipal::Execution(exec) => Uuid::from_u128(exec.run_id.as_u128()),
AuthenticatedPrincipal::Service { service_id } => *service_id,
AuthenticatedPrincipal::System { .. } => Uuid::nil(),
}
}
/// Returns true if this principal can operate on the given project based
/// on the project role. Administrators bypass project-level checks.
pub fn can_operate_project(&self, project_role: Option<ProjectRole>) -> bool {
if self.is_admin() {
return true;
}
project_role
.map(|r| r >= ProjectRole::Operator)
.unwrap_or(false)
}
}
/// A Pod identity established through cryptographic challenge-response.
#[derive(Debug, Clone)]
pub struct PodPrincipal {
pub pod_id: Uuid,
pub device_id: Uuid,
pub key_fingerprint: String,
pub approved_at: DateTime<Utc>,
}
/// A transport-level request context that carries the authenticated principal
/// and correlation metadata through the application layer.
#[derive(Debug, Clone)]
pub struct RequestContext {
pub principal: AuthenticatedPrincipal,
pub correlation_id: Uuid,
pub authenticated_at: DateTime<Utc>,
}
impl RequestContext {
pub fn new(principal: AuthenticatedPrincipal) -> Self {
Self {
principal,
correlation_id: Uuid::new_v4(),
authenticated_at: Utc::now(),
}
}
pub fn with_correlation_id(principal: AuthenticatedPrincipal, correlation_id: Uuid) -> Self {
Self {
principal,
correlation_id,
authenticated_at: Utc::now(),
}
}
}
// ---------------------------------------------------------------------------
// Reservoir principal types (Section 1)
// ---------------------------------------------------------------------------
/// A principal that can authenticate to Reservoir.
#[derive(Debug, Clone)]
pub enum ReservoirPrincipal {
User {
user_id: Uuid,
global_role: GlobalRole,
},
Pod {
pod_id: Uuid,
},
Execution(ExecutionPrincipal),
Service {
service_id: Uuid,
role: ReservoirServiceRole,
},
}
/// Explicit infrastructure roles accepted by Reservoir. Adding a new service
/// does not implicitly grant it Reef's storage authority.
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum ReservoirServiceRole {
ReefStorage,
}
/// Authoritative result returned when Reef validates a user session token.
///
/// Consumers must use `expires_at` as the upper bound for any derived
/// connection authentication rather than granting a new local lifetime.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct TokenValidationResult {
pub valid: bool,
pub user_id: Option<Uuid>,
pub global_role: Option<GlobalRole>,
pub expires_at: Option<DateTime<Utc>>,
pub session_id: Option<Uuid>,
}
/// Connection-level context for Reservoir MTP connections.
///
/// Starts unauthenticated and is populated after an `AuthRequest` exchange.
/// The dispatcher must check `principal.is_some()` before accepting any
/// storage request.
#[derive(Debug, Clone)]
pub struct ReservoirConnectionContext {
pub principal: Option<ReservoirPrincipal>,
pub authenticated_until: Option<DateTime<Utc>>,
pub session_id: Option<Uuid>,
pub correlation_id: Uuid,
/// The user's access token, retained for remote role resolution.
access_token: Option<String>,
}
impl ReservoirConnectionContext {
pub fn new() -> Self {
Self {
principal: None,
authenticated_until: None,
session_id: None,
correlation_id: Uuid::new_v4(),
access_token: None,
}
}
pub fn is_authenticated(&self) -> bool {
if let Some(expiry) = self.authenticated_until {
self.principal.is_some() && Utc::now() < expiry
} else {
false
}
}
pub fn require_authenticated(&self) -> Result<&ReservoirPrincipal, AuthError> {
if self.is_authenticated() {
Ok(self.principal.as_ref().expect("checked above"))
} else {
Err(AuthError::AuthenticationRequired)
}
}
/// Authenticate a connection until an authoritative absolute deadline.
pub fn authenticate_until(
&mut self,
principal: ReservoirPrincipal,
expires_at: DateTime<Utc>,
session_id: Option<Uuid>,
) {
self.principal = Some(principal);
self.authenticated_until = Some(expires_at);
self.session_id = session_id;
}
/// Set the access token for remote role resolution.
pub fn set_access_token(&mut self, token: String) {
self.access_token = Some(token);
}
/// Get the access token, if available.
pub fn access_token(&self) -> Option<&str> {
self.access_token.as_deref()
}
}
// ---------------------------------------------------------------------------
// Authorization errors
// ---------------------------------------------------------------------------
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum AuthError {
AuthenticationRequired,
AuthorizationDenied(String),
Expired,
InvalidPrincipal,
/// Pod must complete challenge-response before accessing protected operations.
PodAuthenticationRequired,
/// A challenge-response handshake is required to complete Pod registration.
ChallengeRequired,
}
impl std::fmt::Display for AuthError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
AuthError::AuthenticationRequired => write!(f, "authentication required"),
AuthError::AuthorizationDenied(msg) => write!(f, "authorization denied: {}", msg),
AuthError::Expired => write!(f, "credential expired"),
AuthError::InvalidPrincipal => write!(f, "invalid principal"),
AuthError::PodAuthenticationRequired => write!(f, "pod authentication required"),
AuthError::ChallengeRequired => write!(f, "challenge-response required"),
}
}
}
impl std::error::Error for AuthError {}
/// Errors for dispatch ownership checks (Section 5).
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum DispatchAuthError {
NotFound,
WrongPod,
LeaseExpired,
InvalidState,
}
impl std::fmt::Display for DispatchAuthError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
DispatchAuthError::NotFound => write!(f, "dispatch not found"),
DispatchAuthError::WrongPod => write!(f, "dispatch assigned to different pod"),
DispatchAuthError::LeaseExpired => write!(f, "dispatch lease expired"),
DispatchAuthError::InvalidState => write!(f, "dispatch in invalid state"),
}
}
}
impl std::error::Error for DispatchAuthError {}
// ---------------------------------------------------------------------------
// Authorization service trait (Section 9)
// ---------------------------------------------------------------------------
/// A project authorization service. Implementations check whether an
/// authenticated principal has the required role for an action on a project.
///
/// The same implementation must be used by HTTP and MTP handlers to ensure
/// consistent authorization behavior across transports.
pub trait AuthorizationService: Send + Sync {
/// Check whether `context.principal` may perform `action` on `project_id`.
///
/// Returns the resolved project role on success, or an `AuthError` on
/// denial. Database errors must also map to denial (fail-closed).
fn authorize_project(
&self,
context: &RequestContext,
project_id: Uuid,
action: ProjectAction,
) -> Result<ProjectRole, AuthError>;
/// Resolve the set of project IDs visible to the given principal.
fn visible_projects(&self, principal: &AuthenticatedPrincipal) -> HashSet<Uuid>;
}
/// Errors that can occur during credential verification.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum CredentialError {
@ -19,6 +371,8 @@ pub enum CredentialError {
PodMismatch,
/// The attempt is no longer active (completed, cancelled, or not found).
AttemptInactive,
/// The requested tool is not in the credential's allow-list.
ToolNotAllowed,
}
impl std::fmt::Display for CredentialError {
@ -30,6 +384,7 @@ impl std::fmt::Display for CredentialError {
Self::Revoked => write!(f, "credential revoked"),
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"),
}
}
}
@ -221,7 +576,8 @@ pub fn sign_credential(
use sha2::Sha256;
let claims = principal.to_claims();
let payload_json = serde_json::to_vec(&claims).map_err(|_| CredentialError::MalformedPayload)?;
let payload_json =
serde_json::to_vec(&claims).map_err(|_| CredentialError::MalformedPayload)?;
let payload_b64 = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(&payload_json);
let mut mac =
@ -322,10 +678,345 @@ impl From<CredentialClaims> for ExecutionPrincipal {
}
}
// ---------------------------------------------------------------------------
// Discussion execution credentials (Task 4)
// ---------------------------------------------------------------------------
/// The verifiable claims inside a signed discussion execution credential.
///
/// Discussion credentials carry chat/project context instead of task/run context,
/// binding the discussion agent to a specific chat session and project scope.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DiscussionCredentialClaims {
/// The user who initiated the discussion.
pub user_id: Uuid,
/// The chat session this credential is scoped to.
pub discuss_id: Uuid,
/// The project this discussion can access (None for non-project chats).
pub project_id: Option<Uuid>,
/// The pod this credential is bound to.
pub pod_id: Uuid,
/// Tools this principal is authorized to invoke.
pub allowed_tools: Vec<String>,
/// Absolute deadline after which this principal is invalid.
pub expires_at: DateTime<Utc>,
/// When this credential was minted.
pub issued_at: DateTime<Utc>,
/// Unique credential identifier for revocation tracking.
pub credential_id: Uuid,
}
/// A cryptographically bounded execution identity for discussion agents.
///
/// Minted by Reef when dispatching a discuss turn to a Pod, this principal
/// carries the chat/project scope needed for tool authorization without
/// reusing task-agent fields.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DiscussionExecutionPrincipal {
pub user_id: Uuid,
pub discuss_id: Uuid,
pub project_id: Option<Uuid>,
pub pod_id: Uuid,
pub allowed_tools: Vec<String>,
pub expires_at: DateTime<Utc>,
pub issued_at: DateTime<Utc>,
pub credential_id: Uuid,
}
impl DiscussionExecutionPrincipal {
/// Mint a new discussion principal for a chat turn dispatch.
pub fn mint(
user_id: Uuid,
discuss_id: Uuid,
project_id: Option<Uuid>,
pod_id: Uuid,
allowed_tools: Vec<String>,
timeout_secs: u64,
) -> Self {
let now = Utc::now();
Self {
user_id,
discuss_id,
project_id,
pod_id,
allowed_tools,
expires_at: now + chrono::Duration::seconds(timeout_secs as i64),
issued_at: now,
credential_id: Uuid::new_v4(),
}
}
/// Convert into `DiscussionCredentialClaims` suitable for signing.
pub fn to_claims(&self) -> DiscussionCredentialClaims {
DiscussionCredentialClaims {
user_id: self.user_id,
discuss_id: self.discuss_id,
project_id: self.project_id,
pod_id: self.pod_id,
allowed_tools: self.allowed_tools.clone(),
expires_at: self.expires_at,
issued_at: self.issued_at,
credential_id: self.credential_id,
}
}
/// Check whether this principal is still valid at the given timestamp.
pub fn is_valid_at(&self, now: DateTime<Utc>) -> bool {
now <= self.expires_at
}
}
/// Sign a discussion principal's claims with HMAC-SHA256.
pub fn sign_discussion_credential(
principal: &DiscussionExecutionPrincipal,
secret: &[u8],
) -> Result<String, CredentialError> {
use base64::Engine;
use hmac::{Hmac, Mac};
use sha2::Sha256;
let claims = principal.to_claims();
let payload_json =
serde_json::to_vec(&claims).map_err(|_| CredentialError::MalformedPayload)?;
let payload_b64 = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(&payload_json);
let mut mac =
Hmac::<Sha256>::new_from_slice(secret).map_err(|_| CredentialError::MalformedPayload)?;
mac.update(payload_b64.as_bytes());
let signature = mac.finalize().into_bytes();
let sig_b64 = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(signature);
Ok(format!("{}.{}", payload_b64, sig_b64))
}
/// Verify the cryptographic signature and expiry of a signed discussion credential.
///
/// Returns the verified discussion claims on success. The caller must still
/// perform stateful authorization (chat existence, project access, revocation).
pub fn verify_discussion_signature(
credential: &str,
secret: &[u8],
) -> Result<DiscussionCredentialClaims, CredentialError> {
use base64::Engine;
use hmac::{Hmac, Mac};
use sha2::Sha256;
let (payload_b64, sig_b64) =
SignedCredential::parse(credential).ok_or(CredentialError::MalformedPayload)?;
let payload_bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD
.decode(payload_b64)
.map_err(|_| CredentialError::MalformedPayload)?;
let claims: DiscussionCredentialClaims =
serde_json::from_slice(&payload_bytes).map_err(|_| CredentialError::MalformedPayload)?;
let mut mac =
Hmac::<Sha256>::new_from_slice(secret).map_err(|_| CredentialError::MalformedPayload)?;
mac.update(payload_b64.as_bytes());
let sig_bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD
.decode(sig_b64)
.map_err(|_| CredentialError::MalformedPayload)?;
mac.verify_slice(&sig_bytes)
.map_err(|_| CredentialError::InvalidSignature)?;
if Utc::now() > claims.expires_at {
return Err(CredentialError::Expired);
}
Ok(claims)
}
// ---------------------------------------------------------------------------
// Model gateway credentials (Release Blockers 5, Task 2)
// ---------------------------------------------------------------------------
/// The verifiable claims inside a signed model gateway credential.
///
/// A model gateway credential is a short-lived, attempt-scoped capability that
/// authorizes a sandboxed connector-krill process to make model-provider API
/// calls through the Pod-local gateway. The credential binds to a specific
/// attempt, pod, and provider, preventing cross-attempt or cross-pod abuse.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ModelGatewayClaims {
/// Unique credential identifier for revocation tracking.
pub credential_id: Uuid,
/// The attempt this credential is scoped to.
pub attempt_id: Uuid,
/// The pod this credential is bound to.
pub pod_id: Uuid,
/// The model provider this credential grants access to (e.g. "openai", "anthropic").
pub provider: String,
/// Models this credential is allowed to access. Empty means all models for the provider.
pub allowed_models: Vec<String>,
/// Absolute deadline after which this credential is invalid.
pub expires_at: DateTime<Utc>,
/// When this credential was minted.
pub issued_at: DateTime<Utc>,
}
/// A cryptographically bounded model-provider access identity.
///
/// Minted by the Pod when dispatching an attempt, this principal carries the
/// attempt/pod/provider scope needed for model gateway authorization without
/// exposing the actual provider API key to the sandbox.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ModelGatewayPrincipal {
pub attempt_id: Uuid,
pub pod_id: Uuid,
pub provider: String,
pub allowed_models: Vec<String>,
pub expires_at: DateTime<Utc>,
pub issued_at: DateTime<Utc>,
pub credential_id: Uuid,
}
impl ModelGatewayPrincipal {
/// Mint a new model gateway principal for an attempt.
pub fn mint(
attempt_id: Uuid,
pod_id: Uuid,
provider: String,
allowed_models: Vec<String>,
timeout_secs: u64,
) -> Self {
let now = Utc::now();
Self {
attempt_id,
pod_id,
provider,
allowed_models,
expires_at: now + chrono::Duration::seconds(timeout_secs as i64),
issued_at: now,
credential_id: Uuid::new_v4(),
}
}
/// Convert into `ModelGatewayClaims` suitable for signing.
pub fn to_claims(&self) -> ModelGatewayClaims {
ModelGatewayClaims {
credential_id: self.credential_id,
attempt_id: self.attempt_id,
pod_id: self.pod_id,
provider: self.provider.clone(),
allowed_models: self.allowed_models.clone(),
expires_at: self.expires_at,
issued_at: self.issued_at,
}
}
/// Check whether this principal is still valid at the given timestamp.
pub fn is_valid_at(&self, now: DateTime<Utc>) -> bool {
now <= self.expires_at
}
}
/// Sign a model gateway principal's claims with HMAC-SHA256.
///
/// The shared secret is held only by the Pod; connector-krill receives the
/// credential string and the Pod validates it at the gateway boundary.
pub fn sign_model_gateway_credential(
principal: &ModelGatewayPrincipal,
secret: &[u8],
) -> Result<String, CredentialError> {
use base64::Engine;
use hmac::{Hmac, Mac};
use sha2::Sha256;
let claims = principal.to_claims();
let payload_json =
serde_json::to_vec(&claims).map_err(|_| CredentialError::MalformedPayload)?;
let payload_b64 = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(&payload_json);
let mut mac =
Hmac::<Sha256>::new_from_slice(secret).map_err(|_| CredentialError::MalformedPayload)?;
mac.update(payload_b64.as_bytes());
let signature = mac.finalize().into_bytes();
let sig_b64 = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(signature);
Ok(format!("{}.{}", payload_b64, sig_b64))
}
/// Verify the cryptographic signature and expiry of a signed model gateway credential.
///
/// Returns the verified claims on success. The caller must still perform
/// stateful authorization (attempt active, pod binding) after this returns `Ok`.
pub fn verify_model_gateway_signature(
credential: &str,
secret: &[u8],
) -> Result<ModelGatewayClaims, CredentialError> {
use base64::Engine;
use hmac::{Hmac, Mac};
use sha2::Sha256;
let (payload_b64, sig_b64) =
SignedCredential::parse(credential).ok_or(CredentialError::MalformedPayload)?;
let payload_bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD
.decode(payload_b64)
.map_err(|_| CredentialError::MalformedPayload)?;
let claims: ModelGatewayClaims =
serde_json::from_slice(&payload_bytes).map_err(|_| CredentialError::MalformedPayload)?;
let mut mac =
Hmac::<Sha256>::new_from_slice(secret).map_err(|_| CredentialError::MalformedPayload)?;
mac.update(payload_b64.as_bytes());
let sig_bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD
.decode(sig_b64)
.map_err(|_| CredentialError::MalformedPayload)?;
mac.verify_slice(&sig_bytes)
.map_err(|_| CredentialError::InvalidSignature)?;
if Utc::now() > claims.expires_at {
return Err(CredentialError::Expired);
}
Ok(claims)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn reservoir_connection_uses_authoritative_expiry() {
let user_id = Uuid::new_v4();
let session_id = Uuid::new_v4();
let expires_at = Utc::now() + chrono::Duration::minutes(5);
let mut context = ReservoirConnectionContext::new();
context.authenticate_until(
ReservoirPrincipal::User {
user_id,
global_role: GlobalRole::User,
},
expires_at,
Some(session_id),
);
assert!(context.is_authenticated());
assert_eq!(context.authenticated_until, Some(expires_at));
assert_eq!(context.session_id, Some(session_id));
}
#[test]
fn reservoir_connection_rejects_expired_session() {
let mut context = ReservoirConnectionContext::new();
context.authenticate_until(
ReservoirPrincipal::User {
user_id: Uuid::new_v4(),
global_role: GlobalRole::User,
},
Utc::now() - chrono::Duration::seconds(1),
Some(Uuid::new_v4()),
);
assert!(!context.is_authenticated());
assert_eq!(
context.require_authenticated().unwrap_err(),
AuthError::AuthenticationRequired
);
}
fn test_secret() -> Vec<u8> {
b"test-secret-key-for-hmac-signing-operations".to_vec()
}
@ -604,4 +1295,229 @@ mod tests {
.decode(sig)
.is_ok());
}
// ── Discussion credential tests ─────────────────────────────────
#[test]
fn test_mint_discussion_principal() {
let user_id = Uuid::new_v4();
let discuss_id = Uuid::new_v4();
let project_id = Some(Uuid::new_v4());
let pod_id = Uuid::new_v4();
let principal = DiscussionExecutionPrincipal::mint(
user_id,
discuss_id,
project_id,
pod_id,
vec!["read_file".to_string(), "kanban_list_board".to_string()],
300,
);
assert_eq!(principal.user_id, user_id);
assert_eq!(principal.discuss_id, discuss_id);
assert_eq!(principal.project_id, project_id);
assert_eq!(principal.pod_id, pod_id);
assert!(principal.is_valid_at(Utc::now()));
}
#[test]
fn test_sign_and_verify_discussion_credential() {
let secret = test_secret();
let user_id = Uuid::new_v4();
let discuss_id = Uuid::new_v4();
let project_id = Some(Uuid::new_v4());
let pod_id = Uuid::new_v4();
let principal = DiscussionExecutionPrincipal::mint(
user_id,
discuss_id,
project_id,
pod_id,
vec!["read_file".to_string()],
300,
);
let credential = sign_discussion_credential(&principal, &secret).unwrap();
let claims = verify_discussion_signature(&credential, &secret).unwrap();
assert_eq!(claims.user_id, user_id);
assert_eq!(claims.discuss_id, discuss_id);
assert_eq!(claims.project_id, project_id);
assert_eq!(claims.pod_id, pod_id);
assert_eq!(claims.allowed_tools, vec!["read_file".to_string()]);
}
#[test]
fn test_discussion_credential_rejects_wrong_signature() {
let secret = test_secret();
let wrong_secret = b"wrong-secret-key-for-hmac-signing-operations";
let principal = DiscussionExecutionPrincipal::mint(
Uuid::new_v4(),
Uuid::new_v4(),
Some(Uuid::new_v4()),
Uuid::new_v4(),
vec![],
300,
);
let credential = sign_discussion_credential(&principal, &secret).unwrap();
let result = verify_discussion_signature(&credential, wrong_secret);
assert_eq!(result.unwrap_err(), CredentialError::InvalidSignature);
}
#[test]
fn test_discussion_credential_rejects_expired() {
let secret = test_secret();
let mut principal = DiscussionExecutionPrincipal::mint(
Uuid::new_v4(),
Uuid::new_v4(),
Some(Uuid::new_v4()),
Uuid::new_v4(),
vec![],
300,
);
// Force expiry
principal.expires_at = Utc::now() - chrono::Duration::hours(1);
let credential = sign_discussion_credential(&principal, &secret).unwrap();
let result = verify_discussion_signature(&credential, &secret);
assert_eq!(result.unwrap_err(), CredentialError::Expired);
}
#[test]
fn test_discussion_credential_format_is_two_base64_parts() {
let secret = test_secret();
let principal = DiscussionExecutionPrincipal::mint(
Uuid::new_v4(),
Uuid::new_v4(),
None,
Uuid::new_v4(),
vec!["web_search".to_string()],
300,
);
let credential = sign_discussion_credential(&principal, &secret).unwrap();
let (payload, sig) = SignedCredential::parse(&credential).unwrap();
assert!(!payload.is_empty());
assert!(!sig.is_empty());
use base64::Engine;
assert!(base64::engine::general_purpose::URL_SAFE_NO_PAD
.decode(payload)
.is_ok());
assert!(base64::engine::general_purpose::URL_SAFE_NO_PAD
.decode(sig)
.is_ok());
}
#[test]
fn test_discussion_credential_no_project_id() {
let secret = test_secret();
let principal = DiscussionExecutionPrincipal::mint(
Uuid::new_v4(),
Uuid::new_v4(),
None, // no project
Uuid::new_v4(),
vec!["web_search".to_string()],
300,
);
let credential = sign_discussion_credential(&principal, &secret).unwrap();
let claims = verify_discussion_signature(&credential, &secret).unwrap();
assert!(claims.project_id.is_none());
}
#[test]
fn test_task_credential_rejected_as_discussion() {
let secret = test_secret();
let principal = ExecutionPrincipal::mint(
Uuid::new_v4(),
Uuid::new_v4(),
Uuid::new_v4(),
1,
Uuid::new_v4(),
AgentType::BuildAgent,
vec![],
3600,
);
// A task credential should fail discussion verification because
// the JSON structure is different.
let credential = sign_credential(&principal, &secret).unwrap();
let result = verify_discussion_signature(&credential, &secret);
assert!(result.is_err());
}
#[test]
fn test_model_gateway_credential_roundtrip() {
let secret = test_secret();
let principal = ModelGatewayPrincipal::mint(
Uuid::new_v4(),
Uuid::new_v4(),
"openai".to_string(),
vec!["gpt-4".to_string(), "gpt-3.5-turbo".to_string()],
300,
);
let credential = sign_model_gateway_credential(&principal, &secret).unwrap();
let claims = verify_model_gateway_signature(&credential, &secret).unwrap();
assert_eq!(claims.credential_id, principal.credential_id);
assert_eq!(claims.attempt_id, principal.attempt_id);
assert_eq!(claims.pod_id, principal.pod_id);
assert_eq!(claims.provider, "openai");
assert_eq!(
claims.allowed_models,
vec!["gpt-4".to_string(), "gpt-3.5-turbo".to_string()]
);
}
#[test]
fn test_model_gateway_credential_rejects_wrong_signature() {
let secret = test_secret();
let wrong_secret = b"wrong-secret-key-for-hmac-signing-operations";
let principal = ModelGatewayPrincipal::mint(
Uuid::new_v4(),
Uuid::new_v4(),
"openai".to_string(),
vec![],
300,
);
let credential = sign_model_gateway_credential(&principal, &secret).unwrap();
let result = verify_model_gateway_signature(&credential, wrong_secret);
assert_eq!(result.unwrap_err(), CredentialError::InvalidSignature);
}
#[test]
fn test_model_gateway_credential_rejects_expired() {
let secret = test_secret();
let mut principal = ModelGatewayPrincipal::mint(
Uuid::new_v4(),
Uuid::new_v4(),
"openai".to_string(),
vec![],
300,
);
// Force expiry
principal.expires_at = Utc::now() - chrono::Duration::hours(1);
let credential = sign_model_gateway_credential(&principal, &secret).unwrap();
let result = verify_model_gateway_signature(&credential, &secret);
assert_eq!(result.unwrap_err(), CredentialError::Expired);
}
#[test]
fn test_model_gateway_credential_empty_models_allows_all() {
let secret = test_secret();
let principal = ModelGatewayPrincipal::mint(
Uuid::new_v4(),
Uuid::new_v4(),
"anthropic".to_string(),
vec![], // empty = all models
300,
);
let credential = sign_model_gateway_credential(&principal, &secret).unwrap();
let claims = verify_model_gateway_signature(&credential, &secret).unwrap();
assert!(claims.allowed_models.is_empty());
}
}

View file

@ -1,5 +1,27 @@
use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use crate::principal::ProjectAction;
/// Authoritative execution ownership for every tool.
/// Each tool declares exactly one execution location. Connector-krill uses this
/// to route calls and must never fall back to local execution for Reef-owned tools.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ToolExecutionLocation {
/// Tool executes exclusively on Reef (the authorization boundary).
/// Connector must not locally execute after Reef rejection/failure.
Reef,
/// Tool executes locally on the connector (e.g. web_search, web_fetch).
Local,
}
impl Default for ToolExecutionLocation {
fn default() -> Self {
Self::Local
}
}
/* Context in which a tool should be available */
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
@ -64,6 +86,266 @@ impl ApprovalRequirement {
}
}
// ---------------------------------------------------------------------------
// Verified tool scope (Task 1)
// ---------------------------------------------------------------------------
/// Authoritative scope derived from a validated execution credential.
/// Each field overrides any client- or model-supplied value for the
/// corresponding argument.
#[derive(Debug, Clone, Default)]
pub struct VerifiedToolScope {
pub project_id: Option<Uuid>,
pub run_id: Option<Uuid>,
pub attempt_id: Option<u32>,
pub todo_id: Option<Uuid>,
pub pod_id: Option<Uuid>,
}
// ---------------------------------------------------------------------------
// Canonical scope argument names
// ---------------------------------------------------------------------------
/// Canonical argument names for tool execution scope. These are the only
/// argument names that should be used for scope-related fields in tool
/// executors and approval records.
///
/// * `project_id` — The project UUID this tool call is scoped to.
/// * `run_id` — The unique run identifier for task-agent execution.
/// * `agent_run_id` — Alias for `run_id`. Both are set to the same trusted
/// value by `apply_verified_scope` so executors using either alias receive
/// the verified scope.
/// * `attempt_id` — The attempt number (1-indexed) for task-agent execution.
/// * `todo_id` — The todo being serviced.
/// * `pod_id` — The pod this credential is bound to.
/// * `discuss_id` — The chat session this discussion credential is scoped to.
///
/// When a credential provides a `run_id`, both `run_id` and `agent_run_id`
/// are normalized to the same value. Executors should prefer `run_id` for new
/// code, but existing executors using `agent_run_id` continue to work.
/// Replace the corresponding tool arguments with the trusted values from a
/// validated credential. Client-supplied values are overwritten unconditionally
/// when the credential covers that field.
///
/// Normalization includes all known aliases: when the credential provides a
/// `run_id`, both `run_id` and `agent_run_id` are set to the same trusted
/// value so that executors using either alias receive the verified scope.
pub fn apply_verified_scope(
args: &mut serde_json::Map<String, serde_json::Value>,
scope: &VerifiedToolScope,
) {
if let Some(project_id) = scope.project_id {
args.insert(
"project_id".into(),
serde_json::json!(project_id.to_string()),
);
}
if let Some(run_id) = scope.run_id {
let value = serde_json::json!(run_id.to_string());
// Normalize both aliases to the same trusted value.
args.insert("run_id".into(), value.clone());
args.insert("agent_run_id".into(), value);
}
if let Some(attempt_id) = scope.attempt_id {
args.insert("attempt_id".into(), serde_json::json!(attempt_id));
}
if let Some(todo_id) = scope.todo_id {
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()),
);
}
}
/// Normalize all credential-derived tool arguments immediately after caller
/// authentication and credential validation. This ensures that:
///
/// 1. Verified scope (project_id, run_id, attempt_id, todo_id, pod_id) is
/// applied before any approval or authorization decision.
/// 2. All aliases (e.g., `run_id` and `agent_run_id`) are set to the same
/// trusted value.
/// 3. Approval records store normalized arguments that would execute.
///
/// For task-agent calls with verified claims, this applies the full verified
/// scope. For discussion-agent calls, it applies the discussion scope.
/// For human calls without credentials, it resolves the effective project
/// from all client sources.
pub fn normalize_tool_call(
args: &mut serde_json::Map<String, serde_json::Value>,
verified_claims: Option<&crate::principal::CredentialClaims>,
verified_discussion_claims: Option<&crate::principal::DiscussionCredentialClaims>,
payload_project: Option<Uuid>,
argument_project: Option<Uuid>,
) -> Result<(), String> {
if let Some(claims) = verified_claims {
// Task-agent call: apply full verified scope from credential.
apply_verified_scope(
args,
&VerifiedToolScope {
project_id: Some(claims.project_id),
run_id: Some(claims.run_id),
attempt_id: Some(claims.attempt_id),
todo_id: Some(claims.todo_id),
pod_id: Some(claims.pod_id),
},
);
} else if let Some(disc_claims) = verified_discussion_claims {
// Discussion-agent call: apply discussion scope from credential.
if let Some(project_id) = disc_claims.project_id {
args.insert(
"project_id".into(),
serde_json::json!(project_id.to_string()),
);
}
args.insert(
"discuss_id".into(),
serde_json::json!(disc_claims.discuss_id.to_string()),
);
args.insert(
"pod_id".into(),
serde_json::json!(disc_claims.pod_id.to_string()),
);
} else {
// Human call without credential: resolve effective project from all
// client sources and reject contradictions.
let effective = resolve_effective_project(payload_project, argument_project, None);
match effective {
EffectiveProject::Resolved(pid) => {
args.insert("project_id".into(), serde_json::json!(pid.to_string()));
}
EffectiveProject::Conflicting { first, second } => {
return Err(format!(
"conflicting project_id between request envelope ({first}) and arguments ({second})"
));
}
EffectiveProject::None => {}
}
}
Ok(())
}
// ---------------------------------------------------------------------------
// Effective project resolution (Task 2)
// ---------------------------------------------------------------------------
/// Outcome of resolving a single authoritative project from multiple sources.
#[derive(Debug, Clone)]
pub enum EffectiveProject {
/// Exactly one project UUID was identified (from any source).
Resolved(Uuid),
/// 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,
},
}
/// Resolve a single effective project from the three main sources available
/// during tool routing:
///
/// * `payload_project` — top-level `project_id` from the request envelope.
/// * `argument_project` — `project_id` supplied inside the `arguments` map.
/// * `derived_project` — project resolved from server-owned state (discussion,
/// entity lookup, etc.).
///
/// The function distinguishes server-derived values (authoritative) from
/// client-supplied assertions. When a server-derived project exists it wins;
/// when only client values exist they must agree.
pub fn resolve_effective_project(
payload_project: Option<Uuid>,
argument_project: Option<Uuid>,
derived_project: Option<Uuid>,
) -> EffectiveProject {
// Collect all non-None sources.
let mut sources: Vec<(Uuid, bool)> = Vec::new();
if let Some(p) = payload_project {
sources.push((p, false)); // client-supplied
}
if let Some(a) = argument_project {
sources.push((a, false)); // client-supplied
}
if let Some(d) = derived_project {
sources.push((d, true)); // server-derived
}
if sources.is_empty() {
return EffectiveProject::None;
}
// Prefer the server-derived value when present.
if let Some(&(derived_id, true)) = sources.iter().find(|(_, server)| *server) {
// Verify that all other sources agree with the derived value.
for &(other_id, _is_server) in &sources {
if other_id != derived_id {
return EffectiveProject::Conflicting {
first: derived_id,
second: other_id,
};
}
}
return EffectiveProject::Resolved(derived_id);
}
// No server-derived value: all client sources must agree.
let first = sources[0].0;
for &(other_id, _) in &sources[1..] {
if other_id != first {
return EffectiveProject::Conflicting {
first,
second: other_id,
};
}
}
EffectiveProject::Resolved(first)
}
// ---------------------------------------------------------------------------
// Rate-limit identity (Task 8)
// ---------------------------------------------------------------------------
/// Specific execution identity used for tool rate limiting.
///
/// Each variant derives from trusted server state (validated session,
/// verified credential, etc.) rather than from client-supplied values.
/// This ensures different execution contexts get independent rate-limit
/// buckets and prevents unrelated callers from sharing a quota.
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub enum ToolRateLimitIdentity {
/// Authenticated human user session.
UserSession(Uuid),
/// Task-agent execution attempt (from verified execution credential).
TaskAttempt(Uuid),
/// Discussion-agent session (from verified discussion credential).
Discussion(Uuid),
/// Trusted internal service call.
Service(String),
}
impl ToolRateLimitIdentity {
/// Generate a rate-limit bucket key from this identity.
pub fn key(&self) -> String {
match self {
Self::UserSession(id) => format!("session:{id}"),
Self::TaskAttempt(id) => format!("attempt:{id}"),
Self::Discussion(id) => format!("discussion:{id}"),
Self::Service(id) => format!("service:{id}"),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ToolDefinition {
#[serde(default)]
@ -82,6 +364,11 @@ pub struct ToolDefinition {
Project-scoped tools are filtered out when no project is selected. */
#[serde(default)]
pub project_scoped: bool,
/* The project-level authorization action required to invoke this tool.
Must be `Some` for every project-scoped tool. A `None` value for a
project-scoped tool causes authorization to fail closed. */
#[serde(default)]
pub required_project_action: Option<ProjectAction>,
/* Maximum execution time in milliseconds. Defaults to 30000 (30s). */
#[serde(default = "default_timeout_ms")]
pub timeout_ms: u64,
@ -102,6 +389,12 @@ 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). */
#[serde(default)]
pub execution_location: ToolExecutionLocation,
}
const fn default_timeout_ms() -> u64 {
@ -119,12 +412,14 @@ impl Default for ToolDefinition {
approval_requirement: ApprovalRequirement::None,
version: String::new(),
project_scoped: false,
required_project_action: None,
timeout_ms: default_timeout_ms(),
deprecated: false,
deprecation_message: String::new(),
execution_context: ExecutionContext::default(),
context_visibility: ContextVisibility::default(),
required_depth: 0,
execution_location: ToolExecutionLocation::default(),
}
}
}
@ -671,12 +966,14 @@ macro_rules! tool {
approval_requirement: ApprovalRequirement::from_flags($dangerous, $approval),
version: "1.0.0".to_string(),
project_scoped: $scoped,
required_project_action: None,
timeout_ms: default_timeout_ms(),
deprecated: false,
deprecation_message: String::new(),
execution_context: ExecutionContext::default(),
context_visibility: ContextVisibility::default(),
required_depth: 0,
execution_location: ToolExecutionLocation::default(),
}
};
($name:expr, $desc:expr, $cat:expr, $dangerous:expr, $approval:expr, $scoped:expr, $params:expr, $timeout:expr) => {
@ -689,12 +986,14 @@ macro_rules! tool {
approval_requirement: ApprovalRequirement::from_flags($dangerous, $approval),
version: "1.0.0".to_string(),
project_scoped: $scoped,
required_project_action: None,
timeout_ms: $timeout,
deprecated: false,
deprecation_message: String::new(),
execution_context: ExecutionContext::default(),
context_visibility: ContextVisibility::default(),
required_depth: 0,
execution_location: ToolExecutionLocation::default(),
}
};
}
@ -907,7 +1206,11 @@ 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. */
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",
@ -932,7 +1235,10 @@ 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. */
/* 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)
}
@ -1932,6 +2238,62 @@ pub fn tool_definitions() -> Vec<ToolDefinition> {
}
}
/* Assign required_project_action for every project-scoped tool.
This replaces the old mutates()-based authorization with explicit
per-tool metadata. A project-scoped tool without an action causes
authorization to fail closed. */
let action_map: std::collections::HashMap<&str, ProjectAction> = [
// Kanban read
("kanban_list_board", ProjectAction::Read),
// Kanban todo mutations
("kanban_create_todo", ProjectAction::CreateTodo),
("kanban_update_todo", ProjectAction::ModifyFiles),
("kanban_delete_todo", ProjectAction::ModifyFiles),
("kanban_move_todo", ProjectAction::ModifyFiles),
// Kanban sub-task mutations
("kanban_create_task", ProjectAction::ModifyFiles),
("kanban_update_task", ProjectAction::ModifyFiles),
("kanban_add_task", ProjectAction::ModifyFiles),
("kanban_complete_task", ProjectAction::ModifyFiles),
("kanban_remove_task", ProjectAction::ModifyFiles),
// Kanban tag mutations
("kanban_add_tag", ProjectAction::ModifyFiles),
// Documentation read
("document_file", ProjectAction::Read),
("document_project", ProjectAction::Read),
("find_references", ProjectAction::Read),
("file_dependencies", ProjectAction::Read),
("documentation_tree", ProjectAction::Read),
("verify_documentation", ProjectAction::Read),
("document_folder", ProjectAction::Read),
("get_documentation_context", ProjectAction::Read),
// 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),
("change_planner_mode", ProjectAction::Read),
("audit_assumptions", ProjectAction::Read),
("identify_blind_spots", ProjectAction::Read),
("check_dependencies", ProjectAction::Read),
("evaluate_plan_risk", ProjectAction::Read),
("compare_project_patterns", ProjectAction::Read),
("find_similar_risks", ProjectAction::Read),
]
.iter()
.cloned()
.collect();
for tool in tools.iter_mut() {
if tool.project_scoped {
if let Some(&action) = action_map.get(tool.id.as_str()) {
tool.required_project_action = Some(action);
}
}
}
/* Assign required_depth per tool. Depth 0 = visible to all agents.
Depth 1 = needs one planning cycle (bash, write/edits, build tools).
Depth 2 = needs moderate depth (kanban mutation, workspace creation).
@ -2010,6 +2372,87 @@ pub fn tool_definitions() -> Vec<ToolDefinition> {
}
}
/* 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)
"read_file",
"file_read",
"write_file",
"file_write",
"edit_file",
"list_directory",
"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",
"create_venv",
"install_dependencies",
"workspace_info",
// Kanban (always Reef — no valid local fallback)
"kanban_list_board",
"kanban_create_todo",
"kanban_update_todo",
"kanban_delete_todo",
"kanban_move_todo",
"kanban_create_task",
"kanban_update_task",
"kanban_add_task",
"kanban_complete_task",
"kanban_remove_task",
"kanban_add_tag",
// Documentation (Reef-backed)
"document_file",
"document_project",
"find_references",
"file_dependencies",
"documentation_tree",
"verify_documentation",
"document_folder",
"store_file_doc",
"get_documentation_context",
// Strategic (Reef-backed)
"propose_strategic_item",
"split_task",
"change_planner_mode",
"audit_assumptions",
"identify_blind_spots",
"check_dependencies",
"evaluate_plan_risk",
"compare_project_patterns",
"find_similar_risks",
// Agent (Reef-backed)
"report_completion",
"submit_batch_plan",
]
.iter()
.copied()
.collect();
for tool in tools.iter_mut() {
if reef_tools.contains(tool.id.as_str()) {
tool.execution_location = ToolExecutionLocation::Reef;
} else {
tool.execution_location = ToolExecutionLocation::Local;
}
}
tools
}
@ -2854,4 +3297,195 @@ Done with tools.
"tool-result: without closing fence should be rejected by finish()"
);
}
#[test]
fn every_project_scoped_tool_has_required_project_action() {
let tools = tool_definitions();
let mut violations = Vec::new();
for tool in &tools {
if tool.project_scoped && tool.required_project_action.is_none() {
violations.push(tool.id.clone());
}
}
assert!(
violations.is_empty(),
"project-scoped tools missing required_project_action: {:?}",
violations
);
}
#[test]
fn non_project_scoped_tools_have_no_required_project_action() {
let tools = tool_definitions();
let mut violations = Vec::new();
for tool in &tools {
if !tool.project_scoped && tool.required_project_action.is_some() {
violations.push(tool.id.clone());
}
}
assert!(
violations.is_empty(),
"non-project-scoped tools with required_project_action set: {:?}",
violations
);
}
#[test]
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.
assert!(
tool.execution_location == ToolExecutionLocation::Reef
|| tool.execution_location == ToolExecutionLocation::Local,
"tool '{}' has unclassified execution_location",
tool.id
);
}
}
#[test]
fn reef_owned_mutation_tools_are_classified() {
let tools = tool_definitions();
let reef_tools: Vec<&str> = tools
.iter()
.filter(|t| t.execution_location == ToolExecutionLocation::Reef)
.map(|t| t.id.as_str())
.collect();
// Privileged mutation tools must be Reef-owned
for name in &["write_file", "edit_file", "bash", "delete_workspace"] {
assert!(
reef_tools.contains(name),
"privileged tool '{}' must be Reef-owned",
name
);
}
}
#[test]
fn global_network_tools_are_local() {
let tools = tool_definitions();
for name in &["web_search", "web_fetch", "web_api"] {
let tool = tools.iter().find(|t| t.id == *name).unwrap();
assert_eq!(
tool.execution_location,
ToolExecutionLocation::Local,
"global tool '{}' must be Local",
name
);
}
}
#[test]
fn no_tool_is_both_reef_and_local() {
let tools = tool_definitions();
for tool in &tools {
// The enum guarantees exactly one variant, but we verify
// the field is consistently set.
assert!(
tool.execution_location == ToolExecutionLocation::Reef
|| tool.execution_location == ToolExecutionLocation::Local,
"tool '{}' has invalid execution_location",
tool.id
);
}
}
#[test]
fn all_kanban_tools_are_reef_owned() {
let tools = tool_definitions();
for tool in &tools {
if tool.id.starts_with("kanban_") {
assert_eq!(
tool.execution_location,
ToolExecutionLocation::Reef,
"kanban tool '{}' must be Reef-owned",
tool.id
);
}
}
}
#[test]
fn all_strategic_tools_are_reef_owned() {
let tools = tool_definitions();
for tool in &tools {
if tool.category == "strategic" {
assert_eq!(
tool.execution_location,
ToolExecutionLocation::Reef,
"strategic tool '{}' must be Reef-owned",
tool.id
);
}
}
}
#[test]
fn all_documentation_tools_are_reef_owned() {
let tools = tool_definitions();
for tool in &tools {
if tool.category == "documentation" {
assert_eq!(
tool.execution_location,
ToolExecutionLocation::Reef,
"documentation tool '{}' must be Reef-owned",
tool.id
);
}
}
}
#[test]
fn apply_verified_scope_normalizes_agent_run_id() {
let run_id = Uuid::new_v4();
let scope = VerifiedToolScope {
run_id: Some(run_id),
..Default::default()
};
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"),
);
apply_verified_scope(&mut args, &scope);
// Both aliases must be set to the trusted value
assert_eq!(
args.get("run_id").and_then(|v| v.as_str()),
Some(run_id.to_string().as_str())
);
assert_eq!(
args.get("agent_run_id").and_then(|v| v.as_str()),
Some(run_id.to_string().as_str())
);
}
#[test]
fn apply_verified_scope_overwrites_conflicting_project_id() {
let project_id = Uuid::new_v4();
let scope = VerifiedToolScope {
project_id: Some(project_id),
..Default::default()
};
let mut args = serde_json::Map::new();
args.insert(
"project_id".into(),
serde_json::json!("spoofed-project"),
);
apply_verified_scope(&mut args, &scope);
assert_eq!(
args.get("project_id").and_then(|v| v.as_str()),
Some(project_id.to_string().as_str())
);
}
}