Hollidays
This commit is contained in:
parent
5140f4ddc8
commit
7a139d855f
5 changed files with 1653 additions and 19 deletions
|
|
@ -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"
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
25
src/lib.rs
25
src/lib.rs
|
|
@ -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)]
|
||||
|
|
|
|||
918
src/principal.rs
918
src/principal.rs
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
638
src/tools.rs
638
src/tools.rs
|
|
@ -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())
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue