feat: livrable ticket #96 — effective permissions pour assistants de ticket
This commit is contained in:
@ -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 {
|
||||
|
||||
@ -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 [
|
||||
|
||||
Reference in New Issue
Block a user