General Improvements

This commit is contained in:
Alex 2026-08-03 00:32:05 +02:00
commit 9824a32add
Signed by: alex
SSH key fingerprint: SHA256:D1+Ub8o0v4K5y1JNivW8IxEOelqLSvPmUzBbDIoZkRQ
15 changed files with 1376 additions and 48 deletions

66
Cargo.lock generated
View file

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

View file

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

View file

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

View file

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

393
src/data_retention.rs Normal file
View 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);
}
}

View file

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

View file

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

View file

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

View file

@ -7,10 +7,12 @@ pub mod agents;
pub mod ai_response;
pub mod conclusion;
pub mod coral;
pub mod data_retention;
pub mod enums;
pub mod errors;
pub mod krill;
pub mod mutations;
pub mod principal;
pub mod project;
pub mod sandbox;
pub mod sync;
@ -26,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!(

View file

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

588
src/principal.rs Normal file
View 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());
}
}

View file

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

View file

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

View file

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

View file

@ -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]