Merge origin/main

This commit is contained in:
Alex Emmet 2026-08-12 17:37:43 +02:00
commit 1d408f0734
15 changed files with 3030 additions and 49 deletions

66
Cargo.lock generated
View file

@ -12,6 +12,15 @@ dependencies = [
"generic-array",
]
[[package]]
name = "aho-corasick"
version = "1.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301"
dependencies = [
"memchr",
]
[[package]]
name = "android_system_properties"
version = "0.1.5"
@ -101,6 +110,12 @@ dependencies = [
"pkg-config",
]
[[package]]
name = "base64"
version = "0.21.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567"
[[package]]
name = "base64"
version = "0.22.1"
@ -422,6 +437,7 @@ checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
dependencies = [
"block-buffer 0.10.4",
"crypto-common 0.1.7",
"subtle",
]
[[package]]
@ -599,7 +615,16 @@ version = "0.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4aaa26c720c68b866f2c96ef5c1264b3e6f473fe5d4ce61cd44bbe913e553018"
dependencies = [
"hmac",
"hmac 0.13.0",
]
[[package]]
name = "hmac"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e"
dependencies = [
"digest 0.10.7",
]
[[package]]
@ -972,7 +997,7 @@ name = "mtp-codec"
version = "0.1.0"
source = "git+https://git@git.methanium.net/methanium/mtp.git#6a65e43ca9f7d128b691c8946adaeecccc2cb2ec"
dependencies = [
"base64",
"base64 0.22.1",
"byteorder",
"mtp-common",
"mtp-crypto",
@ -996,7 +1021,7 @@ name = "mtp-crypto"
version = "0.1.0"
source = "git+https://git@git.methanium.net/methanium/mtp.git#6a65e43ca9f7d128b691c8946adaeecccc2cb2ec"
dependencies = [
"base64",
"base64 0.22.1",
"chacha20poly1305",
"ed25519-dalek",
"getrandom 0.4.3",
@ -1154,7 +1179,7 @@ version = "3.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be"
dependencies = [
"base64",
"base64 0.22.1",
"serde_core",
]
@ -1401,6 +1426,35 @@ dependencies = [
"bitflags",
]
[[package]]
name = "regex"
version = "1.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d"
dependencies = [
"aho-corasick",
"memchr",
"regex-automata",
"regex-syntax",
]
[[package]]
name = "regex-automata"
version = "0.4.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8fcfdb36bda0c880c5931cdc7a2bcdc8ba4556847b9d912bca70bc94708711ad"
dependencies = [
"aho-corasick",
"memchr",
"regex-syntax",
]
[[package]]
name = "regex-syntax"
version = "0.8.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
[[package]]
name = "ring"
version = "0.17.14"
@ -1668,10 +1722,14 @@ name = "shoal-types"
version = "0.1.0"
dependencies = [
"async-trait",
"base64 0.21.7",
"chrono",
"hmac 0.12.1",
"mtp",
"regex",
"serde",
"serde_json",
"sha2 0.10.9",
"tempfile",
"thiserror 2.0.18",
"tokio",

View file

@ -16,6 +16,10 @@ mtp = { git = "https://git@git.methanium.net/methanium/mtp.git", features = [
tokio = "1"
async-trait = "0.1"
url = "2"
regex = "1"
hmac = "0.12"
sha2 = "0.10"
base64 = "0.21"
[dev-dependencies]
tokio = { version = "1", features = ["full"] }

View file

@ -1,5 +1,5 @@
use serde::{Deserialize, Serialize};
use crate::task::SessionBudget;
use serde::{Deserialize, Serialize};
/* ExploreAgent is intentionally limited to read-only tools so it can be
dispatched without approval for safe, unrestricted codebase exploration. */

View file

@ -105,12 +105,7 @@ impl StreamEvent {
Self::ToolExecuting { tool_name, tool_id }
}
pub fn tool_result(
tool_name: String,
tool_id: String,
status: String,
result: String,
) -> Self {
pub fn tool_result(tool_name: String, tool_id: String, status: String, result: String) -> Self {
Self::ToolResult {
tool_name,
tool_id,

398
src/data_retention.rs Normal file
View file

@ -0,0 +1,398 @@
use regex::Regex;
use std::sync::LazyLock;
/// Patterns for sensitive data that should be redacted from logs and event payloads.
static REDACTION_PATTERNS: LazyLock<Vec<(String, Regex)>> = LazyLock::new(|| {
vec![
(
"api_key".to_string(),
Regex::new(r#"(?i)(api[_-]?key|apikey)\s*[:=]\s*["']?([A-Za-z0-9\-_]{20,})["']?"#)
.unwrap(),
),
(
"bearer_token".to_string(),
Regex::new(
r#"(?i)(bearer|token|authorization)\s*[:=]\s*["']?([A-Za-z0-9\-_\.]{20,})["']?"#,
)
.unwrap(),
),
(
"password".to_string(),
Regex::new(r#"(?i)(password|passwd|pwd)\s*[:=]\s*["']?(\S{8,})["']?"#).unwrap(),
),
(
"private_key".to_string(),
Regex::new(r#"-----BEGIN\s+(RSA\s+)?PRIVATE\s+KEY-----"#).unwrap(),
),
(
"connection_string".to_string(),
Regex::new(r#"(?i)(mongodb|postgres|mysql|redis|amqp)://\S+"#).unwrap(),
),
(
"jwt_token".to_string(),
Regex::new(r#"eyJ[A-Za-z0-9_-]{10,}\.eyJ[A-Za-z0-9_-]{10,}"#).unwrap(),
),
]
});
/// Redact sensitive information from a string.
///
/// Replaces matched patterns with `<REDACTED:{label}>` to prevent accidental
/// leakage of secrets through logs, event payloads, or error messages.
pub fn redact_secrets(input: &str) -> String {
let mut result = input.to_string();
for (label, pattern) in REDACTION_PATTERNS.iter() {
result = pattern
.replace_all(&result, format!("<REDACTED:{}>", label))
.to_string();
}
result
}
/// Truncate a string to a maximum byte length, appending "..." if truncated.
pub fn truncate_bytes(input: &str, max_bytes: usize) -> String {
let bytes = input.as_bytes();
if bytes.len() <= max_bytes {
input.to_string()
} else {
let truncated = String::from_utf8_lossy(&bytes[..max_bytes.saturating_sub(3)]);
format!("{}...", truncated)
}
}
/// Data classification for storage rules.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum DataClass {
/// Source code files — retained for the project lifetime.
SourceCode,
/// LLM prompts and system instructions — confidential, short retention.
Prompt,
/// Model-generated output — confidential, short retention.
ModelOutput,
/// Tool call arguments — confidential, may contain secrets.
ToolArguments,
/// Tool call results — confidential, may contain file content.
ToolResult,
/// User approval decisions — audit trail, long retention.
ApprovalData,
/// API keys, tokens, credentials — secret, never persisted in logs.
Credentials,
/// File mutation content (diffs, snapshots) — internal, medium retention.
MutationContent,
/// Operational traces (heartbeats, health checks) — internal, short retention.
OperationalTrace,
/// Domain events — internal, medium retention.
DomainEvent,
/// UI events (Lookout replay) — internal, short retention.
UiEvent,
/// Execution logs — confidential, short retention.
ExecutionLog,
/// Agent run records — internal, medium retention.
AgentRun,
/// Documentation output — source code, project lifetime.
Documentation,
}
/// Retention configuration per data class.
#[derive(Debug, Clone)]
pub struct RetentionPolicy {
/// Maximum number of days to retain processed events.
pub event_retention_days: u32,
/// Maximum number of days to retain completed agent runs.
pub run_retention_days: u32,
/// Maximum number of days to retain audit logs.
pub audit_retention_days: u32,
/// Maximum size of event payloads in bytes (larger payloads are truncated).
pub max_event_payload_bytes: usize,
/// Maximum number of days to retain execution logs.
pub execution_log_retention_days: u32,
/// Maximum number of days to retain UI replay events.
pub ui_event_retention_days: u32,
/// Maximum number of days to retain documentation.
pub documentation_retention_days: u32,
}
impl Default for RetentionPolicy {
fn default() -> Self {
Self {
event_retention_days: 30,
run_retention_days: 90,
audit_retention_days: 60,
max_event_payload_bytes: 64 * 1024, // 64 KB
execution_log_retention_days: 14,
ui_event_retention_days: 7,
documentation_retention_days: 365,
}
}
}
impl RetentionPolicy {
/// Create a retention policy from environment variables with sensible defaults.
pub fn from_env() -> Self {
Self {
event_retention_days: read_env_u32("SHOAL_EVENT_RETENTION_DAYS", 30),
run_retention_days: read_env_u32("SHOAL_RUN_RETENTION_DAYS", 90),
audit_retention_days: read_env_u32("SHOAL_AUDIT_RETENTION_DAYS", 60),
max_event_payload_bytes: read_env_usize("SHOAL_MAX_EVENT_PAYLOAD_BYTES", 64 * 1024),
execution_log_retention_days: read_env_u32("SHOAL_EXEC_LOG_RETENTION_DAYS", 14),
ui_event_retention_days: read_env_u32("SHOAL_UI_EVENT_RETENTION_DAYS", 7),
documentation_retention_days: read_env_u32("SHOAL_DOCS_RETENTION_DAYS", 365),
}
}
/// 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::Prompt | DataClass::ModelOutput => 7,
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,
DataClass::OperationalTrace => 3,
DataClass::DomainEvent => self.event_retention_days,
DataClass::UiEvent => self.ui_event_retention_days,
DataClass::ExecutionLog => self.execution_log_retention_days,
}
}
/// Whether the data class should be redacted before persistence.
pub fn should_redact(&self, class: DataClass) -> bool {
matches!(
class,
DataClass::ToolArguments
| DataClass::ToolResult
| DataClass::Credentials
| DataClass::Prompt
| DataClass::ModelOutput
| DataClass::ExecutionLog
)
}
}
/// Applied retention policy service. Wraps a `RetentionPolicy` and provides
/// methods to enforce redaction and truncation on data before it reaches any
/// store (domain events, UI events, execution logs, agent runs, etc.).
pub struct DataRetentionService {
policy: RetentionPolicy,
}
impl DataRetentionService {
pub fn new(policy: RetentionPolicy) -> Self {
Self { policy }
}
pub fn from_env() -> Self {
Self::new(RetentionPolicy::from_env())
}
/// Returns a reference to the underlying policy.
pub fn policy(&self) -> &RetentionPolicy {
&self.policy
}
/// Apply redaction and truncation to a payload string based on its data class.
pub fn prepare_for_storage(&self, data: &str, class: DataClass) -> String {
let result = if self.policy.should_redact(class) {
redact_secrets(data)
} else {
data.to_string()
};
let max_bytes = match class {
DataClass::DomainEvent | DataClass::UiEvent => self.policy.max_event_payload_bytes,
DataClass::ExecutionLog => 32 * 1024, // 32 KB
DataClass::AgentRun => 128 * 1024, // 128 KB
_ => usize::MAX,
};
truncate_bytes(&result, max_bytes)
}
/// Prepare a JSON value for storage, redacting sensitive fields.
pub fn prepare_json_for_storage(
&self,
value: &serde_json::Value,
class: DataClass,
) -> serde_json::Value {
if !self.policy.should_redact(class) {
return value.clone();
}
redact_json_secrets(value)
}
/// Returns true if the given data class should be retained at all.
/// Credentials, for example, should never be persisted.
pub fn should_retain(&self, class: DataClass) -> bool {
self.policy.retention_days(class) > 0
}
}
/// Recursively redact sensitive fields in a JSON value.
fn redact_json_secrets(value: &serde_json::Value) -> serde_json::Value {
match value {
serde_json::Value::String(s) => serde_json::Value::String(redact_secrets(s)),
serde_json::Value::Object(map) => {
let redacted: serde_json::Map<String, serde_json::Value> = map
.iter()
.map(|(k, v)| {
let new_v = if is_sensitive_json_field(k) {
serde_json::Value::String("<REDACTED>".to_string())
} else {
redact_json_secrets(v)
};
(k.clone(), new_v)
})
.collect();
serde_json::Value::Object(redacted)
}
serde_json::Value::Array(arr) => {
serde_json::Value::Array(arr.iter().map(redact_json_secrets).collect())
}
other => other.clone(),
}
}
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"
)
}
fn read_env_u32(key: &str, default: u32) -> u32 {
std::env::var(key)
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(default)
}
fn read_env_usize(key: &str, default: usize) -> usize {
std::env::var(key)
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(default)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_redact_api_key() {
let input = r#"{"api_key": "sk-abcdefghijklmnopqrstuvwxyz123456"}"#;
let redacted = redact_secrets(input);
assert!(redacted.contains("<REDACTED:api_key>"));
assert!(!redacted.contains("sk-abcdefghijklmnopqrstuvwxyz123456"));
}
#[test]
fn test_redact_bearer_token() {
let input = "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9";
let redacted = redact_secrets(input);
assert!(redacted.contains("<REDACTED:bearer_token>"));
}
#[test]
fn test_redact_password() {
let input = r#"password = "supersecret123""#;
let redacted = redact_secrets(input);
assert!(redacted.contains("<REDACTED:password>"));
assert!(!redacted.contains("supersecret123"));
}
#[test]
fn test_redact_private_key() {
let input = "-----BEGIN RSA PRIVATE KEY-----";
let redacted = redact_secrets(input);
assert!(redacted.contains("<REDACTED:private_key>"));
}
#[test]
fn test_no_redaction_for_clean_text() {
let input = "This is a normal log message with no secrets.";
let redacted = redact_secrets(input);
assert_eq!(input, redacted);
}
#[test]
fn test_truncate_bytes() {
assert_eq!(truncate_bytes("hello", 10), "hello");
assert_eq!(truncate_bytes("hello world", 8), "hello...");
assert_eq!(truncate_bytes("", 5), "");
}
#[test]
fn test_retention_policy_defaults() {
let policy = RetentionPolicy::default();
assert_eq!(policy.event_retention_days, 30);
assert_eq!(policy.run_retention_days, 90);
assert_eq!(policy.audit_retention_days, 60);
assert_eq!(policy.max_event_payload_bytes, 64 * 1024);
assert_eq!(policy.execution_log_retention_days, 14);
assert_eq!(policy.ui_event_retention_days, 7);
assert_eq!(policy.documentation_retention_days, 365);
}
#[test]
fn test_data_retention_service_redacts_tool_args() {
let svc = DataRetentionService::from_env();
let input = r#"{"api_key": "sk-abcdefghijklmnopqrstuvwxyz123456"}"#;
let result = svc.prepare_for_storage(input, DataClass::ToolArguments);
assert!(result.contains("<REDACTED:api_key>"));
}
#[test]
fn test_data_retention_service_no_redact_for_source_code() {
let svc = DataRetentionService::from_env();
let input = "fn main() { let api_key = \"test\"; }";
let result = svc.prepare_for_storage(input, DataClass::SourceCode);
assert_eq!(input, result);
}
#[test]
fn test_data_retention_service_credentials_never_retained() {
let svc = DataRetentionService::from_env();
assert!(!svc.should_retain(DataClass::Credentials));
}
#[test]
fn test_redact_json_secrets() {
let value = serde_json::json!({
"name": "test",
"api_key": "sk-abcdefghijklmnopqrstuvwxyz123456",
"nested": {
"password": "hunter2"
}
});
let redacted = redact_json_secrets(&value);
assert_eq!(redacted["name"], "test");
assert_eq!(redacted["api_key"], "<REDACTED>");
assert_eq!(redacted["nested"]["password"], "<REDACTED>");
}
#[test]
fn test_redact_connection_string() {
let input = "Connecting to postgres://user:pass@localhost/db";
let redacted = redact_secrets(input);
assert!(redacted.contains("<REDACTED:connection_string>"));
}
#[test]
fn test_retention_days_by_class() {
let policy = RetentionPolicy::default();
assert_eq!(policy.retention_days(DataClass::Credentials), 0);
assert_eq!(policy.retention_days(DataClass::OperationalTrace), 3);
assert_eq!(policy.retention_days(DataClass::Prompt), 7);
assert_eq!(policy.retention_days(DataClass::DomainEvent), 30);
assert_eq!(policy.retention_days(DataClass::AgentRun), 90);
}
}

View file

@ -2,6 +2,7 @@ use serde::{Deserialize, Serialize};
use std::fmt;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ToDoMode {
Discussion,
Finalized,
@ -34,6 +35,7 @@ impl std::str::FromStr for ToDoMode {
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ToDoStatus {
Pending,
InProgress,
@ -44,6 +46,10 @@ pub enum ToDoStatus {
Failed,
PendingApproval,
Draft,
/// Agent succeeded and reported mutations; awaiting mutation review/approval.
ChangesPending,
/// Mutations have been approved; awaiting application and validation.
ChangesApproved,
}
impl Default for ToDoStatus {
@ -64,6 +70,8 @@ impl fmt::Display for ToDoStatus {
ToDoStatus::Failed => write!(f, "failed"),
ToDoStatus::Draft => write!(f, "draft"),
ToDoStatus::PendingApproval => write!(f, "pending_approval"),
ToDoStatus::ChangesPending => write!(f, "changes_pending"),
ToDoStatus::ChangesApproved => write!(f, "changes_approved"),
}
}
}
@ -81,6 +89,8 @@ impl std::str::FromStr for ToDoStatus {
"failed" => Ok(ToDoStatus::Failed),
"draft" => Ok(ToDoStatus::Draft),
"pending_approval" => Ok(ToDoStatus::PendingApproval),
"changes_pending" => Ok(ToDoStatus::ChangesPending),
"changes_approved" => Ok(ToDoStatus::ChangesApproved),
_ => Err(format!("unknown status: {}", s)),
}
}
@ -262,6 +272,172 @@ impl std::fmt::Display for ToDoSource {
}
}
impl std::str::FromStr for ToDoSource {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s {
"user" => Ok(ToDoSource::User),
"planner" => Ok(ToDoSource::Planner),
"automation" => Ok(ToDoSource::Automation),
"delegation" => Ok(ToDoSource::Delegation),
_ => Err(format!("unknown todo source: {}", s)),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ExecutionKind {
Manual,
Planner,
Build,
TestEvaluation,
TestImplementation,
Documentation,
Explore,
}
impl Default for ExecutionKind {
fn default() -> Self {
ExecutionKind::Manual
}
}
impl std::fmt::Display for ExecutionKind {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
ExecutionKind::Manual => write!(f, "manual"),
ExecutionKind::Planner => write!(f, "planner"),
ExecutionKind::Build => write!(f, "build"),
ExecutionKind::TestEvaluation => write!(f, "test_evaluation"),
ExecutionKind::TestImplementation => write!(f, "test_implementation"),
ExecutionKind::Documentation => write!(f, "documentation"),
ExecutionKind::Explore => write!(f, "explore"),
}
}
}
impl std::str::FromStr for ExecutionKind {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s {
"manual" => Ok(ExecutionKind::Manual),
"planner" => Ok(ExecutionKind::Planner),
"build" => Ok(ExecutionKind::Build),
"test_evaluation" => Ok(ExecutionKind::TestEvaluation),
"test_implementation" => Ok(ExecutionKind::TestImplementation),
"documentation" => Ok(ExecutionKind::Documentation),
"explore" => Ok(ExecutionKind::Explore),
_ => Err(format!("unknown execution kind: {}", s)),
}
}
}
/// Lifecycle stage of a mutation set reported by an agent.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum MutationLifecycle {
/// Agent generated mutations but they have not yet been staged for review.
Generated,
/// Mutations are staged and ready for human or agent-flow review.
Staged,
/// Mutations have been reviewed (may follow with approve/reject).
Reviewed,
/// Mutations have been approved for application.
Approved,
/// Mutations have been applied to the working tree.
Applied,
/// Applied mutations have passed validation (tests, checks).
Validated,
/// Mutations have been committed.
Committed,
/// Mutations were rejected.
Rejected,
}
impl Default for MutationLifecycle {
fn default() -> Self {
MutationLifecycle::Generated
}
}
impl fmt::Display for MutationLifecycle {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
MutationLifecycle::Generated => write!(f, "generated"),
MutationLifecycle::Staged => write!(f, "staged"),
MutationLifecycle::Reviewed => write!(f, "reviewed"),
MutationLifecycle::Approved => write!(f, "approved"),
MutationLifecycle::Applied => write!(f, "applied"),
MutationLifecycle::Validated => write!(f, "validated"),
MutationLifecycle::Committed => write!(f, "committed"),
MutationLifecycle::Rejected => write!(f, "rejected"),
}
}
}
impl std::str::FromStr for MutationLifecycle {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s {
"generated" => Ok(MutationLifecycle::Generated),
"staged" => Ok(MutationLifecycle::Staged),
"reviewed" => Ok(MutationLifecycle::Reviewed),
"approved" => Ok(MutationLifecycle::Approved),
"applied" => Ok(MutationLifecycle::Applied),
"validated" => Ok(MutationLifecycle::Validated),
"committed" => Ok(MutationLifecycle::Committed),
"rejected" => Ok(MutationLifecycle::Rejected),
_ => Err(format!("unknown mutation lifecycle: {}", s)),
}
}
}
/// Controls how task completion depends on mutation lifecycle.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum CompletionPolicy {
/// Mutations are auto-applied without review; task completes after validation.
AutoApply,
/// Mutations require human/agent-flow approval before application.
ApprovalRequired,
/// Mutations are recorded but never applied (sandbox-only).
DryRun,
/// No mutations expected; task completes when agent succeeds.
DocumentationOnly,
}
impl Default for CompletionPolicy {
fn default() -> Self {
CompletionPolicy::ApprovalRequired
}
}
impl fmt::Display for CompletionPolicy {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
CompletionPolicy::AutoApply => write!(f, "auto_apply"),
CompletionPolicy::ApprovalRequired => write!(f, "approval_required"),
CompletionPolicy::DryRun => write!(f, "dry_run"),
CompletionPolicy::DocumentationOnly => write!(f, "documentation_only"),
}
}
}
impl std::str::FromStr for CompletionPolicy {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s {
"auto_apply" => Ok(CompletionPolicy::AutoApply),
"approval_required" => Ok(CompletionPolicy::ApprovalRequired),
"dry_run" => Ok(CompletionPolicy::DryRun),
"documentation_only" => Ok(CompletionPolicy::DocumentationOnly),
_ => Err(format!("unknown completion policy: {}", s)),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum TestStrategy {

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 {
@ -29,8 +99,7 @@ impl ValidationError {
| ValidationError::InvalidPriority
| ValidationError::InvalidStatusTransition { .. }
| ValidationError::InvalidField(_) => 400,
ValidationError::CircularDependency(_)
| ValidationError::DependencyNotFound(_) => 409,
ValidationError::CircularDependency(_) | ValidationError::DependencyNotFound(_) => 409,
}
}
}

View file

@ -9,7 +9,9 @@ pub enum PodProtocolVersion {
}
impl Default for PodProtocolVersion {
fn default() -> Self { Self::V1 }
fn default() -> Self {
Self::V1
}
}
pub type KrillId = Uuid;

View file

@ -7,10 +7,12 @@ pub mod agents;
pub mod ai_response;
pub mod conclusion;
pub mod coral;
pub mod data_retention;
pub mod enums;
pub mod errors;
pub mod krill;
pub mod mutations;
pub mod principal;
pub mod project;
pub mod sandbox;
pub mod sync;
@ -26,22 +28,34 @@ pub use conclusion::{
Artifact, ArtifactType, ConclusionCard, ConclusionMetrics, ConclusionTrigger,
};
pub use coral::{Coral, CoralId};
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;
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,
compute_tools_hash, format_tool_error, format_tool_result, format_tools_json, is_global_tool,
is_reef_proxy_tool, parse_tool_call_blocks, parse_tool_call_stream, tool_definitions,
tool_ids_for_agent_type, apply_verified_scope, normalize_tool_call, resolve_effective_project,
ApprovalRequirement, CompositionStep, ContextVisibility, EffectiveProject, ExecutionContext,
MarketplaceSearchResults, ParsedToolCall, TestAction, ToolComposition, ToolDefinition,
ToolDependency, ToolDocumentation, ToolExample, ToolExecutor, ToolListing, ToolParser,
ToolPlugin, ToolRegistry, ToolState, ToolTest, ToolTestResult, ToolTestSuite,
ToolTestSuiteResult, ToolVersion, GLOBAL_TOOLS, REEF_PROXY_TOOLS,
ToolTestSuiteResult, ToolVersion, VerifiedToolScope, GLOBAL_TOOLS, REEF_PROXY_TOOLS,
};
#[cfg(test)]
@ -60,7 +74,8 @@ mod tests {
"TypeMap missing AuthRequest"
);
assert!(
tm.comm_id_enum(CommunicationType::PasswordAuthRequest).is_some(),
tm.comm_id_enum(CommunicationType::PasswordAuthRequest)
.is_some(),
"TypeMap missing PasswordAuthRequest"
);
assert!(

View file

@ -1,4 +1,5 @@
use serde::{Deserialize, Serialize};
use std::collections::BTreeSet;
/// A single file change inferred from a sandbox diff: `hash_before` absent means the file
/// was created, `hash_after` absent means it was deleted, both present means it was modified.
@ -8,3 +9,66 @@ pub struct FileMutation {
pub hash_before: Option<String>,
pub hash_after: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PathValidationError {
AbsolutePath(String),
UnresolvedParent(String),
EmptyPath,
}
impl std::fmt::Display for PathValidationError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::AbsolutePath(p) => write!(f, "absolute path not allowed: {}", p),
Self::UnresolvedParent(p) => write!(f, "unresolved .. segment: {}", p),
Self::EmptyPath => write!(f, "empty path"),
}
}
}
impl std::error::Error for PathValidationError {}
/// Normalize a repository-relative file path: convert backslashes, strip leading `./`,
/// reject absolute paths and unresolved `..` segments.
pub fn normalize_repository_path(path: &str) -> Result<String, PathValidationError> {
let normalized = path.replace('\\', "/");
let trimmed = normalized.trim_start_matches("./");
if trimmed.is_empty() {
return Err(PathValidationError::EmptyPath);
}
if trimmed.starts_with('/') {
return Err(PathValidationError::AbsolutePath(trimmed.to_string()));
}
// Check for unresolved .. segments
for component in trimmed.split('/') {
if component == ".." {
return Err(PathValidationError::UnresolvedParent(trimmed.to_string()));
}
}
Ok(trimmed.to_string())
}
/// Merge declared affected files with mutation-derived paths, normalizing and
/// deduplicating the result. Deletion mutations are preserved — a deleted source
/// file can require documentation removal or test updates.
pub fn merged_affected_files(
declared: &[String],
mutations: &[FileMutation],
) -> Result<Vec<String>, PathValidationError> {
let mut files = BTreeSet::new();
for path in declared
.iter()
.cloned()
.chain(mutations.iter().map(|m| m.file.clone()))
{
files.insert(normalize_repository_path(&path)?);
}
Ok(files.into_iter().collect())
}

1523
src/principal.rs Normal file

File diff suppressed because it is too large Load diff

View file

@ -8,6 +8,29 @@ pub enum ConflictResolution {
ResolvedManual,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub enum ResolutionStrategy {
Local,
Remote,
}
impl ResolutionStrategy {
pub fn from_str(s: &str) -> Option<Self> {
match s {
"local" => Some(ResolutionStrategy::Local),
"remote" => Some(ResolutionStrategy::Remote),
_ => None,
}
}
pub fn as_str(&self) -> &'static str {
match self {
ResolutionStrategy::Local => "local",
ResolutionStrategy::Remote => "remote",
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FileConflict {
pub file: String,
@ -89,6 +112,7 @@ pub struct TopicChange {
pub file_path: String,
pub operation: TopicChangeOperation,
pub blob_hash: String,
pub approved: Option<bool>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]

View file

@ -218,19 +218,13 @@ impl ConnectorConfig {
if let Some(tokens) = self.max_tokens {
if tokens <= 0 {
errors.push(format!(
"max_tokens must be > 0, got {}",
tokens
));
errors.push(format!("max_tokens must be > 0, got {}", tokens));
}
}
if let Some(temp) = self.temperature {
if !(0.0..=2.0).contains(&temp) {
errors.push(format!(
"temperature must be in [0.0, 2.0], got {}",
temp
));
errors.push(format!("temperature must be in [0.0, 2.0], got {}", temp));
}
}
@ -253,7 +247,12 @@ pub struct AgentTaskSpec {
pub resource_limits: ResourceLimits,
pub parent_task_id: Option<Uuid>,
pub pre_approved_plan_id: Option<Uuid>,
pub execution_token: Option<Uuid>,
/// Signed execution credential minted by the dispatcher. Carries the
/// verified identity (project, todo, run, attempt, pod, agent type,
/// allowed tools) as an HMAC-signed token. The API verifies this before
/// authorizing any tool execution.
#[serde(default)]
pub execution_credential: Option<String>,
pub session_budget: Option<SessionBudget>,
/// Maximum number of checklist tasks this agent run may add. `None` leaves
/// task creation unconstrained for backwards-compatible callers.
@ -304,7 +303,7 @@ impl AgentTaskSpec {
},
parent_task_id: None,
pre_approved_plan_id: None,
execution_token: None,
execution_credential: None,
session_budget: None,
task_budget: None,
agent_type: None,

View file

@ -2,7 +2,7 @@ use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use crate::enums::{ToDoMode, ToDoSource, ToDoStatus};
use crate::enums::{ExecutionKind, ToDoMode, ToDoSource, ToDoStatus};
use crate::errors::ValidationError;
use std::collections::HashSet;
@ -41,6 +41,16 @@ pub struct ToDo {
pub tags: Vec<String>,
pub estimated_tokens: u32,
pub tools_json: Option<String>,
#[serde(default)]
pub execution_kind: ExecutionKind,
#[serde(default)]
pub assigned_agent_type: Option<String>,
#[serde(default)]
pub revision: u64,
pub completed_at: Option<DateTime<Utc>>,
pub failure_reason: Option<String>,
pub created_by_run_id: Option<Uuid>,
pub idempotency_key: Option<String>,
}
impl Default for ToDo {
@ -68,6 +78,13 @@ impl Default for ToDo {
tags: Vec::new(),
estimated_tokens: 0,
tools_json: None,
execution_kind: ExecutionKind::default(),
assigned_agent_type: None,
revision: 0,
completed_at: None,
failure_reason: None,
created_by_run_id: None,
idempotency_key: None,
}
}
}
@ -97,6 +114,13 @@ impl ToDo {
tags: Vec::new(),
estimated_tokens: 0,
tools_json: None,
execution_kind: ExecutionKind::default(),
assigned_agent_type: None,
revision: 0,
completed_at: None,
failure_reason: None,
created_by_run_id: None,
idempotency_key: None,
}
}

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)
}
@ -1865,7 +2171,7 @@ pub fn tool_definitions() -> Vec<ToolDefinition> {
// ========================
tool!(
"report_completion",
"Report completion status of a todo back to the Bridge",
"Report completion status of the current task. The todo_id is derived from the execution credential.",
"agent",
false,
false,
@ -1873,14 +2179,14 @@ pub fn tool_definitions() -> Vec<ToolDefinition> {
serde_json::json!({
"type": "object",
"properties": {
"todo_id": { "type": "string", "description": "ID of the todo being reported" },
"status": { "type": "string", "enum": ["done", "failed", "delegated", "needs_retry", "pending_approval"], "description": "Completion status" },
"status": { "type": "string", "enum": ["completed", "failed", "delegated", "needs_retry", "pending_approval"], "description": "Completion status" },
"summary": { "type": "string", "description": "Summary of what was done (max 3 sentences)" },
"artifact_refs": { "type": "array", "items": { "type": "string" }, "description": "File paths, test logs" },
"affected_files": { "type": "array", "items": { "type": "string" }, "description": "Files that were modified" },
"validation_commands": { "type": "array", "items": { "type": "string" }, "description": "Commands run to validate the work" },
"retry_plan": { "type": "string", "description": "Optional retry plan if status is needs_retry" },
"subtask_ids": { "type": "array", "items": { "type": "string" }, "description": "Subtask IDs if status is delegated" }
"proposed_mutations": { "type": "array", "items": { "type": "string" }, "description": "Proposed file mutations" }
},
"required": ["todo_id", "status"]
"required": ["status"]
})
),
tool!(
@ -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
}
@ -2071,8 +2514,6 @@ pub fn tool_ids_for_agent_type(agent_type: &crate::agents::AgentType) -> Option<
"list_files",
"list_directory",
"bash",
"kanban_create_todo",
"kanban_update_todo",
"report_completion",
]),
crate::agents::AgentType::Planner => by_visibility(&[
@ -2434,12 +2875,10 @@ Done with tools.
let results = parse_tool_call_blocks(text);
assert_eq!(results.len(), 1);
assert_eq!(results[0].call_id, "call_01");
assert!(
results[0].payload["content"]
assert!(results[0].payload["content"]
.as_str()
.unwrap_or("")
.contains("🎉")
);
.contains("🎉"));
}
#[test]
@ -2858,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())
);
}
}