diff --git a/crates/application/src/ticket_assistant.rs b/crates/application/src/ticket_assistant.rs index b140115..163424d 100644 --- a/crates/application/src/ticket_assistant.rs +++ b/crates/application/src/ticket_assistant.rs @@ -9,8 +9,9 @@ use domain::ports::{ use domain::profile::StructuredAdapter; use domain::AgentProfile; use domain::{ - AgentToolPolicy, AgentToolPolicyStore, AssistantContextProvider, DomainEvent, EventBus, - IssueRef, IssueStore, NetworkPolicy, ProfileId, ProfileStore, Project, SessionId, + AgentToolPolicy, AgentToolPolicyStore, AssistantContextProvider, DomainEvent, + EffectivePermissions, EventBus, IssueRef, IssueStore, NetworkPolicy, PermissionStore, + ProfileId, ProfileStore, Project, SessionId, }; use crate::terminal::StructuredSessions; @@ -26,6 +27,7 @@ pub struct OpenTicketAssistant { structured: Arc, policies: Arc, events: Arc, + permissions: Option>, system_permissions: Option>, } @@ -73,10 +75,21 @@ impl OpenTicketAssistant { structured, policies, events, + permissions: None, system_permissions: None, } } + /// Wires the project agent permission store used for assistant launches. + /// + /// Ticket assistants are not agents, so only project defaults are resolved; + /// agent overrides are deliberately ignored. + #[must_use] + pub fn with_permission_store(mut self, store: Arc) -> Self { + self.permissions = Some(store); + self + } + /// Wires the project system permission store used for assistant launches. #[must_use] pub fn with_system_permission_store(mut self, store: Arc) -> Self { @@ -115,6 +128,9 @@ impl OpenTicketAssistant { .structured .ticket_assistant_requester(input.issue_ref) .is_some(); + let permissions = self + .resolve_project_default_permissions(&input.project) + .await?; let policy = AgentToolPolicy::new( vec![ "idea_ticket_read".to_owned(), @@ -138,6 +154,7 @@ impl OpenTicketAssistant { &profile, &prepared, &requester, + permissions.as_ref(), ) .await { @@ -227,6 +244,20 @@ impl OpenTicketAssistant { let doc = store.load_system_permissions(project).await?; Ok(doc.project_default.and_then(|set| set.network)) } + + async fn resolve_project_default_permissions( + &self, + project: &Project, + ) -> Result, AppError> { + let Some(store) = &self.permissions else { + return Ok(None); + }; + let doc = store.load_permissions(project).await?; + Ok(domain::permission::resolve( + doc.project_defaults.as_ref(), + None, + )) + } } fn codex_network_access(network: Option) -> bool { diff --git a/crates/application/tests/ticket_assistant.rs b/crates/application/tests/ticket_assistant.rs index 008d856..67e8f8d 100644 --- a/crates/application/tests/ticket_assistant.rs +++ b/crates/application/tests/ticket_assistant.rs @@ -7,19 +7,21 @@ use application::{ StructuredSessions, }; use async_trait::async_trait; +use domain::permission::{EffectivePermissions, Posture}; use domain::ports::{ - AgentSession, AgentSessionError, AgentSessionFactory, ReplyStream, RuntimeError, SessionPlan, - StoreError, StructuredProviderLaunchPolicy, StructuredSessionEnvironment, - StructuredSessionEnvironmentPreparer, SystemPermissionStore, + AgentSession, AgentSessionError, AgentSessionFactory, PermissionStore, ReplyStream, + RuntimeError, SessionPlan, StoreError, StructuredProviderLaunchPolicy, + StructuredSessionEnvironment, StructuredSessionEnvironmentPreparer, SystemPermissionStore, }; use domain::profile::StructuredAdapter; use domain::{ - AgentProfile, AgentToolPolicy, AgentToolPolicyStore, AssistantContextError, - AssistantContextProvider, ContextInjection, DomainEvent, EventBus, EventStream, Issue, - IssueActor, IssueCarnet, IssueId, IssueListFilter, IssueNumber, IssuePriority, IssueRef, - IssueStatus, IssueStore, IssueStoreError, IssueVersion, MarkdownDoc, NetworkPolicy, - PreparedContext, ProfileId, ProfileStore, Project, ProjectId, ProjectPath, - ProjectSystemPermissions, RemoteRef, SessionId, SystemPermissionSet, + AgentId, AgentPermissionOverride, AgentProfile, AgentToolPolicy, AgentToolPolicyStore, + AssistantContextError, AssistantContextProvider, ContextInjection, DomainEvent, EventBus, + EventStream, Issue, IssueActor, IssueCarnet, IssueId, IssueListFilter, IssueNumber, + IssuePriority, IssueRef, IssueStatus, IssueStore, IssueStoreError, IssueVersion, MarkdownDoc, + NetworkPolicy, PermissionSet, PreparedContext, ProfileId, ProfileStore, Project, ProjectId, + ProjectPath, ProjectPermissions, ProjectSystemPermissions, RemoteRef, SessionId, + SystemPermissionSet, }; use uuid::Uuid; @@ -194,7 +196,14 @@ impl AssistantContextProvider for FakeAssistantContext { #[derive(Default)] struct FakeEnvironmentPreparer { - calls: Mutex>, + calls: Mutex< + Vec<( + IssueRef, + String, + PreparedContext, + Option, + )>, + >, } #[async_trait] @@ -206,11 +215,14 @@ impl StructuredSessionEnvironmentPreparer for FakeEnvironmentPreparer { _profile: &AgentProfile, prepared: &PreparedContext, requester: &str, + permissions: Option<&EffectivePermissions>, ) -> Result { - self.calls - .lock() - .unwrap() - .push((issue_ref, requester.to_owned(), prepared.clone())); + self.calls.lock().unwrap().push(( + issue_ref, + requester.to_owned(), + prepared.clone(), + permissions.cloned(), + )); Ok(StructuredSessionEnvironment { cwd: ProjectPath::new("/tmp/app-data/assistant/tickets/1/7").unwrap(), env: vec![( @@ -346,6 +358,27 @@ impl SystemPermissionStore for FakeSystemPermissionStore { } } +struct FakePermissionStore(ProjectPermissions); + +#[async_trait] +impl PermissionStore for FakePermissionStore { + async fn load_permissions(&self, _project: &Project) -> Result { + Ok(self.0.clone()) + } + + async fn save_permissions( + &self, + _project: &Project, + _permissions: &ProjectPermissions, + ) -> Result<(), StoreError> { + Ok(()) + } +} + +fn permission_doc(project_defaults: Option) -> ProjectPermissions { + ProjectPermissions::new(project_defaults, Vec::new()) +} + #[tokio::test] async fn open_then_close_ticket_assistant_sets_policy_injects_context_and_emits_events() { let issue = issue(7); @@ -406,6 +439,7 @@ async fn open_then_close_ticket_assistant_sets_policy_injects_context_and_emits_ assert_eq!(environment_calls.len(), 1); assert_eq!(environment_calls[0].0, issue.reference()); assert_eq!(environment_calls[0].1, output.requester); + assert!(environment_calls[0].3.is_none()); drop(environment_calls); let starts = factory.starts.lock().unwrap(); assert_eq!(starts[0].1.as_str(), "/tmp/app-data/assistant/tickets/1/7"); @@ -442,6 +476,120 @@ async fn open_then_close_ticket_assistant_sets_policy_injects_context_and_emits_ )); } +#[tokio::test] +async fn ticket_assistant_without_project_defaults_keeps_native_permissions() { + let issue = issue(7); + let profile_id = ProfileId::from_uuid(Uuid::from_u128(9)); + let environment = Arc::new(FakeEnvironmentPreparer::default()); + let open = OpenTicketAssistant::new( + Arc::new(FakeIssues { + issue: issue.clone(), + }), + Arc::new(FakeProfiles { + profile: profile(profile_id), + }), + Arc::new(FakeAssistantContext::default()), + environment.clone(), + Arc::new(FakeFactory::default()), + Arc::new(StructuredSessions::new()), + Arc::new(FakePolicies::default()), + Arc::new(SpyBus::default()), + ) + .with_permission_store(Arc::new(FakePermissionStore(permission_doc(None)))); + + open.execute(OpenTicketAssistantInput { + project: project(), + issue_ref: issue.reference(), + profile_id, + }) + .await + .unwrap(); + + let calls = environment.calls.lock().unwrap(); + assert!(calls[0].3.is_none()); +} + +#[tokio::test] +async fn ticket_assistant_projects_project_default_permissions() { + let issue = issue(7); + let profile_id = ProfileId::from_uuid(Uuid::from_u128(9)); + let environment = Arc::new(FakeEnvironmentPreparer::default()); + let open = OpenTicketAssistant::new( + Arc::new(FakeIssues { + issue: issue.clone(), + }), + Arc::new(FakeProfiles { + profile: profile(profile_id), + }), + Arc::new(FakeAssistantContext::default()), + environment.clone(), + Arc::new(FakeFactory::default()), + Arc::new(StructuredSessions::new()), + Arc::new(FakePolicies::default()), + Arc::new(SpyBus::default()), + ) + .with_permission_store(Arc::new(FakePermissionStore(permission_doc(Some( + PermissionSet::new(Vec::new(), Posture::Allow), + ))))); + + open.execute(OpenTicketAssistantInput { + project: project(), + issue_ref: issue.reference(), + profile_id, + }) + .await + .unwrap(); + + let calls = environment.calls.lock().unwrap(); + assert_eq!( + calls[0].3.as_ref().map(EffectivePermissions::fallback), + Some(Posture::Allow) + ); +} + +#[tokio::test] +async fn ticket_assistant_ignores_agent_permission_overrides() { + let issue = issue(7); + let profile_id = ProfileId::from_uuid(Uuid::from_u128(9)); + let environment = Arc::new(FakeEnvironmentPreparer::default()); + let doc = ProjectPermissions::new( + Some(PermissionSet::new(Vec::new(), Posture::Allow)), + vec![AgentPermissionOverride::new( + AgentId::from_uuid(Uuid::from_u128(42)), + PermissionSet::new(Vec::new(), Posture::Deny), + )], + ); + let open = OpenTicketAssistant::new( + Arc::new(FakeIssues { + issue: issue.clone(), + }), + Arc::new(FakeProfiles { + profile: profile(profile_id), + }), + Arc::new(FakeAssistantContext::default()), + environment.clone(), + Arc::new(FakeFactory::default()), + Arc::new(StructuredSessions::new()), + Arc::new(FakePolicies::default()), + Arc::new(SpyBus::default()), + ) + .with_permission_store(Arc::new(FakePermissionStore(doc))); + + open.execute(OpenTicketAssistantInput { + project: project(), + issue_ref: issue.reference(), + profile_id, + }) + .await + .unwrap(); + + let calls = environment.calls.lock().unwrap(); + assert_eq!( + calls[0].3.as_ref().map(EffectivePermissions::fallback), + Some(Posture::Allow) + ); +} + #[tokio::test] async fn codex_ticket_assistant_uses_project_network_permission_for_structured_policy() { for (policy, network_access) in [ diff --git a/crates/backend/src/lib.rs b/crates/backend/src/lib.rs index 082b4b5..b6bba99 100644 --- a/crates/backend/src/lib.rs +++ b/crates/backend/src/lib.rs @@ -1651,6 +1651,8 @@ impl BackendCore { let system_permission_store = Arc::new(FsSystemPermissionStore::new(Arc::clone(&fs_port))); let system_permission_store_port = Arc::clone(&system_permission_store) as Arc; + let permission_store = Arc::new(FsPermissionStore::new(Arc::clone(&fs_port))); + let permission_store_port = Arc::clone(&permission_store) as Arc; let assistant_context_provider = Arc::new(FsAssistantContextStore::new( Arc::clone(&fs_port), app_data_dir.to_string_lossy().into_owned(), @@ -1682,6 +1684,7 @@ impl BackendCore { Arc::clone(&tool_policy_store), Arc::clone(&events_port), ) + .with_permission_store(Arc::clone(&permission_store_port)) .with_system_permission_store(Arc::clone(&system_permission_store_port)), ); let close_ticket_assistant = Arc::new(CloseTicketAssistant::new( @@ -1691,8 +1694,6 @@ impl BackendCore { )); // --- Project permissions (LP1) --- - let permission_store = Arc::new(FsPermissionStore::new(Arc::clone(&fs_port))); - let permission_store_port = Arc::clone(&permission_store) as Arc; let runtime_permission_probe = Arc::new(ReadOnlyRuntimePermissionProbe) as Arc; let get_project_system_permissions = Arc::new(GetProjectSystemPermissions::new( diff --git a/crates/domain/src/ports.rs b/crates/domain/src/ports.rs index 587961f..4a84d27 100644 --- a/crates/domain/src/ports.rs +++ b/crates/domain/src/ports.rs @@ -49,6 +49,7 @@ use crate::model_catalogue::{CliVersion, CompatibilityMatrix}; use crate::model_server::{ HfModelRef, LocalModelServerConfig, ModelPath, ModelServerEndpoint, ModelServerStatus, }; +use crate::permission::EffectivePermissions; use crate::permission::ProjectPermissions; use crate::plugin::{ ContentHash, PluginBundleUrl, PluginId, PluginManifest, PluginMcpServerSpec, @@ -210,6 +211,7 @@ pub trait StructuredSessionEnvironmentPreparer: Send + Sync { profile: &AgentProfile, prepared: &PreparedContext, requester: &str, + permissions: Option<&EffectivePermissions>, ) -> Result; } diff --git a/crates/infrastructure/src/assistant/mod.rs b/crates/infrastructure/src/assistant/mod.rs index c62bd8f..a6dd61b 100644 --- a/crates/infrastructure/src/assistant/mod.rs +++ b/crates/infrastructure/src/assistant/mod.rs @@ -214,6 +214,7 @@ impl TicketAssistantEnvironmentPreparer { cwd: &ProjectPath, requester: &str, env: &mut Vec<(String, String)>, + eff: Option<&EffectivePermissions>, ) -> Result<(), RuntimeError> { let Some(mcp) = &profile.mcp else { return Ok(()); @@ -244,14 +245,8 @@ impl TicketAssistantEnvironmentPreparer { if profile.structured_adapter != Some(StructuredAdapter::OpenCode) { return Ok(()); } - // No `PermissionStore` is wired for ticket-assistant sessions (no - // per-agent policy exists in this path, for any CLI) — `eff` is - // always `None` here, which per `opencode_permission_block`'s - // contract omits the `permission` key entirely and preserves - // OpenCode's native prompting, matching Claude/Codex's behaviour - // on this same path. let body = if let Some(opencode) = profile.opencode.as_ref() { - opencode_config_json(opencode, project.root.as_str(), runtime.as_ref(), None) + opencode_config_json(opencode, project.root.as_str(), runtime.as_ref(), eff) .to_string() } else if let Some(provider) = profile.opencode_provider.as_ref() { let api_key = self.resolve_opencode_provider_api_key(provider).await?; @@ -260,7 +255,7 @@ impl TicketAssistantEnvironmentPreparer { &api_key, project.root.as_str(), runtime.as_ref(), - None, + eff, ) .to_string() } else { @@ -304,6 +299,7 @@ impl StructuredSessionEnvironmentPreparer for TicketAssistantEnvironmentPreparer profile: &AgentProfile, prepared: &PreparedContext, requester: &str, + permissions: Option<&EffectivePermissions>, ) -> Result { let run_dir = self.run_dir(project, issue_ref)?; self.create_dir(run_dir.as_str()).await?; @@ -313,8 +309,15 @@ impl StructuredSessionEnvironmentPreparer for TicketAssistantEnvironmentPreparer let mut env = spec.env; self.materialise_context(spec.context_plan, &spec.cwd, prepared, &mut env) .await?; - self.materialise_mcp(project, profile, &spec.cwd, requester, &mut env) - .await?; + self.materialise_mcp( + project, + profile, + &spec.cwd, + requester, + &mut env, + permissions, + ) + .await?; Ok(StructuredSessionEnvironment { cwd: spec.cwd, env, diff --git a/crates/infrastructure/tests/assistant_context_store.rs b/crates/infrastructure/tests/assistant_context_store.rs index 5eab2d9..f12d5bd 100644 --- a/crates/infrastructure/tests/assistant_context_store.rs +++ b/crates/infrastructure/tests/assistant_context_store.rs @@ -3,8 +3,11 @@ use std::sync::Arc; use application::McpRuntime; use async_trait::async_trait; +use domain::permission::{EffectivePermissions, PermissionSet, Posture}; use domain::ports::{SessionPlan, StructuredSessionEnvironmentPreparer}; -use domain::profile::{McpCapability, McpConfigStrategy, McpTransport}; +use domain::profile::{ + McpCapability, McpConfigStrategy, McpTransport, OpenCodeConfig, StructuredAdapter, +}; use domain::{ AgentProfile, AgentRuntime, AssistantContextProvider, ContextInjection, ContextInjectionPlan, FileSystem, Issue, IssueActor, IssueId, IssueNumber, IssuePriority, IssueStatus, MarkdownDoc, @@ -122,6 +125,32 @@ fn mcp_profile() -> AgentProfile { )) } +fn opencode_profile() -> AgentProfile { + AgentProfile::new( + ProfileId::from_uuid(Uuid::from_u128(10)), + "OpenCode", + "opencode", + Vec::new(), + ContextInjection::stdin(), + None, + "{projectRoot}", + None, + ) + .unwrap() + .with_structured_adapter(StructuredAdapter::OpenCode) + .with_opencode( + OpenCodeConfig::new("http://localhost:8080/v1", None, "qwen3-coder", None, None).unwrap(), + ) + .with_mcp(McpCapability::new( + McpConfigStrategy::open_code_config("opencode.json").unwrap(), + McpTransport::Stdio, + )) +} + +fn effective(fallback: Posture) -> EffectivePermissions { + domain::permission::resolve(Some(&PermissionSet::new(Vec::new(), fallback)), None).unwrap() +} + #[tokio::test] async fn default_context_is_embedded_and_injects_the_ticket() { let tmp = TempDir::new(); @@ -205,6 +234,7 @@ async fn environment_preparer_materialises_context_and_mcp_under_isolated_app_da &mcp_profile(), &prepared, "ticket-assistant:00000000000000000000000000000001:7", + None, ) .await .unwrap(); @@ -246,3 +276,102 @@ async fn environment_preparer_materialises_context_and_mcp_under_isolated_app_da "assistant context must not be written into the real project ticket carnet" ); } + +#[tokio::test] +async fn opencode_ticket_assistant_projects_project_permissions_into_isolated_config() { + let tmp = TempDir::new(); + let fs: Arc = Arc::new(LocalFileSystem::new()); + let app_data_dir = tmp.app_data_dir(); + let project = project(tmp.project_root()); + let prepared = PreparedContext { + content: MarkdownDoc::new("assistant-only context"), + relative_path: "ticket-assistant.md".to_owned(), + project_root: project.root.as_str().to_owned(), + }; + let requester = "ticket-assistant:00000000000000000000000000000001:7".to_owned(); + let preparer = TicketAssistantEnvironmentPreparer::new( + fs.clone(), + app_data_dir, + Arc::new(FakeRuntime), + Arc::new(move |_, _| { + Some(McpRuntime { + exe: "/opt/idea/idea".to_owned(), + endpoint: "127.0.0.1:4567".to_owned(), + project_id: "00000000000000000000000000000001".to_owned(), + requester: requester.clone(), + }) + }), + Arc::new(FsSecretStore::new(fs.clone(), tmp.app_data_dir())), + ); + + let env = preparer + .prepare_ticket_assistant( + &project, + issue(7).reference(), + &opencode_profile(), + &prepared, + "ticket-assistant:00000000000000000000000000000001:7", + Some(&effective(Posture::Deny)), + ) + .await + .unwrap(); + + let config_path = PathBuf::from(env.cwd.as_str()).join("opencode.json"); + let config: serde_json::Value = serde_json::from_slice( + &fs.read(&RemotePath::new(config_path.to_string_lossy().into_owned())) + .await + .unwrap(), + ) + .unwrap(); + assert_eq!(config["permission"]["bash"], "deny"); + assert_eq!(config["permission"]["edit"], "deny"); +} + +#[tokio::test] +async fn claude_ticket_assistant_does_not_project_effective_permissions() { + let tmp = TempDir::new(); + let fs: Arc = Arc::new(LocalFileSystem::new()); + let app_data_dir = tmp.app_data_dir(); + let project = project(tmp.project_root()); + let prepared = PreparedContext { + content: MarkdownDoc::new("assistant-only context"), + relative_path: "ticket-assistant.md".to_owned(), + project_root: project.root.as_str().to_owned(), + }; + let requester = "ticket-assistant:00000000000000000000000000000001:7".to_owned(); + let preparer = TicketAssistantEnvironmentPreparer::new( + fs.clone(), + app_data_dir, + Arc::new(FakeRuntime), + Arc::new(move |_, _| { + Some(McpRuntime { + exe: "/opt/idea/idea".to_owned(), + endpoint: "127.0.0.1:4567".to_owned(), + project_id: "00000000000000000000000000000001".to_owned(), + requester: requester.clone(), + }) + }), + Arc::new(FsSecretStore::new(fs.clone(), tmp.app_data_dir())), + ); + + let env = preparer + .prepare_ticket_assistant( + &project, + issue(7).reference(), + &mcp_profile(), + &prepared, + "ticket-assistant:00000000000000000000000000000001:7", + Some(&effective(Posture::Deny)), + ) + .await + .unwrap(); + + let mcp_path = PathBuf::from(env.cwd.as_str()).join(".mcp.json"); + let mcp: serde_json::Value = serde_json::from_slice( + &fs.read(&RemotePath::new(mcp_path.to_string_lossy().into_owned())) + .await + .unwrap(), + ) + .unwrap(); + assert!(mcp.get("permission").is_none()); +}