Merge origin/main
This commit is contained in:
commit
1d408f0734
15 changed files with 3030 additions and 49 deletions
66
Cargo.lock
generated
66
Cargo.lock
generated
|
|
@ -12,6 +12,15 @@ dependencies = [
|
||||||
"generic-array",
|
"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]]
|
[[package]]
|
||||||
name = "android_system_properties"
|
name = "android_system_properties"
|
||||||
version = "0.1.5"
|
version = "0.1.5"
|
||||||
|
|
@ -101,6 +110,12 @@ dependencies = [
|
||||||
"pkg-config",
|
"pkg-config",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "base64"
|
||||||
|
version = "0.21.7"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "base64"
|
name = "base64"
|
||||||
version = "0.22.1"
|
version = "0.22.1"
|
||||||
|
|
@ -422,6 +437,7 @@ checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"block-buffer 0.10.4",
|
"block-buffer 0.10.4",
|
||||||
"crypto-common 0.1.7",
|
"crypto-common 0.1.7",
|
||||||
|
"subtle",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
@ -599,7 +615,16 @@ version = "0.13.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "4aaa26c720c68b866f2c96ef5c1264b3e6f473fe5d4ce61cd44bbe913e553018"
|
checksum = "4aaa26c720c68b866f2c96ef5c1264b3e6f473fe5d4ce61cd44bbe913e553018"
|
||||||
dependencies = [
|
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]]
|
[[package]]
|
||||||
|
|
@ -972,7 +997,7 @@ name = "mtp-codec"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
source = "git+https://git@git.methanium.net/methanium/mtp.git#6a65e43ca9f7d128b691c8946adaeecccc2cb2ec"
|
source = "git+https://git@git.methanium.net/methanium/mtp.git#6a65e43ca9f7d128b691c8946adaeecccc2cb2ec"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"base64",
|
"base64 0.22.1",
|
||||||
"byteorder",
|
"byteorder",
|
||||||
"mtp-common",
|
"mtp-common",
|
||||||
"mtp-crypto",
|
"mtp-crypto",
|
||||||
|
|
@ -996,7 +1021,7 @@ name = "mtp-crypto"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
source = "git+https://git@git.methanium.net/methanium/mtp.git#6a65e43ca9f7d128b691c8946adaeecccc2cb2ec"
|
source = "git+https://git@git.methanium.net/methanium/mtp.git#6a65e43ca9f7d128b691c8946adaeecccc2cb2ec"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"base64",
|
"base64 0.22.1",
|
||||||
"chacha20poly1305",
|
"chacha20poly1305",
|
||||||
"ed25519-dalek",
|
"ed25519-dalek",
|
||||||
"getrandom 0.4.3",
|
"getrandom 0.4.3",
|
||||||
|
|
@ -1154,7 +1179,7 @@ version = "3.0.6"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be"
|
checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"base64",
|
"base64 0.22.1",
|
||||||
"serde_core",
|
"serde_core",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
@ -1401,6 +1426,35 @@ dependencies = [
|
||||||
"bitflags",
|
"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]]
|
[[package]]
|
||||||
name = "ring"
|
name = "ring"
|
||||||
version = "0.17.14"
|
version = "0.17.14"
|
||||||
|
|
@ -1668,10 +1722,14 @@ name = "shoal-types"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-trait",
|
"async-trait",
|
||||||
|
"base64 0.21.7",
|
||||||
"chrono",
|
"chrono",
|
||||||
|
"hmac 0.12.1",
|
||||||
"mtp",
|
"mtp",
|
||||||
|
"regex",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
"sha2 0.10.9",
|
||||||
"tempfile",
|
"tempfile",
|
||||||
"thiserror 2.0.18",
|
"thiserror 2.0.18",
|
||||||
"tokio",
|
"tokio",
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,10 @@ mtp = { git = "https://git@git.methanium.net/methanium/mtp.git", features = [
|
||||||
tokio = "1"
|
tokio = "1"
|
||||||
async-trait = "0.1"
|
async-trait = "0.1"
|
||||||
url = "2"
|
url = "2"
|
||||||
|
regex = "1"
|
||||||
|
hmac = "0.12"
|
||||||
|
sha2 = "0.10"
|
||||||
|
base64 = "0.21"
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
tokio = { version = "1", features = ["full"] }
|
tokio = { version = "1", features = ["full"] }
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
use crate::task::SessionBudget;
|
use crate::task::SessionBudget;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
/* ExploreAgent is intentionally limited to read-only tools so it can be
|
/* ExploreAgent is intentionally limited to read-only tools so it can be
|
||||||
dispatched without approval for safe, unrestricted codebase exploration. */
|
dispatched without approval for safe, unrestricted codebase exploration. */
|
||||||
|
|
|
||||||
|
|
@ -105,12 +105,7 @@ impl StreamEvent {
|
||||||
Self::ToolExecuting { tool_name, tool_id }
|
Self::ToolExecuting { tool_name, tool_id }
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn tool_result(
|
pub fn tool_result(tool_name: String, tool_id: String, status: String, result: String) -> Self {
|
||||||
tool_name: String,
|
|
||||||
tool_id: String,
|
|
||||||
status: String,
|
|
||||||
result: String,
|
|
||||||
) -> Self {
|
|
||||||
Self::ToolResult {
|
Self::ToolResult {
|
||||||
tool_name,
|
tool_name,
|
||||||
tool_id,
|
tool_id,
|
||||||
|
|
|
||||||
398
src/data_retention.rs
Normal file
398
src/data_retention.rs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
176
src/enums.rs
176
src/enums.rs
|
|
@ -2,6 +2,7 @@ use serde::{Deserialize, Serialize};
|
||||||
use std::fmt;
|
use std::fmt;
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
pub enum ToDoMode {
|
pub enum ToDoMode {
|
||||||
Discussion,
|
Discussion,
|
||||||
Finalized,
|
Finalized,
|
||||||
|
|
@ -34,6 +35,7 @@ impl std::str::FromStr for ToDoMode {
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
pub enum ToDoStatus {
|
pub enum ToDoStatus {
|
||||||
Pending,
|
Pending,
|
||||||
InProgress,
|
InProgress,
|
||||||
|
|
@ -44,6 +46,10 @@ pub enum ToDoStatus {
|
||||||
Failed,
|
Failed,
|
||||||
PendingApproval,
|
PendingApproval,
|
||||||
Draft,
|
Draft,
|
||||||
|
/// Agent succeeded and reported mutations; awaiting mutation review/approval.
|
||||||
|
ChangesPending,
|
||||||
|
/// Mutations have been approved; awaiting application and validation.
|
||||||
|
ChangesApproved,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for ToDoStatus {
|
impl Default for ToDoStatus {
|
||||||
|
|
@ -64,6 +70,8 @@ impl fmt::Display for ToDoStatus {
|
||||||
ToDoStatus::Failed => write!(f, "failed"),
|
ToDoStatus::Failed => write!(f, "failed"),
|
||||||
ToDoStatus::Draft => write!(f, "draft"),
|
ToDoStatus::Draft => write!(f, "draft"),
|
||||||
ToDoStatus::PendingApproval => write!(f, "pending_approval"),
|
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),
|
"failed" => Ok(ToDoStatus::Failed),
|
||||||
"draft" => Ok(ToDoStatus::Draft),
|
"draft" => Ok(ToDoStatus::Draft),
|
||||||
"pending_approval" => Ok(ToDoStatus::PendingApproval),
|
"pending_approval" => Ok(ToDoStatus::PendingApproval),
|
||||||
|
"changes_pending" => Ok(ToDoStatus::ChangesPending),
|
||||||
|
"changes_approved" => Ok(ToDoStatus::ChangesApproved),
|
||||||
_ => Err(format!("unknown status: {}", s)),
|
_ => 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)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
#[serde(rename_all = "snake_case")]
|
#[serde(rename_all = "snake_case")]
|
||||||
pub enum TestStrategy {
|
pub enum TestStrategy {
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,74 @@
|
||||||
|
use serde::Serialize;
|
||||||
use thiserror::Error;
|
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)]
|
#[derive(Debug, Clone, Error)]
|
||||||
pub enum ValidationError {
|
pub enum ValidationError {
|
||||||
|
|
@ -29,8 +99,7 @@ impl ValidationError {
|
||||||
| ValidationError::InvalidPriority
|
| ValidationError::InvalidPriority
|
||||||
| ValidationError::InvalidStatusTransition { .. }
|
| ValidationError::InvalidStatusTransition { .. }
|
||||||
| ValidationError::InvalidField(_) => 400,
|
| ValidationError::InvalidField(_) => 400,
|
||||||
ValidationError::CircularDependency(_)
|
ValidationError::CircularDependency(_) | ValidationError::DependencyNotFound(_) => 409,
|
||||||
| ValidationError::DependencyNotFound(_) => 409,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,9 @@ pub enum PodProtocolVersion {
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for PodProtocolVersion {
|
impl Default for PodProtocolVersion {
|
||||||
fn default() -> Self { Self::V1 }
|
fn default() -> Self {
|
||||||
|
Self::V1
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub type KrillId = Uuid;
|
pub type KrillId = Uuid;
|
||||||
|
|
|
||||||
31
src/lib.rs
31
src/lib.rs
|
|
@ -7,10 +7,12 @@ pub mod agents;
|
||||||
pub mod ai_response;
|
pub mod ai_response;
|
||||||
pub mod conclusion;
|
pub mod conclusion;
|
||||||
pub mod coral;
|
pub mod coral;
|
||||||
|
pub mod data_retention;
|
||||||
pub mod enums;
|
pub mod enums;
|
||||||
pub mod errors;
|
pub mod errors;
|
||||||
pub mod krill;
|
pub mod krill;
|
||||||
pub mod mutations;
|
pub mod mutations;
|
||||||
|
pub mod principal;
|
||||||
pub mod project;
|
pub mod project;
|
||||||
pub mod sandbox;
|
pub mod sandbox;
|
||||||
pub mod sync;
|
pub mod sync;
|
||||||
|
|
@ -26,22 +28,34 @@ pub use conclusion::{
|
||||||
Artifact, ArtifactType, ConclusionCard, ConclusionMetrics, ConclusionTrigger,
|
Artifact, ArtifactType, ConclusionCard, ConclusionMetrics, ConclusionTrigger,
|
||||||
};
|
};
|
||||||
pub use coral::{Coral, CoralId};
|
pub use coral::{Coral, CoralId};
|
||||||
|
pub use data_retention::{
|
||||||
|
redact_secrets, truncate_bytes, DataClass, DataRetentionService, RetentionPolicy,
|
||||||
|
};
|
||||||
pub use enums::*;
|
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 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 project::{Project, ProjectFile, ProjectId, ProjectSettings};
|
||||||
pub use task::{Task, TaskResult};
|
pub use task::{Task, TaskResult};
|
||||||
pub use todo::{Dependency, ToDo};
|
pub use todo::{Dependency, ToDo};
|
||||||
pub use tools::{
|
pub use tools::{
|
||||||
compute_tools_hash, format_tool_error, format_tool_result, format_tools_json,
|
compute_tools_hash, format_tool_error, format_tool_result, format_tools_json, is_global_tool,
|
||||||
is_global_tool, is_reef_proxy_tool,
|
is_reef_proxy_tool, parse_tool_call_blocks, parse_tool_call_stream, tool_definitions,
|
||||||
parse_tool_call_blocks, parse_tool_call_stream, tool_definitions, tool_ids_for_agent_type,
|
tool_ids_for_agent_type, apply_verified_scope, normalize_tool_call, resolve_effective_project,
|
||||||
ApprovalRequirement, CompositionStep, ContextVisibility, ExecutionContext,
|
ApprovalRequirement, CompositionStep, ContextVisibility, EffectiveProject, ExecutionContext,
|
||||||
MarketplaceSearchResults, ParsedToolCall, TestAction, ToolComposition, ToolDefinition,
|
MarketplaceSearchResults, ParsedToolCall, TestAction, ToolComposition, ToolDefinition,
|
||||||
ToolDependency, ToolDocumentation, ToolExample, ToolExecutor, ToolListing, ToolParser,
|
ToolDependency, ToolDocumentation, ToolExample, ToolExecutor, ToolListing, ToolParser,
|
||||||
ToolPlugin, ToolRegistry, ToolState, ToolTest, ToolTestResult, ToolTestSuite,
|
ToolPlugin, ToolRegistry, ToolState, ToolTest, ToolTestResult, ToolTestSuite,
|
||||||
ToolTestSuiteResult, ToolVersion, GLOBAL_TOOLS, REEF_PROXY_TOOLS,
|
ToolTestSuiteResult, ToolVersion, VerifiedToolScope, GLOBAL_TOOLS, REEF_PROXY_TOOLS,
|
||||||
};
|
};
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
|
@ -60,7 +74,8 @@ mod tests {
|
||||||
"TypeMap missing AuthRequest"
|
"TypeMap missing AuthRequest"
|
||||||
);
|
);
|
||||||
assert!(
|
assert!(
|
||||||
tm.comm_id_enum(CommunicationType::PasswordAuthRequest).is_some(),
|
tm.comm_id_enum(CommunicationType::PasswordAuthRequest)
|
||||||
|
.is_some(),
|
||||||
"TypeMap missing PasswordAuthRequest"
|
"TypeMap missing PasswordAuthRequest"
|
||||||
);
|
);
|
||||||
assert!(
|
assert!(
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::collections::BTreeSet;
|
||||||
|
|
||||||
/// A single file change inferred from a sandbox diff: `hash_before` absent means the file
|
/// 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.
|
/// 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_before: Option<String>,
|
||||||
pub hash_after: 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
1523
src/principal.rs
Normal file
File diff suppressed because it is too large
Load diff
24
src/sync.rs
24
src/sync.rs
|
|
@ -8,6 +8,29 @@ pub enum ConflictResolution {
|
||||||
ResolvedManual,
|
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)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct FileConflict {
|
pub struct FileConflict {
|
||||||
pub file: String,
|
pub file: String,
|
||||||
|
|
@ -89,6 +112,7 @@ pub struct TopicChange {
|
||||||
pub file_path: String,
|
pub file_path: String,
|
||||||
pub operation: TopicChangeOperation,
|
pub operation: TopicChangeOperation,
|
||||||
pub blob_hash: String,
|
pub blob_hash: String,
|
||||||
|
pub approved: Option<bool>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
|
|
|
||||||
19
src/task.rs
19
src/task.rs
|
|
@ -218,19 +218,13 @@ impl ConnectorConfig {
|
||||||
|
|
||||||
if let Some(tokens) = self.max_tokens {
|
if let Some(tokens) = self.max_tokens {
|
||||||
if tokens <= 0 {
|
if tokens <= 0 {
|
||||||
errors.push(format!(
|
errors.push(format!("max_tokens must be > 0, got {}", tokens));
|
||||||
"max_tokens must be > 0, got {}",
|
|
||||||
tokens
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(temp) = self.temperature {
|
if let Some(temp) = self.temperature {
|
||||||
if !(0.0..=2.0).contains(&temp) {
|
if !(0.0..=2.0).contains(&temp) {
|
||||||
errors.push(format!(
|
errors.push(format!("temperature must be in [0.0, 2.0], got {}", temp));
|
||||||
"temperature must be in [0.0, 2.0], got {}",
|
|
||||||
temp
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -253,7 +247,12 @@ pub struct AgentTaskSpec {
|
||||||
pub resource_limits: ResourceLimits,
|
pub resource_limits: ResourceLimits,
|
||||||
pub parent_task_id: Option<Uuid>,
|
pub parent_task_id: Option<Uuid>,
|
||||||
pub pre_approved_plan_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>,
|
pub session_budget: Option<SessionBudget>,
|
||||||
/// Maximum number of checklist tasks this agent run may add. `None` leaves
|
/// Maximum number of checklist tasks this agent run may add. `None` leaves
|
||||||
/// task creation unconstrained for backwards-compatible callers.
|
/// task creation unconstrained for backwards-compatible callers.
|
||||||
|
|
@ -304,7 +303,7 @@ impl AgentTaskSpec {
|
||||||
},
|
},
|
||||||
parent_task_id: None,
|
parent_task_id: None,
|
||||||
pre_approved_plan_id: None,
|
pre_approved_plan_id: None,
|
||||||
execution_token: None,
|
execution_credential: None,
|
||||||
session_budget: None,
|
session_budget: None,
|
||||||
task_budget: None,
|
task_budget: None,
|
||||||
agent_type: None,
|
agent_type: None,
|
||||||
|
|
|
||||||
26
src/todo.rs
26
src/todo.rs
|
|
@ -2,7 +2,7 @@ use chrono::{DateTime, Utc};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use crate::enums::{ToDoMode, ToDoSource, ToDoStatus};
|
use crate::enums::{ExecutionKind, ToDoMode, ToDoSource, ToDoStatus};
|
||||||
use crate::errors::ValidationError;
|
use crate::errors::ValidationError;
|
||||||
use std::collections::HashSet;
|
use std::collections::HashSet;
|
||||||
|
|
||||||
|
|
@ -41,6 +41,16 @@ pub struct ToDo {
|
||||||
pub tags: Vec<String>,
|
pub tags: Vec<String>,
|
||||||
pub estimated_tokens: u32,
|
pub estimated_tokens: u32,
|
||||||
pub tools_json: Option<String>,
|
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 {
|
impl Default for ToDo {
|
||||||
|
|
@ -68,6 +78,13 @@ impl Default for ToDo {
|
||||||
tags: Vec::new(),
|
tags: Vec::new(),
|
||||||
estimated_tokens: 0,
|
estimated_tokens: 0,
|
||||||
tools_json: None,
|
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(),
|
tags: Vec::new(),
|
||||||
estimated_tokens: 0,
|
estimated_tokens: 0,
|
||||||
tools_json: None,
|
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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
658
src/tools.rs
658
src/tools.rs
|
|
@ -1,5 +1,27 @@
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use serde::{Deserialize, Serialize};
|
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 */
|
/* Context in which a tool should be available */
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
#[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)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct ToolDefinition {
|
pub struct ToolDefinition {
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
|
|
@ -82,6 +364,11 @@ pub struct ToolDefinition {
|
||||||
Project-scoped tools are filtered out when no project is selected. */
|
Project-scoped tools are filtered out when no project is selected. */
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub project_scoped: bool,
|
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). */
|
/* Maximum execution time in milliseconds. Defaults to 30000 (30s). */
|
||||||
#[serde(default = "default_timeout_ms")]
|
#[serde(default = "default_timeout_ms")]
|
||||||
pub timeout_ms: u64,
|
pub timeout_ms: u64,
|
||||||
|
|
@ -102,6 +389,12 @@ pub struct ToolDefinition {
|
||||||
1+ = only visible to agents with max_depth >= this value. */
|
1+ = only visible to agents with max_depth >= this value. */
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub required_depth: u32,
|
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 {
|
const fn default_timeout_ms() -> u64 {
|
||||||
|
|
@ -119,12 +412,14 @@ impl Default for ToolDefinition {
|
||||||
approval_requirement: ApprovalRequirement::None,
|
approval_requirement: ApprovalRequirement::None,
|
||||||
version: String::new(),
|
version: String::new(),
|
||||||
project_scoped: false,
|
project_scoped: false,
|
||||||
|
required_project_action: None,
|
||||||
timeout_ms: default_timeout_ms(),
|
timeout_ms: default_timeout_ms(),
|
||||||
deprecated: false,
|
deprecated: false,
|
||||||
deprecation_message: String::new(),
|
deprecation_message: String::new(),
|
||||||
execution_context: ExecutionContext::default(),
|
execution_context: ExecutionContext::default(),
|
||||||
context_visibility: ContextVisibility::default(),
|
context_visibility: ContextVisibility::default(),
|
||||||
required_depth: 0,
|
required_depth: 0,
|
||||||
|
execution_location: ToolExecutionLocation::default(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -671,12 +966,14 @@ macro_rules! tool {
|
||||||
approval_requirement: ApprovalRequirement::from_flags($dangerous, $approval),
|
approval_requirement: ApprovalRequirement::from_flags($dangerous, $approval),
|
||||||
version: "1.0.0".to_string(),
|
version: "1.0.0".to_string(),
|
||||||
project_scoped: $scoped,
|
project_scoped: $scoped,
|
||||||
|
required_project_action: None,
|
||||||
timeout_ms: default_timeout_ms(),
|
timeout_ms: default_timeout_ms(),
|
||||||
deprecated: false,
|
deprecated: false,
|
||||||
deprecation_message: String::new(),
|
deprecation_message: String::new(),
|
||||||
execution_context: ExecutionContext::default(),
|
execution_context: ExecutionContext::default(),
|
||||||
context_visibility: ContextVisibility::default(),
|
context_visibility: ContextVisibility::default(),
|
||||||
required_depth: 0,
|
required_depth: 0,
|
||||||
|
execution_location: ToolExecutionLocation::default(),
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
($name:expr, $desc:expr, $cat:expr, $dangerous:expr, $approval:expr, $scoped:expr, $params:expr, $timeout:expr) => {
|
($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),
|
approval_requirement: ApprovalRequirement::from_flags($dangerous, $approval),
|
||||||
version: "1.0.0".to_string(),
|
version: "1.0.0".to_string(),
|
||||||
project_scoped: $scoped,
|
project_scoped: $scoped,
|
||||||
|
required_project_action: None,
|
||||||
timeout_ms: $timeout,
|
timeout_ms: $timeout,
|
||||||
deprecated: false,
|
deprecated: false,
|
||||||
deprecation_message: String::new(),
|
deprecation_message: String::new(),
|
||||||
execution_context: ExecutionContext::default(),
|
execution_context: ExecutionContext::default(),
|
||||||
context_visibility: ContextVisibility::default(),
|
context_visibility: ContextVisibility::default(),
|
||||||
required_depth: 0,
|
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.
|
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
|
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] = &[
|
pub const REEF_PROXY_TOOLS: &[&str] = &[
|
||||||
"read_file",
|
"read_file",
|
||||||
"file_read",
|
"file_read",
|
||||||
|
|
@ -932,7 +1235,10 @@ pub fn is_global_tool(name: &str) -> bool {
|
||||||
GLOBAL_TOOLS.contains(&name)
|
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 {
|
pub fn is_reef_proxy_tool(name: &str) -> bool {
|
||||||
REEF_PROXY_TOOLS.contains(&name)
|
REEF_PROXY_TOOLS.contains(&name)
|
||||||
}
|
}
|
||||||
|
|
@ -1865,7 +2171,7 @@ pub fn tool_definitions() -> Vec<ToolDefinition> {
|
||||||
// ========================
|
// ========================
|
||||||
tool!(
|
tool!(
|
||||||
"report_completion",
|
"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",
|
"agent",
|
||||||
false,
|
false,
|
||||||
false,
|
false,
|
||||||
|
|
@ -1873,14 +2179,14 @@ pub fn tool_definitions() -> Vec<ToolDefinition> {
|
||||||
serde_json::json!({
|
serde_json::json!({
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
"todo_id": { "type": "string", "description": "ID of the todo being reported" },
|
"status": { "type": "string", "enum": ["completed", "failed", "delegated", "needs_retry", "pending_approval"], "description": "Completion status" },
|
||||||
"status": { "type": "string", "enum": ["done", "failed", "delegated", "needs_retry", "pending_approval"], "description": "Completion status" },
|
|
||||||
"summary": { "type": "string", "description": "Summary of what was done (max 3 sentences)" },
|
"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" },
|
"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!(
|
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.
|
/* Assign required_depth per tool. Depth 0 = visible to all agents.
|
||||||
Depth 1 = needs one planning cycle (bash, write/edits, build tools).
|
Depth 1 = needs one planning cycle (bash, write/edits, build tools).
|
||||||
Depth 2 = needs moderate depth (kanban mutation, workspace creation).
|
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
|
tools
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -2071,8 +2514,6 @@ pub fn tool_ids_for_agent_type(agent_type: &crate::agents::AgentType) -> Option<
|
||||||
"list_files",
|
"list_files",
|
||||||
"list_directory",
|
"list_directory",
|
||||||
"bash",
|
"bash",
|
||||||
"kanban_create_todo",
|
|
||||||
"kanban_update_todo",
|
|
||||||
"report_completion",
|
"report_completion",
|
||||||
]),
|
]),
|
||||||
crate::agents::AgentType::Planner => by_visibility(&[
|
crate::agents::AgentType::Planner => by_visibility(&[
|
||||||
|
|
@ -2434,12 +2875,10 @@ Done with tools.
|
||||||
let results = parse_tool_call_blocks(text);
|
let results = parse_tool_call_blocks(text);
|
||||||
assert_eq!(results.len(), 1);
|
assert_eq!(results.len(), 1);
|
||||||
assert_eq!(results[0].call_id, "call_01");
|
assert_eq!(results[0].call_id, "call_01");
|
||||||
assert!(
|
assert!(results[0].payload["content"]
|
||||||
results[0].payload["content"]
|
|
||||||
.as_str()
|
.as_str()
|
||||||
.unwrap_or("")
|
.unwrap_or("")
|
||||||
.contains("🎉")
|
.contains("🎉"));
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
@ -2858,4 +3297,195 @@ Done with tools.
|
||||||
"tool-result: without closing fence should be rejected by finish()"
|
"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