feat: livrable ticket #96 — effective permissions pour assistants de ticket

This commit is contained in:
2026-07-27 13:37:08 +02:00
parent 865f90eafd
commit e74a9703f9
6 changed files with 343 additions and 29 deletions

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 [