General Improvements
This commit is contained in:
parent
f1c7317fab
commit
9824a32add
15 changed files with 1376 additions and 48 deletions
66
Cargo.lock
generated
66
Cargo.lock
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"] }
|
||||
|
|
|
|||
|
|
@ -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. */
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
393
src/data_retention.rs
Normal file
393
src/data_retention.rs
Normal file
|
|
@ -0,0 +1,393 @@
|
|||
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;
|
||||
|
||||
#[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 {
|
||||
|
|
|
|||
|
|
@ -29,8 +29,7 @@ impl ValidationError {
|
|||
| ValidationError::InvalidPriority
|
||||
| ValidationError::InvalidStatusTransition { .. }
|
||||
| ValidationError::InvalidField(_) => 400,
|
||||
ValidationError::CircularDependency(_)
|
||||
| ValidationError::DependencyNotFound(_) => 409,
|
||||
ValidationError::CircularDependency(_) | ValidationError::DependencyNotFound(_) => 409,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
24
src/lib.rs
24
src/lib.rs
|
|
@ -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,21 +28,24 @@ 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 krill::{KrillConfig, KrillDescriptor, KrillId, PodProtocolVersion};
|
||||
pub use mutations::FileMutation;
|
||||
pub use mutations::{FileMutation, PathValidationError, merged_affected_files, normalize_repository_path};
|
||||
pub use principal::ExecutionPrincipal;
|
||||
pub use project::{Project, ProjectFile, ProjectId, ProjectSettings};
|
||||
pub use task::{Task, TaskResult};
|
||||
pub use todo::{Dependency, ToDo};
|
||||
pub use tools::{
|
||||
compute_tools_hash, format_tool_error, format_tool_result, format_tools_json,
|
||||
is_global_tool, is_reef_proxy_tool,
|
||||
parse_tool_call_blocks, parse_tool_call_stream, tool_definitions, tool_ids_for_agent_type,
|
||||
ApprovalRequirement, CompositionStep, ContextVisibility, ExecutionContext,
|
||||
MarketplaceSearchResults, ParsedToolCall, TestAction, ToolComposition, ToolDefinition,
|
||||
ToolDependency, ToolDocumentation, ToolExample, ToolExecutor, ToolListing, ToolParser,
|
||||
ToolPlugin, ToolRegistry, ToolState, ToolTest, ToolTestResult, ToolTestSuite,
|
||||
compute_tools_hash, format_tool_error, format_tool_result, format_tools_json, is_global_tool,
|
||||
is_reef_proxy_tool, parse_tool_call_blocks, parse_tool_call_stream, tool_definitions,
|
||||
tool_ids_for_agent_type, ApprovalRequirement, CompositionStep, ContextVisibility,
|
||||
ExecutionContext, MarketplaceSearchResults, ParsedToolCall, TestAction, ToolComposition,
|
||||
ToolDefinition, ToolDependency, ToolDocumentation, ToolExample, ToolExecutor, ToolListing,
|
||||
ToolParser, ToolPlugin, ToolRegistry, ToolState, ToolTest, ToolTestResult, ToolTestSuite,
|
||||
ToolTestSuiteResult, ToolVersion, GLOBAL_TOOLS, REEF_PROXY_TOOLS,
|
||||
};
|
||||
|
||||
|
|
@ -60,7 +65,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!(
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
}
|
||||
|
|
|
|||
588
src/principal.rs
Normal file
588
src/principal.rs
Normal file
|
|
@ -0,0 +1,588 @@
|
|||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::agents::AgentType;
|
||||
|
||||
/// Errors that can occur during credential verification.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum CredentialError {
|
||||
/// The credential signature does not match the payload.
|
||||
InvalidSignature,
|
||||
/// The credential has expired.
|
||||
Expired,
|
||||
/// The credential payload is malformed or could not be deserialized.
|
||||
MalformedPayload,
|
||||
/// The credential ID has been revoked.
|
||||
Revoked,
|
||||
/// The pod identity does not match the assigned pod.
|
||||
PodMismatch,
|
||||
/// The attempt is no longer active (completed, cancelled, or not found).
|
||||
AttemptInactive,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for CredentialError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::InvalidSignature => write!(f, "invalid credential signature"),
|
||||
Self::Expired => write!(f, "credential expired"),
|
||||
Self::MalformedPayload => write!(f, "malformed credential payload"),
|
||||
Self::Revoked => write!(f, "credential revoked"),
|
||||
Self::PodMismatch => write!(f, "credential not valid for this pod"),
|
||||
Self::AttemptInactive => write!(f, "attempt inactive"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for CredentialError {}
|
||||
|
||||
/// The verifiable claims inside a signed execution credential.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct CredentialClaims {
|
||||
/// The project this execution is scoped to.
|
||||
pub project_id: Uuid,
|
||||
/// The todo being serviced.
|
||||
pub todo_id: Uuid,
|
||||
/// The unique run identifier.
|
||||
pub run_id: Uuid,
|
||||
/// Attempt number (1-indexed).
|
||||
pub attempt_id: u32,
|
||||
/// The pod this credential is bound to.
|
||||
pub pod_id: Uuid,
|
||||
/// Agent type classification.
|
||||
pub agent_type: AgentType,
|
||||
/// Tools this principal is authorized to invoke.
|
||||
pub allowed_tools: Vec<String>,
|
||||
/// Absolute deadline after which this principal is invalid.
|
||||
pub expires_at: DateTime<Utc>,
|
||||
/// When this credential was minted.
|
||||
pub issued_at: DateTime<Utc>,
|
||||
/// Unique credential identifier for revocation tracking.
|
||||
pub credential_id: Uuid,
|
||||
}
|
||||
|
||||
/// A signed execution credential that binds an agent run attempt to a specific
|
||||
/// pod, project, and tool set. The credential is HMAC-SHA256 signed by the
|
||||
/// dispatcher and verified by the API before any tool execution is authorized.
|
||||
///
|
||||
/// # Format
|
||||
///
|
||||
/// The credential string is `base64(payload).base64(hmac_signature)` where
|
||||
/// payload is the JSON-serialized `CredentialClaims`.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SignedCredential {
|
||||
/// The base64-encoded JSON payload.
|
||||
pub payload: String,
|
||||
/// The base64-encoded HMAC-SHA256 signature.
|
||||
pub signature: String,
|
||||
}
|
||||
|
||||
impl SignedCredential {
|
||||
/// Parse a credential string into its two base64 components.
|
||||
pub fn parse(credential: &str) -> Option<(&str, &str)> {
|
||||
let (payload, sig) = credential.split_once('.')?;
|
||||
if payload.is_empty() || sig.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some((payload, sig))
|
||||
}
|
||||
}
|
||||
|
||||
/// A cryptographically bounded execution identity for agent runs.
|
||||
///
|
||||
/// Instead of trusting agent-supplied request headers or model-generated IDs
|
||||
/// for mutation authorization, every tool execution carries a signed
|
||||
/// `ExecutionPrincipal` that is minted by the dispatcher at dispatch time and
|
||||
/// verified before any state mutation. This prevents agents from escalating
|
||||
/// privileges or spoofing their identity.
|
||||
///
|
||||
/// # Fields
|
||||
///
|
||||
/// * `subject` – The agent type that was authorized to run.
|
||||
/// * `project_id` – The project this execution is scoped to.
|
||||
/// * `todo_id` – The specific todo this run is servicing.
|
||||
/// * `run_id` – The unique run identifier minted by the dispatcher.
|
||||
/// * `attempt_id` – Monotonic attempt counter for this run (1-indexed).
|
||||
/// * `pod_id` – The pod this credential is bound to.
|
||||
/// * `agent_type` – The agent type classification for tool-visibility checks.
|
||||
/// * `allowed_tools` – Explicit tool allow-list derived from the agent policy.
|
||||
/// * `expires_at` – Absolute deadline after which the principal is no longer valid.
|
||||
/// * `issued_at` – Timestamp when this principal was minted.
|
||||
/// * `credential_id` – Unique identifier for revocation tracking.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ExecutionPrincipal {
|
||||
/// The identity subject (agent type string).
|
||||
pub subject: String,
|
||||
/// Project scope for this execution.
|
||||
pub project_id: Uuid,
|
||||
/// The todo being serviced.
|
||||
pub todo_id: Uuid,
|
||||
/// The unique run identifier.
|
||||
pub run_id: Uuid,
|
||||
/// Attempt number (1-indexed).
|
||||
pub attempt_id: u32,
|
||||
/// The pod this credential is bound to.
|
||||
pub pod_id: Uuid,
|
||||
/// Agent type classification.
|
||||
pub agent_type: AgentType,
|
||||
/// Tools this principal is authorized to invoke.
|
||||
pub allowed_tools: Vec<String>,
|
||||
/// Absolute deadline after which this principal is invalid.
|
||||
pub expires_at: DateTime<Utc>,
|
||||
/// When this principal was minted.
|
||||
pub issued_at: DateTime<Utc>,
|
||||
/// Unique credential identifier for revocation tracking.
|
||||
pub credential_id: Uuid,
|
||||
}
|
||||
|
||||
impl ExecutionPrincipal {
|
||||
/// Mint a new principal for a dispatch attempt.
|
||||
pub fn mint(
|
||||
project_id: Uuid,
|
||||
todo_id: Uuid,
|
||||
run_id: Uuid,
|
||||
attempt_id: u32,
|
||||
pod_id: Uuid,
|
||||
agent_type: AgentType,
|
||||
allowed_tools: Vec<String>,
|
||||
timeout_secs: u64,
|
||||
) -> Self {
|
||||
let now = Utc::now();
|
||||
Self {
|
||||
subject: agent_type.as_str().to_string(),
|
||||
project_id,
|
||||
todo_id,
|
||||
run_id,
|
||||
attempt_id,
|
||||
pod_id,
|
||||
agent_type,
|
||||
allowed_tools,
|
||||
expires_at: now + chrono::Duration::seconds(timeout_secs as i64),
|
||||
issued_at: now,
|
||||
credential_id: Uuid::new_v4(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Check whether this principal is still valid at the given timestamp.
|
||||
pub fn is_valid_at(&self, now: DateTime<Utc>) -> bool {
|
||||
now <= self.expires_at
|
||||
}
|
||||
|
||||
/// Check whether the given tool is in the allow-list.
|
||||
pub fn can_invoke_tool(&self, tool_name: &str) -> bool {
|
||||
self.allowed_tools.iter().any(|t| t == tool_name)
|
||||
}
|
||||
|
||||
/// Convert this principal into a `CredentialClaims` suitable for signing.
|
||||
pub fn to_claims(&self) -> CredentialClaims {
|
||||
CredentialClaims {
|
||||
project_id: self.project_id,
|
||||
todo_id: self.todo_id,
|
||||
run_id: self.run_id,
|
||||
attempt_id: self.attempt_id,
|
||||
pod_id: self.pod_id,
|
||||
agent_type: self.agent_type.clone(),
|
||||
allowed_tools: self.allowed_tools.clone(),
|
||||
expires_at: self.expires_at,
|
||||
issued_at: self.issued_at,
|
||||
credential_id: self.credential_id,
|
||||
}
|
||||
}
|
||||
|
||||
/// Create an expired principal (for testing).
|
||||
#[cfg(test)]
|
||||
pub fn expired(project_id: Uuid, todo_id: Uuid, run_id: Uuid) -> Self {
|
||||
Self {
|
||||
subject: "test".to_string(),
|
||||
project_id,
|
||||
todo_id,
|
||||
run_id,
|
||||
attempt_id: 1,
|
||||
pod_id: Uuid::new_v4(),
|
||||
agent_type: AgentType::BuildAgent,
|
||||
allowed_tools: vec![],
|
||||
expires_at: Utc::now() - chrono::Duration::hours(1),
|
||||
issued_at: Utc::now() - chrono::Duration::hours(2),
|
||||
credential_id: Uuid::new_v4(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Sign a principal's claims with HMAC-SHA256, producing a `SignedCredential`.
|
||||
///
|
||||
/// The shared secret is held only by the Reef server; Pods receive the
|
||||
/// credential string and verify it via `verify_credential`.
|
||||
pub fn sign_credential(
|
||||
principal: &ExecutionPrincipal,
|
||||
secret: &[u8],
|
||||
) -> Result<String, CredentialError> {
|
||||
use base64::Engine;
|
||||
use hmac::{Hmac, Mac};
|
||||
use sha2::Sha256;
|
||||
|
||||
let claims = principal.to_claims();
|
||||
let payload_json = serde_json::to_vec(&claims).map_err(|_| CredentialError::MalformedPayload)?;
|
||||
let payload_b64 = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(&payload_json);
|
||||
|
||||
let mut mac =
|
||||
Hmac::<Sha256>::new_from_slice(secret).map_err(|_| CredentialError::MalformedPayload)?;
|
||||
mac.update(payload_b64.as_bytes());
|
||||
let signature = mac.finalize().into_bytes();
|
||||
let sig_b64 = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(signature);
|
||||
|
||||
Ok(format!("{}.{}", payload_b64, sig_b64))
|
||||
}
|
||||
|
||||
/// Verify and decode a signed credential, returning the claims if valid.
|
||||
///
|
||||
/// Checks performed:
|
||||
/// 1. Signature verification (HMAC-SHA256)
|
||||
/// 2. Expiry verification
|
||||
/// 3. Pod identity binding
|
||||
///
|
||||
/// Revocation and attempt-active checks require external state (database) and
|
||||
/// must be performed by the caller after this returns `Ok`.
|
||||
pub fn verify_credential(
|
||||
credential: &str,
|
||||
secret: &[u8],
|
||||
expected_pod_id: Uuid,
|
||||
) -> Result<CredentialClaims, CredentialError> {
|
||||
use base64::Engine;
|
||||
use hmac::{Hmac, Mac};
|
||||
use sha2::Sha256;
|
||||
|
||||
let (payload_b64, sig_b64) = SignedCredential::parse(credential)
|
||||
.ok_or(CredentialError::MalformedPayload)?;
|
||||
|
||||
// Decode payload
|
||||
let payload_bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD
|
||||
.decode(payload_b64)
|
||||
.map_err(|_| CredentialError::MalformedPayload)?;
|
||||
let claims: CredentialClaims =
|
||||
serde_json::from_slice(&payload_bytes).map_err(|_| CredentialError::MalformedPayload)?;
|
||||
|
||||
// Verify signature
|
||||
let mut mac =
|
||||
Hmac::<Sha256>::new_from_slice(secret).map_err(|_| CredentialError::MalformedPayload)?;
|
||||
mac.update(payload_b64.as_bytes());
|
||||
let sig_bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD
|
||||
.decode(sig_b64)
|
||||
.map_err(|_| CredentialError::MalformedPayload)?;
|
||||
mac.verify_slice(&sig_bytes)
|
||||
.map_err(|_| CredentialError::InvalidSignature)?;
|
||||
|
||||
// Check expiry
|
||||
if Utc::now() > claims.expires_at {
|
||||
return Err(CredentialError::Expired);
|
||||
}
|
||||
|
||||
// Check pod binding
|
||||
if claims.pod_id != expected_pod_id {
|
||||
return Err(CredentialError::PodMismatch);
|
||||
}
|
||||
|
||||
Ok(claims)
|
||||
}
|
||||
|
||||
/// Convert verified `CredentialClaims` back into an `ExecutionPrincipal`.
|
||||
impl From<CredentialClaims> for ExecutionPrincipal {
|
||||
fn from(claims: CredentialClaims) -> Self {
|
||||
Self {
|
||||
subject: claims.agent_type.as_str().to_string(),
|
||||
project_id: claims.project_id,
|
||||
todo_id: claims.todo_id,
|
||||
run_id: claims.run_id,
|
||||
attempt_id: claims.attempt_id,
|
||||
pod_id: claims.pod_id,
|
||||
agent_type: claims.agent_type,
|
||||
allowed_tools: claims.allowed_tools,
|
||||
expires_at: claims.expires_at,
|
||||
issued_at: claims.issued_at,
|
||||
credential_id: claims.credential_id,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn test_secret() -> Vec<u8> {
|
||||
b"test-secret-key-for-hmac-signing-operations".to_vec()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_mint_principal() {
|
||||
let project_id = Uuid::new_v4();
|
||||
let todo_id = Uuid::new_v4();
|
||||
let run_id = Uuid::new_v4();
|
||||
let pod_id = Uuid::new_v4();
|
||||
|
||||
let principal = ExecutionPrincipal::mint(
|
||||
project_id,
|
||||
todo_id,
|
||||
run_id,
|
||||
1,
|
||||
pod_id,
|
||||
AgentType::BuildAgent,
|
||||
vec!["read_file".to_string(), "write_file".to_string()],
|
||||
3600,
|
||||
);
|
||||
|
||||
assert_eq!(principal.subject, "build_agent");
|
||||
assert_eq!(principal.project_id, project_id);
|
||||
assert_eq!(principal.todo_id, todo_id);
|
||||
assert_eq!(principal.run_id, run_id);
|
||||
assert_eq!(principal.attempt_id, 1);
|
||||
assert_eq!(principal.pod_id, pod_id);
|
||||
assert!(principal.is_valid_at(Utc::now()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_tool_authorization() {
|
||||
let principal = ExecutionPrincipal::mint(
|
||||
Uuid::new_v4(),
|
||||
Uuid::new_v4(),
|
||||
Uuid::new_v4(),
|
||||
1,
|
||||
Uuid::new_v4(),
|
||||
AgentType::ExploreAgent,
|
||||
vec!["read_file".to_string(), "search_files".to_string()],
|
||||
3600,
|
||||
);
|
||||
|
||||
assert!(principal.can_invoke_tool("read_file"));
|
||||
assert!(principal.can_invoke_tool("search_files"));
|
||||
assert!(!principal.can_invoke_tool("write_file"));
|
||||
assert!(!principal.can_invoke_tool("edit_file"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_empty_allowed_tools_denies_all() {
|
||||
let principal = ExecutionPrincipal::mint(
|
||||
Uuid::new_v4(),
|
||||
Uuid::new_v4(),
|
||||
Uuid::new_v4(),
|
||||
1,
|
||||
Uuid::new_v4(),
|
||||
AgentType::BuildAgent,
|
||||
vec![],
|
||||
3600,
|
||||
);
|
||||
|
||||
assert!(!principal.can_invoke_tool("any_tool"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_expired_principal() {
|
||||
let project_id = Uuid::new_v4();
|
||||
let todo_id = Uuid::new_v4();
|
||||
let run_id = Uuid::new_v4();
|
||||
let principal = ExecutionPrincipal::expired(project_id, todo_id, run_id);
|
||||
|
||||
assert!(!principal.is_valid_at(Utc::now()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_serialization_roundtrip() {
|
||||
let principal = ExecutionPrincipal::mint(
|
||||
Uuid::new_v4(),
|
||||
Uuid::new_v4(),
|
||||
Uuid::new_v4(),
|
||||
2,
|
||||
Uuid::new_v4(),
|
||||
AgentType::Planner,
|
||||
vec!["read_file".to_string()],
|
||||
600,
|
||||
);
|
||||
|
||||
let json = serde_json::to_string(&principal).unwrap();
|
||||
let deserialized: ExecutionPrincipal = serde_json::from_str(&json).unwrap();
|
||||
|
||||
assert_eq!(principal.subject, deserialized.subject);
|
||||
assert_eq!(principal.project_id, deserialized.project_id);
|
||||
assert_eq!(principal.run_id, deserialized.run_id);
|
||||
assert_eq!(principal.attempt_id, deserialized.attempt_id);
|
||||
assert_eq!(principal.pod_id, deserialized.pod_id);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sign_and_verify_credential() {
|
||||
let secret = test_secret();
|
||||
let pod_id = Uuid::new_v4();
|
||||
let principal = ExecutionPrincipal::mint(
|
||||
Uuid::new_v4(),
|
||||
Uuid::new_v4(),
|
||||
Uuid::new_v4(),
|
||||
1,
|
||||
pod_id,
|
||||
AgentType::BuildAgent,
|
||||
vec!["read_file".to_string()],
|
||||
3600,
|
||||
);
|
||||
|
||||
let credential = sign_credential(&principal, &secret).unwrap();
|
||||
let claims = verify_credential(&credential, &secret, pod_id).unwrap();
|
||||
|
||||
assert_eq!(claims.project_id, principal.project_id);
|
||||
assert_eq!(claims.todo_id, principal.todo_id);
|
||||
assert_eq!(claims.run_id, principal.run_id);
|
||||
assert_eq!(claims.attempt_id, 1);
|
||||
assert_eq!(claims.pod_id, pod_id);
|
||||
assert_eq!(claims.agent_type, AgentType::BuildAgent);
|
||||
assert_eq!(claims.allowed_tools, vec!["read_file".to_string()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_verify_rejects_wrong_signature() {
|
||||
let secret = test_secret();
|
||||
let wrong_secret = b"wrong-secret-key-for-hmac-signing-operations";
|
||||
let pod_id = Uuid::new_v4();
|
||||
let principal = ExecutionPrincipal::mint(
|
||||
Uuid::new_v4(),
|
||||
Uuid::new_v4(),
|
||||
Uuid::new_v4(),
|
||||
1,
|
||||
pod_id,
|
||||
AgentType::BuildAgent,
|
||||
vec![],
|
||||
3600,
|
||||
);
|
||||
|
||||
let credential = sign_credential(&principal, &secret).unwrap();
|
||||
let result = verify_credential(&credential, wrong_secret, pod_id);
|
||||
assert_eq!(result.unwrap_err(), CredentialError::InvalidSignature);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_verify_rejects_wrong_pod() {
|
||||
let secret = test_secret();
|
||||
let pod_id = Uuid::new_v4();
|
||||
let wrong_pod_id = Uuid::new_v4();
|
||||
let principal = ExecutionPrincipal::mint(
|
||||
Uuid::new_v4(),
|
||||
Uuid::new_v4(),
|
||||
Uuid::new_v4(),
|
||||
1,
|
||||
pod_id,
|
||||
AgentType::BuildAgent,
|
||||
vec![],
|
||||
3600,
|
||||
);
|
||||
|
||||
let credential = sign_credential(&principal, &secret).unwrap();
|
||||
let result = verify_credential(&credential, &secret, wrong_pod_id);
|
||||
assert_eq!(result.unwrap_err(), CredentialError::PodMismatch);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_verify_rejects_expired_credential() {
|
||||
let secret = test_secret();
|
||||
let pod_id = Uuid::new_v4();
|
||||
let mut principal = ExecutionPrincipal::mint(
|
||||
Uuid::new_v4(),
|
||||
Uuid::new_v4(),
|
||||
Uuid::new_v4(),
|
||||
1,
|
||||
pod_id,
|
||||
AgentType::BuildAgent,
|
||||
vec![],
|
||||
3600,
|
||||
);
|
||||
// Force expiry
|
||||
principal.expires_at = Utc::now() - chrono::Duration::hours(1);
|
||||
|
||||
let credential = sign_credential(&principal, &secret).unwrap();
|
||||
let result = verify_credential(&credential, &secret, pod_id);
|
||||
assert_eq!(result.unwrap_err(), CredentialError::Expired);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_verify_rejects_malformed_credential() {
|
||||
let result = verify_credential("not-a-valid-credential", b"secret", Uuid::new_v4());
|
||||
assert_eq!(result.unwrap_err(), CredentialError::MalformedPayload);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_verify_rejects_tampered_payload() {
|
||||
let secret = test_secret();
|
||||
let pod_id = Uuid::new_v4();
|
||||
let principal = ExecutionPrincipal::mint(
|
||||
Uuid::new_v4(),
|
||||
Uuid::new_v4(),
|
||||
Uuid::new_v4(),
|
||||
1,
|
||||
pod_id,
|
||||
AgentType::BuildAgent,
|
||||
vec![],
|
||||
3600,
|
||||
);
|
||||
|
||||
let credential = sign_credential(&principal, &secret).unwrap();
|
||||
// Tamper with the payload
|
||||
let parts: Vec<&str> = credential.splitn(2, '.').collect();
|
||||
let tampered = format!("{}.{}", parts[0], parts[1]);
|
||||
// Modify a char in the signature to invalidate it
|
||||
let mut sig_chars: Vec<char> = tampered.chars().collect();
|
||||
if let Some(c) = sig_chars.last_mut() {
|
||||
*c = if *c == 'A' { 'B' } else { 'A' };
|
||||
}
|
||||
let tampered_credential: String = sig_chars.iter().collect();
|
||||
let result = verify_credential(&tampered_credential, &secret, pod_id);
|
||||
assert_eq!(result.unwrap_err(), CredentialError::InvalidSignature);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_credential_claims_roundtrip() {
|
||||
let principal = ExecutionPrincipal::mint(
|
||||
Uuid::new_v4(),
|
||||
Uuid::new_v4(),
|
||||
Uuid::new_v4(),
|
||||
3,
|
||||
Uuid::new_v4(),
|
||||
AgentType::Planner,
|
||||
vec!["read_file".to_string(), "kanban_create_todo".to_string()],
|
||||
600,
|
||||
);
|
||||
|
||||
let claims = principal.to_claims();
|
||||
let restored: ExecutionPrincipal = claims.into();
|
||||
|
||||
assert_eq!(restored.project_id, principal.project_id);
|
||||
assert_eq!(restored.run_id, principal.run_id);
|
||||
assert_eq!(restored.attempt_id, 3);
|
||||
assert_eq!(restored.agent_type, AgentType::Planner);
|
||||
assert_eq!(
|
||||
restored.allowed_tools,
|
||||
vec!["read_file".to_string(), "kanban_create_todo".to_string()]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_credential_format_is_two_base64_parts() {
|
||||
let secret = test_secret();
|
||||
let principal = ExecutionPrincipal::mint(
|
||||
Uuid::new_v4(),
|
||||
Uuid::new_v4(),
|
||||
Uuid::new_v4(),
|
||||
1,
|
||||
Uuid::new_v4(),
|
||||
AgentType::BuildAgent,
|
||||
vec![],
|
||||
3600,
|
||||
);
|
||||
|
||||
let credential = sign_credential(&principal, &secret).unwrap();
|
||||
let (payload, sig) = SignedCredential::parse(&credential).unwrap();
|
||||
assert!(!payload.is_empty());
|
||||
assert!(!sig.is_empty());
|
||||
// Verify both parts are valid base64
|
||||
use base64::Engine;
|
||||
assert!(base64::engine::general_purpose::URL_SAFE_NO_PAD
|
||||
.decode(payload)
|
||||
.is_ok());
|
||||
assert!(base64::engine::general_purpose::URL_SAFE_NO_PAD
|
||||
.decode(sig)
|
||||
.is_ok());
|
||||
}
|
||||
}
|
||||
24
src/sync.rs
24
src/sync.rs
|
|
@ -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)]
|
||||
|
|
|
|||
19
src/task.rs
19
src/task.rs
|
|
@ -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,
|
||||
|
|
|
|||
26
src/todo.rs
26
src/todo.rs
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
24
src/tools.rs
24
src/tools.rs
|
|
@ -1865,7 +1865,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 +1873,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!(
|
||||
|
|
@ -2071,8 +2071,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 +2432,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"]
|
||||
.as_str()
|
||||
.unwrap_or("")
|
||||
.contains("🎉")
|
||||
);
|
||||
assert!(results[0].payload["content"]
|
||||
.as_str()
|
||||
.unwrap_or("")
|
||||
.contains("🎉"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
|
|||
Loading…
Reference in a new issue