From ec4d8d92950e255ebec292c5f443524ad8e5e4d0 Mon Sep 17 00:00:00 2001 From: Codex Date: Sun, 19 Jul 2026 17:51:41 +0800 Subject: [PATCH 1/5] feat: add task capability manifest contract --- apps/desktop/src-tauri/src/kernel/mod.rs | 1 + apps/desktop/src-tauri/src/kernel/policy.rs | 27 + .../src/kernel/task_capability_manifest.rs | 1995 +++++++++++++++++ scripts/release-source-check.mjs | 41 + 4 files changed, 2064 insertions(+) create mode 100644 apps/desktop/src-tauri/src/kernel/task_capability_manifest.rs diff --git a/apps/desktop/src-tauri/src/kernel/mod.rs b/apps/desktop/src-tauri/src/kernel/mod.rs index 6600c71..b400690 100644 --- a/apps/desktop/src-tauri/src/kernel/mod.rs +++ b/apps/desktop/src-tauri/src/kernel/mod.rs @@ -31,6 +31,7 @@ pub mod sandbox; pub mod skill; pub mod skill_source; pub mod soul; +pub mod task_capability_manifest; pub mod task_lifecycle; pub mod tool_runtime; pub mod tool_strategy; diff --git a/apps/desktop/src-tauri/src/kernel/policy.rs b/apps/desktop/src-tauri/src/kernel/policy.rs index 8909091..d49113b 100644 --- a/apps/desktop/src-tauri/src/kernel/policy.rs +++ b/apps/desktop/src-tauri/src/kernel/policy.rs @@ -32,6 +32,33 @@ pub enum CapabilityKind { SkillUse, } +impl CapabilityKind { + pub const fn as_str(self) -> &'static str { + match self { + Self::FileRead => "file_read", + Self::FileWrite => "file_write", + Self::NetworkSearch => "network_search", + Self::BrowserBrowse => "browser_browse", + Self::BrowserSubmit => "browser_submit", + Self::EmailRead => "email_read", + Self::EmailDraft => "email_draft", + Self::EmailSend => "email_send", + Self::ConnectorAttachmentRead => "connector_attachment_read", + Self::ConnectorWrite => "connector_write", + Self::DriveRead => "drive_read", + Self::DriveWrite => "drive_write", + Self::TerminalRead => "terminal_read", + Self::TerminalWrite => "terminal_write", + Self::ComputerScreenshot => "computer_screenshot", + Self::ComputerControl => "computer_control", + Self::AppUpdateCheck => "app_update_check", + Self::AppUpdateDownload => "app_update_download", + Self::AppUpdateInstall => "app_update_install", + Self::SkillUse => "skill_use", + } + } +} + #[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] #[serde(rename_all = "snake_case")] pub enum CapabilityFamily { diff --git a/apps/desktop/src-tauri/src/kernel/task_capability_manifest.rs b/apps/desktop/src-tauri/src/kernel/task_capability_manifest.rs new file mode 100644 index 0000000..72727f1 --- /dev/null +++ b/apps/desktop/src-tauri/src/kernel/task_capability_manifest.rs @@ -0,0 +1,1995 @@ +use std::collections::{BTreeMap, BTreeSet}; +use std::fmt; + +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use sha2::{Digest, Sha256}; +use uuid::Uuid; + +use crate::kernel::goal_lifecycle::{GoalLifecycleProjection, GoalTargetBindingKind}; +use crate::kernel::policy::{ + builtin_capability_catalog, capability_risk, CapabilityDescriptor, CapabilityKind, RiskLevel, +}; +use crate::kernel::tool_runtime::{builtin_tool_catalog, ToolContract}; + +pub const TASK_CAPABILITY_MANIFEST_VERSION: &str = "ds-agent.task-capability-manifest/v1"; +pub const TASK_CAPABILITY_MANIFEST_SCHEMA_REVISION: u32 = 1; +pub const TASK_AUTHORIZATION_PREVIEW_VERSION: &str = "ds-agent.task-authorization-preview/v1"; +pub const TASK_AUTHORIZATION_PREVIEW_SCHEMA_REVISION: u32 = 1; +pub const TASK_AUTHORIZATION_PREVIEW_RENDERER_REVISION: u32 = 1; + +const MAX_JSON_BYTES: usize = 64 * 1024; +const MAX_CAPABILITIES: usize = 32; +const MAX_ITEMS_PER_FIELD: usize = 64; +const MAX_ID_BYTES: usize = 128; +const MAX_DISPLAY_BYTES: usize = 512; +const MANIFEST_REVISION_DOMAIN: &[u8] = b"ds-agent.task-capability-manifest-revision.v1\0"; +const MANIFEST_FINGERPRINT_DOMAIN: &[u8] = b"ds-agent.task-capability-manifest-fingerprint.v1\0"; +const PREVIEW_HASH_DOMAIN: &[u8] = b"ds-agent.task-authorization-preview-hash.v1\0"; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum TaskCapabilityManifestError { + JsonTooLarge, + InvalidJson, + UnsupportedVersion, + InvalidIdentifier, + InvalidBindingHash, + InvalidDisplay, + SecretLikeContent, + CollectionOutOfBounds, + DuplicateValue, + NonCanonicalOrder, + GoalNotFrozen, + TaskGoalIdentityMismatch, + GoalRevisionMismatch, + GoalFingerprintMismatch, + UnknownCapability, + UnknownTool, + ToolCapabilityMismatch, + CatalogRiskMismatch, + GoalCapabilityMismatch, + ScopeBindingMismatch, + UnknownApplication, + UnknownVerifier, + IncompleteGoalBinding, + ManifestIntegrityMismatch, + PreviewIntegrityMismatch, +} + +impl fmt::Display for TaskCapabilityManifestError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(match self { + Self::JsonTooLarge => "task capability manifest json is too large", + Self::InvalidJson => "task capability manifest json is invalid", + Self::UnsupportedVersion => "task capability manifest version is unsupported", + Self::InvalidIdentifier => "task capability manifest contains an invalid identifier", + Self::InvalidBindingHash => "task capability manifest contains an invalid binding hash", + Self::InvalidDisplay => "task capability manifest contains invalid display text", + Self::SecretLikeContent => "task capability manifest contains secret-like content", + Self::CollectionOutOfBounds => { + "task capability manifest collection is outside its bounds" + } + Self::DuplicateValue => "task capability manifest contains a duplicate value", + Self::NonCanonicalOrder => "task capability manifest is not canonically ordered", + Self::GoalNotFrozen => "task capability manifest requires a frozen goal", + Self::TaskGoalIdentityMismatch => { + "task capability manifest task or goal identity is stale" + } + Self::GoalRevisionMismatch => "task capability manifest goal revision is stale", + Self::GoalFingerprintMismatch => "task capability manifest goal fingerprint is stale", + Self::UnknownCapability => "task capability manifest capability is unknown", + Self::UnknownTool => "task capability manifest tool is unknown", + Self::ToolCapabilityMismatch => { + "task capability manifest tool and capability do not match" + } + Self::CatalogRiskMismatch => "task capability manifest catalog risk is inconsistent", + Self::GoalCapabilityMismatch => { + "task capability manifest exceeds or omits the frozen goal capability scope" + } + Self::ScopeBindingMismatch => "task capability manifest scope binding is stale", + Self::UnknownApplication => "task capability manifest application is not locally bound", + Self::UnknownVerifier => "task capability manifest verifier is not frozen in the goal", + Self::IncompleteGoalBinding => { + "task capability manifest does not bind the complete frozen goal" + } + Self::ManifestIntegrityMismatch => "task capability manifest integrity check failed", + Self::PreviewIntegrityMismatch => "task authorization preview integrity check failed", + }) + } +} + +impl std::error::Error for TaskCapabilityManifestError {} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +pub struct TaskCapabilityNeedProposal { + pub capability: String, + pub tool_ids: Vec, + pub application_ids: Vec, + pub path_target_ids: Vec, + pub account_target_ids: Vec, + pub recipient_target_ids: Vec, + pub time_window_target_ids: Vec, + pub external_target_ids: Vec, + pub verifier_ids: Vec, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +pub struct TaskCapabilityManifestProposal { + pub version: String, + pub task_id: Uuid, + pub goal_id: Uuid, + pub goal_revision: String, + pub goal_fingerprint: String, + pub expires_at: DateTime, + pub capabilities: Vec, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct TaskCapabilityManifestProposalWire { + version: String, + task_id: Uuid, + goal_id: Uuid, + goal_revision: String, + goal_fingerprint: String, + expires_at: DateTime, + capabilities: Vec, +} + +impl From for TaskCapabilityManifestProposal { + fn from(wire: TaskCapabilityManifestProposalWire) -> Self { + Self { + version: wire.version, + task_id: wire.task_id, + goal_id: wire.goal_id, + goal_revision: wire.goal_revision, + goal_fingerprint: wire.goal_fingerprint, + expires_at: wire.expires_at, + capabilities: wire.capabilities, + } + } +} + +impl<'de> Deserialize<'de> for TaskCapabilityManifestProposal { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let proposal = Self::from(TaskCapabilityManifestProposalWire::deserialize( + deserializer, + )?); + proposal.validate().map_err(serde::de::Error::custom)?; + Ok(proposal) + } +} + +impl TaskCapabilityManifestProposal { + pub fn parse_json(json: &str) -> Result { + if json.len() > MAX_JSON_BYTES { + return Err(TaskCapabilityManifestError::JsonTooLarge); + } + let wire: TaskCapabilityManifestProposalWire = + serde_json::from_str(json).map_err(|_| TaskCapabilityManifestError::InvalidJson)?; + let proposal = Self::from(wire); + proposal.validate()?; + Ok(proposal) + } + + pub fn parse_value(value: Value) -> Result { + let encoded = + serde_json::to_vec(&value).map_err(|_| TaskCapabilityManifestError::InvalidJson)?; + if encoded.len() > MAX_JSON_BYTES { + return Err(TaskCapabilityManifestError::JsonTooLarge); + } + let wire: TaskCapabilityManifestProposalWire = + serde_json::from_value(value).map_err(|_| TaskCapabilityManifestError::InvalidJson)?; + let proposal = Self::from(wire); + proposal.validate()?; + Ok(proposal) + } + + pub fn to_json(&self) -> Result { + self.validate()?; + serde_json::to_string(self).map_err(|_| TaskCapabilityManifestError::InvalidJson) + } + + pub fn validate(&self) -> Result<(), TaskCapabilityManifestError> { + if self.version != TASK_CAPABILITY_MANIFEST_VERSION { + return Err(TaskCapabilityManifestError::UnsupportedVersion); + } + if self.task_id != self.goal_id { + return Err(TaskCapabilityManifestError::TaskGoalIdentityMismatch); + } + validate_hash(&self.goal_revision)?; + validate_hash(&self.goal_fingerprint)?; + if self.capabilities.is_empty() || self.capabilities.len() > MAX_CAPABILITIES { + return Err(TaskCapabilityManifestError::CollectionOutOfBounds); + } + + validate_strict_order( + self.capabilities + .iter() + .map(|entry| entry.capability.as_str()), + )?; + let capability_catalog = builtin_capability_catalog(); + for entry in &self.capabilities { + validate_id(&entry.capability)?; + if capability_from_name(&capability_catalog, &entry.capability).is_none() { + return Err(TaskCapabilityManifestError::UnknownCapability); + } + validate_nonempty_id_list(&entry.tool_ids)?; + validate_id_list(&entry.application_ids)?; + validate_id_list(&entry.path_target_ids)?; + validate_id_list(&entry.account_target_ids)?; + validate_id_list(&entry.recipient_target_ids)?; + validate_id_list(&entry.time_window_target_ids)?; + validate_id_list(&entry.external_target_ids)?; + validate_nonempty_id_list(&entry.verifier_ids)?; + for target_id in entry + .path_target_ids + .iter() + .chain(&entry.account_target_ids) + .chain(&entry.recipient_target_ids) + .chain(&entry.time_window_target_ids) + { + if entry.external_target_ids.binary_search(target_id).is_err() { + return Err(TaskCapabilityManifestError::IncompleteGoalBinding); + } + } + } + let encoded = + serde_json::to_vec(self).map_err(|_| TaskCapabilityManifestError::InvalidJson)?; + if encoded.len() > MAX_JSON_BYTES { + return Err(TaskCapabilityManifestError::JsonTooLarge); + } + Ok(()) + } +} + +#[derive(Clone, Debug, Default)] +pub struct TaskCapabilityManifestContext { + application_labels: BTreeMap, + target_labels: BTreeMap, +} + +impl TaskCapabilityManifestContext { + pub fn with_application( + mut self, + application_id: impl Into, + display_label: impl Into, + ) -> Self { + self.application_labels + .insert(application_id.into(), display_label.into()); + self + } + + pub fn with_target_display( + mut self, + target_id: impl Into, + display_label: impl Into, + ) -> Self { + self.target_labels + .insert(target_id.into(), display_label.into()); + self + } +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +pub struct TaskToolBinding { + pub tool_id: String, + pub tool_version: String, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +pub struct TaskApplicationBinding { + pub application_id: String, + pub display_label: String, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +pub struct TaskScopeBinding { + pub target_id: String, + pub binding_kind: GoalTargetBindingKind, + pub display_label: String, + pub authority_fingerprint: String, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +pub struct TaskVerifierSummary { + pub verifier_id: String, + pub evidence_kind: String, + pub summary: String, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +pub struct TaskCapabilityManifestEntry { + pub capability: CapabilityKind, + pub risk_level: RiskLevel, + pub tools: Vec, + pub applications: Vec, + pub path_scopes: Vec, + pub account_scopes: Vec, + pub recipient_scopes: Vec, + pub time_windows: Vec, + pub external_targets: Vec, + pub verifiers: Vec, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +pub struct TaskCapabilityManifest { + pub version: String, + pub schema_revision: u32, + pub task_id: Uuid, + pub goal_id: Uuid, + pub goal_revision: String, + pub goal_fingerprint: String, + pub expires_at: DateTime, + pub aggregate_risk: RiskLevel, + pub capabilities: Vec, + pub revision: String, + pub fingerprint: String, +} + +impl TaskCapabilityManifest { + pub fn parse_json(json: &str) -> Result { + if json.len() > MAX_JSON_BYTES { + return Err(TaskCapabilityManifestError::JsonTooLarge); + } + let manifest: Self = + serde_json::from_str(json).map_err(|_| TaskCapabilityManifestError::InvalidJson)?; + manifest.validate_integrity()?; + Ok(manifest) + } + + pub fn canonical_json(&self) -> Result { + self.validate_integrity()?; + serde_json::to_string(self).map_err(|_| TaskCapabilityManifestError::InvalidJson) + } + + pub fn validate_integrity(&self) -> Result<(), TaskCapabilityManifestError> { + if self.version != TASK_CAPABILITY_MANIFEST_VERSION + || self.schema_revision != TASK_CAPABILITY_MANIFEST_SCHEMA_REVISION + { + return Err(TaskCapabilityManifestError::UnsupportedVersion); + } + if self.task_id != self.goal_id { + return Err(TaskCapabilityManifestError::TaskGoalIdentityMismatch); + } + validate_hash(&self.goal_revision)?; + validate_hash(&self.goal_fingerprint)?; + validate_hash(&self.revision)?; + validate_hash(&self.fingerprint)?; + if self.capabilities.is_empty() || self.capabilities.len() > MAX_CAPABILITIES { + return Err(TaskCapabilityManifestError::CollectionOutOfBounds); + } + validate_strict_order( + self.capabilities + .iter() + .map(|entry| entry.capability.as_str()), + )?; + let tool_catalog = builtin_tool_catalog() + .into_iter() + .map(|tool| (tool.id.clone(), tool)) + .collect::>(); + let capability_catalog = builtin_capability_catalog(); + let mut aggregate = RiskLevel::Low; + let mut seen_tools = BTreeSet::new(); + for entry in &self.capabilities { + let descriptor = capability_catalog + .iter() + .find(|descriptor| descriptor.capability == entry.capability) + .ok_or(TaskCapabilityManifestError::UnknownCapability)?; + if entry.risk_level != capability_risk(entry.capability) + || entry.risk_level != descriptor.risk_level + { + return Err(TaskCapabilityManifestError::CatalogRiskMismatch); + } + if risk_rank(entry.risk_level) > risk_rank(aggregate) { + aggregate = entry.risk_level; + } + validate_tool_bindings(&entry.tools)?; + for tool in &entry.tools { + let contract = tool_catalog + .get(&tool.tool_id) + .ok_or(TaskCapabilityManifestError::UnknownTool)?; + if contract.version != tool.tool_version + || contract.capability != entry.capability + || contract.risk_level != entry.risk_level + { + return Err(TaskCapabilityManifestError::ToolCapabilityMismatch); + } + if !seen_tools.insert(tool.tool_id.as_str()) { + return Err(TaskCapabilityManifestError::DuplicateValue); + } + } + validate_application_bindings(&entry.applications)?; + validate_scope_bindings(&entry.path_scopes, scope_is_path)?; + validate_scope_bindings(&entry.account_scopes, |kind| { + kind == GoalTargetBindingKind::Account + })?; + validate_scope_bindings(&entry.recipient_scopes, |kind| { + kind == GoalTargetBindingKind::Recipient + })?; + validate_scope_bindings(&entry.time_windows, |kind| { + kind == GoalTargetBindingKind::TimeWindow + })?; + validate_scope_bindings(&entry.external_targets, |_| true)?; + validate_verifier_summaries(&entry.verifiers)?; + for scope in entry + .path_scopes + .iter() + .chain(&entry.account_scopes) + .chain(&entry.recipient_scopes) + .chain(&entry.time_windows) + { + if !entry.external_targets.contains(scope) { + return Err(TaskCapabilityManifestError::IncompleteGoalBinding); + } + } + } + if aggregate != self.aggregate_risk { + return Err(TaskCapabilityManifestError::CatalogRiskMismatch); + } + if self.revision != manifest_revision_for(self) + || self.fingerprint != manifest_fingerprint_for(self) + { + return Err(TaskCapabilityManifestError::ManifestIntegrityMismatch); + } + Ok(()) + } + + pub fn validate_for_goal( + &self, + goal: &GoalLifecycleProjection, + ) -> Result<(), TaskCapabilityManifestError> { + validate_manifest_against_goal( + self, + goal, + &builtin_tool_catalog(), + &builtin_capability_catalog(), + ) + } +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +pub struct TaskPreviewLabel { + pub id: String, + pub display_label: String, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +pub struct TaskCapabilityPreviewEntry { + pub capability: CapabilityKind, + pub risk_level: RiskLevel, + pub applications: Vec, + pub path_scopes: Vec, + pub account_scopes: Vec, + pub recipient_scopes: Vec, + pub time_windows: Vec, + pub external_targets: Vec, + pub verifiers: Vec, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +pub struct TaskAuthorizationPreview { + pub version: String, + pub schema_revision: u32, + pub renderer_revision: u32, + pub task_id: Uuid, + pub goal_id: Uuid, + pub goal_revision: String, + pub goal_fingerprint: String, + pub manifest_revision: String, + pub manifest_fingerprint: String, + pub expires_at: DateTime, + pub aggregate_risk: RiskLevel, + pub capabilities: Vec, + pub preview_hash: String, +} + +impl TaskAuthorizationPreview { + pub fn parse_json(json: &str) -> Result { + if json.len() > MAX_JSON_BYTES { + return Err(TaskCapabilityManifestError::JsonTooLarge); + } + let preview: Self = + serde_json::from_str(json).map_err(|_| TaskCapabilityManifestError::InvalidJson)?; + preview.validate_integrity()?; + Ok(preview) + } + + pub fn canonical_json(&self) -> Result { + self.validate_integrity()?; + serde_json::to_string(self).map_err(|_| TaskCapabilityManifestError::InvalidJson) + } + + pub fn validate_integrity(&self) -> Result<(), TaskCapabilityManifestError> { + if self.version != TASK_AUTHORIZATION_PREVIEW_VERSION + || self.schema_revision != TASK_AUTHORIZATION_PREVIEW_SCHEMA_REVISION + || self.renderer_revision != TASK_AUTHORIZATION_PREVIEW_RENDERER_REVISION + { + return Err(TaskCapabilityManifestError::UnsupportedVersion); + } + if self.task_id != self.goal_id { + return Err(TaskCapabilityManifestError::TaskGoalIdentityMismatch); + } + for hash in [ + &self.goal_revision, + &self.goal_fingerprint, + &self.manifest_revision, + &self.manifest_fingerprint, + &self.preview_hash, + ] { + validate_hash(hash)?; + } + if self.capabilities.is_empty() || self.capabilities.len() > MAX_CAPABILITIES { + return Err(TaskCapabilityManifestError::CollectionOutOfBounds); + } + validate_strict_order( + self.capabilities + .iter() + .map(|entry| entry.capability.as_str()), + )?; + let mut aggregate = RiskLevel::Low; + for entry in &self.capabilities { + if entry.risk_level != capability_risk(entry.capability) { + return Err(TaskCapabilityManifestError::CatalogRiskMismatch); + } + if risk_rank(entry.risk_level) > risk_rank(aggregate) { + aggregate = entry.risk_level; + } + validate_preview_labels(&entry.applications)?; + validate_preview_labels(&entry.path_scopes)?; + validate_preview_labels(&entry.account_scopes)?; + validate_preview_labels(&entry.recipient_scopes)?; + validate_preview_labels(&entry.time_windows)?; + validate_preview_labels(&entry.external_targets)?; + validate_verifier_summaries(&entry.verifiers)?; + } + if self.aggregate_risk != aggregate { + return Err(TaskCapabilityManifestError::CatalogRiskMismatch); + } + if self.preview_hash != preview_hash_for(self) { + return Err(TaskCapabilityManifestError::PreviewIntegrityMismatch); + } + Ok(()) + } + + pub fn validate_for_manifest( + &self, + manifest: &TaskCapabilityManifest, + ) -> Result<(), TaskCapabilityManifestError> { + let expected = task_authorization_preview(manifest)?; + if *self != expected { + return Err(TaskCapabilityManifestError::PreviewIntegrityMismatch); + } + Ok(()) + } +} + +pub fn compile_task_capability_manifest( + canonical_task_id: Uuid, + goal: &GoalLifecycleProjection, + proposal: &TaskCapabilityManifestProposal, + context: &TaskCapabilityManifestContext, +) -> Result { + let tool_catalog = builtin_tool_catalog(); + let capability_catalog = builtin_capability_catalog(); + compile_with_catalogs( + canonical_task_id, + goal, + proposal, + context, + &tool_catalog, + &capability_catalog, + ) +} + +pub fn task_authorization_preview( + manifest: &TaskCapabilityManifest, +) -> Result { + manifest.validate_integrity()?; + let capabilities = manifest + .capabilities + .iter() + .map(|entry| TaskCapabilityPreviewEntry { + capability: entry.capability, + risk_level: entry.risk_level, + applications: entry + .applications + .iter() + .map(|binding| TaskPreviewLabel { + id: binding.application_id.clone(), + display_label: binding.display_label.clone(), + }) + .collect(), + path_scopes: preview_scope_labels(&entry.path_scopes), + account_scopes: preview_scope_labels(&entry.account_scopes), + recipient_scopes: preview_scope_labels(&entry.recipient_scopes), + time_windows: preview_scope_labels(&entry.time_windows), + external_targets: preview_scope_labels(&entry.external_targets), + verifiers: entry.verifiers.clone(), + }) + .collect(); + let mut preview = TaskAuthorizationPreview { + version: TASK_AUTHORIZATION_PREVIEW_VERSION.to_string(), + schema_revision: TASK_AUTHORIZATION_PREVIEW_SCHEMA_REVISION, + renderer_revision: TASK_AUTHORIZATION_PREVIEW_RENDERER_REVISION, + task_id: manifest.task_id, + goal_id: manifest.goal_id, + goal_revision: manifest.goal_revision.clone(), + goal_fingerprint: manifest.goal_fingerprint.clone(), + manifest_revision: manifest.revision.clone(), + manifest_fingerprint: manifest.fingerprint.clone(), + expires_at: manifest.expires_at, + aggregate_risk: manifest.aggregate_risk, + capabilities, + preview_hash: String::new(), + }; + preview.preview_hash = preview_hash_for(&preview); + Ok(preview) +} + +fn compile_with_catalogs( + canonical_task_id: Uuid, + goal: &GoalLifecycleProjection, + proposal: &TaskCapabilityManifestProposal, + context: &TaskCapabilityManifestContext, + tool_catalog: &[ToolContract], + capability_catalog: &[CapabilityDescriptor], +) -> Result { + proposal.validate()?; + let frozen = goal + .frozen() + .ok_or(TaskCapabilityManifestError::GoalNotFrozen)?; + if canonical_task_id != goal.goal_id + || proposal.task_id != canonical_task_id + || proposal.goal_id != goal.goal_id + { + return Err(TaskCapabilityManifestError::TaskGoalIdentityMismatch); + } + if proposal.goal_revision != frozen.revision { + return Err(TaskCapabilityManifestError::GoalRevisionMismatch); + } + if proposal.goal_fingerprint != frozen.fingerprint { + return Err(TaskCapabilityManifestError::GoalFingerprintMismatch); + } + + let goal_capabilities = frozen + .envelope + .validated_capabilities + .iter() + .map(|capability| (capability.tool_id.as_str(), capability)) + .collect::>(); + let goal_targets = frozen + .envelope + .bound_targets + .iter() + .map(|target| (target.target_id.as_str(), target)) + .collect::>(); + let goal_verifiers = frozen + .envelope + .verifiers + .iter() + .map(|verifier| (verifier.verifier_id.as_str(), verifier)) + .collect::>(); + let tools = tool_catalog + .iter() + .map(|tool| (tool.id.as_str(), tool)) + .collect::>(); + let mut covered_tools = BTreeSet::new(); + let mut covered_targets = BTreeSet::new(); + let mut covered_verifiers = BTreeSet::new(); + let mut aggregate_risk = RiskLevel::Low; + let mut capabilities = Vec::with_capacity(proposal.capabilities.len()); + + for entry in &proposal.capabilities { + let capability = capability_from_name(capability_catalog, &entry.capability) + .ok_or(TaskCapabilityManifestError::UnknownCapability)?; + let descriptor = capability_catalog + .iter() + .find(|descriptor| descriptor.capability == capability) + .ok_or(TaskCapabilityManifestError::UnknownCapability)?; + let risk_level = capability_risk(capability); + if descriptor.risk_level != risk_level { + return Err(TaskCapabilityManifestError::CatalogRiskMismatch); + } + if risk_rank(risk_level) > risk_rank(aggregate_risk) { + aggregate_risk = risk_level; + } + + let mut tool_bindings = Vec::with_capacity(entry.tool_ids.len()); + for tool_id in &entry.tool_ids { + let tool = tools + .get(tool_id.as_str()) + .ok_or(TaskCapabilityManifestError::UnknownTool)?; + if tool.capability != capability { + return Err(TaskCapabilityManifestError::ToolCapabilityMismatch); + } + if tool.risk_level != risk_level { + return Err(TaskCapabilityManifestError::CatalogRiskMismatch); + } + let goal_capability = goal_capabilities + .get(tool_id.as_str()) + .ok_or(TaskCapabilityManifestError::GoalCapabilityMismatch)?; + if goal_capability.capability != capability.as_str() + || goal_capability.risk_level != risk_level + { + return Err(TaskCapabilityManifestError::GoalCapabilityMismatch); + } + if !covered_tools.insert(tool_id.as_str()) { + return Err(TaskCapabilityManifestError::DuplicateValue); + } + tool_bindings.push(TaskToolBinding { + tool_id: tool.id.clone(), + tool_version: tool.version.clone(), + }); + } + + let applications = entry + .application_ids + .iter() + .map(|application_id| { + let display_label = context + .application_labels + .get(application_id) + .ok_or(TaskCapabilityManifestError::UnknownApplication)?; + validate_display(display_label)?; + Ok(TaskApplicationBinding { + application_id: application_id.clone(), + display_label: display_label.clone(), + }) + }) + .collect::, TaskCapabilityManifestError>>()?; + let path_scopes = compile_scopes( + &entry.path_target_ids, + &goal_targets, + context, + scope_is_path, + )?; + let account_scopes = + compile_scopes(&entry.account_target_ids, &goal_targets, context, |kind| { + kind == GoalTargetBindingKind::Account + })?; + let recipient_scopes = compile_scopes( + &entry.recipient_target_ids, + &goal_targets, + context, + |kind| kind == GoalTargetBindingKind::Recipient, + )?; + let time_windows = compile_scopes( + &entry.time_window_target_ids, + &goal_targets, + context, + |kind| kind == GoalTargetBindingKind::TimeWindow, + )?; + let external_targets = + compile_scopes(&entry.external_target_ids, &goal_targets, context, |_| true)?; + covered_targets.extend(entry.external_target_ids.iter().map(String::as_str)); + + let verifiers = entry + .verifier_ids + .iter() + .map(|verifier_id| { + let verifier = goal_verifiers + .get(verifier_id.as_str()) + .ok_or(TaskCapabilityManifestError::UnknownVerifier)?; + covered_verifiers.insert(verifier_id.as_str()); + validate_display(&verifier.description)?; + Ok(TaskVerifierSummary { + verifier_id: verifier.verifier_id.clone(), + evidence_kind: verifier.evidence_kind.clone(), + summary: verifier.description.clone(), + }) + }) + .collect::, TaskCapabilityManifestError>>()?; + + capabilities.push(TaskCapabilityManifestEntry { + capability, + risk_level, + tools: tool_bindings, + applications, + path_scopes, + account_scopes, + recipient_scopes, + time_windows, + external_targets, + verifiers, + }); + } + + if covered_tools != goal_capabilities.keys().copied().collect() + || covered_targets != goal_targets.keys().copied().collect() + || covered_verifiers != goal_verifiers.keys().copied().collect() + { + return Err(TaskCapabilityManifestError::IncompleteGoalBinding); + } + + let mut manifest = TaskCapabilityManifest { + version: TASK_CAPABILITY_MANIFEST_VERSION.to_string(), + schema_revision: TASK_CAPABILITY_MANIFEST_SCHEMA_REVISION, + task_id: canonical_task_id, + goal_id: goal.goal_id, + goal_revision: frozen.revision.clone(), + goal_fingerprint: frozen.fingerprint.clone(), + expires_at: proposal.expires_at, + aggregate_risk, + capabilities, + revision: String::new(), + fingerprint: String::new(), + }; + manifest.revision = manifest_revision_for(&manifest); + manifest.fingerprint = manifest_fingerprint_for(&manifest); + manifest.validate_integrity()?; + validate_manifest_against_goal(&manifest, goal, tool_catalog, capability_catalog)?; + Ok(manifest) +} + +fn validate_manifest_against_goal( + manifest: &TaskCapabilityManifest, + goal: &GoalLifecycleProjection, + tool_catalog: &[ToolContract], + capability_catalog: &[CapabilityDescriptor], +) -> Result<(), TaskCapabilityManifestError> { + manifest.validate_integrity()?; + let frozen = goal + .frozen() + .ok_or(TaskCapabilityManifestError::GoalNotFrozen)?; + if manifest.task_id != goal.goal_id || manifest.goal_id != goal.goal_id { + return Err(TaskCapabilityManifestError::TaskGoalIdentityMismatch); + } + if manifest.goal_revision != frozen.revision { + return Err(TaskCapabilityManifestError::GoalRevisionMismatch); + } + if manifest.goal_fingerprint != frozen.fingerprint { + return Err(TaskCapabilityManifestError::GoalFingerprintMismatch); + } + let expected_tools = frozen + .envelope + .validated_capabilities + .iter() + .map(|capability| capability.tool_id.as_str()) + .collect::>(); + let manifest_tools = manifest + .capabilities + .iter() + .flat_map(|entry| entry.tools.iter().map(|tool| tool.tool_id.as_str())) + .collect::>(); + if manifest_tools != expected_tools { + return Err(TaskCapabilityManifestError::GoalCapabilityMismatch); + } + let expected_targets = frozen + .envelope + .bound_targets + .iter() + .map(|target| (target.target_id.as_str(), target)) + .collect::>(); + let expected_verifiers = frozen + .envelope + .verifiers + .iter() + .map(|verifier| (verifier.verifier_id.as_str(), verifier)) + .collect::>(); + let mut manifest_targets = BTreeSet::new(); + let mut manifest_verifiers = BTreeSet::new(); + for entry in &manifest.capabilities { + let descriptor = capability_catalog + .iter() + .find(|descriptor| descriptor.capability == entry.capability) + .ok_or(TaskCapabilityManifestError::UnknownCapability)?; + if descriptor.risk_level != entry.risk_level + || capability_risk(entry.capability) != entry.risk_level + { + return Err(TaskCapabilityManifestError::CatalogRiskMismatch); + } + for tool in &entry.tools { + let contract = tool_catalog + .iter() + .find(|contract| contract.id == tool.tool_id) + .ok_or(TaskCapabilityManifestError::UnknownTool)?; + if contract.version != tool.tool_version + || contract.capability != entry.capability + || contract.risk_level != entry.risk_level + { + return Err(TaskCapabilityManifestError::ToolCapabilityMismatch); + } + let goal_capability = frozen + .envelope + .validated_capabilities + .iter() + .find(|capability| capability.tool_id == tool.tool_id) + .ok_or(TaskCapabilityManifestError::GoalCapabilityMismatch)?; + if goal_capability.capability != entry.capability.as_str() + || goal_capability.risk_level != entry.risk_level + { + return Err(TaskCapabilityManifestError::GoalCapabilityMismatch); + } + } + for scope in entry + .path_scopes + .iter() + .chain(&entry.account_scopes) + .chain(&entry.recipient_scopes) + .chain(&entry.time_windows) + .chain(&entry.external_targets) + { + let target = expected_targets + .get(scope.target_id.as_str()) + .ok_or(TaskCapabilityManifestError::ScopeBindingMismatch)?; + if target.binding_kind != scope.binding_kind + || target.authority_fingerprint != scope.authority_fingerprint + { + return Err(TaskCapabilityManifestError::ScopeBindingMismatch); + } + } + manifest_targets.extend( + entry + .external_targets + .iter() + .map(|target| target.target_id.as_str()), + ); + for verifier in &entry.verifiers { + let expected = expected_verifiers + .get(verifier.verifier_id.as_str()) + .ok_or(TaskCapabilityManifestError::UnknownVerifier)?; + if expected.evidence_kind != verifier.evidence_kind + || expected.description != verifier.summary + { + return Err(TaskCapabilityManifestError::UnknownVerifier); + } + manifest_verifiers.insert(verifier.verifier_id.as_str()); + } + } + if manifest_targets != expected_targets.keys().copied().collect() + || manifest_verifiers != expected_verifiers.keys().copied().collect() + { + return Err(TaskCapabilityManifestError::IncompleteGoalBinding); + } + Ok(()) +} + +fn compile_scopes( + target_ids: &[String], + goal_targets: &BTreeMap<&str, &crate::kernel::goal_lifecycle::BoundGoalTarget>, + context: &TaskCapabilityManifestContext, + kind_allowed: impl Fn(GoalTargetBindingKind) -> bool, +) -> Result, TaskCapabilityManifestError> { + target_ids + .iter() + .map(|target_id| { + let target = goal_targets + .get(target_id.as_str()) + .ok_or(TaskCapabilityManifestError::ScopeBindingMismatch)?; + if !kind_allowed(target.binding_kind) { + return Err(TaskCapabilityManifestError::ScopeBindingMismatch); + } + validate_hash(&target.authority_fingerprint)?; + let display_label = context + .target_labels + .get(target_id) + .ok_or(TaskCapabilityManifestError::ScopeBindingMismatch)?; + validate_display(display_label)?; + Ok(TaskScopeBinding { + target_id: target_id.clone(), + binding_kind: target.binding_kind, + display_label: display_label.clone(), + authority_fingerprint: target.authority_fingerprint.clone(), + }) + }) + .collect() +} + +fn capability_from_name(catalog: &[CapabilityDescriptor], name: &str) -> Option { + catalog + .iter() + .find(|descriptor| descriptor.capability.as_str() == name) + .map(|descriptor| descriptor.capability) +} + +fn preview_scope_labels(bindings: &[TaskScopeBinding]) -> Vec { + bindings + .iter() + .map(|binding| TaskPreviewLabel { + id: binding.target_id.clone(), + display_label: binding.display_label.clone(), + }) + .collect() +} + +#[derive(Serialize)] +struct ManifestRevisionCanonical<'a> { + version: &'a str, + schema_revision: u32, + task_id: Uuid, + goal_id: Uuid, + goal_revision: &'a str, + goal_fingerprint: &'a str, + expires_at: DateTime, + aggregate_risk: RiskLevel, + capabilities: &'a [TaskCapabilityManifestEntry], +} + +#[derive(Serialize)] +struct ManifestFingerprintCanonical<'a> { + #[serde(flatten)] + manifest: ManifestRevisionCanonical<'a>, + revision: &'a str, +} + +#[derive(Serialize)] +struct PreviewCanonical<'a> { + version: &'a str, + schema_revision: u32, + renderer_revision: u32, + task_id: Uuid, + goal_id: Uuid, + goal_revision: &'a str, + goal_fingerprint: &'a str, + manifest_revision: &'a str, + manifest_fingerprint: &'a str, + expires_at: DateTime, + aggregate_risk: RiskLevel, + capabilities: &'a [TaskCapabilityPreviewEntry], +} + +fn manifest_revision_canonical(manifest: &TaskCapabilityManifest) -> ManifestRevisionCanonical<'_> { + ManifestRevisionCanonical { + version: &manifest.version, + schema_revision: manifest.schema_revision, + task_id: manifest.task_id, + goal_id: manifest.goal_id, + goal_revision: &manifest.goal_revision, + goal_fingerprint: &manifest.goal_fingerprint, + expires_at: manifest.expires_at, + aggregate_risk: manifest.aggregate_risk, + capabilities: &manifest.capabilities, + } +} + +fn manifest_revision_for(manifest: &TaskCapabilityManifest) -> String { + domain_hash( + MANIFEST_REVISION_DOMAIN, + &serde_json::to_vec(&manifest_revision_canonical(manifest)).unwrap_or_default(), + ) +} + +fn manifest_fingerprint_for(manifest: &TaskCapabilityManifest) -> String { + domain_hash( + MANIFEST_FINGERPRINT_DOMAIN, + &serde_json::to_vec(&ManifestFingerprintCanonical { + manifest: manifest_revision_canonical(manifest), + revision: &manifest.revision, + }) + .unwrap_or_default(), + ) +} + +fn preview_hash_for(preview: &TaskAuthorizationPreview) -> String { + domain_hash( + PREVIEW_HASH_DOMAIN, + &serde_json::to_vec(&PreviewCanonical { + version: &preview.version, + schema_revision: preview.schema_revision, + renderer_revision: preview.renderer_revision, + task_id: preview.task_id, + goal_id: preview.goal_id, + goal_revision: &preview.goal_revision, + goal_fingerprint: &preview.goal_fingerprint, + manifest_revision: &preview.manifest_revision, + manifest_fingerprint: &preview.manifest_fingerprint, + expires_at: preview.expires_at, + aggregate_risk: preview.aggregate_risk, + capabilities: &preview.capabilities, + }) + .unwrap_or_default(), + ) +} + +fn domain_hash(domain: &[u8], value: &[u8]) -> String { + let mut digest = Sha256::new(); + digest.update(domain); + digest.update((value.len() as u64).to_be_bytes()); + digest.update(value); + format!("{:x}", digest.finalize()) +} + +fn validate_hash(value: &str) -> Result<(), TaskCapabilityManifestError> { + if value.len() != 64 + || !value + .bytes() + .all(|byte| byte.is_ascii_digit() || matches!(byte, b'a'..=b'f')) + { + return Err(TaskCapabilityManifestError::InvalidBindingHash); + } + Ok(()) +} + +fn validate_id(value: &str) -> Result<(), TaskCapabilityManifestError> { + if value.is_empty() + || value != value.trim() + || value.len() > MAX_ID_BYTES + || !value + .bytes() + .next() + .is_some_and(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit()) + || !value.bytes().all(|byte| { + byte.is_ascii_lowercase() || byte.is_ascii_digit() || matches!(byte, b'.' | b'_' | b'-') + }) + { + return Err(TaskCapabilityManifestError::InvalidIdentifier); + } + if contains_secret_like_content(value) { + return Err(TaskCapabilityManifestError::SecretLikeContent); + } + Ok(()) +} + +fn validate_display(value: &str) -> Result<(), TaskCapabilityManifestError> { + if value.is_empty() + || value != value.trim() + || value.len() > MAX_DISPLAY_BYTES + || value.chars().any(char::is_control) + { + return Err(TaskCapabilityManifestError::InvalidDisplay); + } + if contains_secret_like_content(value) || contains_private_internal_reference(value) { + return Err(TaskCapabilityManifestError::SecretLikeContent); + } + Ok(()) +} + +fn validate_id_list(values: &[String]) -> Result<(), TaskCapabilityManifestError> { + if values.len() > MAX_ITEMS_PER_FIELD { + return Err(TaskCapabilityManifestError::CollectionOutOfBounds); + } + for value in values { + validate_id(value)?; + } + validate_strict_order(values.iter().map(String::as_str)) +} + +fn validate_nonempty_id_list(values: &[String]) -> Result<(), TaskCapabilityManifestError> { + if values.is_empty() { + return Err(TaskCapabilityManifestError::CollectionOutOfBounds); + } + validate_id_list(values) +} + +fn validate_strict_order<'a>( + values: impl IntoIterator, +) -> Result<(), TaskCapabilityManifestError> { + let mut previous: Option<&str> = None; + for value in values { + if let Some(previous) = previous { + if previous == value { + return Err(TaskCapabilityManifestError::DuplicateValue); + } + if previous > value { + return Err(TaskCapabilityManifestError::NonCanonicalOrder); + } + } + previous = Some(value); + } + Ok(()) +} + +fn validate_tool_bindings(values: &[TaskToolBinding]) -> Result<(), TaskCapabilityManifestError> { + if values.is_empty() || values.len() > MAX_ITEMS_PER_FIELD { + return Err(TaskCapabilityManifestError::CollectionOutOfBounds); + } + validate_strict_order(values.iter().map(|value| value.tool_id.as_str()))?; + for value in values { + validate_id(&value.tool_id)?; + validate_id(&value.tool_version)?; + } + Ok(()) +} + +fn validate_application_bindings( + values: &[TaskApplicationBinding], +) -> Result<(), TaskCapabilityManifestError> { + if values.len() > MAX_ITEMS_PER_FIELD { + return Err(TaskCapabilityManifestError::CollectionOutOfBounds); + } + validate_strict_order(values.iter().map(|value| value.application_id.as_str()))?; + for value in values { + validate_id(&value.application_id)?; + validate_display(&value.display_label)?; + } + Ok(()) +} + +fn validate_scope_bindings( + values: &[TaskScopeBinding], + kind_allowed: impl Fn(GoalTargetBindingKind) -> bool, +) -> Result<(), TaskCapabilityManifestError> { + if values.len() > MAX_ITEMS_PER_FIELD { + return Err(TaskCapabilityManifestError::CollectionOutOfBounds); + } + validate_strict_order(values.iter().map(|value| value.target_id.as_str()))?; + for value in values { + validate_id(&value.target_id)?; + validate_display(&value.display_label)?; + validate_hash(&value.authority_fingerprint)?; + if !kind_allowed(value.binding_kind) { + return Err(TaskCapabilityManifestError::ScopeBindingMismatch); + } + } + Ok(()) +} + +fn validate_verifier_summaries( + values: &[TaskVerifierSummary], +) -> Result<(), TaskCapabilityManifestError> { + if values.is_empty() || values.len() > MAX_ITEMS_PER_FIELD { + return Err(TaskCapabilityManifestError::CollectionOutOfBounds); + } + validate_strict_order(values.iter().map(|value| value.verifier_id.as_str()))?; + for value in values { + validate_id(&value.verifier_id)?; + validate_id(&value.evidence_kind)?; + validate_display(&value.summary)?; + } + Ok(()) +} + +fn validate_preview_labels(values: &[TaskPreviewLabel]) -> Result<(), TaskCapabilityManifestError> { + if values.len() > MAX_ITEMS_PER_FIELD { + return Err(TaskCapabilityManifestError::CollectionOutOfBounds); + } + validate_strict_order(values.iter().map(|value| value.id.as_str()))?; + for value in values { + validate_id(&value.id)?; + validate_display(&value.display_label)?; + } + Ok(()) +} + +fn scope_is_path(kind: GoalTargetBindingKind) -> bool { + matches!( + kind, + GoalTargetBindingKind::Workspace | GoalTargetBindingKind::Path + ) +} + +fn risk_rank(risk: RiskLevel) -> u8 { + match risk { + RiskLevel::Low => 0, + RiskLevel::Medium => 1, + RiskLevel::High => 2, + RiskLevel::Critical => 3, + } +} + +fn contains_private_internal_reference(value: &str) -> bool { + let lower = value.to_ascii_lowercase(); + let bytes = lower.as_bytes(); + let windows_absolute = bytes.windows(3).any(|window| { + window[0].is_ascii_alphabetic() && window[1] == b':' && matches!(window[2], b'\\' | b'/') + }); + windows_absolute + || lower.starts_with("/") + || lower.starts_with("\\\\") + || lower.contains("\\appdata\\") + || lower.contains("/appdata/") + || lower.contains("/users/") + || lower.contains("/home/") + || lower.contains("vault://") + || lower.contains("provider_ref") + || lower.contains("provider-ref") + || lower.contains("provider://") + || lower.contains("remote_ref") + || lower.contains("remote-ref") + || lower.contains("credential_handle") + || lower.contains("credential-handle") + || lower.contains("credential handle") + || lower.contains("claim_token") + || lower.contains("claim-token") +} + +fn contains_secret_like_content(value: &str) -> bool { + let lower = value.to_ascii_lowercase(); + contains_token_after(&lower, "bearer ", 12) + || contains_token_after(&lower, "api_key=", 12) + || contains_token_after(&lower, "api_key:", 12) + || contains_token_after(&lower, "api-key=", 12) + || contains_token_after(&lower, "api-key:", 12) + || contains_token_after(&lower, "password=", 12) + || contains_token_after(&lower, "password:", 12) + || contains_token_after(&lower, "secret=", 12) + || contains_token_after(&lower, "secret:", 12) + || contains_token_after(&lower, "token=", 12) + || contains_token_after(&lower, "token:", 12) + || lower.match_indices("sk-").any(|(index, _)| { + lower[index + 3..] + .bytes() + .take_while(|byte| byte.is_ascii_alphanumeric() || matches!(*byte, b'_' | b'-')) + .count() + >= 12 + }) +} + +fn contains_token_after(value: &str, marker: &str, minimum_length: usize) -> bool { + value.match_indices(marker).any(|(index, _)| { + value[index + marker.len()..] + .trim_start_matches([' ', '\'', '"']) + .bytes() + .take_while(|byte| byte.is_ascii_alphanumeric() || matches!(*byte, b'_' | b'-' | b'.')) + .count() + >= minimum_length + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::kernel::event_store::EventStore; + use crate::kernel::goal_envelope::{ + GoalDoneWhenProposal, GoalEnvelopeProposal, GoalExternalTargetProposal, + GoalVerifierProposal, GOAL_ENVELOPE_PROPOSAL_VERSION, + }; + use crate::kernel::goal_lifecycle::{GoalTargetBindingKind, GoalValidationContext}; + use crate::kernel::local_directory::WorkspaceReadinessCode; + use crate::kernel::models::AccessMode; + use crate::kernel::policy::{ + exact_tool_preview_hash, CapabilityAccessRequest, PermissionResolution, + TOOL_APPROVAL_PREVIEW_REVISION, + }; + use crate::kernel::tool_runtime::{CONNECTOR_MUTATE_TOOL_ID, FILE_WRITE_TOOL_ID}; + use tempfile::tempdir; + + const TASK_ID: Uuid = Uuid::from_u128(0x31a); + + fn frozen_goal_for(store: &EventStore, task_id: Uuid) -> GoalLifecycleProjection { + let proposal = GoalEnvelopeProposal { + version: GOAL_ENVELOPE_PROPOSAL_VERSION.to_string(), + user_goal: "Create a verified report and send one approved external update." + .to_string(), + assumptions: Vec::new(), + constraints: vec!["Use only locally bound task scopes.".to_string()], + done_when: vec![ + GoalDoneWhenProposal { + done_when_id: "external-state-verified".to_string(), + description: "The external state is independently verified.".to_string(), + }, + GoalDoneWhenProposal { + done_when_id: "report-verified".to_string(), + description: "The local report is independently verified.".to_string(), + }, + ], + required_artifacts: Vec::new(), + verifiers: vec![ + GoalVerifierProposal { + verifier_id: "external-verifier-v1".to_string(), + done_when_id: "external-state-verified".to_string(), + description: "Verify the remote object state after the operation.".to_string(), + evidence_kind: "connector_remote_state".to_string(), + }, + GoalVerifierProposal { + verifier_id: "report-verifier-v1".to_string(), + done_when_id: "report-verified".to_string(), + description: "Verify the report hash and rendered output.".to_string(), + evidence_kind: "artifact_hash".to_string(), + }, + ], + proposed_capabilities: vec![ + CONNECTOR_MUTATE_TOOL_ID.to_string(), + FILE_WRITE_TOOL_ID.to_string(), + ], + external_targets: vec![ + GoalExternalTargetProposal { + target_id: "finance-account".to_string(), + description: "A locally bound external account.".to_string(), + }, + GoalExternalTargetProposal { + target_id: "finance-recipient".to_string(), + description: "A locally bound recipient.".to_string(), + }, + GoalExternalTargetProposal { + target_id: "report-folder".to_string(), + description: "A locally bound workspace path scope.".to_string(), + }, + GoalExternalTargetProposal { + target_id: "weekday-window".to_string(), + description: "A locally bound schedule window.".to_string(), + }, + ], + stop_conditions: vec!["Stop when any exact scope changes.".to_string()], + }; + let context = + GoalValidationContext::new(AccessMode::FullAccess, WorkspaceReadinessCode::Ready) + .with_max_risk(RiskLevel::Critical) + .with_enabled_tool(CONNECTOR_MUTATE_TOOL_ID, true) + .with_enabled_tool(FILE_WRITE_TOOL_ID, true) + .with_approval_route(CONNECTOR_MUTATE_TOOL_ID) + .with_approval_route(FILE_WRITE_TOOL_ID) + .with_verifier_kind("artifact_hash") + .with_verifier_kind("connector_remote_state") + .with_target_binding( + "finance-account", + GoalTargetBindingKind::Account, + b"account-authority-v1", + ) + .with_target_binding( + "finance-recipient", + GoalTargetBindingKind::Recipient, + b"recipient-authority-v1", + ) + .with_target_binding( + "report-folder", + GoalTargetBindingKind::Path, + b"path-authority-v1", + ) + .with_target_binding( + "weekday-window", + GoalTargetBindingKind::TimeWindow, + b"time-authority-v1", + ) + .allowing_local_effects() + .allowing_external_effects(); + let validated = store + .submit_goal_proposal(task_id, &proposal, &context) + .expect("goal validates"); + store + .freeze_goal_envelope(task_id, validated.revision().expect("goal revision")) + .expect("goal freezes") + } + + fn frozen_goal(store: &EventStore) -> GoalLifecycleProjection { + frozen_goal_for(store, TASK_ID) + } + + fn manifest_context() -> TaskCapabilityManifestContext { + TaskCapabilityManifestContext::default() + .with_application("excel", "Microsoft Excel") + .with_application("outlook", "Microsoft Outlook") + .with_target_display("finance-account", "Work mailbox account") + .with_target_display("finance-recipient", "finance-test@example.com") + .with_target_display("report-folder", "Workspace / reports") + .with_target_display("weekday-window", "Weekdays 09:00-17:00 Asia/Shanghai") + } + + fn manifest_proposal(goal: &GoalLifecycleProjection) -> TaskCapabilityManifestProposal { + let frozen = goal.frozen().expect("frozen goal"); + TaskCapabilityManifestProposal { + version: TASK_CAPABILITY_MANIFEST_VERSION.to_string(), + task_id: TASK_ID, + goal_id: TASK_ID, + goal_revision: frozen.revision.clone(), + goal_fingerprint: frozen.fingerprint.clone(), + expires_at: "2030-01-02T03:04:05Z".parse().unwrap(), + capabilities: vec![ + TaskCapabilityNeedProposal { + capability: "connector_write".to_string(), + tool_ids: vec![CONNECTOR_MUTATE_TOOL_ID.to_string()], + application_ids: vec!["outlook".to_string()], + path_target_ids: Vec::new(), + account_target_ids: vec!["finance-account".to_string()], + recipient_target_ids: vec!["finance-recipient".to_string()], + time_window_target_ids: vec!["weekday-window".to_string()], + external_target_ids: vec![ + "finance-account".to_string(), + "finance-recipient".to_string(), + "weekday-window".to_string(), + ], + verifier_ids: vec!["external-verifier-v1".to_string()], + }, + TaskCapabilityNeedProposal { + capability: "file_write".to_string(), + tool_ids: vec![FILE_WRITE_TOOL_ID.to_string()], + application_ids: vec!["excel".to_string()], + path_target_ids: vec!["report-folder".to_string()], + account_target_ids: Vec::new(), + recipient_target_ids: Vec::new(), + time_window_target_ids: Vec::new(), + external_target_ids: vec!["report-folder".to_string()], + verifier_ids: vec!["report-verifier-v1".to_string()], + }, + ], + } + } + + fn compiled_fixture() -> ( + GoalLifecycleProjection, + TaskCapabilityManifestProposal, + TaskCapabilityManifestContext, + TaskCapabilityManifest, + TaskAuthorizationPreview, + ) { + let store = EventStore::open_memory().unwrap(); + let goal = frozen_goal(&store); + let proposal = manifest_proposal(&goal); + let context = manifest_context(); + let manifest = compile_task_capability_manifest(TASK_ID, &goal, &proposal, &context) + .expect("manifest compiles"); + let preview = task_authorization_preview(&manifest).expect("preview renders"); + (goal, proposal, context, manifest, preview) + } + + fn rehash_manifest(manifest: &mut TaskCapabilityManifest) { + manifest.revision = manifest_revision_for(manifest); + manifest.fingerprint = manifest_fingerprint_for(manifest); + } + + #[test] + fn strict_proposal_rejects_versions_missing_unknown_and_oversized_inputs() { + let (_, proposal, _, _, _) = compiled_fixture(); + let mut value = serde_json::to_value(&proposal).unwrap(); + value["version"] = Value::String("ds-agent.task-capability-manifest/v2".to_string()); + assert_eq!( + TaskCapabilityManifestProposal::parse_value(value), + Err(TaskCapabilityManifestError::UnsupportedVersion) + ); + + let mut missing = serde_json::to_value(&proposal).unwrap(); + missing.as_object_mut().unwrap().remove("expires_at"); + assert_eq!( + TaskCapabilityManifestProposal::parse_value(missing), + Err(TaskCapabilityManifestError::InvalidJson) + ); + + let mut unknown = serde_json::to_value(&proposal).unwrap(); + unknown["authorization"] = Value::String("approved".to_string()); + assert_eq!( + TaskCapabilityManifestProposal::parse_value(unknown), + Err(TaskCapabilityManifestError::InvalidJson) + ); + + assert_eq!( + TaskCapabilityManifestProposal::parse_json(&format!( + "{{\"padding\":\"{}\"}}", + "x".repeat(MAX_JSON_BYTES) + )), + Err(TaskCapabilityManifestError::JsonTooLarge) + ); + assert_eq!( + TaskCapabilityManifestProposal::parse_json(&proposal.to_json().unwrap()).unwrap(), + proposal + ); + } + + #[test] + fn model_and_frontend_authority_or_risk_fields_are_never_accepted() { + let (_, proposal, _, _, _) = compiled_fixture(); + for field in ["risk", "risk_level", "approved", "grant", "decision"] { + let mut top = serde_json::to_value(&proposal).unwrap(); + top[field] = Value::String("critical".to_string()); + assert_eq!( + TaskCapabilityManifestProposal::parse_value(top), + Err(TaskCapabilityManifestError::InvalidJson), + "top-level {field}" + ); + + let mut nested = serde_json::to_value(&proposal).unwrap(); + nested["capabilities"][0][field] = Value::String("low".to_string()); + assert_eq!( + TaskCapabilityManifestProposal::parse_value(nested), + Err(TaskCapabilityManifestError::InvalidJson), + "nested {field}" + ); + } + } + + #[test] + fn unknown_duplicate_and_noncanonical_capabilities_fail_closed() { + let (_, proposal, _, _, _) = compiled_fixture(); + let mut unknown = proposal.clone(); + unknown.capabilities[1].capability = "future_unknown".to_string(); + assert_eq!( + unknown.validate(), + Err(TaskCapabilityManifestError::UnknownCapability) + ); + + let mut duplicate = proposal.clone(); + duplicate.capabilities[1].capability = "connector_write".to_string(); + assert_eq!( + duplicate.validate(), + Err(TaskCapabilityManifestError::DuplicateValue) + ); + + let mut unordered = proposal.clone(); + unordered.capabilities.reverse(); + assert_eq!( + unordered.validate(), + Err(TaskCapabilityManifestError::NonCanonicalOrder) + ); + + let mut unordered_tools = proposal.clone(); + unordered_tools.capabilities[0].tool_ids = vec!["z.tool".to_string(), "a.tool".to_string()]; + assert_eq!( + unordered_tools.validate(), + Err(TaskCapabilityManifestError::NonCanonicalOrder) + ); + } + + #[test] + fn exact_task_goal_revision_and_fingerprint_are_mandatory() { + let (goal, proposal, context, _, _) = compiled_fixture(); + assert_eq!( + compile_task_capability_manifest(Uuid::from_u128(99), &goal, &proposal, &context), + Err(TaskCapabilityManifestError::TaskGoalIdentityMismatch) + ); + + let mut wrong_task = proposal.clone(); + wrong_task.task_id = Uuid::from_u128(99); + wrong_task.goal_id = wrong_task.task_id; + assert_eq!( + compile_task_capability_manifest(wrong_task.task_id, &goal, &wrong_task, &context), + Err(TaskCapabilityManifestError::TaskGoalIdentityMismatch) + ); + + let mut wrong_revision = proposal.clone(); + wrong_revision.goal_revision = "1".repeat(64); + assert_eq!( + compile_task_capability_manifest(TASK_ID, &goal, &wrong_revision, &context), + Err(TaskCapabilityManifestError::GoalRevisionMismatch) + ); + + let mut wrong_fingerprint = proposal.clone(); + wrong_fingerprint.goal_fingerprint = "2".repeat(64); + assert_eq!( + compile_task_capability_manifest(TASK_ID, &goal, &wrong_fingerprint, &context), + Err(TaskCapabilityManifestError::GoalFingerprintMismatch) + ); + + let (_, _, _, mut tampered_scope, _) = compiled_fixture(); + let entry = &mut tampered_scope.capabilities[0]; + for binding in entry + .account_scopes + .iter_mut() + .chain(entry.external_targets.iter_mut()) + .filter(|binding| binding.target_id == "finance-account") + { + binding.authority_fingerprint = "3".repeat(64); + } + rehash_manifest(&mut tampered_scope); + assert!(tampered_scope.validate_integrity().is_ok()); + assert_eq!( + tampered_scope.validate_for_goal(&goal), + Err(TaskCapabilityManifestError::ScopeBindingMismatch) + ); + + let (_, _, _, mut tampered_verifier, _) = compiled_fixture(); + tampered_verifier.capabilities[0].verifiers[0].summary = + "A different verifier summary.".to_string(); + rehash_manifest(&mut tampered_verifier); + assert!(tampered_verifier.validate_integrity().is_ok()); + assert_eq!( + tampered_verifier.validate_for_goal(&goal), + Err(TaskCapabilityManifestError::UnknownVerifier) + ); + } + + #[test] + fn kernel_catalog_alone_derives_capability_and_aggregate_risk() { + let (goal, proposal, context, manifest, preview) = compiled_fixture(); + assert_eq!(manifest.aggregate_risk, RiskLevel::Critical); + assert_eq!(manifest.capabilities[0].risk_level, RiskLevel::Critical); + assert_eq!(manifest.capabilities[1].risk_level, RiskLevel::High); + assert_eq!(preview.aggregate_risk, RiskLevel::Critical); + assert!(manifest.validate_integrity().is_ok()); + assert!(preview.validate_for_manifest(&manifest).is_ok()); + + let mut tool_catalog = builtin_tool_catalog(); + tool_catalog + .iter_mut() + .find(|tool| tool.id == FILE_WRITE_TOOL_ID) + .unwrap() + .risk_level = RiskLevel::Low; + assert_eq!( + compile_with_catalogs( + TASK_ID, + &goal, + &proposal, + &context, + &tool_catalog, + &builtin_capability_catalog(), + ), + Err(TaskCapabilityManifestError::CatalogRiskMismatch) + ); + } + + #[test] + fn capability_tool_and_complete_goal_coverage_cannot_drift() { + let (goal, proposal, context, _, _) = compiled_fixture(); + let mut mismatch = proposal.clone(); + mismatch.capabilities[0].tool_ids = vec![FILE_WRITE_TOOL_ID.to_string()]; + mismatch.capabilities[1].tool_ids = vec![CONNECTOR_MUTATE_TOOL_ID.to_string()]; + assert_eq!( + compile_task_capability_manifest(TASK_ID, &goal, &mismatch, &context), + Err(TaskCapabilityManifestError::ToolCapabilityMismatch) + ); + + let mut omitted = proposal.clone(); + omitted.capabilities.pop(); + assert_eq!( + compile_task_capability_manifest(TASK_ID, &goal, &omitted, &context), + Err(TaskCapabilityManifestError::IncompleteGoalBinding) + ); + } + + #[test] + fn path_account_recipient_schedule_expiry_application_and_verifier_drift_change_binding() { + let (goal, proposal, context, manifest, preview) = compiled_fixture(); + let variants = [ + context + .clone() + .with_target_display("report-folder", "Workspace / revised-reports"), + context + .clone() + .with_target_display("finance-account", "Revised work mailbox account"), + context + .clone() + .with_target_display("finance-recipient", "other-test@example.com"), + context + .clone() + .with_target_display("weekday-window", "Weekdays 10:00-18:00 Asia/Shanghai"), + ]; + for changed_context in variants { + let changed = + compile_task_capability_manifest(TASK_ID, &goal, &proposal, &changed_context) + .unwrap(); + assert_ne!(changed.fingerprint, manifest.fingerprint); + assert_eq!( + preview.validate_for_manifest(&changed), + Err(TaskCapabilityManifestError::PreviewIntegrityMismatch) + ); + } + + let mut changed_expiry = proposal.clone(); + changed_expiry.expires_at = "2031-01-02T03:04:05Z".parse().unwrap(); + let changed = + compile_task_capability_manifest(TASK_ID, &goal, &changed_expiry, &context).unwrap(); + assert_ne!(changed.fingerprint, manifest.fingerprint); + + let mut changed_application = proposal.clone(); + changed_application.capabilities[1].application_ids = vec!["outlook".to_string()]; + let changed = + compile_task_capability_manifest(TASK_ID, &goal, &changed_application, &context) + .unwrap(); + assert_ne!(changed.fingerprint, manifest.fingerprint); + + let mut changed_verifier = proposal.clone(); + changed_verifier.capabilities[0].verifier_ids = vec!["report-verifier-v1".to_string()]; + changed_verifier.capabilities[1].verifier_ids = vec!["external-verifier-v1".to_string()]; + let changed = + compile_task_capability_manifest(TASK_ID, &goal, &changed_verifier, &context).unwrap(); + assert_ne!(changed.fingerprint, manifest.fingerprint); + } + + #[test] + fn preview_revision_hash_tamper_replay_and_cross_domain_reuse_fail() { + let (_goal, proposal, context, manifest, preview) = compiled_fixture(); + assert_ne!(preview.preview_hash, manifest.revision); + assert_ne!(preview.preview_hash, manifest.fingerprint); + + let mut wrong_revision = preview.clone(); + wrong_revision.renderer_revision += 1; + assert_eq!( + wrong_revision.validate_integrity(), + Err(TaskCapabilityManifestError::UnsupportedVersion) + ); + + let mut tampered = preview.clone(); + tampered.capabilities[0].account_scopes[0].display_label = "Tampered account".to_string(); + assert_eq!( + tampered.validate_integrity(), + Err(TaskCapabilityManifestError::PreviewIntegrityMismatch) + ); + + let mut wrong_hash = preview.clone(); + wrong_hash.preview_hash = manifest.fingerprint.clone(); + assert_eq!( + wrong_hash.validate_integrity(), + Err(TaskCapabilityManifestError::PreviewIntegrityMismatch) + ); + + let other_store = EventStore::open_memory().unwrap(); + let other_task_id = Uuid::from_u128(0x31b); + let other_goal = frozen_goal_for(&other_store, other_task_id); + let mut replay_proposal = proposal.clone(); + replay_proposal.task_id = other_task_id; + replay_proposal.goal_id = other_task_id; + replay_proposal.goal_revision = other_goal.frozen().unwrap().revision.clone(); + replay_proposal.goal_fingerprint = other_goal.frozen().unwrap().fingerprint.clone(); + let other_manifest = compile_task_capability_manifest( + other_task_id, + &other_goal, + &replay_proposal, + &context, + ) + .unwrap(); + assert_ne!(other_manifest.fingerprint, manifest.fingerprint); + assert_eq!( + preview.validate_for_manifest(&other_manifest), + Err(TaskCapabilityManifestError::PreviewIntegrityMismatch) + ); + } + + #[test] + fn canonical_manifest_and_preview_survive_restart_deterministically() { + let directory = tempdir().unwrap(); + let database = directory.path().join("kernel.sqlite3"); + let first_manifest; + let first_preview; + let proposal; + { + let store = EventStore::open(&database).unwrap(); + let goal = frozen_goal(&store); + proposal = manifest_proposal(&goal); + first_manifest = + compile_task_capability_manifest(TASK_ID, &goal, &proposal, &manifest_context()) + .unwrap(); + first_preview = task_authorization_preview(&first_manifest).unwrap(); + } + + let reopened = EventStore::open(&database).unwrap(); + let goal = reopened + .goal_envelope_projection(TASK_ID) + .unwrap() + .expect("legacy GoalEnvelope projection reopens"); + let next_manifest = + compile_task_capability_manifest(TASK_ID, &goal, &proposal, &manifest_context()) + .unwrap(); + let next_preview = task_authorization_preview(&next_manifest).unwrap(); + assert!(next_manifest.validate_for_goal(&goal).is_ok()); + assert_eq!(next_manifest, first_manifest); + assert_eq!(next_preview, first_preview); + assert_eq!( + TaskCapabilityManifest::parse_json(&first_manifest.canonical_json().unwrap()).unwrap(), + first_manifest + ); + assert_eq!( + TaskAuthorizationPreview::parse_json(&first_preview.canonical_json().unwrap()).unwrap(), + first_preview + ); + } + + #[test] + fn secret_provider_credential_claim_and_private_path_labels_are_rejected() { + let (goal, proposal, context, manifest, preview) = compiled_fixture(); + for forbidden in [ + format!("sk-{}", "a".repeat(20)), + "provider_ref=message-123".to_string(), + "credential_handle=vault-item".to_string(), + "claim_token=opaque-claim".to_string(), + r"C:\Users\owner\AppData\Local\private".to_string(), + ] { + let changed = context + .clone() + .with_target_display("report-folder", forbidden); + assert_eq!( + compile_task_capability_manifest(TASK_ID, &goal, &proposal, &changed), + Err(TaskCapabilityManifestError::SecretLikeContent) + ); + } + + let public_preview = serde_json::to_string(&preview).unwrap(); + for forbidden in [ + "authority_fingerprint", + "tool_id", + "provider_ref", + "credential_handle", + "claim_token", + "AppData", + ] { + assert!(!public_preview.contains(forbidden)); + } + let manifest_json = serde_json::to_string(&manifest).unwrap(); + for forbidden in [ + "provider_ref", + "credential_handle", + "claim_token", + "AppData", + ] { + assert!(!manifest_json.contains(forbidden)); + } + } + + #[test] + fn malformed_proposal_creates_no_permission_execution_event_or_completion_state() { + let store = EventStore::open_memory().unwrap(); + let malformed = serde_json::json!({ + "version": TASK_CAPABILITY_MANIFEST_VERSION, + "task_id": TASK_ID, + "goal_id": TASK_ID, + "goal_revision": "1".repeat(64), + "goal_fingerprint": "2".repeat(64), + "expires_at": "2030-01-02T03:04:05Z", + "capabilities": [], + "approved": true + }); + assert!(TaskCapabilityManifestProposal::parse_value(malformed).is_err()); + assert!(store.list_recent(10).unwrap().is_empty()); + assert!(store.list_capability_access_records().unwrap().is_empty()); + assert!(store.goal_envelope_projection(TASK_ID).unwrap().is_none()); + assert!(store.goal_completion_projection(TASK_ID).unwrap().is_none()); + } + + #[test] + fn existing_exact_tool_approval_contract_remains_compatible() { + let mut request = CapabilityAccessRequest { + id: Uuid::from_u128(400), + access_mode: AccessMode::AskEveryStep, + family: crate::kernel::policy::CapabilityFamily::File, + capability: CapabilityKind::FileWrite, + title: "Write local files".to_string(), + summary: "Write one exact file.".to_string(), + risk_level: RiskLevel::High, + decision: crate::kernel::policy::PolicyDecision::Ask, + status: crate::kernel::policy::CapabilityAccessStatus::PendingApproval, + reason: "exact approval required".to_string(), + exact_tool: None, + created_at: Utc::now(), + }; + request + .bind_exact_tool(FILE_WRITE_TOOL_ID, "a".repeat(64), "Write reports/brief.md") + .unwrap(); + let scope = request.exact_tool.as_ref().unwrap(); + assert_eq!(scope.preview_revision, TOOL_APPROVAL_PREVIEW_REVISION); + assert_eq!( + scope.preview_hash, + exact_tool_preview_hash(scope.preview_revision, &scope.preview) + ); + assert!(PermissionResolution::new_exact( + request.id, + true, + "Approved exact tool".to_string(), + 0, + scope, + ) + .is_ok()); + } + + #[test] + fn manifest_or_preview_deserialization_rejects_unknown_and_tampered_fields() { + let (_, _, _, manifest, preview) = compiled_fixture(); + let mut manifest_value = serde_json::to_value(&manifest).unwrap(); + manifest_value["capabilities"][0]["approved"] = Value::Bool(true); + assert_eq!( + TaskCapabilityManifest::parse_json(&manifest_value.to_string()), + Err(TaskCapabilityManifestError::InvalidJson) + ); + + let mut tampered_manifest = manifest.clone(); + tampered_manifest.capabilities[0].risk_level = RiskLevel::Low; + assert_eq!( + tampered_manifest.validate_integrity(), + Err(TaskCapabilityManifestError::CatalogRiskMismatch) + ); + + let mut unknown_tool = manifest.clone(); + unknown_tool.capabilities[0].tools[0].tool_id = "future.unknown".to_string(); + rehash_manifest(&mut unknown_tool); + assert_eq!( + unknown_tool.validate_integrity(), + Err(TaskCapabilityManifestError::UnknownTool) + ); + + let mut preview_value = serde_json::to_value(&preview).unwrap(); + preview_value["grant"] = Value::String("reusable".to_string()); + assert_eq!( + TaskAuthorizationPreview::parse_json(&preview_value.to_string()), + Err(TaskCapabilityManifestError::InvalidJson) + ); + + let mut downgraded_preview = preview.clone(); + downgraded_preview.aggregate_risk = RiskLevel::Low; + downgraded_preview.preview_hash = preview_hash_for(&downgraded_preview); + assert_eq!( + downgraded_preview.validate_integrity(), + Err(TaskCapabilityManifestError::CatalogRiskMismatch) + ); + } +} diff --git a/scripts/release-source-check.mjs b/scripts/release-source-check.mjs index c0a02ec..b4aee71 100644 --- a/scripts/release-source-check.mjs +++ b/scripts/release-source-check.mjs @@ -507,6 +507,7 @@ checkExternalBridgeUserVisibleErrors(); checkLocalReleaseHelperSelfTests(); checkStepOneOnboardingReadinessContract(); checkStepTwoGoalEnvelopeReleaseBoundary(); +checkStepThreeCapabilityManifestReleaseBoundary(); checkPackageManagerBaseline(); checkGovernanceDocs(); checkCodeSigningAndPrivacyPolicies(); @@ -4699,6 +4700,46 @@ function checkStepTwoGoalEnvelopeReleaseBoundary() { ); } +function checkStepThreeCapabilityManifestReleaseBoundary() { + const manifestPath = + "apps/desktop/src-tauri/src/kernel/task_capability_manifest.rs"; + const manifest = readText(manifestPath); + const kernelModules = readText("apps/desktop/src-tauri/src/kernel/mod.rs"); + const policy = readText("apps/desktop/src-tauri/src/kernel/policy.rs"); + const eventStore = readText("apps/desktop/src-tauri/src/kernel/event_store.rs"); + const commands = readText("apps/desktop/src-tauri/src/commands.rs"); + const publicTypes = readText("apps/desktop/src/types.ts"); + const app = readText("apps/desktop/src/App.tsx"); + + for (const [filePath, content, phrase, label] of [ + [manifestPath, manifest, "ds-agent.task-capability-manifest/v1", "task capability manifest version"], + [manifestPath, manifest, "deny_unknown_fields", "task capability manifest unknown-field rejection"], + [manifestPath, manifest, "builtin_tool_catalog", "task manifest reuses the Kernel tool catalog"], + [manifestPath, manifest, "builtin_capability_catalog", "task manifest reuses the Kernel capability catalog"], + [manifestPath, manifest, "capability_risk", "task manifest derives Kernel risk"], + [manifestPath, manifest, "ds-agent.task-capability-manifest-revision.v1", "domain-separated manifest revision"], + [manifestPath, manifest, "ds-agent.task-capability-manifest-fingerprint.v1", "domain-separated manifest fingerprint"], + [manifestPath, manifest, "ds-agent.task-authorization-preview-hash.v1", "domain-separated authorization preview hash"], + [manifestPath, manifest, "TASK_AUTHORIZATION_PREVIEW_RENDERER_REVISION", "versioned authorization preview renderer"], + [manifestPath, manifest, "validate_for_manifest", "exact manifest-to-preview validation"], + [manifestPath, manifest, "existing_exact_tool_approval_contract_remains_compatible", "exact-tool approval regression"], + [manifestPath, manifest, "malformed_proposal_creates_no_permission_execution_event_or_completion_state", "malformed proposal authority regression"], + ["apps/desktop/src-tauri/src/kernel/mod.rs", kernelModules, "pub mod task_capability_manifest;", "Kernel manifest module registration"], + ["apps/desktop/src-tauri/src/kernel/policy.rs", policy, "pub const fn as_str", "canonical Kernel capability name"], + ]) { + checkTextIncludes(filePath, content, phrase, label); + } + + for (const [filePath, content, label] of [ + ["apps/desktop/src-tauri/src/kernel/event_store.rs", eventStore, "C3A adds no durable authorization state"], + ["apps/desktop/src-tauri/src/commands.rs", commands, "C3A adds no command authority"], + ["apps/desktop/src/types.ts", publicTypes, "C3A adds no public manifest DTO"], + ["apps/desktop/src/App.tsx", app, "C3A adds no authorization UI"], + ]) { + checkTextDoesNotInclude(filePath, content, "task_capability_manifest", label); + } +} + function checkOperationsBriefingSmokeEvidence() { if (!existsSync(smokeEvidenceDir)) { failures.push(`${smokeEvidenceDir} is required for live Operations Briefing smoke tests`); From 75d05ebb18ffd1867f958db80ef8792c7fac7ba7 Mon Sep 17 00:00:00 2001 From: Codex Date: Sun, 19 Jul 2026 18:38:34 +0800 Subject: [PATCH 2/5] feat: add exact-task grouped approval kernel --- .../src-tauri/src/kernel/event_store.rs | 55 +- .../kernel/event_store/grouped_approval.rs | 1874 +++++++++++++++++ apps/desktop/src-tauri/src/kernel/mod.rs | 1 + .../src/kernel/task_grouped_approval.rs | 958 +++++++++ scripts/release-source-check.mjs | 64 + 5 files changed, 2947 insertions(+), 5 deletions(-) create mode 100644 apps/desktop/src-tauri/src/kernel/event_store/grouped_approval.rs create mode 100644 apps/desktop/src-tauri/src/kernel/task_grouped_approval.rs diff --git a/apps/desktop/src-tauri/src/kernel/event_store.rs b/apps/desktop/src-tauri/src/kernel/event_store.rs index 76481e5..43fe87e 100644 --- a/apps/desktop/src-tauri/src/kernel/event_store.rs +++ b/apps/desktop/src-tauri/src/kernel/event_store.rs @@ -3,6 +3,7 @@ mod artifact; mod computer_use; mod connector_draft; +mod grouped_approval; mod read_execution; mod revocation; mod workspace_undo; @@ -1977,6 +1978,7 @@ impl EventStore { END"#, [], )?; + grouped_approval::migrate(self)?; self.replay_execution_projection_events()?; self.fail_legacy_connector_attachment_tools()?; Ok(()) @@ -10330,6 +10332,30 @@ impl EventStore { } pub fn append(&self, event: &KernelEvent) -> EventStoreResult<()> { + if event.event_type.starts_with("task_grouped_approval.") { + return Err(EventStoreError::InvalidState( + "task grouped approval events require the dedicated transactional state machine" + .to_string(), + )); + } + if event.event_type == PERMISSION_RESOLUTION_RECORDED_EVENT { + let resolution: PermissionResolution = serde_json::from_str(&event.payload_json)?; + if grouped_approval::is_grouped_request(self, resolution.request_id)? { + return Err(EventStoreError::InvalidState( + "task grouped approval resolution cannot use the generic event path" + .to_string(), + )); + } + } + if event.event_type == CAPABILITY_ACCESS_REQUESTED_EVENT { + let request: CapabilityAccessRequest = serde_json::from_str(&event.payload_json)?; + if grouped_approval::is_grouped_request(self, request.id)? { + return Err(EventStoreError::InvalidState( + "task grouped approval request cannot be replaced through the generic event path" + .to_string(), + )); + } + } let transaction = self.conn.unchecked_transaction()?; Self::insert_kernel_event(&transaction, event)?; transaction.commit()?; @@ -14051,11 +14077,15 @@ impl EventStore { pub fn list_pending_capability_access_records( &self, ) -> EventStoreResult> { - Ok(self - .list_capability_access_records()? - .into_iter() - .filter(|record| record.effective_status == CapabilityAccessStatus::PendingApproval) - .collect()) + let mut pending = Vec::new(); + for record in self.list_capability_access_records()? { + if record.effective_status == CapabilityAccessStatus::PendingApproval + && !grouped_approval::is_grouped_request(self, record.request.id)? + { + pending.push(record); + } + } + Ok(pending) } pub fn has_user_approved_capability( @@ -14090,6 +14120,11 @@ impl EventStore { approved: bool, note: String, ) -> EventStoreResult { + if grouped_approval::is_grouped_request(self, request_id)? { + return Err(EventStoreError::InvalidState( + "task grouped approval requires the single exact-task resolver".to_string(), + )); + } let record = self.capability_access_record_by_id(request_id)?; if record.request.capability == CapabilityKind::ConnectorAttachmentRead { @@ -14126,6 +14161,11 @@ impl EventStore { expected_preview_revision: u32, expected_preview_hash: &str, ) -> EventStoreResult { + if grouped_approval::is_grouped_request(self, request_id)? { + return Err(EventStoreError::InvalidState( + "task grouped approval requires the single exact-task resolver".to_string(), + )); + } let record = self.capability_access_record_by_id(request_id)?; if record.request.capability != CapabilityKind::ConnectorWrite || record.effective_status != CapabilityAccessStatus::PendingApproval @@ -14171,6 +14211,11 @@ impl EventStore { expected_preview_revision: u32, expected_preview_hash: &str, ) -> EventStoreResult { + if grouped_approval::is_grouped_request(self, request_id)? { + return Err(EventStoreError::InvalidState( + "task grouped approval requires the single exact-task resolver".to_string(), + )); + } let record = self.capability_access_record_by_id(request_id)?; if record.request.capability != CapabilityKind::ConnectorAttachmentRead || record.effective_status != CapabilityAccessStatus::PendingApproval diff --git a/apps/desktop/src-tauri/src/kernel/event_store/grouped_approval.rs b/apps/desktop/src-tauri/src/kernel/event_store/grouped_approval.rs new file mode 100644 index 0000000..3f047d4 --- /dev/null +++ b/apps/desktop/src-tauri/src/kernel/event_store/grouped_approval.rs @@ -0,0 +1,1874 @@ +use chrono::{DateTime, SecondsFormat, Utc}; +use rusqlite::{params, OptionalExtension, Transaction, TransactionBehavior}; +use uuid::Uuid; + +use super::{ + EventStore, EventStoreError, EventStoreResult, CAPABILITY_ACCESS_REQUESTED_EVENT, + PERMISSION_RESOLUTION_RECORDED_EVENT, +}; +use crate::kernel::models::{AccessMode, KernelEvent}; +use crate::kernel::policy::{ + exact_tool_preview_hash, request_capability_access, CapabilityAccessRequest, + CapabilityAccessStatus, PermissionResolution, PolicyDecision, +}; +use crate::kernel::task_capability_manifest::{TaskAuthorizationPreview, TaskCapabilityManifest}; +use crate::kernel::task_grouped_approval::{ + capability_request_event_id_for, event_id_for, item_event_id_for, legacy_consumption_id_for, + permission_resolution_event_id_for, permission_resolution_id_for, TaskGroupedApproval, + TaskGroupedApprovalActor, TaskGroupedApprovalError, TaskGroupedApprovalResolutionClaim, + TaskGroupedApprovalStatus, TaskGroupedCapabilityAudit, TaskGroupedCapabilityClaim, + TaskGroupedCapabilityGrant, TASK_GROUPED_APPROVAL_VERSION, +}; + +const TASK_GROUPED_APPROVAL_PREPARED_EVENT: &str = "task_grouped_approval.prepared"; +const TASK_GROUPED_APPROVAL_RESOLVED_EVENT: &str = "task_grouped_approval.resolved"; +const TASK_GROUPED_APPROVAL_REVOKED_EVENT: &str = "task_grouped_approval.revoked"; +const TASK_GROUPED_APPROVAL_EXPIRED_EVENT: &str = "task_grouped_approval.expired"; +const TASK_GROUPED_APPROVAL_SCOPE_CHANGED_EVENT: &str = "task_grouped_approval.scope_changed"; + +pub(super) fn is_grouped_request(store: &EventStore, request_id: Uuid) -> EventStoreResult { + Ok(store + .conn + .query_row( + r#"SELECT 1 FROM task_grouped_approval_item_audit + WHERE approval_request_id = ?1 + UNION ALL + SELECT 1 FROM task_grouped_approval_state AS state, + json_each(state.projection_json, '$.capability_audits') AS item + WHERE json_extract(item.value, '$.approval_request_id') = ?1 + LIMIT 1"#, + params![request_id.to_string()], + |_| Ok(()), + ) + .optional()? + .is_some()) +} + +pub(super) fn migrate(store: &EventStore) -> EventStoreResult<()> { + store.conn.execute_batch( + r#" + CREATE TABLE IF NOT EXISTS task_grouped_approval_state ( + group_id TEXT PRIMARY KEY NOT NULL, + task_id TEXT NOT NULL, + schema_version TEXT NOT NULL, + manifest_revision TEXT NOT NULL, + manifest_fingerprint TEXT NOT NULL, + preview_schema_revision INTEGER NOT NULL, + preview_renderer_revision INTEGER NOT NULL, + preview_hash TEXT NOT NULL, + expires_at TEXT NOT NULL, + status TEXT NOT NULL, + row_revision INTEGER NOT NULL, + projection_json TEXT NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ); + + CREATE INDEX IF NOT EXISTS idx_task_grouped_approval_task_status + ON task_grouped_approval_state (task_id, status, updated_at); + + CREATE UNIQUE INDEX IF NOT EXISTS idx_task_grouped_approval_one_active + ON task_grouped_approval_state (task_id) + WHERE status IN ('pending', 'approved'); + + CREATE TABLE IF NOT EXISTS task_grouped_approval_item_audit ( + audit_event_id TEXT PRIMARY KEY NOT NULL, + group_id TEXT NOT NULL, + item_id TEXT NOT NULL, + capability TEXT NOT NULL, + risk_level TEXT NOT NULL, + tool_id TEXT NOT NULL, + approval_request_id TEXT NOT NULL, + transition TEXT NOT NULL, + group_revision INTEGER NOT NULL, + created_at TEXT NOT NULL, + UNIQUE (group_id, item_id, group_revision) + ); + + CREATE INDEX IF NOT EXISTS idx_task_grouped_approval_item_group + ON task_grouped_approval_item_audit + (group_id, group_revision, item_id); + "#, + )?; + validate_all_rows(store) +} + +impl EventStore { + pub fn prepare_task_grouped_approval( + &self, + task_id: Uuid, + manifest: &TaskCapabilityManifest, + preview: &TaskAuthorizationPreview, + now: DateTime, + ) -> EventStoreResult { + let goal = self + .goal_envelope_projection(task_id)? + .ok_or_else(|| invalid("task grouped approval requires a frozen goal"))?; + manifest + .validate_for_goal(&goal) + .map_err(|_| invalid("task grouped approval manifest is stale or invalid"))?; + preview + .validate_for_manifest(manifest) + .map_err(|_| invalid("task grouped approval preview is stale or invalid"))?; + let prepared = TaskGroupedApproval::new(manifest.clone(), preview.clone(), now) + .map_err(group_error)?; + if prepared.task_id != task_id { + return Err(invalid("task grouped approval task binding changed")); + } + + let transaction = Transaction::new_unchecked(&self.conn, TransactionBehavior::Immediate)?; + if let Some(existing) = load_group(&transaction, prepared.id)? { + transaction.commit()?; + return Ok(existing); + } + + let task_group_ids = { + let mut statement = transaction.prepare( + r#"SELECT group_id FROM task_grouped_approval_state + WHERE task_id = ?1 + ORDER BY created_at ASC, group_id ASC"#, + )?; + let ids = statement + .query_map(params![task_id.to_string()], |row| row.get::<_, String>(0))? + .collect::, _>>()?; + ids + }; + let mut task_already_resolved = false; + for existing_id in task_group_ids { + let existing_id = Uuid::parse_str(&existing_id)?; + let existing = load_group(&transaction, existing_id)? + .ok_or_else(|| invalid("task grouped approval projection disappeared"))?; + task_already_resolved |= existing.resolution.is_some(); + if !matches!( + existing.status, + TaskGroupedApprovalStatus::Pending | TaskGroupedApprovalStatus::Approved + ) { + continue; + } + let existing_expired = now >= existing.manifest.expires_at; + if existing.status == TaskGroupedApprovalStatus::Pending { + resolve_item_requests( + &transaction, + &existing, + false, + if existing_expired { + "Task grouped approval expired before replacement." + } else { + "Task grouped approval invalidated because the exact scope changed." + }, + now, + )?; + } + let changed = if existing_expired { + existing.expire(now).map_err(group_error)? + } else { + existing.scope_changed(now).map_err(group_error)? + }; + persist_transition(&transaction, &existing, &changed)?; + } + if task_already_resolved { + transaction.commit()?; + return Err(invalid( + "task grouped approval already has a user resolution; a new exact task is required", + )); + } + + insert_item_requests(&transaction, &prepared)?; + persist_new_group(&transaction, &prepared)?; + transaction.commit()?; + Ok(prepared) + } + + pub fn task_grouped_approval( + &self, + group_id: Uuid, + ) -> EventStoreResult> { + let group = load_group_connection(self, group_id)?; + if let Some(group) = &group { + validate_audit_history(self, group)?; + } + Ok(group) + } + + pub fn resolve_task_grouped_approval( + &self, + claim: &TaskGroupedApprovalResolutionClaim, + approved: bool, + now: DateTime, + ) -> EventStoreResult { + if claim.actor != TaskGroupedApprovalActor::User { + return Err(invalid( + "DeepSeek and frontend payloads cannot resolve task grouped approval", + )); + } + let transaction = Transaction::new_unchecked(&self.conn, TransactionBehavior::Immediate)?; + let current = load_group(&transaction, claim.group_id)? + .ok_or_else(|| EventStoreError::NotFound("task grouped approval".to_string()))?; + if current.resolution_replay_matches(claim, approved) + && matches!( + current.status, + TaskGroupedApprovalStatus::Approved | TaskGroupedApprovalStatus::Rejected + ) + { + transaction.commit()?; + return Ok(current); + } + if current.status != TaskGroupedApprovalStatus::Pending + || !current.binding_matches_resolution_claim(claim) + { + return Err(invalid( + "task grouped approval resolution is stale or already terminal", + )); + } + if now >= current.manifest.expires_at { + resolve_item_requests( + &transaction, + ¤t, + false, + "Task grouped approval expired before user resolution.", + now, + )?; + let expired = current.expire(now).map_err(group_error)?; + persist_transition(&transaction, ¤t, &expired)?; + transaction.commit()?; + return Err(invalid("task grouped approval expired")); + } + + resolve_item_requests( + &transaction, + ¤t, + approved, + if approved { + "Task grouped approval approved by the user." + } else { + "Task grouped approval rejected by the user." + }, + now, + )?; + let resolved = current.resolve(claim, approved, now).map_err(group_error)?; + persist_transition(&transaction, ¤t, &resolved)?; + transaction.commit()?; + Ok(resolved) + } + + pub fn revoke_task_grouped_approval( + &self, + claim: &TaskGroupedApprovalResolutionClaim, + now: DateTime, + ) -> EventStoreResult { + if !matches!( + claim.actor, + TaskGroupedApprovalActor::User | TaskGroupedApprovalActor::KernelLifecycle + ) { + return Err(invalid( + "DeepSeek and frontend payloads cannot revoke task grouped approval", + )); + } + let transaction = Transaction::new_unchecked(&self.conn, TransactionBehavior::Immediate)?; + let current = load_group(&transaction, claim.group_id)? + .ok_or_else(|| EventStoreError::NotFound("task grouped approval".to_string()))?; + if current.status == TaskGroupedApprovalStatus::Revoked + && terminal_replay_matches(¤t, claim) + { + transaction.commit()?; + return Ok(current); + } + if current.status != TaskGroupedApprovalStatus::Approved + || !current.binding_matches_resolution_claim(claim) + { + return Err(invalid( + "task grouped approval revocation is stale or unavailable", + )); + } + let revoked = current.revoke(claim.actor, now).map_err(group_error)?; + persist_transition(&transaction, ¤t, &revoked)?; + transaction.commit()?; + Ok(revoked) + } + + pub fn expire_task_grouped_approval( + &self, + group_id: Uuid, + task_id: Uuid, + now: DateTime, + ) -> EventStoreResult { + let transaction = Transaction::new_unchecked(&self.conn, TransactionBehavior::Immediate)?; + let current = load_group(&transaction, group_id)? + .ok_or_else(|| EventStoreError::NotFound("task grouped approval".to_string()))?; + if current.task_id != task_id { + return Err(invalid("task grouped approval task binding changed")); + } + if current.status == TaskGroupedApprovalStatus::Expired { + transaction.commit()?; + return Ok(current); + } + if now < current.manifest.expires_at + || !matches!( + current.status, + TaskGroupedApprovalStatus::Pending | TaskGroupedApprovalStatus::Approved + ) + { + return Err(invalid( + "task grouped approval cannot expire from its current state", + )); + } + if current.status == TaskGroupedApprovalStatus::Pending { + resolve_item_requests( + &transaction, + ¤t, + false, + "Task grouped approval expired before user resolution.", + now, + )?; + } + let expired = current.expire(now).map_err(group_error)?; + persist_transition(&transaction, ¤t, &expired)?; + transaction.commit()?; + Ok(expired) + } + + pub fn authorize_task_grouped_capability( + &self, + claim: &TaskGroupedCapabilityClaim, + now: DateTime, + ) -> EventStoreResult { + let current_goal = self.goal_envelope_projection(claim.task_id)?; + let transaction = Transaction::new_unchecked(&self.conn, TransactionBehavior::Immediate)?; + let current = load_group(&transaction, claim.group_id)? + .ok_or_else(|| EventStoreError::NotFound("task grouped approval".to_string()))?; + + if now >= current.manifest.expires_at + && matches!( + current.status, + TaskGroupedApprovalStatus::Pending | TaskGroupedApprovalStatus::Approved + ) + { + if current.status == TaskGroupedApprovalStatus::Pending { + resolve_item_requests( + &transaction, + ¤t, + false, + "Task grouped approval expired before capability use.", + now, + )?; + } + let expired = current.expire(now).map_err(group_error)?; + persist_transition(&transaction, ¤t, &expired)?; + transaction.commit()?; + return Err(invalid("task grouped approval expired")); + } + + let goal_is_current = current_goal + .as_ref() + .is_some_and(|goal| current.manifest.validate_for_goal(goal).is_ok()); + if !goal_is_current + && matches!( + current.status, + TaskGroupedApprovalStatus::Pending | TaskGroupedApprovalStatus::Approved + ) + { + if current.status == TaskGroupedApprovalStatus::Pending { + resolve_item_requests( + &transaction, + ¤t, + false, + "Task grouped approval invalidated because the frozen goal changed.", + now, + )?; + } + let changed = current.scope_changed(now).map_err(group_error)?; + persist_transition(&transaction, ¤t, &changed)?; + transaction.commit()?; + return Err(invalid("task grouped approval frozen scope changed")); + } + + if current.status != TaskGroupedApprovalStatus::Approved { + return Err(invalid("task grouped approval carries no authority")); + } + let item = current + .capability_item(claim) + .map_err(|_| invalid("task grouped capability claim is stale or mismatched"))?; + validate_underlying_item_approved(&transaction, ¤t, item)?; + let grant = TaskGroupedCapabilityGrant { + group_id: current.id, + task_id: current.task_id, + projection_revision: current.projection_revision, + manifest_revision: current.manifest.revision.clone(), + manifest_fingerprint: current.manifest.fingerprint.clone(), + preview_renderer_revision: current.preview.renderer_revision, + preview_hash: current.preview.preview_hash.clone(), + capability: item.capability, + tool_id: item.tool_id.clone(), + request_fingerprint: item.request_fingerprint.clone(), + approval_request_id: item.approval_request_id, + expires_at: current.manifest.expires_at, + }; + transaction.commit()?; + Ok(grant) + } +} + +fn insert_item_requests( + transaction: &Transaction<'_>, + group: &TaskGroupedApproval, +) -> EventStoreResult<()> { + for item in &group.capability_audits { + let mut request = request_capability_access(AccessMode::AskEveryStep, item.capability) + .map_err(invalid)?; + request.id = item.approval_request_id; + request.created_at = group.created_at; + request + .bind_exact_tool( + item.tool_id.clone(), + item.request_fingerprint.clone(), + item.exact_preview.clone(), + ) + .map_err(invalid)?; + if request.status != CapabilityAccessStatus::PendingApproval + || request.decision != PolicyDecision::Ask + || request.risk_level != item.risk_level + { + return Err(invalid( + "task grouped approval did not derive a pending exact policy request", + )); + } + let event = KernelEvent { + id: capability_request_event_id_for(group.id, &item.item_id), + event_type: CAPABILITY_ACCESS_REQUESTED_EVENT.to_string(), + payload_json: serde_json::to_string(&request)?, + created_at: group.created_at, + }; + EventStore::insert_kernel_event(transaction, &event)?; + } + Ok(()) +} + +fn resolve_item_requests( + transaction: &Transaction<'_>, + group: &TaskGroupedApproval, + approved: bool, + note: &str, + now: DateTime, +) -> EventStoreResult<()> { + for item in &group.capability_audits { + let (request_json, resolution_json, effective_status_json, row_revision) = transaction + .query_row( + r#"SELECT request_json, resolution_json, effective_status, row_revision + FROM capability_access_state WHERE request_id = ?1"#, + params![item.approval_request_id.to_string()], + |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, Option>(1)?, + row.get::<_, String>(2)?, + row.get::<_, u64>(3)?, + )) + }, + ) + .optional()? + .ok_or_else(|| invalid("task grouped approval exact item request is missing"))?; + let request: CapabilityAccessRequest = serde_json::from_str(&request_json)?; + let effective_status: CapabilityAccessStatus = + serde_json::from_str(&effective_status_json)?; + validate_underlying_request(group, item, &request)?; + if resolution_json.is_some() + || effective_status != CapabilityAccessStatus::PendingApproval + || row_revision != 0 + { + return Err(invalid( + "task grouped approval exact item request is stale or resolved", + )); + } + let scope = request + .exact_tool + .as_ref() + .ok_or_else(|| invalid("task grouped approval exact preview is missing"))?; + let mut resolution = PermissionResolution::new_exact( + request.id, + approved, + note.to_string(), + row_revision, + scope, + ) + .map_err(invalid)?; + resolution.id = permission_resolution_id_for(group.id, &item.item_id, approved); + resolution.created_at = now; + let event = KernelEvent { + id: permission_resolution_event_id_for(resolution.id), + event_type: PERMISSION_RESOLUTION_RECORDED_EVENT.to_string(), + payload_json: serde_json::to_string(&resolution)?, + created_at: now, + }; + EventStore::insert_kernel_event(transaction, &event)?; + if approved { + transaction.execute( + r#"INSERT INTO capability_approval_consumptions + (request_id, capability_invocation_id, consumed_at) + VALUES (?1, ?2, ?3)"#, + params![ + item.approval_request_id.to_string(), + legacy_consumption_id_for(group.id, &item.item_id).to_string(), + timestamp(now), + ], + )?; + } + } + Ok(()) +} + +fn validate_underlying_request( + group: &TaskGroupedApproval, + item: &TaskGroupedCapabilityAudit, + request: &CapabilityAccessRequest, +) -> EventStoreResult<()> { + let scope = request + .exact_tool + .as_ref() + .ok_or_else(|| invalid("task grouped approval exact preview is missing"))?; + if request.id != item.approval_request_id + || request.access_mode != AccessMode::AskEveryStep + || request.capability != item.capability + || request.risk_level != item.risk_level + || request.decision != PolicyDecision::Ask + || request.status != CapabilityAccessStatus::PendingApproval + || request.created_at != group.created_at + || scope.tool_id != item.tool_id + || scope.request_fingerprint != item.request_fingerprint + || scope.preview != item.exact_preview + || scope.preview_revision != item.exact_preview_revision + || scope.preview_hash != item.exact_preview_hash + || scope.preview_hash != exact_tool_preview_hash(scope.preview_revision, &scope.preview) + { + return Err(invalid( + "task grouped approval exact item binding is invalid", + )); + } + Ok(()) +} + +fn validate_underlying_item_approved( + transaction: &Transaction<'_>, + group: &TaskGroupedApproval, + item: &TaskGroupedCapabilityAudit, +) -> EventStoreResult<()> { + let (request_json, resolution_json, effective_status_json, row_revision) = transaction + .query_row( + r#"SELECT request_json, resolution_json, effective_status, row_revision + FROM capability_access_state WHERE request_id = ?1"#, + params![item.approval_request_id.to_string()], + |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, Option>(1)?, + row.get::<_, String>(2)?, + row.get::<_, u64>(3)?, + )) + }, + ) + .optional()? + .ok_or_else(|| invalid("task grouped approval exact item request is missing"))?; + let request: CapabilityAccessRequest = serde_json::from_str(&request_json)?; + validate_underlying_request(group, item, &request)?; + let resolution: PermissionResolution = serde_json::from_str( + resolution_json + .as_deref() + .ok_or_else(|| invalid("task grouped approval exact item resolution is missing"))?, + )?; + let effective_status: CapabilityAccessStatus = serde_json::from_str(&effective_status_json)?; + let scope = request + .exact_tool + .as_ref() + .ok_or_else(|| invalid("task grouped approval exact preview is missing"))?; + if !resolution.approved + || effective_status != CapabilityAccessStatus::Approved + || row_revision != 1 + || resolution.request_id != request.id + || resolution.expected_request_revision != Some(0) + || resolution.exact_preview_revision != Some(scope.preview_revision) + || resolution.exact_preview_hash.as_deref() != Some(scope.preview_hash.as_str()) + || resolution.id != permission_resolution_id_for(group.id, &item.item_id, true) + { + return Err(invalid( + "task grouped approval exact item resolution is invalid", + )); + } + let consumption: Option = transaction + .query_row( + r#"SELECT capability_invocation_id FROM capability_approval_consumptions + WHERE request_id = ?1"#, + params![item.approval_request_id.to_string()], + |row| row.get(0), + ) + .optional()?; + if consumption.as_deref() + != Some( + legacy_consumption_id_for(group.id, &item.item_id) + .to_string() + .as_str(), + ) + { + return Err(invalid( + "task grouped approval item escaped the task-only authority boundary", + )); + } + Ok(()) +} + +fn persist_new_group( + transaction: &Transaction<'_>, + group: &TaskGroupedApproval, +) -> EventStoreResult<()> { + group.validate_integrity().map_err(group_error)?; + transaction.execute( + r#"INSERT INTO task_grouped_approval_state + (group_id, task_id, schema_version, manifest_revision, + manifest_fingerprint, preview_schema_revision, + preview_renderer_revision, preview_hash, expires_at, status, + row_revision, projection_json, created_at, updated_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14)"#, + state_params(group)?, + )?; + insert_group_event(transaction, group)?; + insert_item_audit_events(transaction, group) +} + +fn persist_transition( + transaction: &Transaction<'_>, + previous: &TaskGroupedApproval, + next: &TaskGroupedApproval, +) -> EventStoreResult<()> { + previous.validate_integrity().map_err(group_error)?; + next.validate_integrity().map_err(group_error)?; + if next.id != previous.id + || next.task_id != previous.task_id + || next.projection_revision != previous.projection_revision + 1 + || next.manifest != previous.manifest + || next.preview != previous.preview + { + return Err(invalid("task grouped approval transition binding changed")); + } + let changed = transaction.execute( + r#"UPDATE task_grouped_approval_state SET + status = ?2, row_revision = ?3, projection_json = ?4, updated_at = ?5 + WHERE group_id = ?1 AND row_revision = ?6 + AND manifest_revision = ?7 AND manifest_fingerprint = ?8 + AND preview_schema_revision = ?9 + AND preview_renderer_revision = ?10 AND preview_hash = ?11"#, + params![ + next.id.to_string(), + next.status.as_str(), + next.projection_revision, + next.canonical_json().map_err(group_error)?, + timestamp(next.updated_at), + previous.projection_revision, + next.manifest.revision, + next.manifest.fingerprint, + next.preview.schema_revision, + next.preview.renderer_revision, + next.preview.preview_hash, + ], + )?; + if changed != 1 { + return Err(invalid("task grouped approval projection revision changed")); + } + insert_group_event(transaction, next)?; + insert_item_audit_events(transaction, next) +} + +fn insert_group_event( + transaction: &Transaction<'_>, + group: &TaskGroupedApproval, +) -> EventStoreResult<()> { + let event = KernelEvent { + id: event_id_for(group), + event_type: event_type_for(group.status).to_string(), + payload_json: serde_json::to_string(&group.event_receipt())?, + created_at: group.updated_at, + }; + EventStore::insert_kernel_event(transaction, &event) +} + +fn insert_item_audit_events( + transaction: &Transaction<'_>, + group: &TaskGroupedApproval, +) -> EventStoreResult<()> { + for item in &group.capability_audits { + let changed = transaction.execute( + r#"INSERT OR IGNORE INTO task_grouped_approval_item_audit + (audit_event_id, group_id, item_id, capability, risk_level, + tool_id, approval_request_id, transition, group_revision, created_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)"#, + params![ + item_event_id_for(group, item).to_string(), + group.id.to_string(), + item.item_id, + item.capability.as_str(), + risk_name(item.risk_level), + item.tool_id, + item.approval_request_id.to_string(), + group.status.as_str(), + group.projection_revision, + timestamp(group.updated_at), + ], + )?; + if changed != 1 { + return Err(invalid( + "task grouped approval duplicate item audit transition", + )); + } + } + Ok(()) +} + +fn state_params(group: &TaskGroupedApproval) -> EventStoreResult<[rusqlite::types::Value; 14]> { + Ok([ + group.id.to_string().into(), + group.task_id.to_string().into(), + TASK_GROUPED_APPROVAL_VERSION.to_string().into(), + group.manifest.revision.clone().into(), + group.manifest.fingerprint.clone().into(), + i64::from(group.preview.schema_revision).into(), + i64::from(group.preview.renderer_revision).into(), + group.preview.preview_hash.clone().into(), + timestamp(group.manifest.expires_at).into(), + group.status.as_str().to_string().into(), + i64::try_from(group.projection_revision) + .map_err(|_| invalid("task grouped approval revision is too large"))? + .into(), + group.canonical_json().map_err(group_error)?.into(), + timestamp(group.created_at).into(), + timestamp(group.updated_at).into(), + ]) +} + +fn load_group( + transaction: &Transaction<'_>, + group_id: Uuid, +) -> EventStoreResult> { + let row = transaction + .query_row( + r#"SELECT task_id, schema_version, manifest_revision, + manifest_fingerprint, preview_schema_revision, + preview_renderer_revision, preview_hash, expires_at, + status, row_revision, projection_json, created_at, updated_at + FROM task_grouped_approval_state WHERE group_id = ?1"#, + params![group_id.to_string()], + read_group_row, + ) + .optional()?; + row.map(|row| validate_row(group_id, row)).transpose() +} + +fn load_group_connection( + store: &EventStore, + group_id: Uuid, +) -> EventStoreResult> { + let row = store + .conn + .query_row( + r#"SELECT task_id, schema_version, manifest_revision, + manifest_fingerprint, preview_schema_revision, + preview_renderer_revision, preview_hash, expires_at, + status, row_revision, projection_json, created_at, updated_at + FROM task_grouped_approval_state WHERE group_id = ?1"#, + params![group_id.to_string()], + read_group_row, + ) + .optional()?; + row.map(|row| validate_row(group_id, row)).transpose() +} + +type GroupRow = ( + String, + String, + String, + String, + u32, + u32, + String, + String, + String, + u64, + String, + String, + String, +); + +fn read_group_row(row: &rusqlite::Row<'_>) -> rusqlite::Result { + Ok(( + row.get(0)?, + row.get(1)?, + row.get(2)?, + row.get(3)?, + row.get(4)?, + row.get(5)?, + row.get(6)?, + row.get(7)?, + row.get(8)?, + row.get(9)?, + row.get(10)?, + row.get(11)?, + row.get(12)?, + )) +} + +fn validate_row(group_id: Uuid, row: GroupRow) -> EventStoreResult { + let ( + task_id, + schema_version, + manifest_revision, + manifest_fingerprint, + preview_schema_revision, + preview_renderer_revision, + preview_hash, + expires_at, + status, + row_revision, + projection_json, + created_at, + updated_at, + ) = row; + let group = TaskGroupedApproval::parse_json(&projection_json).map_err(group_error)?; + if group.id != group_id + || group.task_id != Uuid::parse_str(&task_id)? + || schema_version != TASK_GROUPED_APPROVAL_VERSION + || group.manifest.revision != manifest_revision + || group.manifest.fingerprint != manifest_fingerprint + || group.preview.schema_revision != preview_schema_revision + || group.preview.renderer_revision != preview_renderer_revision + || group.preview.preview_hash != preview_hash + || timestamp(group.manifest.expires_at) != expires_at + || group.status.as_str() != status + || group.projection_revision != row_revision + || timestamp(group.created_at) != created_at + || timestamp(group.updated_at) != updated_at + { + return Err(invalid("task grouped approval projection columns drifted")); + } + Ok(group) +} + +fn validate_all_rows(store: &EventStore) -> EventStoreResult<()> { + let ids = { + let mut statement = store + .conn + .prepare("SELECT group_id FROM task_grouped_approval_state ORDER BY group_id")?; + let ids = statement + .query_map([], |row| row.get::<_, String>(0))? + .collect::, _>>()?; + ids + }; + for id in ids { + let id = Uuid::parse_str(&id)?; + let group = load_group_connection(store, id)? + .ok_or_else(|| invalid("task grouped approval migration lost a projection"))?; + validate_audit_history(store, &group)?; + } + Ok(()) +} + +fn validate_audit_history(store: &EventStore, group: &TaskGroupedApproval) -> EventStoreResult<()> { + let mut statement = store.conn.prepare( + r#"SELECT audit_event_id, item_id, capability, risk_level, tool_id, + approval_request_id, transition, group_revision + FROM task_grouped_approval_item_audit + WHERE group_id = ?1 ORDER BY group_revision ASC, item_id ASC"#, + )?; + let rows = statement + .query_map(params![group.id.to_string()], |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, String>(1)?, + row.get::<_, String>(2)?, + row.get::<_, String>(3)?, + row.get::<_, String>(4)?, + row.get::<_, String>(5)?, + row.get::<_, String>(6)?, + row.get::<_, u64>(7)?, + )) + })? + .collect::, _>>()?; + let expected_count = group.capability_audits.len().checked_mul( + usize::try_from(group.projection_revision) + .map_err(|_| invalid("task grouped approval revision is too large"))? + + 1, + ); + if expected_count != Some(rows.len()) { + return Err(invalid( + "task grouped approval per-capability audit history is incomplete", + )); + } + for revision in 0..=group.projection_revision { + let transition = if revision == 0 { + TaskGroupedApprovalStatus::Pending + } else if revision == group.projection_revision { + group.status + } else { + TaskGroupedApprovalStatus::Approved + }; + let mut revision_group = group.clone(); + revision_group.status = transition; + revision_group.projection_revision = revision; + for item in &group.capability_audits { + let expected_event = item_event_id_for(&revision_group, item).to_string(); + let row = rows + .iter() + .find(|row| row.1 == item.item_id && row.7 == revision) + .ok_or_else(|| invalid("task grouped approval item audit transition is missing"))?; + if row.0 != expected_event + || row.2 != item.capability.as_str() + || row.3 != risk_name(item.risk_level) + || row.4 != item.tool_id + || row.5 != item.approval_request_id.to_string() + || row.6 != transition.as_str() + { + return Err(invalid( + "task grouped approval item audit transition was tampered", + )); + } + } + } + Ok(()) +} + +fn terminal_replay_matches( + group: &TaskGroupedApproval, + claim: &TaskGroupedApprovalResolutionClaim, +) -> bool { + group.id == claim.group_id + && group.task_id == claim.task_id + && group.projection_revision == claim.expected_projection_revision.saturating_add(1) + && group.manifest.revision == claim.manifest_revision + && group.manifest.fingerprint == claim.manifest_fingerprint + && group.preview.schema_revision == claim.preview_schema_revision + && group.preview.renderer_revision == claim.preview_renderer_revision + && group.preview.preview_hash == claim.preview_hash +} + +fn event_type_for(status: TaskGroupedApprovalStatus) -> &'static str { + match status { + TaskGroupedApprovalStatus::Pending => TASK_GROUPED_APPROVAL_PREPARED_EVENT, + TaskGroupedApprovalStatus::Approved | TaskGroupedApprovalStatus::Rejected => { + TASK_GROUPED_APPROVAL_RESOLVED_EVENT + } + TaskGroupedApprovalStatus::Revoked => TASK_GROUPED_APPROVAL_REVOKED_EVENT, + TaskGroupedApprovalStatus::Expired => TASK_GROUPED_APPROVAL_EXPIRED_EVENT, + TaskGroupedApprovalStatus::ScopeChanged => TASK_GROUPED_APPROVAL_SCOPE_CHANGED_EVENT, + } +} + +fn risk_name(risk: crate::kernel::policy::RiskLevel) -> &'static str { + match risk { + crate::kernel::policy::RiskLevel::Low => "low", + crate::kernel::policy::RiskLevel::Medium => "medium", + crate::kernel::policy::RiskLevel::High => "high", + crate::kernel::policy::RiskLevel::Critical => "critical", + } +} + +fn timestamp(value: DateTime) -> String { + value.to_rfc3339_opts(SecondsFormat::Nanos, true) +} + +fn group_error(error: TaskGroupedApprovalError) -> EventStoreError { + invalid(error.to_string()) +} + +fn invalid(message: impl Into) -> EventStoreError { + EventStoreError::InvalidState(message.into()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::kernel::goal_envelope::{ + GoalDoneWhenProposal, GoalEnvelopeProposal, GoalExternalTargetProposal, + GoalVerifierProposal, GOAL_ENVELOPE_PROPOSAL_VERSION, + }; + use crate::kernel::goal_lifecycle::{GoalTargetBindingKind, GoalValidationContext}; + use crate::kernel::local_directory::WorkspaceReadinessCode; + use crate::kernel::policy::{request_capability_access, CapabilityGrantState, CapabilityKind}; + use crate::kernel::task_capability_manifest::{ + compile_task_capability_manifest, task_authorization_preview, + TaskCapabilityManifestContext, TaskCapabilityManifestProposal, TaskCapabilityNeedProposal, + TASK_CAPABILITY_MANIFEST_VERSION, + }; + use crate::kernel::tool_runtime::{CONNECTOR_MUTATE_TOOL_ID, FILE_WRITE_TOOL_ID}; + use rusqlite::Connection; + use tempfile::tempdir; + + const TASK_ID: Uuid = Uuid::from_u128(0x33b); + const OTHER_TASK_ID: Uuid = Uuid::from_u128(0x33c); + + fn fixed_now() -> DateTime { + "2029-01-02T03:04:05Z".parse().unwrap() + } + + fn fixed_expiry() -> DateTime { + "2030-01-02T03:04:05Z".parse().unwrap() + } + + fn frozen_goal_for( + store: &EventStore, + task_id: Uuid, + path_authority: &[u8], + ) -> crate::kernel::goal_lifecycle::GoalLifecycleProjection { + let proposal = GoalEnvelopeProposal { + version: GOAL_ENVELOPE_PROPOSAL_VERSION.to_string(), + user_goal: "Create a verified report and send one approved external update." + .to_string(), + assumptions: Vec::new(), + constraints: vec!["Use only locally bound task scopes.".to_string()], + done_when: vec![ + GoalDoneWhenProposal { + done_when_id: "external-state-verified".to_string(), + description: "The external state is independently verified.".to_string(), + }, + GoalDoneWhenProposal { + done_when_id: "report-verified".to_string(), + description: "The local report is independently verified.".to_string(), + }, + ], + required_artifacts: Vec::new(), + verifiers: vec![ + GoalVerifierProposal { + verifier_id: "external-verifier-v1".to_string(), + done_when_id: "external-state-verified".to_string(), + description: "Verify the remote object state after the operation.".to_string(), + evidence_kind: "connector_remote_state".to_string(), + }, + GoalVerifierProposal { + verifier_id: "report-verifier-v1".to_string(), + done_when_id: "report-verified".to_string(), + description: "Verify the report hash and rendered output.".to_string(), + evidence_kind: "artifact_hash".to_string(), + }, + ], + proposed_capabilities: vec![ + CONNECTOR_MUTATE_TOOL_ID.to_string(), + FILE_WRITE_TOOL_ID.to_string(), + ], + external_targets: vec![ + GoalExternalTargetProposal { + target_id: "finance-account".to_string(), + description: "A locally bound external account.".to_string(), + }, + GoalExternalTargetProposal { + target_id: "finance-recipient".to_string(), + description: "A locally bound recipient.".to_string(), + }, + GoalExternalTargetProposal { + target_id: "report-folder".to_string(), + description: "A locally bound workspace path scope.".to_string(), + }, + GoalExternalTargetProposal { + target_id: "weekday-window".to_string(), + description: "A locally bound schedule window.".to_string(), + }, + ], + stop_conditions: vec!["Stop when any exact scope changes.".to_string()], + }; + let context = + GoalValidationContext::new(AccessMode::FullAccess, WorkspaceReadinessCode::Ready) + .with_max_risk(crate::kernel::policy::RiskLevel::Critical) + .with_enabled_tool(CONNECTOR_MUTATE_TOOL_ID, true) + .with_enabled_tool(FILE_WRITE_TOOL_ID, true) + .with_approval_route(CONNECTOR_MUTATE_TOOL_ID) + .with_approval_route(FILE_WRITE_TOOL_ID) + .with_verifier_kind("artifact_hash") + .with_verifier_kind("connector_remote_state") + .with_target_binding( + "finance-account", + GoalTargetBindingKind::Account, + b"account-authority-v1", + ) + .with_target_binding( + "finance-recipient", + GoalTargetBindingKind::Recipient, + b"recipient-authority-v1", + ) + .with_target_binding("report-folder", GoalTargetBindingKind::Path, path_authority) + .with_target_binding( + "weekday-window", + GoalTargetBindingKind::TimeWindow, + b"time-authority-v1", + ) + .allowing_local_effects() + .allowing_external_effects(); + let validated = store + .submit_goal_proposal(task_id, &proposal, &context) + .expect("goal validates"); + store + .freeze_goal_envelope(task_id, validated.revision().expect("goal revision")) + .expect("goal freezes") + } + + fn compiled_fixture_for( + store: &EventStore, + task_id: Uuid, + path_authority: &[u8], + path_label: &str, + expires_at: DateTime, + ) -> (TaskCapabilityManifest, TaskAuthorizationPreview) { + let goal = frozen_goal_for(store, task_id, path_authority); + let frozen = goal.frozen().expect("frozen goal"); + let proposal = TaskCapabilityManifestProposal { + version: TASK_CAPABILITY_MANIFEST_VERSION.to_string(), + task_id, + goal_id: task_id, + goal_revision: frozen.revision.clone(), + goal_fingerprint: frozen.fingerprint.clone(), + expires_at, + capabilities: vec![ + TaskCapabilityNeedProposal { + capability: "connector_write".to_string(), + tool_ids: vec![CONNECTOR_MUTATE_TOOL_ID.to_string()], + application_ids: vec!["outlook".to_string()], + path_target_ids: Vec::new(), + account_target_ids: vec!["finance-account".to_string()], + recipient_target_ids: vec!["finance-recipient".to_string()], + time_window_target_ids: vec!["weekday-window".to_string()], + external_target_ids: vec![ + "finance-account".to_string(), + "finance-recipient".to_string(), + "weekday-window".to_string(), + ], + verifier_ids: vec!["external-verifier-v1".to_string()], + }, + TaskCapabilityNeedProposal { + capability: "file_write".to_string(), + tool_ids: vec![FILE_WRITE_TOOL_ID.to_string()], + application_ids: vec!["excel".to_string()], + path_target_ids: vec!["report-folder".to_string()], + account_target_ids: Vec::new(), + recipient_target_ids: Vec::new(), + time_window_target_ids: Vec::new(), + external_target_ids: vec!["report-folder".to_string()], + verifier_ids: vec!["report-verifier-v1".to_string()], + }, + ], + }; + let context = TaskCapabilityManifestContext::default() + .with_application("excel", "Microsoft Excel") + .with_application("outlook", "Microsoft Outlook") + .with_target_display("finance-account", "Work mailbox account") + .with_target_display("finance-recipient", "finance-test@example.com") + .with_target_display("report-folder", path_label) + .with_target_display("weekday-window", "Weekdays 09:00-17:00 Asia/Shanghai"); + let manifest = compile_task_capability_manifest(task_id, &goal, &proposal, &context) + .expect("manifest compiles"); + let preview = task_authorization_preview(&manifest).expect("preview renders"); + (manifest, preview) + } + + fn compiled_fixture(store: &EventStore) -> (TaskCapabilityManifest, TaskAuthorizationPreview) { + compiled_fixture_for( + store, + TASK_ID, + b"path-authority-v1", + "Workspace / reports", + fixed_expiry(), + ) + } + + fn prepare(store: &EventStore) -> TaskGroupedApproval { + let (manifest, preview) = compiled_fixture(store); + store + .prepare_task_grouped_approval(TASK_ID, &manifest, &preview, fixed_now()) + .expect("group prepares") + } + + fn approve(store: &EventStore, group: &TaskGroupedApproval) -> TaskGroupedApproval { + store + .resolve_task_grouped_approval( + &TaskGroupedApprovalResolutionClaim::from_group( + group, + TaskGroupedApprovalActor::User, + ), + true, + fixed_now() + chrono::Duration::minutes(1), + ) + .expect("group approves") + } + + fn event_count(store: &EventStore, event_type: &str) -> i64 { + store + .conn + .query_row( + "SELECT COUNT(*) FROM kernel_events WHERE event_type = ?1", + params![event_type], + |row| row.get(0), + ) + .unwrap() + } + + #[test] + fn exact_group_approval_uses_one_user_resolution_and_per_capability_audit() { + let store = EventStore::open_memory().unwrap(); + let pending = prepare(&store); + assert_eq!(pending.capability_audits.len(), 2); + assert_eq!(event_count(&store, TASK_GROUPED_APPROVAL_PREPARED_EVENT), 1); + assert!(store + .list_pending_capability_access_records() + .unwrap() + .is_empty()); + assert_eq!( + store + .list_capability_access_records() + .unwrap() + .into_iter() + .filter(|record| { + pending + .capability_audits + .iter() + .any(|item| item.approval_request_id == record.request.id) + }) + .count(), + 2 + ); + for item in &pending.capability_audits { + assert!(store + .resolve_capability_access_request( + item.approval_request_id, + true, + "attempted per-item approval".to_string(), + ) + .is_err()); + assert!(store + .append_permission_resolution(&PermissionResolution::new( + item.approval_request_id, + true, + "forged model resolution".to_string(), + )) + .is_err()); + } + let connector_item = pending + .capability_audits + .iter() + .find(|item| item.capability == CapabilityKind::ConnectorWrite) + .unwrap(); + assert!(store + .resolve_connector_mutation_access_request( + connector_item.approval_request_id, + true, + "attempted connector bypass".to_string(), + 0, + connector_item.exact_preview_revision, + &connector_item.exact_preview_hash, + ) + .is_err()); + + let approved = approve(&store, &pending); + assert_eq!(approved.status, TaskGroupedApprovalStatus::Approved); + assert!(approved.status.carries_authority()); + assert_eq!( + store + .prepare_task_grouped_approval( + TASK_ID, + &approved.manifest, + &approved.preview, + fixed_now() + chrono::Duration::minutes(2), + ) + .unwrap(), + approved + ); + assert_eq!(event_count(&store, TASK_GROUPED_APPROVAL_RESOLVED_EVENT), 1); + assert_eq!( + store + .conn + .query_row( + "SELECT COUNT(*) FROM task_grouped_approval_item_audit WHERE group_id = ?1", + params![approved.id.to_string()], + |row| row.get::<_, i64>(0), + ) + .unwrap(), + 4 + ); + assert_eq!( + store + .list_permission_resolutions() + .unwrap() + .into_iter() + .filter(|resolution| { + approved + .capability_audits + .iter() + .any(|item| item.approval_request_id == resolution.request_id) + }) + .count(), + 2 + ); + assert_eq!( + store + .available_capability_grant_request_id(CapabilityKind::FileWrite) + .unwrap(), + None, + "group item approvals must never escape through the legacy reusable grant lookup" + ); + for item in &approved.capability_audits { + let claim = TaskGroupedCapabilityClaim::from_group_item(&approved, item); + let grant = store + .authorize_task_grouped_capability( + &claim, + fixed_now() + chrono::Duration::minutes(2), + ) + .expect("exact item authorizes"); + assert_eq!(grant.group_id, approved.id); + assert_eq!(grant.task_id, TASK_ID); + assert_eq!(grant.capability, item.capability); + assert_eq!(grant.tool_id, item.tool_id); + assert_eq!(grant.request_fingerprint, item.request_fingerprint); + assert_eq!(grant.approval_request_id, item.approval_request_id); + assert_eq!(grant.manifest_revision, approved.manifest.revision); + assert_eq!(grant.manifest_fingerprint, approved.manifest.fingerprint); + assert_eq!(grant.preview_hash, approved.preview.preview_hash); + assert_eq!( + grant.preview_renderer_revision, + approved.preview.renderer_revision + ); + assert_eq!(grant.projection_revision, 1); + assert_eq!(grant.expires_at, fixed_expiry()); + } + } + + #[test] + fn exact_group_rejection_and_resolution_replay_are_safe_and_idempotent() { + let store = EventStore::open_memory().unwrap(); + let pending = prepare(&store); + let claim = TaskGroupedApprovalResolutionClaim::from_group( + &pending, + TaskGroupedApprovalActor::User, + ); + let rejected = store + .resolve_task_grouped_approval( + &claim, + false, + fixed_now() + chrono::Duration::minutes(1), + ) + .unwrap(); + assert_eq!(rejected.status, TaskGroupedApprovalStatus::Rejected); + assert!(!rejected.status.carries_authority()); + assert_eq!( + store + .resolve_task_grouped_approval( + &claim, + false, + fixed_now() + chrono::Duration::minutes(2), + ) + .unwrap(), + rejected + ); + assert!( + store + .resolve_task_grouped_approval( + &claim, + true, + fixed_now() + chrono::Duration::minutes(2), + ) + .is_err() + ); + let capability_claim = + TaskGroupedCapabilityClaim::from_group_item(&rejected, &rejected.capability_audits[0]); + assert!(store + .authorize_task_grouped_capability( + &capability_claim, + fixed_now() + chrono::Duration::minutes(2), + ) + .is_err()); + assert_eq!(event_count(&store, TASK_GROUPED_APPROVAL_RESOLVED_EVENT), 1); + } + + #[test] + fn revoke_is_exact_idempotent_and_model_or_frontend_self_authority_is_rejected() { + let store = EventStore::open_memory().unwrap(); + let pending = prepare(&store); + for actor in [ + TaskGroupedApprovalActor::DeepSeekModel, + TaskGroupedApprovalActor::FrontendPayload, + ] { + let claim = TaskGroupedApprovalResolutionClaim::from_group(&pending, actor); + assert!(store + .resolve_task_grouped_approval( + &claim, + true, + fixed_now() + chrono::Duration::minutes(1), + ) + .is_err()); + } + assert!(store + .task_grouped_approval(pending.id) + .unwrap() + .unwrap() + .resolution + .is_none()); + + let approved = approve(&store, &pending); + for actor in [ + TaskGroupedApprovalActor::DeepSeekModel, + TaskGroupedApprovalActor::FrontendPayload, + ] { + let claim = TaskGroupedApprovalResolutionClaim::from_group(&approved, actor); + assert!(store + .revoke_task_grouped_approval(&claim, fixed_now() + chrono::Duration::minutes(2),) + .is_err()); + } + let revoke_claim = TaskGroupedApprovalResolutionClaim::from_group( + &approved, + TaskGroupedApprovalActor::User, + ); + let revoked = store + .revoke_task_grouped_approval(&revoke_claim, fixed_now() + chrono::Duration::minutes(2)) + .unwrap(); + assert_eq!(revoked.status, TaskGroupedApprovalStatus::Revoked); + assert_eq!( + store + .revoke_task_grouped_approval( + &revoke_claim, + fixed_now() + chrono::Duration::minutes(3), + ) + .unwrap(), + revoked + ); + let capability_claim = + TaskGroupedCapabilityClaim::from_group_item(&approved, &approved.capability_audits[0]); + assert!(store + .authorize_task_grouped_capability( + &capability_claim, + fixed_now() + chrono::Duration::minutes(3), + ) + .is_err()); + assert_eq!(event_count(&store, TASK_GROUPED_APPROVAL_REVOKED_EVENT), 1); + + let kernel_store = EventStore::open_memory().unwrap(); + let kernel_pending = prepare(&kernel_store); + let kernel_approved = approve(&kernel_store, &kernel_pending); + let kernel_claim = TaskGroupedApprovalResolutionClaim::from_group( + &kernel_approved, + TaskGroupedApprovalActor::KernelLifecycle, + ); + assert_eq!( + kernel_store + .revoke_task_grouped_approval( + &kernel_claim, + fixed_now() + chrono::Duration::minutes(2), + ) + .unwrap() + .status, + TaskGroupedApprovalStatus::Revoked + ); + } + + #[test] + fn expiry_is_durable_for_pending_and_approved_groups() { + let store = EventStore::open_memory().unwrap(); + let pending = prepare(&store); + let after_expiry = fixed_expiry() + chrono::Duration::seconds(1); + let resolution_claim = TaskGroupedApprovalResolutionClaim::from_group( + &pending, + TaskGroupedApprovalActor::User, + ); + assert!(store + .resolve_task_grouped_approval(&resolution_claim, true, after_expiry) + .is_err()); + let expired = store.task_grouped_approval(pending.id).unwrap().unwrap(); + assert_eq!(expired.status, TaskGroupedApprovalStatus::Expired); + assert_eq!( + store + .expire_task_grouped_approval(pending.id, TASK_ID, after_expiry) + .unwrap(), + expired + ); + assert!(store + .expire_task_grouped_approval(pending.id, OTHER_TASK_ID, after_expiry) + .is_err()); + + let store = EventStore::open_memory().unwrap(); + let pending = prepare(&store); + let approved = approve(&store, &pending); + let claim = + TaskGroupedCapabilityClaim::from_group_item(&approved, &approved.capability_audits[0]); + assert!(store + .authorize_task_grouped_capability(&claim, after_expiry) + .is_err()); + assert_eq!( + store + .task_grouped_approval(approved.id) + .unwrap() + .unwrap() + .status, + TaskGroupedApprovalStatus::Expired + ); + } + + #[test] + fn wrong_and_stale_resolution_or_capability_bindings_have_zero_authority() { + let store = EventStore::open_memory().unwrap(); + let pending = prepare(&store); + let base = TaskGroupedApprovalResolutionClaim::from_group( + &pending, + TaskGroupedApprovalActor::User, + ); + let mut wrong_claims = Vec::new(); + let mut claim = base.clone(); + claim.group_id = Uuid::new_v4(); + wrong_claims.push(claim); + let mut claim = base.clone(); + claim.task_id = OTHER_TASK_ID; + wrong_claims.push(claim); + let mut claim = base.clone(); + claim.expected_projection_revision += 1; + wrong_claims.push(claim); + let mut claim = base.clone(); + claim.manifest_revision = "a".repeat(64); + wrong_claims.push(claim); + let mut claim = base.clone(); + claim.manifest_fingerprint = "b".repeat(64); + wrong_claims.push(claim); + let mut claim = base.clone(); + claim.preview_schema_revision += 1; + wrong_claims.push(claim); + let mut claim = base.clone(); + claim.preview_renderer_revision += 1; + wrong_claims.push(claim); + let mut claim = base.clone(); + claim.preview_hash = "c".repeat(64); + wrong_claims.push(claim); + for claim in wrong_claims { + assert!(store + .resolve_task_grouped_approval( + &claim, + true, + fixed_now() + chrono::Duration::minutes(1), + ) + .is_err()); + } + let approved = approve(&store, &pending); + let base = + TaskGroupedCapabilityClaim::from_group_item(&approved, &approved.capability_audits[0]); + let mut wrong_claims = Vec::new(); + let mut claim = base.clone(); + claim.task_id = OTHER_TASK_ID; + wrong_claims.push(claim); + let mut claim = base.clone(); + claim.expected_projection_revision += 1; + wrong_claims.push(claim); + let mut claim = base.clone(); + claim.manifest_revision = "d".repeat(64); + wrong_claims.push(claim); + let mut claim = base.clone(); + claim.manifest_fingerprint = "e".repeat(64); + wrong_claims.push(claim); + let mut claim = base.clone(); + claim.preview_hash = "f".repeat(64); + wrong_claims.push(claim); + let mut claim = base.clone(); + claim.capability = CapabilityKind::FileRead; + wrong_claims.push(claim); + let mut claim = base.clone(); + claim.tool_id = FILE_WRITE_TOOL_ID.to_string(); + wrong_claims.push(claim); + let mut claim = base; + claim.request_fingerprint = "0".repeat(64); + wrong_claims.push(claim); + for claim in wrong_claims { + assert!(store + .authorize_task_grouped_capability( + &claim, + fixed_now() + chrono::Duration::minutes(2), + ) + .is_err()); + } + } + + #[test] + fn scope_or_external_target_change_invalidates_old_group_and_cross_task_reuse() { + let store = EventStore::open_memory().unwrap(); + let pending = prepare(&store); + let approved = approve(&store, &pending); + let old_claim = + TaskGroupedCapabilityClaim::from_group_item(&approved, &approved.capability_audits[0]); + + let (changed_manifest, changed_preview) = compiled_fixture_for( + &store, + TASK_ID, + b"path-authority-v2", + "Workspace / approved-reports", + fixed_expiry(), + ); + assert!(store + .prepare_task_grouped_approval( + TASK_ID, + &changed_manifest, + &changed_preview, + fixed_now() + chrono::Duration::minutes(3), + ) + .is_err()); + assert_eq!( + store + .task_grouped_approval(approved.id) + .unwrap() + .unwrap() + .status, + TaskGroupedApprovalStatus::ScopeChanged + ); + assert!(store + .authorize_task_grouped_capability( + &old_claim, + fixed_now() + chrono::Duration::minutes(4), + ) + .is_err()); + + assert_eq!( + store + .conn + .query_row( + "SELECT COUNT(*) FROM task_grouped_approval_state WHERE task_id = ?1", + params![TASK_ID.to_string()], + |row| row.get::<_, i64>(0), + ) + .unwrap(), + 1, + "a resolved exact task cannot prompt for a second grouped resolution" + ); + + let pending_store = EventStore::open_memory().unwrap(); + let old_pending = prepare(&pending_store); + let (changed_manifest, changed_preview) = compiled_fixture_for( + &pending_store, + TASK_ID, + b"path-authority-v2", + "Workspace / approved-reports", + fixed_expiry(), + ); + let replacement = pending_store + .prepare_task_grouped_approval( + TASK_ID, + &changed_manifest, + &changed_preview, + fixed_now() + chrono::Duration::minutes(3), + ) + .unwrap(); + assert_ne!(replacement.id, old_pending.id); + assert_eq!(replacement.status, TaskGroupedApprovalStatus::Pending); + assert_eq!( + pending_store + .task_grouped_approval(old_pending.id) + .unwrap() + .unwrap() + .status, + TaskGroupedApprovalStatus::ScopeChanged + ); + let mut cross_task = old_claim; + cross_task.task_id = OTHER_TASK_ID; + assert!(store + .authorize_task_grouped_capability( + &cross_task, + fixed_now() + chrono::Duration::minutes(4), + ) + .is_err()); + } + + #[test] + fn duplicate_prepare_and_restart_are_deterministic_without_duplicate_events() { + let directory = tempdir().unwrap(); + let path = directory.path().join("grouped-approval.db"); + let store = EventStore::open(&path).unwrap(); + let (manifest, preview) = compiled_fixture(&store); + let first = store + .prepare_task_grouped_approval(TASK_ID, &manifest, &preview, fixed_now()) + .unwrap(); + let duplicate = store + .prepare_task_grouped_approval( + TASK_ID, + &manifest, + &preview, + fixed_now() + chrono::Duration::seconds(1), + ) + .unwrap(); + assert_eq!(first, duplicate); + assert_eq!(event_count(&store, TASK_GROUPED_APPROVAL_PREPARED_EVENT), 1); + assert!(store + .append( + &KernelEvent::new(TASK_GROUPED_APPROVAL_PREPARED_EVENT, first.event_receipt(),) + .unwrap(), + ) + .is_err()); + assert_eq!(event_count(&store, TASK_GROUPED_APPROVAL_PREPARED_EVENT), 1); + assert_eq!( + store + .conn + .query_row( + "SELECT COUNT(*) FROM task_grouped_approval_item_audit", + [], + |row| row.get::<_, i64>(0), + ) + .unwrap(), + 2 + ); + let approved = approve(&store, &first); + drop(store); + + let reopened = EventStore::open(&path).unwrap(); + let restored = reopened.task_grouped_approval(first.id).unwrap().unwrap(); + assert_eq!(restored, approved); + let claim = + TaskGroupedCapabilityClaim::from_group_item(&restored, &restored.capability_audits[0]); + reopened + .authorize_task_grouped_capability(&claim, fixed_now() + chrono::Duration::minutes(4)) + .expect("restart preserves exact authority"); + } + + #[test] + fn additive_migration_is_idempotent_and_preserves_legacy_exact_tool_approval() { + let directory = tempdir().unwrap(); + let path = directory.path().join("legacy.db"); + let store = EventStore::open(&path).unwrap(); + let mut request = + request_capability_access(AccessMode::AskEveryStep, CapabilityKind::FileWrite).unwrap(); + request + .bind_exact_tool( + FILE_WRITE_TOOL_ID, + "1".repeat(64), + "Legacy exact file write preview", + ) + .unwrap(); + let request_id = request.id; + store.append_capability_access_request(&request).unwrap(); + store + .resolve_capability_access_request(request_id, true, "legacy user approval".to_string()) + .unwrap(); + store + .conn + .execute_batch( + "DROP TABLE task_grouped_approval_item_audit; + DROP TABLE task_grouped_approval_state;", + ) + .unwrap(); + drop(store); + + for _ in 0..2 { + let reopened = EventStore::open(&path).unwrap(); + let record = reopened + .list_capability_access_records() + .unwrap() + .into_iter() + .find(|record| record.request.id == request_id) + .unwrap(); + assert_eq!(record.effective_status, CapabilityAccessStatus::Approved); + assert_eq!(record.grant_state, CapabilityGrantState::OneShotAvailable); + assert_eq!( + reopened + .conn + .query_row( + "SELECT COUNT(*) FROM task_grouped_approval_state", + [], + |row| row.get::<_, i64>(0), + ) + .unwrap(), + 0 + ); + drop(reopened); + } + } + + #[test] + fn tampered_projection_or_per_item_audit_fails_closed_on_restart() { + let directory = tempdir().unwrap(); + let projection_path = directory.path().join("tampered-projection.db"); + let store = EventStore::open(&projection_path).unwrap(); + let group = prepare(&store); + drop(store); + let connection = Connection::open(&projection_path).unwrap(); + let json: String = connection + .query_row( + "SELECT projection_json FROM task_grouped_approval_state WHERE group_id = ?1", + params![group.id.to_string()], + |row| row.get(0), + ) + .unwrap(); + let mut value: serde_json::Value = serde_json::from_str(&json).unwrap(); + value["preview"]["preview_hash"] = serde_json::Value::String("0".repeat(64)); + connection + .execute( + "UPDATE task_grouped_approval_state SET projection_json = ?2 WHERE group_id = ?1", + params![group.id.to_string(), serde_json::to_string(&value).unwrap()], + ) + .unwrap(); + drop(connection); + assert!(EventStore::open(&projection_path).is_err()); + + let audit_path = directory.path().join("tampered-audit.db"); + let store = EventStore::open(&audit_path).unwrap(); + let group = prepare(&store); + drop(store); + let connection = Connection::open(&audit_path).unwrap(); + connection + .execute( + r#"UPDATE task_grouped_approval_item_audit SET transition = 'approved' + WHERE group_id = ?1 AND group_revision = 0"#, + params![group.id.to_string()], + ) + .unwrap(); + drop(connection); + assert!(EventStore::open(&audit_path).is_err()); + } + + #[test] + fn malformed_input_creates_zero_group_permission_or_execution_authority() { + let store = EventStore::open_memory().unwrap(); + let malformed = r#"{ + "version":"ds-agent.task-capability-manifest/v1", + "task_id":"00000000-0000-0000-0000-00000000033b", + "unexpected_authority":true + }"#; + assert!(TaskCapabilityManifest::parse_json(malformed).is_err()); + assert_eq!( + store + .conn + .query_row( + "SELECT COUNT(*) FROM task_grouped_approval_state", + [], + |row| row.get::<_, i64>(0), + ) + .unwrap(), + 0 + ); + assert!(store.list_capability_access_records().unwrap().is_empty()); + assert!(store.list_tool_invocations().unwrap().is_empty()); + assert_eq!(event_count(&store, TASK_GROUPED_APPROVAL_PREPARED_EVENT), 0); + } + + #[test] + fn grouped_projection_and_events_keep_secret_path_and_provider_refs_out() { + let store = EventStore::open_memory().unwrap(); + let group = prepare(&store); + let serialized = group.canonical_json().unwrap(); + let event_payloads = { + let mut statement = store + .conn + .prepare("SELECT payload_json FROM kernel_events ORDER BY rowid") + .unwrap(); + statement + .query_map([], |row| row.get::<_, String>(0)) + .unwrap() + .collect::, _>>() + .unwrap() + .join("\n") + }; + let combined = format!("{serialized}\n{event_payloads}").to_ascii_lowercase(); + for forbidden in [ + "sk-live-secret-marker", + "c:\\users\\private\\appdata", + "provider-message-id", + "credential_handle", + "claim_token", + "bearer ", + ] { + assert!(!combined.contains(forbidden), "leaked {forbidden}"); + } + for item in &group.capability_audits { + assert!(!item.exact_preview.contains("finance-test@example.com")); + assert!(!item.exact_preview.contains("Workspace / reports")); + } + } +} diff --git a/apps/desktop/src-tauri/src/kernel/mod.rs b/apps/desktop/src-tauri/src/kernel/mod.rs index b400690..bbb5d7a 100644 --- a/apps/desktop/src-tauri/src/kernel/mod.rs +++ b/apps/desktop/src-tauri/src/kernel/mod.rs @@ -32,6 +32,7 @@ pub mod skill; pub mod skill_source; pub mod soul; pub mod task_capability_manifest; +pub mod task_grouped_approval; pub mod task_lifecycle; pub mod tool_runtime; pub mod tool_strategy; diff --git a/apps/desktop/src-tauri/src/kernel/task_grouped_approval.rs b/apps/desktop/src-tauri/src/kernel/task_grouped_approval.rs new file mode 100644 index 0000000..95e8a6e --- /dev/null +++ b/apps/desktop/src-tauri/src/kernel/task_grouped_approval.rs @@ -0,0 +1,958 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use uuid::Uuid; + +use crate::kernel::policy::{ + exact_tool_preview_hash, CapabilityKind, RiskLevel, TOOL_APPROVAL_PREVIEW_REVISION, +}; +use crate::kernel::task_capability_manifest::{ + TaskAuthorizationPreview, TaskCapabilityManifest, TaskCapabilityManifestError, +}; + +pub const TASK_GROUPED_APPROVAL_VERSION: &str = "ds-agent.task-grouped-approval/v1"; +pub const TASK_GROUPED_APPROVAL_EVENT_VERSION: &str = "ds-agent.task-grouped-approval-event/v1"; + +const GROUP_ID_DOMAIN: &[u8] = b"ds-agent.task-grouped-approval-id.v1\0"; +const GROUP_INTEGRITY_DOMAIN: &[u8] = b"ds-agent.task-grouped-approval-integrity.v1\0"; +const ITEM_ID_DOMAIN: &[u8] = b"ds-agent.task-grouped-approval-item-id.v1\0"; +const REQUEST_ID_DOMAIN: &[u8] = b"ds-agent.task-grouped-approval-request-id.v1\0"; +const REQUEST_FINGERPRINT_DOMAIN: &[u8] = + b"ds-agent.task-grouped-approval-request-fingerprint.v1\0"; +const RESOLUTION_ID_DOMAIN: &[u8] = b"ds-agent.task-grouped-approval-resolution-id.v1\0"; +const EVENT_ID_DOMAIN: &[u8] = b"ds-agent.task-grouped-approval-event-id.v1\0"; +const ITEM_EVENT_ID_DOMAIN: &[u8] = b"ds-agent.task-grouped-approval-item-event-id.v1\0"; +const LEGACY_CONSUMPTION_ID_DOMAIN: &[u8] = + b"ds-agent.task-grouped-approval-legacy-consumption-id.v1\0"; +const MAX_GROUPED_APPROVAL_JSON_BYTES: usize = 192 * 1024; +const MAX_AUDIT_ITEMS: usize = 2048; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum TaskGroupedApprovalActor { + User, + KernelLifecycle, + DeepSeekModel, + FrontendPayload, +} + +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum TaskGroupedApprovalStatus { + Pending, + Approved, + Rejected, + Revoked, + Expired, + ScopeChanged, +} + +impl TaskGroupedApprovalStatus { + pub const fn as_str(self) -> &'static str { + match self { + Self::Pending => "pending", + Self::Approved => "approved", + Self::Rejected => "rejected", + Self::Revoked => "revoked", + Self::Expired => "expired", + Self::ScopeChanged => "scope_changed", + } + } + + pub const fn carries_authority(self) -> bool { + matches!(self, Self::Approved) + } +} + +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum TaskGroupedCapabilityAuditStatus { + Pending, + Approved, + Rejected, + Revoked, + Expired, + ScopeChanged, +} + +impl From for TaskGroupedCapabilityAuditStatus { + fn from(value: TaskGroupedApprovalStatus) -> Self { + match value { + TaskGroupedApprovalStatus::Pending => Self::Pending, + TaskGroupedApprovalStatus::Approved => Self::Approved, + TaskGroupedApprovalStatus::Rejected => Self::Rejected, + TaskGroupedApprovalStatus::Revoked => Self::Revoked, + TaskGroupedApprovalStatus::Expired => Self::Expired, + TaskGroupedApprovalStatus::ScopeChanged => Self::ScopeChanged, + } + } +} + +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum TaskGroupedApprovalResolutionSource { + User, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +pub struct TaskGroupedCapabilityAudit { + pub item_id: String, + pub capability: CapabilityKind, + pub risk_level: RiskLevel, + pub tool_id: String, + pub tool_version: String, + pub approval_request_id: Uuid, + pub request_fingerprint: String, + pub exact_preview: String, + pub exact_preview_revision: u32, + pub exact_preview_hash: String, + pub status: TaskGroupedCapabilityAuditStatus, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +pub struct TaskGroupedApprovalResolution { + pub id: Uuid, + pub approved: bool, + pub source: TaskGroupedApprovalResolutionSource, + pub expected_projection_revision: u64, + pub task_id: Uuid, + pub manifest_revision: String, + pub manifest_fingerprint: String, + pub preview_schema_revision: u32, + pub preview_renderer_revision: u32, + pub preview_hash: String, + pub created_at: DateTime, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct TaskGroupedApprovalResolutionClaim { + pub group_id: Uuid, + pub task_id: Uuid, + pub expected_projection_revision: u64, + pub manifest_revision: String, + pub manifest_fingerprint: String, + pub preview_schema_revision: u32, + pub preview_renderer_revision: u32, + pub preview_hash: String, + pub actor: TaskGroupedApprovalActor, +} + +impl TaskGroupedApprovalResolutionClaim { + pub fn from_group(group: &TaskGroupedApproval, actor: TaskGroupedApprovalActor) -> Self { + Self { + group_id: group.id, + task_id: group.task_id, + expected_projection_revision: group.projection_revision, + manifest_revision: group.manifest.revision.clone(), + manifest_fingerprint: group.manifest.fingerprint.clone(), + preview_schema_revision: group.preview.schema_revision, + preview_renderer_revision: group.preview.renderer_revision, + preview_hash: group.preview.preview_hash.clone(), + actor, + } + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct TaskGroupedCapabilityClaim { + pub group_id: Uuid, + pub task_id: Uuid, + pub expected_projection_revision: u64, + pub manifest_revision: String, + pub manifest_fingerprint: String, + pub preview_schema_revision: u32, + pub preview_renderer_revision: u32, + pub preview_hash: String, + pub capability: CapabilityKind, + pub tool_id: String, + pub request_fingerprint: String, +} + +impl TaskGroupedCapabilityClaim { + pub fn from_group_item(group: &TaskGroupedApproval, item: &TaskGroupedCapabilityAudit) -> Self { + Self { + group_id: group.id, + task_id: group.task_id, + expected_projection_revision: group.projection_revision, + manifest_revision: group.manifest.revision.clone(), + manifest_fingerprint: group.manifest.fingerprint.clone(), + preview_schema_revision: group.preview.schema_revision, + preview_renderer_revision: group.preview.renderer_revision, + preview_hash: group.preview.preview_hash.clone(), + capability: item.capability, + tool_id: item.tool_id.clone(), + request_fingerprint: item.request_fingerprint.clone(), + } + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct TaskGroupedCapabilityGrant { + pub group_id: Uuid, + pub task_id: Uuid, + pub projection_revision: u64, + pub manifest_revision: String, + pub manifest_fingerprint: String, + pub preview_renderer_revision: u32, + pub preview_hash: String, + pub capability: CapabilityKind, + pub tool_id: String, + pub request_fingerprint: String, + pub approval_request_id: Uuid, + pub expires_at: DateTime, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +pub struct TaskGroupedApproval { + pub version: String, + pub id: Uuid, + pub task_id: Uuid, + pub manifest: TaskCapabilityManifest, + pub preview: TaskAuthorizationPreview, + pub status: TaskGroupedApprovalStatus, + pub projection_revision: u64, + pub capability_audits: Vec, + pub resolution: Option, + pub created_at: DateTime, + pub updated_at: DateTime, + pub integrity_hash: String, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +pub struct TaskGroupedApprovalEventReceipt { + pub version: String, + pub group_id: Uuid, + pub task_id: Uuid, + pub status: TaskGroupedApprovalStatus, + pub projection_revision: u64, + pub manifest_revision: String, + pub manifest_fingerprint: String, + pub preview_renderer_revision: u32, + pub preview_hash: String, + pub capability_audit_ids: Vec, + pub created_at: DateTime, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum TaskGroupedApprovalError { + ManifestInvalid, + PreviewInvalid, + BindingMismatch, + IntegrityMismatch, + InvalidState, + InvalidActor, + Expired, + CollectionOutOfBounds, + NonCanonicalAudit, +} + +impl std::fmt::Display for TaskGroupedApprovalError { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str(match self { + Self::ManifestInvalid => "task grouped approval manifest is invalid", + Self::PreviewInvalid => "task grouped approval preview is invalid", + Self::BindingMismatch => "task grouped approval binding changed", + Self::IntegrityMismatch => "task grouped approval integrity check failed", + Self::InvalidState => "task grouped approval state is invalid", + Self::InvalidActor => "task grouped approval actor has no authority", + Self::Expired => "task grouped approval expired", + Self::CollectionOutOfBounds => "task grouped approval audit collection is invalid", + Self::NonCanonicalAudit => "task grouped approval audit order is invalid", + }) + } +} + +impl std::error::Error for TaskGroupedApprovalError {} + +impl From for TaskGroupedApprovalError { + fn from(_: TaskCapabilityManifestError) -> Self { + Self::ManifestInvalid + } +} + +#[derive(Serialize)] +struct GroupIdCanonical<'a> { + task_id: Uuid, + goal_revision: &'a str, + goal_fingerprint: &'a str, + manifest_revision: &'a str, + manifest_fingerprint: &'a str, + preview_schema_revision: u32, + preview_renderer_revision: u32, + preview_hash: &'a str, + expires_at: DateTime, +} + +#[derive(Serialize)] +struct ItemCanonical<'a> { + group_id: Uuid, + capability: &'a str, + tool_id: &'a str, + tool_version: &'a str, +} + +#[derive(Serialize)] +struct RequestFingerprintCanonical<'a> { + group_id: Uuid, + task_id: Uuid, + manifest_revision: &'a str, + manifest_fingerprint: &'a str, + preview_schema_revision: u32, + preview_renderer_revision: u32, + preview_hash: &'a str, + capability: &'a str, + tool_id: &'a str, + tool_version: &'a str, + expires_at: DateTime, +} + +#[derive(Serialize)] +struct GroupIntegrityCanonical<'a> { + version: &'a str, + id: Uuid, + task_id: Uuid, + manifest: &'a TaskCapabilityManifest, + preview: &'a TaskAuthorizationPreview, + status: TaskGroupedApprovalStatus, + projection_revision: u64, + capability_audits: &'a [TaskGroupedCapabilityAudit], + resolution: &'a Option, + created_at: DateTime, + updated_at: DateTime, +} + +impl TaskGroupedApproval { + pub fn new( + manifest: TaskCapabilityManifest, + preview: TaskAuthorizationPreview, + now: DateTime, + ) -> Result { + manifest + .validate_integrity() + .map_err(|_| TaskGroupedApprovalError::ManifestInvalid)?; + preview + .validate_for_manifest(&manifest) + .map_err(|_| TaskGroupedApprovalError::PreviewInvalid)?; + if manifest.expires_at <= now { + return Err(TaskGroupedApprovalError::Expired); + } + let id = group_id_for(&manifest, &preview); + let mut capability_audits = Vec::new(); + for entry in &manifest.capabilities { + for tool in &entry.tools { + let item_id = item_id_for(id, entry.capability, &tool.tool_id, &tool.tool_version); + let request_fingerprint = request_fingerprint_for( + id, + &manifest, + &preview, + entry.capability, + &tool.tool_id, + &tool.tool_version, + ); + let exact_preview = + compact_exact_preview(&manifest, &preview, entry.capability, &tool.tool_id); + capability_audits.push(TaskGroupedCapabilityAudit { + approval_request_id: approval_request_id_for(id, &item_id), + item_id, + capability: entry.capability, + risk_level: entry.risk_level, + tool_id: tool.tool_id.clone(), + tool_version: tool.tool_version.clone(), + request_fingerprint, + exact_preview_hash: exact_tool_preview_hash( + TOOL_APPROVAL_PREVIEW_REVISION, + &exact_preview, + ), + exact_preview, + exact_preview_revision: TOOL_APPROVAL_PREVIEW_REVISION, + status: TaskGroupedCapabilityAuditStatus::Pending, + }); + } + } + capability_audits.sort_by(|left, right| left.item_id.cmp(&right.item_id)); + let mut group = Self { + version: TASK_GROUPED_APPROVAL_VERSION.to_string(), + id, + task_id: manifest.task_id, + manifest, + preview, + status: TaskGroupedApprovalStatus::Pending, + projection_revision: 0, + capability_audits, + resolution: None, + created_at: now, + updated_at: now, + integrity_hash: String::new(), + }; + group.integrity_hash = integrity_hash_for(&group); + group.validate_integrity()?; + Ok(group) + } + + pub fn parse_json(json: &str) -> Result { + if json.len() > MAX_GROUPED_APPROVAL_JSON_BYTES { + return Err(TaskGroupedApprovalError::CollectionOutOfBounds); + } + let group: Self = + serde_json::from_str(json).map_err(|_| TaskGroupedApprovalError::IntegrityMismatch)?; + group.validate_integrity()?; + Ok(group) + } + + pub fn canonical_json(&self) -> Result { + self.validate_integrity()?; + let json = + serde_json::to_string(self).map_err(|_| TaskGroupedApprovalError::IntegrityMismatch)?; + if json.len() > MAX_GROUPED_APPROVAL_JSON_BYTES { + return Err(TaskGroupedApprovalError::CollectionOutOfBounds); + } + Ok(json) + } + + pub fn validate_integrity(&self) -> Result<(), TaskGroupedApprovalError> { + if self.version != TASK_GROUPED_APPROVAL_VERSION { + return Err(TaskGroupedApprovalError::IntegrityMismatch); + } + self.manifest + .validate_integrity() + .map_err(|_| TaskGroupedApprovalError::ManifestInvalid)?; + self.preview + .validate_for_manifest(&self.manifest) + .map_err(|_| TaskGroupedApprovalError::PreviewInvalid)?; + if self.task_id != self.manifest.task_id + || self.id != group_id_for(&self.manifest, &self.preview) + || self.created_at >= self.manifest.expires_at + || self.updated_at < self.created_at + { + return Err(TaskGroupedApprovalError::BindingMismatch); + } + if self.capability_audits.is_empty() || self.capability_audits.len() > MAX_AUDIT_ITEMS { + return Err(TaskGroupedApprovalError::CollectionOutOfBounds); + } + if self + .capability_audits + .windows(2) + .any(|pair| pair[0].item_id >= pair[1].item_id) + { + return Err(TaskGroupedApprovalError::NonCanonicalAudit); + } + let mut expected = Vec::new(); + for entry in &self.manifest.capabilities { + for tool in &entry.tools { + expected.push((entry.capability, entry.risk_level, tool)); + } + } + expected.sort_by_key(|(capability, _, tool)| { + item_id_for(self.id, *capability, &tool.tool_id, &tool.tool_version) + }); + if expected.len() != self.capability_audits.len() { + return Err(TaskGroupedApprovalError::BindingMismatch); + } + let expected_status = TaskGroupedCapabilityAuditStatus::from(self.status); + for (audit, (capability, risk_level, tool)) in + self.capability_audits.iter().zip(expected.into_iter()) + { + let item_id = item_id_for(self.id, capability, &tool.tool_id, &tool.tool_version); + let request_fingerprint = request_fingerprint_for( + self.id, + &self.manifest, + &self.preview, + capability, + &tool.tool_id, + &tool.tool_version, + ); + let exact_preview = + compact_exact_preview(&self.manifest, &self.preview, capability, &tool.tool_id); + if audit.item_id != item_id + || audit.capability != capability + || audit.risk_level != risk_level + || audit.tool_id != tool.tool_id + || audit.tool_version != tool.tool_version + || audit.approval_request_id != approval_request_id_for(self.id, &item_id) + || audit.request_fingerprint != request_fingerprint + || audit.exact_preview != exact_preview + || audit.exact_preview_revision != TOOL_APPROVAL_PREVIEW_REVISION + || audit.exact_preview_hash + != exact_tool_preview_hash(TOOL_APPROVAL_PREVIEW_REVISION, &exact_preview) + || audit.status != expected_status + { + return Err(TaskGroupedApprovalError::BindingMismatch); + } + } + match self.status { + TaskGroupedApprovalStatus::Pending => { + if self.projection_revision != 0 || self.resolution.is_some() { + return Err(TaskGroupedApprovalError::InvalidState); + } + } + TaskGroupedApprovalStatus::Approved | TaskGroupedApprovalStatus::Rejected => { + let resolution = self + .resolution + .as_ref() + .ok_or(TaskGroupedApprovalError::InvalidState)?; + if self.projection_revision != 1 + || resolution.approved + != matches!(self.status, TaskGroupedApprovalStatus::Approved) + { + return Err(TaskGroupedApprovalError::InvalidState); + } + validate_resolution(self, resolution)?; + } + TaskGroupedApprovalStatus::Revoked => { + let resolution = self + .resolution + .as_ref() + .ok_or(TaskGroupedApprovalError::InvalidState)?; + if !resolution.approved || self.projection_revision != 2 { + return Err(TaskGroupedApprovalError::InvalidState); + } + validate_resolution(self, resolution)?; + } + TaskGroupedApprovalStatus::Expired | TaskGroupedApprovalStatus::ScopeChanged => { + match &self.resolution { + None if self.projection_revision == 1 => {} + Some(resolution) if resolution.approved && self.projection_revision == 2 => { + validate_resolution(self, resolution)?; + } + _ => return Err(TaskGroupedApprovalError::InvalidState), + } + } + } + if self.integrity_hash != integrity_hash_for(self) { + return Err(TaskGroupedApprovalError::IntegrityMismatch); + } + Ok(()) + } + + pub fn binding_matches_resolution_claim( + &self, + claim: &TaskGroupedApprovalResolutionClaim, + ) -> bool { + self.id == claim.group_id + && self.task_id == claim.task_id + && self.projection_revision == claim.expected_projection_revision + && self.manifest.revision == claim.manifest_revision + && self.manifest.fingerprint == claim.manifest_fingerprint + && self.preview.schema_revision == claim.preview_schema_revision + && self.preview.renderer_revision == claim.preview_renderer_revision + && self.preview.preview_hash == claim.preview_hash + } + + pub fn resolution_replay_matches( + &self, + claim: &TaskGroupedApprovalResolutionClaim, + approved: bool, + ) -> bool { + let Some(resolution) = &self.resolution else { + return false; + }; + resolution.approved == approved + && self.id == claim.group_id + && self.task_id == claim.task_id + && resolution.expected_projection_revision == claim.expected_projection_revision + && resolution.manifest_revision == claim.manifest_revision + && resolution.manifest_fingerprint == claim.manifest_fingerprint + && resolution.preview_schema_revision == claim.preview_schema_revision + && resolution.preview_renderer_revision == claim.preview_renderer_revision + && resolution.preview_hash == claim.preview_hash + } + + pub fn capability_item( + &self, + claim: &TaskGroupedCapabilityClaim, + ) -> Result<&TaskGroupedCapabilityAudit, TaskGroupedApprovalError> { + if self.id != claim.group_id + || self.task_id != claim.task_id + || self.projection_revision != claim.expected_projection_revision + || self.manifest.revision != claim.manifest_revision + || self.manifest.fingerprint != claim.manifest_fingerprint + || self.preview.schema_revision != claim.preview_schema_revision + || self.preview.renderer_revision != claim.preview_renderer_revision + || self.preview.preview_hash != claim.preview_hash + { + return Err(TaskGroupedApprovalError::BindingMismatch); + } + self.capability_audits + .iter() + .find(|item| { + item.capability == claim.capability + && item.tool_id == claim.tool_id + && item.request_fingerprint == claim.request_fingerprint + }) + .ok_or(TaskGroupedApprovalError::BindingMismatch) + } + + pub(crate) fn resolve( + &self, + claim: &TaskGroupedApprovalResolutionClaim, + approved: bool, + now: DateTime, + ) -> Result { + if claim.actor != TaskGroupedApprovalActor::User { + return Err(TaskGroupedApprovalError::InvalidActor); + } + if self.status != TaskGroupedApprovalStatus::Pending + || !self.binding_matches_resolution_claim(claim) + { + return Err(TaskGroupedApprovalError::BindingMismatch); + } + if now >= self.manifest.expires_at { + return Err(TaskGroupedApprovalError::Expired); + } + let resolution = TaskGroupedApprovalResolution { + id: resolution_id_for( + self.id, + approved, + claim.expected_projection_revision, + &claim.preview_hash, + ), + approved, + source: TaskGroupedApprovalResolutionSource::User, + expected_projection_revision: claim.expected_projection_revision, + task_id: claim.task_id, + manifest_revision: claim.manifest_revision.clone(), + manifest_fingerprint: claim.manifest_fingerprint.clone(), + preview_schema_revision: claim.preview_schema_revision, + preview_renderer_revision: claim.preview_renderer_revision, + preview_hash: claim.preview_hash.clone(), + created_at: now, + }; + self.transition( + if approved { + TaskGroupedApprovalStatus::Approved + } else { + TaskGroupedApprovalStatus::Rejected + }, + Some(resolution), + now, + ) + } + + pub(crate) fn revoke( + &self, + actor: TaskGroupedApprovalActor, + now: DateTime, + ) -> Result { + if !matches!( + actor, + TaskGroupedApprovalActor::User | TaskGroupedApprovalActor::KernelLifecycle + ) { + return Err(TaskGroupedApprovalError::InvalidActor); + } + if self.status != TaskGroupedApprovalStatus::Approved { + return Err(TaskGroupedApprovalError::InvalidState); + } + self.transition( + TaskGroupedApprovalStatus::Revoked, + self.resolution.clone(), + now, + ) + } + + pub(crate) fn expire(&self, now: DateTime) -> Result { + if now < self.manifest.expires_at + || !matches!( + self.status, + TaskGroupedApprovalStatus::Pending | TaskGroupedApprovalStatus::Approved + ) + { + return Err(TaskGroupedApprovalError::InvalidState); + } + self.transition( + TaskGroupedApprovalStatus::Expired, + self.resolution.clone(), + now, + ) + } + + pub(crate) fn scope_changed( + &self, + now: DateTime, + ) -> Result { + if !matches!( + self.status, + TaskGroupedApprovalStatus::Pending | TaskGroupedApprovalStatus::Approved + ) { + return Err(TaskGroupedApprovalError::InvalidState); + } + self.transition( + TaskGroupedApprovalStatus::ScopeChanged, + self.resolution.clone(), + now, + ) + } + + pub fn event_receipt(&self) -> TaskGroupedApprovalEventReceipt { + TaskGroupedApprovalEventReceipt { + version: TASK_GROUPED_APPROVAL_EVENT_VERSION.to_string(), + group_id: self.id, + task_id: self.task_id, + status: self.status, + projection_revision: self.projection_revision, + manifest_revision: self.manifest.revision.clone(), + manifest_fingerprint: self.manifest.fingerprint.clone(), + preview_renderer_revision: self.preview.renderer_revision, + preview_hash: self.preview.preview_hash.clone(), + capability_audit_ids: self + .capability_audits + .iter() + .map(|item| item.item_id.clone()) + .collect(), + created_at: self.updated_at, + } + } + + fn transition( + &self, + status: TaskGroupedApprovalStatus, + resolution: Option, + now: DateTime, + ) -> Result { + let mut next = self.clone(); + next.status = status; + next.projection_revision = next + .projection_revision + .checked_add(1) + .ok_or(TaskGroupedApprovalError::InvalidState)?; + next.resolution = resolution; + next.updated_at = now; + for item in &mut next.capability_audits { + item.status = status.into(); + } + next.integrity_hash = integrity_hash_for(&next); + next.validate_integrity()?; + Ok(next) + } +} + +fn validate_resolution( + group: &TaskGroupedApproval, + resolution: &TaskGroupedApprovalResolution, +) -> Result<(), TaskGroupedApprovalError> { + if resolution.source != TaskGroupedApprovalResolutionSource::User + || resolution.task_id != group.task_id + || resolution.manifest_revision != group.manifest.revision + || resolution.manifest_fingerprint != group.manifest.fingerprint + || resolution.preview_schema_revision != group.preview.schema_revision + || resolution.preview_renderer_revision != group.preview.renderer_revision + || resolution.preview_hash != group.preview.preview_hash + || resolution.expected_projection_revision != 0 + || resolution.id + != resolution_id_for( + group.id, + resolution.approved, + resolution.expected_projection_revision, + &resolution.preview_hash, + ) + || resolution.created_at < group.created_at + || resolution.created_at >= group.manifest.expires_at + || resolution.created_at > group.updated_at + { + return Err(TaskGroupedApprovalError::BindingMismatch); + } + Ok(()) +} + +pub(crate) fn group_id_for( + manifest: &TaskCapabilityManifest, + preview: &TaskAuthorizationPreview, +) -> Uuid { + let canonical = GroupIdCanonical { + task_id: manifest.task_id, + goal_revision: &manifest.goal_revision, + goal_fingerprint: &manifest.goal_fingerprint, + manifest_revision: &manifest.revision, + manifest_fingerprint: &manifest.fingerprint, + preview_schema_revision: preview.schema_revision, + preview_renderer_revision: preview.renderer_revision, + preview_hash: &preview.preview_hash, + expires_at: manifest.expires_at, + }; + deterministic_uuid( + GROUP_ID_DOMAIN, + &serde_json::to_vec(&canonical).unwrap_or_default(), + ) +} + +pub(crate) fn event_id_for(group: &TaskGroupedApproval) -> Uuid { + deterministic_uuid( + EVENT_ID_DOMAIN, + format!( + "{}\0{}\0{}", + group.id, + group.status.as_str(), + group.projection_revision + ) + .as_bytes(), + ) +} + +pub(crate) fn item_event_id_for( + group: &TaskGroupedApproval, + item: &TaskGroupedCapabilityAudit, +) -> Uuid { + deterministic_uuid( + ITEM_EVENT_ID_DOMAIN, + format!( + "{}\0{}\0{}\0{}", + group.id, + item.item_id, + group.status.as_str(), + group.projection_revision + ) + .as_bytes(), + ) +} + +pub(crate) fn capability_request_event_id_for(group_id: Uuid, item_id: &str) -> Uuid { + deterministic_uuid( + EVENT_ID_DOMAIN, + format!("capability-request\0{group_id}\0{item_id}").as_bytes(), + ) +} + +pub(crate) fn permission_resolution_event_id_for(resolution_id: Uuid) -> Uuid { + deterministic_uuid( + EVENT_ID_DOMAIN, + format!("permission-resolution\0{resolution_id}").as_bytes(), + ) +} + +pub(crate) fn permission_resolution_id_for(group_id: Uuid, item_id: &str, approved: bool) -> Uuid { + deterministic_uuid( + RESOLUTION_ID_DOMAIN, + format!("item\0{group_id}\0{item_id}\0{approved}").as_bytes(), + ) +} + +pub(crate) fn legacy_consumption_id_for(group_id: Uuid, item_id: &str) -> Uuid { + deterministic_uuid( + LEGACY_CONSUMPTION_ID_DOMAIN, + format!("{group_id}\0{item_id}").as_bytes(), + ) +} + +fn item_id_for( + group_id: Uuid, + capability: CapabilityKind, + tool_id: &str, + tool_version: &str, +) -> String { + domain_hash( + ITEM_ID_DOMAIN, + &serde_json::to_vec(&ItemCanonical { + group_id, + capability: capability.as_str(), + tool_id, + tool_version, + }) + .unwrap_or_default(), + ) +} + +fn approval_request_id_for(group_id: Uuid, item_id: &str) -> Uuid { + deterministic_uuid( + REQUEST_ID_DOMAIN, + format!("{group_id}\0{item_id}").as_bytes(), + ) +} + +fn request_fingerprint_for( + group_id: Uuid, + manifest: &TaskCapabilityManifest, + preview: &TaskAuthorizationPreview, + capability: CapabilityKind, + tool_id: &str, + tool_version: &str, +) -> String { + domain_hash( + REQUEST_FINGERPRINT_DOMAIN, + &serde_json::to_vec(&RequestFingerprintCanonical { + group_id, + task_id: manifest.task_id, + manifest_revision: &manifest.revision, + manifest_fingerprint: &manifest.fingerprint, + preview_schema_revision: preview.schema_revision, + preview_renderer_revision: preview.renderer_revision, + preview_hash: &preview.preview_hash, + capability: capability.as_str(), + tool_id, + tool_version, + expires_at: manifest.expires_at, + }) + .unwrap_or_default(), + ) +} + +fn compact_exact_preview( + manifest: &TaskCapabilityManifest, + preview: &TaskAuthorizationPreview, + capability: CapabilityKind, + tool_id: &str, +) -> String { + format!( + "task={};manifest={}:{};preview={}/{}:{};capability={};tool={};expires={}", + manifest.task_id, + manifest.revision, + manifest.fingerprint, + preview.schema_revision, + preview.renderer_revision, + preview.preview_hash, + capability.as_str(), + tool_id, + manifest.expires_at.to_rfc3339(), + ) +} + +fn resolution_id_for( + group_id: Uuid, + approved: bool, + expected_projection_revision: u64, + preview_hash: &str, +) -> Uuid { + deterministic_uuid( + RESOLUTION_ID_DOMAIN, + format!("group\0{group_id}\0{approved}\0{expected_projection_revision}\0{preview_hash}") + .as_bytes(), + ) +} + +fn integrity_hash_for(group: &TaskGroupedApproval) -> String { + domain_hash( + GROUP_INTEGRITY_DOMAIN, + &serde_json::to_vec(&GroupIntegrityCanonical { + version: &group.version, + id: group.id, + task_id: group.task_id, + manifest: &group.manifest, + preview: &group.preview, + status: group.status, + projection_revision: group.projection_revision, + capability_audits: &group.capability_audits, + resolution: &group.resolution, + created_at: group.created_at, + updated_at: group.updated_at, + }) + .unwrap_or_default(), + ) +} + +fn domain_hash(domain: &[u8], value: &[u8]) -> String { + let mut digest = Sha256::new(); + digest.update(domain); + digest.update((value.len() as u64).to_be_bytes()); + digest.update(value); + format!("{:x}", digest.finalize()) +} + +fn deterministic_uuid(domain: &[u8], value: &[u8]) -> Uuid { + let digest = Sha256::digest([domain, value].concat()); + let mut bytes = [0_u8; 16]; + bytes.copy_from_slice(&digest[..16]); + bytes[6] = (bytes[6] & 0x0f) | 0x50; + bytes[8] = (bytes[8] & 0x3f) | 0x80; + Uuid::from_bytes(bytes) +} diff --git a/scripts/release-source-check.mjs b/scripts/release-source-check.mjs index b4aee71..08348f3 100644 --- a/scripts/release-source-check.mjs +++ b/scripts/release-source-check.mjs @@ -508,6 +508,7 @@ checkLocalReleaseHelperSelfTests(); checkStepOneOnboardingReadinessContract(); checkStepTwoGoalEnvelopeReleaseBoundary(); checkStepThreeCapabilityManifestReleaseBoundary(); +checkStepThreeGroupedApprovalKernelBoundary(); checkPackageManagerBaseline(); checkGovernanceDocs(); checkCodeSigningAndPrivacyPolicies(); @@ -4740,6 +4741,69 @@ function checkStepThreeCapabilityManifestReleaseBoundary() { } } +function checkStepThreeGroupedApprovalKernelBoundary() { + const domainPath = + "apps/desktop/src-tauri/src/kernel/task_grouped_approval.rs"; + const storePath = + "apps/desktop/src-tauri/src/kernel/event_store/grouped_approval.rs"; + const domain = readText(domainPath); + const groupedStore = readText(storePath); + const eventStore = readText("apps/desktop/src-tauri/src/kernel/event_store.rs"); + const kernelModules = readText("apps/desktop/src-tauri/src/kernel/mod.rs"); + const commands = readText("apps/desktop/src-tauri/src/commands.rs"); + const publicTypes = readText("apps/desktop/src/types.ts"); + const app = readText("apps/desktop/src/App.tsx"); + + for (const [filePath, content, phrase, label] of [ + [domainPath, domain, "ds-agent.task-grouped-approval/v1", "task grouped approval version"], + [domainPath, domain, "deny_unknown_fields", "task grouped approval unknown-field rejection"], + [domainPath, domain, "ds-agent.task-grouped-approval-id.v1", "domain-separated grouped approval identity"], + [domainPath, domain, "ds-agent.task-grouped-approval-integrity.v1", "grouped projection integrity binding"], + [domainPath, domain, "ds-agent.task-grouped-approval-request-fingerprint.v1", "exact task capability request fingerprint"], + [domainPath, domain, "exact_tool_preview_hash", "existing exact-tool preview contract reuse"], + [domainPath, domain, "DeepSeekModel", "model self-approval rejection actor"], + [domainPath, domain, "FrontendPayload", "frontend self-approval rejection actor"], + [storePath, groupedStore, "CREATE TABLE IF NOT EXISTS task_grouped_approval_state", "additive grouped approval migration"], + [storePath, groupedStore, "CREATE TABLE IF NOT EXISTS task_grouped_approval_item_audit", "per-capability durable audit migration"], + [storePath, groupedStore, "prepare_task_grouped_approval", "single grouped preparation path"], + [storePath, groupedStore, "resolve_task_grouped_approval", "single grouped resolution path"], + [storePath, groupedStore, "revoke_task_grouped_approval", "grouped revocation path"], + [storePath, groupedStore, "expire_task_grouped_approval", "grouped expiry path"], + [storePath, groupedStore, "authorize_task_grouped_capability", "exact task capability authority check"], + ["apps/desktop/src-tauri/src/kernel/event_store.rs", eventStore, "single exact-task resolver", "legacy per-item resolver is blocked"], + ["apps/desktop/src-tauri/src/kernel/event_store.rs", eventStore, "dedicated transactional state machine", "forged grouped events are blocked"], + [storePath, groupedStore, "exact_group_approval_uses_one_user_resolution_and_per_capability_audit", "single resolution and per-capability audit regression"], + [storePath, groupedStore, "tampered_projection_or_per_item_audit_fails_closed_on_restart", "restart tamper regression"], + [storePath, groupedStore, "additive_migration_is_idempotent_and_preserves_legacy_exact_tool_approval", "legacy exact-tool migration regression"], + ["apps/desktop/src-tauri/src/kernel/event_store.rs", eventStore, "grouped_approval::migrate(self)?", "Event Store grouped approval migration registration"], + ["apps/desktop/src-tauri/src/kernel/mod.rs", kernelModules, "pub mod task_grouped_approval;", "Kernel grouped approval module registration"], + ]) { + checkTextIncludes(filePath, content, phrase, label); + } + + for (const [filePath, content, label] of [ + ["apps/desktop/src-tauri/src/commands.rs", commands, "C3B adds no grouped authorization command"], + ["apps/desktop/src/types.ts", publicTypes, "C3B adds no public grouped authorization DTO"], + ["apps/desktop/src/App.tsx", app, "C3B adds no authorization UI"], + ]) { + checkTextDoesNotInclude(filePath, content, "task_grouped_approval", label); + } + + for (const forbidden of [ + "apply_mutation(", + "send_mail(", + "create_event(", + "computer_control", + ]) { + checkTextDoesNotInclude( + storePath, + groupedStore, + forbidden, + `C3B grouped approval Kernel excludes execution path ${forbidden}`, + ); + } +} + function checkOperationsBriefingSmokeEvidence() { if (!existsSync(smokeEvidenceDir)) { failures.push(`${smokeEvidenceDir} is required for live Operations Briefing smoke tests`); From 6404ee2837a02744b02344389f5c7fabbbff90f8 Mon Sep 17 00:00:00 2001 From: Codex Date: Sun, 19 Jul 2026 19:17:12 +0800 Subject: [PATCH 3/5] feat: add grouped task authorization UI --- apps/desktop/src-tauri/src/commands.rs | 36 ++ .../kernel/event_store/grouped_approval.rs | 368 +++++++++++++++++- .../src/kernel/task_grouped_approval.rs | 165 ++++++++ apps/desktop/src-tauri/src/main.rs | 27 +- apps/desktop/src/App.tsx | 315 ++++++++++++++- apps/desktop/src/types.ts | 42 ++ scripts/desktop-test.mjs | 1 + scripts/release-source-check.mjs | 46 ++- .../task-grouped-authorization-ui.test.mjs | 102 +++++ 9 files changed, 1080 insertions(+), 22 deletions(-) create mode 100644 scripts/task-grouped-authorization-ui.test.mjs diff --git a/apps/desktop/src-tauri/src/commands.rs b/apps/desktop/src-tauri/src/commands.rs index 00b7d28..da8ec21 100644 --- a/apps/desktop/src-tauri/src/commands.rs +++ b/apps/desktop/src-tauri/src/commands.rs @@ -158,6 +158,9 @@ use crate::kernel::skill_source::{ use crate::kernel::soul::{ AgentSoulProfileUpdateAudit, AgentSoulProfileUpdateProposal, AgentSoulProfileUpdateReceipt, }; +use crate::kernel::task_grouped_approval::{ + TaskGroupedAuthorizationIntent, TaskGroupedAuthorizationView, +}; use crate::kernel::tool_runtime::{ builtin_tool_catalog, prepare_tool_execution, tool_approval_preview, tool_request_fingerprint, AgentToolExecutor, ToolEvidence, ToolExecutionOutput, ToolExecutionPlan, ToolExecutionRequest, @@ -16511,6 +16514,39 @@ pub fn list_pending_capability_access_records( .map_err(event_store_error) } +#[tauri::command] +pub fn list_task_grouped_authorizations( + state: State<'_, AppState>, +) -> Result, String> { + let store = state.event_store.lock().map_err(|_| lock_error())?; + store + .list_task_grouped_authorizations(Utc::now()) + .map_err(event_store_error) +} + +#[tauri::command] +pub fn resolve_task_grouped_authorization( + intent: TaskGroupedAuthorizationIntent, + approved: bool, + state: State<'_, AppState>, +) -> Result { + let store = state.event_store.lock().map_err(|_| lock_error())?; + store + .resolve_task_grouped_authorization(&intent, approved, Utc::now()) + .map_err(event_store_error) +} + +#[tauri::command] +pub fn revoke_task_grouped_authorization( + intent: TaskGroupedAuthorizationIntent, + state: State<'_, AppState>, +) -> Result { + let store = state.event_store.lock().map_err(|_| lock_error())?; + store + .revoke_task_grouped_authorization(&intent, Utc::now()) + .map_err(event_store_error) +} + #[tauri::command] pub fn request_capability_access( access_mode: AccessMode, diff --git a/apps/desktop/src-tauri/src/kernel/event_store/grouped_approval.rs b/apps/desktop/src-tauri/src/kernel/event_store/grouped_approval.rs index 3f047d4..bf4ad0d 100644 --- a/apps/desktop/src-tauri/src/kernel/event_store/grouped_approval.rs +++ b/apps/desktop/src-tauri/src/kernel/event_store/grouped_approval.rs @@ -16,8 +16,9 @@ use crate::kernel::task_grouped_approval::{ capability_request_event_id_for, event_id_for, item_event_id_for, legacy_consumption_id_for, permission_resolution_event_id_for, permission_resolution_id_for, TaskGroupedApproval, TaskGroupedApprovalActor, TaskGroupedApprovalError, TaskGroupedApprovalResolutionClaim, - TaskGroupedApprovalStatus, TaskGroupedCapabilityAudit, TaskGroupedCapabilityClaim, - TaskGroupedCapabilityGrant, TASK_GROUPED_APPROVAL_VERSION, + TaskGroupedApprovalStatus, TaskGroupedAuthorizationIntent, TaskGroupedAuthorizationView, + TaskGroupedCapabilityAudit, TaskGroupedCapabilityClaim, TaskGroupedCapabilityGrant, + TASK_GROUPED_APPROVAL_VERSION, }; const TASK_GROUPED_APPROVAL_PREPARED_EVENT: &str = "task_grouped_approval.prepared"; @@ -190,6 +191,114 @@ impl EventStore { Ok(group) } + pub fn list_task_grouped_authorizations( + &self, + now: DateTime, + ) -> EventStoreResult> { + let group_ids = { + let mut statement = self.conn.prepare( + r#"SELECT group_id FROM task_grouped_approval_state + ORDER BY updated_at DESC, group_id ASC"#, + )?; + let ids = statement + .query_map([], |row| row.get::<_, String>(0))? + .collect::, _>>()?; + ids + }; + let mut views = Vec::with_capacity(group_ids.len()); + for group_id in group_ids { + let group_id = Uuid::parse_str(&group_id)?; + let group = self.refresh_task_grouped_approval_state(group_id, None, now)?; + let goal = self + .goal_envelope_projection(group.task_id)? + .ok_or_else(|| invalid("task grouped authorization lost its frozen goal"))?; + views.push(group.authorization_view(&goal).map_err(group_error)?); + } + Ok(views) + } + + pub fn resolve_task_grouped_authorization( + &self, + intent: &TaskGroupedAuthorizationIntent, + approved: bool, + now: DateTime, + ) -> EventStoreResult { + self.refresh_task_grouped_approval_state(intent.group_id, Some(intent.task_id), now)?; + let resolved = + self.resolve_task_grouped_approval(&intent.resolution_claim(), approved, now)?; + let goal = self + .goal_envelope_projection(resolved.task_id)? + .ok_or_else(|| invalid("task grouped authorization lost its frozen goal"))?; + resolved.authorization_view(&goal).map_err(group_error) + } + + pub fn revoke_task_grouped_authorization( + &self, + intent: &TaskGroupedAuthorizationIntent, + now: DateTime, + ) -> EventStoreResult { + self.refresh_task_grouped_approval_state(intent.group_id, Some(intent.task_id), now)?; + let revoked = self.revoke_task_grouped_approval(&intent.resolution_claim(), now)?; + let goal = self + .goal_envelope_projection(revoked.task_id)? + .ok_or_else(|| invalid("task grouped authorization lost its frozen goal"))?; + revoked.authorization_view(&goal).map_err(group_error) + } + + fn refresh_task_grouped_approval_state( + &self, + group_id: Uuid, + expected_task_id: Option, + now: DateTime, + ) -> EventStoreResult { + let current = self + .task_grouped_approval(group_id)? + .ok_or_else(|| EventStoreError::NotFound("task grouped approval".to_string()))?; + if expected_task_id.is_some_and(|task_id| task_id != current.task_id) { + return Err(invalid("task grouped authorization task binding changed")); + } + if !matches!( + current.status, + TaskGroupedApprovalStatus::Pending | TaskGroupedApprovalStatus::Approved + ) { + return Ok(current); + } + if now >= current.manifest.expires_at { + return self.expire_task_grouped_approval(group_id, current.task_id, now); + } + let goal_is_current = self + .goal_envelope_projection(current.task_id)? + .as_ref() + .is_some_and(|goal| current.manifest.validate_for_goal(goal).is_ok()); + if goal_is_current { + return Ok(current); + } + + let transaction = Transaction::new_unchecked(&self.conn, TransactionBehavior::Immediate)?; + let current = load_group(&transaction, group_id)? + .ok_or_else(|| EventStoreError::NotFound("task grouped approval".to_string()))?; + if !matches!( + current.status, + TaskGroupedApprovalStatus::Pending | TaskGroupedApprovalStatus::Approved + ) { + transaction.commit()?; + return Ok(current); + } + if current.status == TaskGroupedApprovalStatus::Pending { + resolve_item_requests( + &transaction, + ¤t, + false, + "Task grouped approval invalidated because the frozen goal changed.", + now, + )?; + } + let changed = current.scope_changed(now).map_err(group_error)?; + persist_transition(&transaction, ¤t, &changed)?; + transaction.commit()?; + Ok(changed) + } + pub fn resolve_task_grouped_approval( &self, claim: &TaskGroupedApprovalResolutionClaim, @@ -993,6 +1102,7 @@ mod tests { TaskCapabilityManifestContext, TaskCapabilityManifestProposal, TaskCapabilityNeedProposal, TASK_CAPABILITY_MANIFEST_VERSION, }; + use crate::kernel::task_grouped_approval::TaskGroupedCapabilityAuditStatus; use crate::kernel::tool_runtime::{CONNECTOR_MUTATE_TOOL_ID, FILE_WRITE_TOOL_ID}; use rusqlite::Connection; use tempfile::tempdir; @@ -1611,6 +1721,16 @@ mod tests { .status, TaskGroupedApprovalStatus::ScopeChanged ); + assert_eq!( + store + .list_task_grouped_authorizations(fixed_now() + chrono::Duration::minutes(3)) + .unwrap() + .into_iter() + .find(|view| view.intent.group_id == approved.id) + .unwrap() + .status, + TaskGroupedApprovalStatus::ScopeChanged + ); assert!(store .authorize_task_grouped_capability( &old_claim, @@ -1813,6 +1933,250 @@ mod tests { assert!(EventStore::open(&audit_path).is_err()); } + #[test] + fn c3c_ui_projection_is_exact_redacted_and_keeps_per_capability_audit_visible() { + let store = EventStore::open_memory().unwrap(); + let pending = prepare(&store); + let views = store + .list_task_grouped_authorizations(fixed_now()) + .expect("authorization views load"); + assert_eq!(views.len(), 1); + let view = &views[0]; + assert_eq!(view.status, TaskGroupedApprovalStatus::Pending); + assert_eq!(view.intent.group_id, pending.id); + assert_eq!(view.intent.task_id, TASK_ID); + assert_eq!(view.intent.expected_projection_revision, 0); + assert_eq!( + view.goal, + "Create a verified report and send one approved external update." + ); + assert_eq!( + view.applications, + vec!["Microsoft Excel", "Microsoft Outlook"] + ); + assert_eq!(view.paths, vec!["Workspace / reports"]); + assert_eq!(view.accounts, vec!["Work mailbox account"]); + assert_eq!(view.recipients, vec!["finance-test@example.com"]); + assert_eq!( + view.time_windows, + vec!["Weekdays 09:00-17:00 Asia/Shanghai"] + ); + assert_eq!(view.verifiers.len(), 2); + assert_eq!(view.capability_audits.len(), 2); + assert!(view + .capability_audits + .iter() + .all(|audit| audit.status == TaskGroupedCapabilityAuditStatus::Pending)); + + let json = serde_json::to_string(view).unwrap().to_ascii_lowercase(); + for forbidden in [ + "approval_request_id", + "request_fingerprint", + "authority_fingerprint", + "exact_preview", + "tool_id", + "credential", + "provider_ref", + "claim", + "token", + "c:\\users\\private\\appdata", + ] { + assert!( + !json.contains(forbidden), + "UI projection leaked {forbidden}" + ); + } + } + + #[test] + fn c3c_exact_ui_intent_rejects_tamper_replay_and_frontend_authority_fields() { + let store = EventStore::open_memory().unwrap(); + let pending = prepare(&store); + let goal = store.goal_envelope_projection(TASK_ID).unwrap().unwrap(); + let intent = pending.authorization_view(&goal).unwrap().intent; + + let mut tampered = Vec::new(); + let mut wrong_group = intent.clone(); + wrong_group.group_id = Uuid::from_u128(0xdead); + tampered.push(wrong_group); + let mut wrong_task = intent.clone(); + wrong_task.task_id = OTHER_TASK_ID; + tampered.push(wrong_task); + let mut wrong_projection_revision = intent.clone(); + wrong_projection_revision.expected_projection_revision += 1; + tampered.push(wrong_projection_revision); + let mut wrong_manifest_revision = intent.clone(); + wrong_manifest_revision.manifest_revision = "0".repeat(64); + tampered.push(wrong_manifest_revision); + let mut wrong_manifest_fingerprint = intent.clone(); + wrong_manifest_fingerprint.manifest_fingerprint = "1".repeat(64); + tampered.push(wrong_manifest_fingerprint); + let mut wrong_preview_schema = intent.clone(); + wrong_preview_schema.preview_schema_revision += 1; + tampered.push(wrong_preview_schema); + let mut wrong_preview_renderer = intent.clone(); + wrong_preview_renderer.preview_renderer_revision += 1; + tampered.push(wrong_preview_renderer); + let mut wrong_preview_hash = intent.clone(); + wrong_preview_hash.preview_hash = "2".repeat(64); + tampered.push(wrong_preview_hash); + + for tampered_intent in tampered { + assert!(store + .resolve_task_grouped_authorization( + &tampered_intent, + true, + fixed_now() + chrono::Duration::minutes(1), + ) + .is_err()); + assert_eq!( + store + .task_grouped_approval(pending.id) + .unwrap() + .unwrap() + .status, + TaskGroupedApprovalStatus::Pending + ); + } + + let base_value = serde_json::to_value(&intent).unwrap(); + for forbidden in [ + "capability", + "risk", + "scope", + "target", + "authority", + "preview", + "grant", + "actor", + "claim", + "token", + ] { + let mut forged = base_value.clone(); + forged.as_object_mut().unwrap().insert( + forbidden.to_string(), + serde_json::Value::String("frontend-forgery".to_string()), + ); + assert!(serde_json::from_value::(forged).is_err()); + } + + let approved = store + .resolve_task_grouped_authorization( + &intent, + true, + fixed_now() + chrono::Duration::minutes(1), + ) + .unwrap(); + assert!(approved + .capability_audits + .iter() + .all(|audit| audit.status == TaskGroupedCapabilityAuditStatus::Approved)); + assert_eq!(event_count(&store, TASK_GROUPED_APPROVAL_RESOLVED_EVENT), 1); + let duplicate = store + .resolve_task_grouped_authorization( + &intent, + true, + fixed_now() + chrono::Duration::minutes(2), + ) + .unwrap(); + assert_eq!(approved, duplicate); + assert!(store + .resolve_task_grouped_authorization( + &intent, + false, + fixed_now() + chrono::Duration::minutes(2), + ) + .is_err()); + for item in &pending.capability_audits { + assert!(store + .resolve_capability_access_request( + item.approval_request_id, + true, + "forged per-item approval".to_string(), + ) + .is_err()); + } + + let revoked = store + .revoke_task_grouped_authorization( + &approved.intent, + fixed_now() + chrono::Duration::minutes(3), + ) + .unwrap(); + let duplicate_revoke = store + .revoke_task_grouped_authorization( + &approved.intent, + fixed_now() + chrono::Duration::minutes(4), + ) + .unwrap(); + assert_eq!(revoked, duplicate_revoke); + assert_eq!(revoked.status, TaskGroupedApprovalStatus::Revoked); + + let reject_store = EventStore::open_memory().unwrap(); + let reject_pending = prepare(&reject_store); + let reject_goal = reject_store + .goal_envelope_projection(TASK_ID) + .unwrap() + .unwrap(); + let rejected = reject_store + .resolve_task_grouped_authorization( + &reject_pending + .authorization_view(&reject_goal) + .unwrap() + .intent, + false, + fixed_now() + chrono::Duration::minutes(1), + ) + .unwrap(); + assert_eq!(rejected.status, TaskGroupedApprovalStatus::Rejected); + assert!(rejected + .capability_audits + .iter() + .all(|audit| audit.status == TaskGroupedCapabilityAuditStatus::Rejected)); + assert_eq!( + event_count(&reject_store, TASK_GROUPED_APPROVAL_RESOLVED_EVENT), + 1 + ); + } + + #[test] + fn c3c_ui_read_refreshes_expiry_and_survives_restart_without_new_authority() { + let directory = tempdir().unwrap(); + let path = directory.path().join("c3c-ui.db"); + let store = EventStore::open(&path).unwrap(); + let expiry = fixed_now() + chrono::Duration::minutes(1); + let (manifest, preview) = compiled_fixture_for( + &store, + TASK_ID, + b"path-authority-v1", + "Workspace / reports", + expiry, + ); + let group = store + .prepare_task_grouped_approval(TASK_ID, &manifest, &preview, fixed_now()) + .unwrap(); + drop(store); + + let reopened = EventStore::open(&path).unwrap(); + let views = reopened + .list_task_grouped_authorizations(expiry + chrono::Duration::seconds(1)) + .unwrap(); + assert_eq!(views.len(), 1); + assert_eq!(views[0].intent.group_id, group.id); + assert_eq!(views[0].status, TaskGroupedApprovalStatus::Expired); + assert!(reopened + .resolve_task_grouped_authorization( + &views[0].intent, + true, + expiry + chrono::Duration::seconds(2), + ) + .is_err()); + assert_eq!( + event_count(&reopened, TASK_GROUPED_APPROVAL_RESOLVED_EVENT), + 0 + ); + } + #[test] fn malformed_input_creates_zero_group_permission_or_execution_authority() { let store = EventStore::open_memory().unwrap(); diff --git a/apps/desktop/src-tauri/src/kernel/task_grouped_approval.rs b/apps/desktop/src-tauri/src/kernel/task_grouped_approval.rs index 95e8a6e..a71b9a8 100644 --- a/apps/desktop/src-tauri/src/kernel/task_grouped_approval.rs +++ b/apps/desktop/src-tauri/src/kernel/task_grouped_approval.rs @@ -1,8 +1,11 @@ +use std::collections::BTreeSet; + use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; use uuid::Uuid; +use crate::kernel::goal_lifecycle::GoalLifecycleProjection; use crate::kernel::policy::{ exact_tool_preview_hash, CapabilityKind, RiskLevel, TOOL_APPROVAL_PREVIEW_REVISION, }; @@ -12,6 +15,7 @@ use crate::kernel::task_capability_manifest::{ pub const TASK_GROUPED_APPROVAL_VERSION: &str = "ds-agent.task-grouped-approval/v1"; pub const TASK_GROUPED_APPROVAL_EVENT_VERSION: &str = "ds-agent.task-grouped-approval-event/v1"; +pub const TASK_GROUPED_AUTHORIZATION_UI_VERSION: &str = "ds-agent.task-grouped-authorization-ui/v1"; const GROUP_ID_DOMAIN: &[u8] = b"ds-agent.task-grouped-approval-id.v1\0"; const GROUP_INTEGRITY_DOMAIN: &[u8] = b"ds-agent.task-grouped-approval-integrity.v1\0"; @@ -236,6 +240,60 @@ pub struct TaskGroupedApprovalEventReceipt { pub created_at: DateTime, } +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +pub struct TaskGroupedAuthorizationIntent { + pub group_id: Uuid, + pub task_id: Uuid, + pub expected_projection_revision: u64, + pub manifest_revision: String, + pub manifest_fingerprint: String, + pub preview_schema_revision: u32, + pub preview_renderer_revision: u32, + pub preview_hash: String, +} + +impl TaskGroupedAuthorizationIntent { + pub fn resolution_claim(&self) -> TaskGroupedApprovalResolutionClaim { + TaskGroupedApprovalResolutionClaim { + group_id: self.group_id, + task_id: self.task_id, + expected_projection_revision: self.expected_projection_revision, + manifest_revision: self.manifest_revision.clone(), + manifest_fingerprint: self.manifest_fingerprint.clone(), + preview_schema_revision: self.preview_schema_revision, + preview_renderer_revision: self.preview_renderer_revision, + preview_hash: self.preview_hash.clone(), + actor: TaskGroupedApprovalActor::User, + } + } +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +pub struct TaskGroupedCapabilityAuditView { + pub capability: CapabilityKind, + pub risk_level: RiskLevel, + pub status: TaskGroupedCapabilityAuditStatus, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +pub struct TaskGroupedAuthorizationView { + pub version: String, + pub intent: TaskGroupedAuthorizationIntent, + pub status: TaskGroupedApprovalStatus, + pub goal: String, + pub applications: Vec, + pub paths: Vec, + pub accounts: Vec, + pub recipients: Vec, + pub time_windows: Vec, + pub external_targets: Vec, + pub expires_at: DateTime, + pub risk_level: RiskLevel, + pub verifiers: Vec, + pub capability_audits: Vec, +} + #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum TaskGroupedApprovalError { ManifestInvalid, @@ -585,6 +643,113 @@ impl TaskGroupedApproval { .ok_or(TaskGroupedApprovalError::BindingMismatch) } + pub fn authorization_view( + &self, + goal: &GoalLifecycleProjection, + ) -> Result { + self.validate_integrity()?; + let frozen = goal + .frozen() + .ok_or(TaskGroupedApprovalError::BindingMismatch)?; + if goal.goal_id != self.task_id { + return Err(TaskGroupedApprovalError::BindingMismatch); + } + if matches!( + self.status, + TaskGroupedApprovalStatus::Pending | TaskGroupedApprovalStatus::Approved + ) { + self.manifest + .validate_for_goal(goal) + .map_err(|_| TaskGroupedApprovalError::BindingMismatch)?; + } + + let mut applications = BTreeSet::new(); + let mut paths = BTreeSet::new(); + let mut accounts = BTreeSet::new(); + let mut recipients = BTreeSet::new(); + let mut time_windows = BTreeSet::new(); + let mut external_targets = BTreeSet::new(); + let mut verifiers = BTreeSet::new(); + for capability in &self.preview.capabilities { + applications.extend( + capability + .applications + .iter() + .map(|value| value.display_label.clone()), + ); + paths.extend( + capability + .path_scopes + .iter() + .map(|value| value.display_label.clone()), + ); + accounts.extend( + capability + .account_scopes + .iter() + .map(|value| value.display_label.clone()), + ); + recipients.extend( + capability + .recipient_scopes + .iter() + .map(|value| value.display_label.clone()), + ); + time_windows.extend( + capability + .time_windows + .iter() + .map(|value| value.display_label.clone()), + ); + external_targets.extend( + capability + .external_targets + .iter() + .map(|value| value.display_label.clone()), + ); + verifiers.extend( + capability + .verifiers + .iter() + .map(|value| value.summary.clone()), + ); + } + + Ok(TaskGroupedAuthorizationView { + version: TASK_GROUPED_AUTHORIZATION_UI_VERSION.to_string(), + intent: TaskGroupedAuthorizationIntent { + group_id: self.id, + task_id: self.task_id, + expected_projection_revision: self.projection_revision, + manifest_revision: self.manifest.revision.clone(), + manifest_fingerprint: self.manifest.fingerprint.clone(), + preview_schema_revision: self.preview.schema_revision, + preview_renderer_revision: self.preview.renderer_revision, + preview_hash: self.preview.preview_hash.clone(), + }, + status: self.status, + goal: frozen.envelope.user_goal.clone(), + applications: applications.into_iter().collect(), + paths: paths.into_iter().collect(), + accounts: accounts.into_iter().collect(), + recipients: recipients.into_iter().collect(), + time_windows: time_windows.into_iter().collect(), + external_targets: external_targets.into_iter().collect(), + expires_at: self.manifest.expires_at, + risk_level: self.manifest.aggregate_risk, + verifiers: verifiers.into_iter().collect(), + capability_audits: self + .capability_audits + .iter() + .map(|audit| TaskGroupedCapabilityAuditView { + capability: audit.capability, + risk_level: audit.risk_level, + status: audit.status, + }) + .collect(), + }) + } + pub(crate) fn resolve( &self, claim: &TaskGroupedApprovalResolutionClaim, diff --git a/apps/desktop/src-tauri/src/main.rs b/apps/desktop/src-tauri/src/main.rs index 2d144bb..678ba3d 100644 --- a/apps/desktop/src-tauri/src/main.rs +++ b/apps/desktop/src-tauri/src/main.rs @@ -48,10 +48,11 @@ use commands::{ list_memory_maintenance_reviews, list_memory_records, list_operations_briefing_runs, list_pending_capability_access_records, list_permission_audit_entries, list_selected_memory_feedback, list_skill_execution_records, list_skill_records, - list_task_records, merge_memory_candidate_with_conflicts, prepare_skill_execution_record, - preview_local_skill_package_manifest, preview_local_skill_zip_package, - preview_memory_candidate_merge, preview_memory_candidate_replace, - preview_remote_skill_zip_package, preview_work_package_import, propose_memory_candidate, + list_task_grouped_authorizations, list_task_records, merge_memory_candidate_with_conflicts, + prepare_skill_execution_record, preview_local_skill_package_manifest, + preview_local_skill_zip_package, preview_memory_candidate_merge, + preview_memory_candidate_replace, preview_remote_skill_zip_package, + preview_work_package_import, propose_memory_candidate, propose_memory_update_candidate_from_feedback, queue_agent_run_guidance_record, queue_expert_team_retries, queue_parent_agent_synthesis, read_drive_boundary, read_email_boundary, read_local_file, record_agent_run_artifact_record, @@ -59,13 +60,14 @@ use commands::{ record_selected_memory_feedback, remove_deepseek_api_key, reobserve_durable_computer_use_session, replace_memory_candidate_conflicts, request_agent_run_cancel_record, request_capability_access, reset_skill_trust, - resolve_capability_access_request, resolve_memory_candidate, resume_agent_chat_action, - run_agent_chat, run_durable_computer_use_step, run_memory_background_maintenance, - run_next_queued_agent_chat_worker, run_operations_briefing, run_skill_update_sweep, - run_terminal_read, run_terminal_write, save_agent_soul_profile, save_deepseek_api_key, - save_deepseek_pricing_settings, save_local_directory_settings, search_memory_records, - search_network_boundary, seed_operations_briefing_evidence_templates, send_email_boundary, - set_skill_enabled, stage_agent_attachments, start_agent_run_record, + resolve_capability_access_request, resolve_memory_candidate, + resolve_task_grouped_authorization, resume_agent_chat_action, + revoke_task_grouped_authorization, run_agent_chat, run_durable_computer_use_step, + run_memory_background_maintenance, run_next_queued_agent_chat_worker, run_operations_briefing, + run_skill_update_sweep, run_terminal_read, run_terminal_write, save_agent_soul_profile, + save_deepseek_api_key, save_deepseek_pricing_settings, save_local_directory_settings, + search_memory_records, search_network_boundary, seed_operations_briefing_evidence_templates, + send_email_boundary, set_skill_enabled, stage_agent_attachments, start_agent_run_record, start_durable_computer_use_session, submit_browser_boundary, take_over_durable_computer_use_step, uninstall_skill, unlock_computer_control, update_memory_candidate_conflict, update_memory_record, verify_deepseek_api_key, @@ -416,10 +418,13 @@ fn main() { list_capability_catalog, list_capability_access_records, list_pending_capability_access_records, + list_task_grouped_authorizations, list_capability_invocations, list_operations_briefing_runs, request_capability_access, resolve_capability_access_request, + resolve_task_grouped_authorization, + revoke_task_grouped_authorization, browse_url, submit_browser_boundary, search_network_boundary, diff --git a/apps/desktop/src/App.tsx b/apps/desktop/src/App.tsx index e3b1179..6b1318c 100644 --- a/apps/desktop/src/App.tsx +++ b/apps/desktop/src/App.tsx @@ -143,6 +143,8 @@ import type { SkillRecord, SkillUpdateSweepResult, TaskRecord, + TaskGroupedAuthorizationIntent, + TaskGroupedAuthorizationView, TerminalReadCommand, ThemeStyle, ThinkingLevel, @@ -880,6 +882,154 @@ function agentChatRunFromRecord(record: AgentRunRecord, displayPrompt?: string): }; } +function taskGroupedAuthorizationStatusLabel( + status: TaskGroupedAuthorizationView["status"], + language: Language, +) { + const labels = language === "zh" + ? { + pending: "等待确认", + approved: "已授权", + rejected: "已拒绝", + revoked: "已撤销", + expired: "已过期", + scope_changed: "范围已变化", + } + : { + pending: "Pending", + approved: "Approved", + rejected: "Rejected", + revoked: "Revoked", + expired: "Expired", + scope_changed: "Scope changed", + }; + return labels[status]; +} + +function TaskGroupedAuthorizationCard({ + authorization, + language, + capabilityLabels, + riskLabels, + pending, + onResolve, + onRevoke, +}: { + authorization: TaskGroupedAuthorizationView; + language: Language; + capabilityLabels: Record; + riskLabels: Record; + pending: boolean; + onResolve: (intent: TaskGroupedAuthorizationIntent, approved: boolean) => void; + onRevoke: (intent: TaskGroupedAuthorizationIntent) => void; +}) { + const field = (values: string[]) => values.join(language === "zh" ? "、" : ", ") || "—"; + const labels = language === "zh" + ? { + title: "本次任务授权", + applications: "应用", + paths: "路径", + accounts: "账户", + recipients: "收件人", + timeWindows: "时间范围", + externalTargets: "外部目标", + expiry: "有效期", + verifiers: "验证方式", + audits: "逐能力审计", + approve: "确认授权", + reject: "拒绝", + revoke: "撤销授权", + updating: "正在核对…", + } + : { + title: "Task authorization", + applications: "Applications", + paths: "Paths", + accounts: "Accounts", + recipients: "Recipients", + timeWindows: "Time range", + externalTargets: "External targets", + expiry: "Expires", + verifiers: "Verification", + audits: "Capability audit", + approve: "Approve task", + reject: "Reject", + revoke: "Revoke authorization", + updating: "Checking…", + }; + + return ( +
+
+
+

{authorization.goal}

+
+ + {riskLabels[authorization.risk_level]} + + + {taskGroupedAuthorizationStatusLabel(authorization.status, language)} + +
+
+
    +
  • {labels.applications}

    {field(authorization.applications)}

  • +
  • {labels.paths}

    {field(authorization.paths)}

  • +
  • {labels.accounts}

    {field(authorization.accounts)}

  • +
  • {labels.recipients}

    {field(authorization.recipients)}

  • +
  • {labels.timeWindows}

    {field(authorization.time_windows)}

  • +
  • {labels.externalTargets}

    {field(authorization.external_targets)}

  • +
  • {labels.expiry}

    {formatTaskDate(authorization.expires_at, language)}

  • +
  • {labels.verifiers}

    {field(authorization.verifiers)}

  • +
+
+
+
{labels.audits}
+
+ {authorization.capability_audits.map((audit, index) => ( +
+
+ {capabilityLabels[audit.capability]} + {riskLabels[audit.risk_level]} +
+ + {taskGroupedAuthorizationStatusLabel(audit.status, language)} + +
+ ))} +
+
+ {authorization.status === "pending" ? ( +
+ + +
+ ) : authorization.status === "approved" ? ( +
+ +
+ ) : null} +
+ ); +} + function capabilityFamilyIcon(family: CapabilityFamily) { switch (family) { case "file": @@ -948,6 +1098,14 @@ export function App() { const [capabilityCatalog, setCapabilityCatalog] = useState([]); const [capabilityRecords, setCapabilityRecords] = useState([]); const [capabilityInvocations, setCapabilityInvocations] = useState([]); + const [taskGroupedAuthorizations, setTaskGroupedAuthorizations] = useState< + TaskGroupedAuthorizationView[] + >([]); + const [taskGroupedAuthorizationPending, setTaskGroupedAuthorizationPending] = useState< + string | null + >(null); + const [taskGroupedAuthorizationNotice, setTaskGroupedAuthorizationNotice] = useState(""); + const [taskGroupedAuthorizationError, setTaskGroupedAuthorizationError] = useState(""); const [agentToolContracts, setAgentToolContracts] = useState([]); const [toolInvocations, setToolInvocations] = useState([]); const [agentContextReceipts, setAgentContextReceipts] = useState([]); @@ -2122,6 +2280,7 @@ export function App() { setCapabilityCatalog([]); setCapabilityRecords([]); setCapabilityInvocations([]); + setTaskGroupedAuthorizations([]); setAgentToolContracts([]); setToolInvocations([]); setAgentContextReceipts([]); @@ -2141,6 +2300,7 @@ export function App() { invoke("list_permission_audit_entries"), invoke("list_capability_catalog"), invoke("list_capability_access_records"), + invoke("list_task_grouped_authorizations"), invoke("list_capability_invocations"), invoke("list_agent_tool_contracts"), invoke("list_agent_tool_invocations"), @@ -2158,6 +2318,7 @@ export function App() { audits, catalog, capabilityAccessRecords, + groupedAuthorizations, invocations, toolContracts, recordedToolInvocations, @@ -2174,6 +2335,7 @@ export function App() { setPermissionAudits(audits); setCapabilityCatalog(catalog); setCapabilityRecords(capabilityAccessRecords); + setTaskGroupedAuthorizations(groupedAuthorizations); setCapabilityInvocations(invocations); setAgentToolContracts(toolContracts); setToolInvocations(recordedToolInvocations); @@ -2204,6 +2366,9 @@ export function App() { setMemoryCandidateError(copy.memory.loadFailed); setAuditError(copy.audit.loadFailed); setCapabilityError(copy.capabilities.loadFailed); + setTaskGroupedAuthorizationError( + language === "zh" ? "无法读取任务授权状态。" : "Could not load task authorization state.", + ); setBriefingError(copy.operationsBriefing.loadFailed); }); }, [ @@ -2213,6 +2378,7 @@ export function App() { copy.operationsBriefing.loadFailed, copy.package.loadFailed, copy.skills.updateFailed, + language, ]); const setLocalSkillEnabled = async (record: SkillRecord, enabled: boolean) => { @@ -2580,20 +2746,30 @@ export function App() { }; async function refreshCapabilityState() { - const [records, audits, invocations, contextReceipts, tools] = await Promise.all([ + const [records, groupedAuthorizations, audits, invocations, contextReceipts, tools] = + await Promise.all([ invoke("list_capability_access_records"), + invoke("list_task_grouped_authorizations"), invoke("list_permission_audit_entries"), invoke("list_capability_invocations"), invoke("list_agent_context_receipts"), invoke("list_agent_tool_invocations"), - ]); + ]); setCapabilityRecords(records); + setTaskGroupedAuthorizations(groupedAuthorizations); setPermissionAudits(audits); setCapabilityInvocations(invocations); setAgentContextReceipts(contextReceipts); setToolInvocations(tools); } + async function refreshTaskGroupedAuthorizationState() { + const groupedAuthorizations = await invoke( + "list_task_grouped_authorizations", + ); + setTaskGroupedAuthorizations(groupedAuthorizations); + } + async function refreshDurableComputerUseState() { const sessions = await invoke( "list_durable_computer_use_sessions", @@ -3946,6 +4122,92 @@ export function App() { } }; + const resolveTaskGroupedAuthorization = async ( + intent: TaskGroupedAuthorizationIntent, + approved: boolean, + ) => { + setTaskGroupedAuthorizationPending(intent.group_id); + setTaskGroupedAuthorizationNotice(""); + setTaskGroupedAuthorizationError(""); + let rejectedByKernel = false; + try { + await invoke("resolve_task_grouped_authorization", { + intent, + approved, + }); + setTaskGroupedAuthorizationNotice( + language === "zh" + ? approved + ? "已按 Kernel 中的精确任务范围完成授权。" + : "已拒绝本次任务授权。" + : approved + ? "The exact Kernel-owned task scope is approved." + : "The task authorization was rejected.", + ); + } catch { + rejectedByKernel = true; + } finally { + try { + await refreshTaskGroupedAuthorizationState(); + if (rejectedByKernel) { + setTaskGroupedAuthorizationError( + language === "zh" + ? "授权意图已过期或与当前范围冲突;已刷新为 Kernel 权威状态。" + : "The intent was stale or conflicted with the current scope; Kernel state was refreshed.", + ); + } + } catch { + setTaskGroupedAuthorizations([]); + setTaskGroupedAuthorizationNotice(""); + setTaskGroupedAuthorizationError( + language === "zh" + ? "无法重读 Kernel 权威状态;旧授权操作已隐藏,未授予任何新权限。" + : "Kernel authority could not be re-read; stale actions were hidden and no new authority was granted.", + ); + } + setTaskGroupedAuthorizationPending(null); + } + }; + + const revokeTaskGroupedAuthorization = async (intent: TaskGroupedAuthorizationIntent) => { + setTaskGroupedAuthorizationPending(intent.group_id); + setTaskGroupedAuthorizationNotice(""); + setTaskGroupedAuthorizationError(""); + let rejectedByKernel = false; + try { + await invoke("revoke_task_grouped_authorization", { + intent, + }); + setTaskGroupedAuthorizationNotice( + language === "zh" + ? "已撤销本次任务授权。" + : "The task authorization was revoked.", + ); + } catch { + rejectedByKernel = true; + } finally { + try { + await refreshTaskGroupedAuthorizationState(); + if (rejectedByKernel) { + setTaskGroupedAuthorizationError( + language === "zh" + ? "撤销意图已过期或与当前范围冲突;已刷新为 Kernel 权威状态。" + : "The revoke intent was stale or conflicted with the current scope; Kernel state was refreshed.", + ); + } + } catch { + setTaskGroupedAuthorizations([]); + setTaskGroupedAuthorizationNotice(""); + setTaskGroupedAuthorizationError( + language === "zh" + ? "无法重读 Kernel 权威状态;旧授权操作已隐藏,未授予任何新权限。" + : "Kernel authority could not be re-read; stale actions were hidden and no new authority was granted.", + ); + } + setTaskGroupedAuthorizationPending(null); + } + }; + const clearPackageStatus = () => { setPackageNotice(""); setPackageError(""); @@ -6036,7 +6298,14 @@ export function App() { ) : null, ); + const taskGroupedAuthorization = message.goal_projection + ? taskGroupedAuthorizations.find( + (authorization) => + authorization.intent.task_id === message.goal_projection?.goal_id, + ) + : undefined; const taskApprovalReady = + !taskGroupedAuthorization && messageApprovalActions.length > 0 && messageApprovalActions.every(({ action }, index) => { if (!action.permission_request_id) { @@ -6143,6 +6412,22 @@ export function App() { ) : null} ) : null} + {message.role === "assistant" && taskGroupedAuthorization ? ( + + void resolveTaskGroupedAuthorization(intent, approved) + } + onRevoke={(intent) => void revokeTaskGroupedAuthorization(intent)} + /> + ) : null} {message.role === "assistant" && message.missing_prerequisites?.length ? (
{copy.chatWorkbench.missingPrerequisitesLabel} @@ -7822,6 +8107,32 @@ export function App() { {copy.capabilities.title}
{capabilityError ?

{capabilityError}

: null} + {taskGroupedAuthorizationNotice ? ( +

{taskGroupedAuthorizationNotice}

+ ) : null} + {taskGroupedAuthorizationError ? ( +

{taskGroupedAuthorizationError}

+ ) : null} + {taskGroupedAuthorizations.length > 0 ? ( +
+ {taskGroupedAuthorizations.map((authorization) => ( + + void resolveTaskGroupedAuthorization(intent, approved) + } + onRevoke={(intent) => void revokeTaskGroupedAuthorization(intent)} + /> + ))} +
+ ) : null}