merge feature/ticket96-effective-permissions-assistants-ticket dans develop

This commit is contained in:
2026-07-27 14:18:59 +02:00
6 changed files with 343 additions and 29 deletions

View File

@ -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<StructuredSessions>,
policies: Arc<dyn AgentToolPolicyStore>,
events: Arc<dyn EventBus>,
permissions: Option<Arc<dyn PermissionStore>>,
system_permissions: Option<Arc<dyn SystemPermissionStore>>,
}
@ -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<dyn PermissionStore>) -> 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<dyn SystemPermissionStore>) -> 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<Option<EffectivePermissions>, 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<NetworkPolicy>) -> bool {

View File

@ -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<Vec<(IssueRef, String, PreparedContext)>>,
calls: Mutex<
Vec<(
IssueRef,
String,
PreparedContext,
Option<EffectivePermissions>,
)>,
>,
}
#[async_trait]
@ -206,11 +215,14 @@ impl StructuredSessionEnvironmentPreparer for FakeEnvironmentPreparer {
_profile: &AgentProfile,
prepared: &PreparedContext,
requester: &str,
permissions: Option<&EffectivePermissions>,
) -> Result<StructuredSessionEnvironment, RuntimeError> {
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<ProjectPermissions, StoreError> {
Ok(self.0.clone())
}
async fn save_permissions(
&self,
_project: &Project,
_permissions: &ProjectPermissions,
) -> Result<(), StoreError> {
Ok(())
}
}
fn permission_doc(project_defaults: Option<PermissionSet>) -> 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 [

View File

@ -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<dyn SystemPermissionStore>;
let permission_store = Arc::new(FsPermissionStore::new(Arc::clone(&fs_port)));
let permission_store_port = Arc::clone(&permission_store) as Arc<dyn PermissionStore>;
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<dyn PermissionStore>;
let runtime_permission_probe =
Arc::new(ReadOnlyRuntimePermissionProbe) as Arc<dyn RuntimePermissionProbe>;
let get_project_system_permissions = Arc::new(GetProjectSystemPermissions::new(

View File

@ -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<StructuredSessionEnvironment, RuntimeError>;
}

View File

@ -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<StructuredSessionEnvironment, RuntimeError> {
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,

View File

@ -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<dyn FileSystem> = 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<dyn FileSystem> = 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());
}