fix(sandbox): writable roots Codex dérivées des permissions effectives du menu agent — #197 (QA verte)

This commit is contained in:
2026-08-14 15:05:15 +02:00
parent 6a98752cb0
commit a60fb3ed92
6 changed files with 482 additions and 146 deletions

View File

@ -7,7 +7,9 @@ use application::{
StructuredSessions,
};
use async_trait::async_trait;
use domain::permission::{EffectivePermissions, Posture};
use domain::permission::{
Capability, Effect, EffectivePermissions, PathScope, PermissionRule, Posture,
};
use domain::ports::{
AgentSession, AgentSessionError, AgentSessionFactory, PermissionStore, ReplyStream,
RuntimeError, SessionPlan, StoreError, StructuredProviderLaunchPolicy,
@ -415,6 +417,15 @@ fn permission_doc(project_defaults: Option<PermissionSet>) -> ProjectPermissions
ProjectPermissions::new(project_defaults, Vec::new())
}
fn file_rule(capability: Capability, effect: Effect, patterns: &[&str]) -> PermissionRule {
PermissionRule::file(
capability,
effect,
PathScope::new(patterns.iter().map(|pattern| (*pattern).to_owned())).unwrap(),
)
.unwrap()
}
#[tokio::test]
async fn open_then_close_ticket_assistant_sets_policy_injects_context_and_emits_events() {
let issue = issue(7);
@ -642,6 +653,7 @@ async fn codex_ticket_assistant_uses_project_network_permission_for_structured_p
let factory = Arc::new(FakeFactory::default());
let system_doc =
ProjectSystemPermissions::new(Some(SystemPermissionSet::new(policy)), Vec::new());
let permission_doc = permission_doc(Some(PermissionSet::new(Vec::new(), Posture::Allow)));
let open = OpenTicketAssistant::new(
Arc::new(FakeIssues {
issue: issue.clone(),
@ -656,6 +668,7 @@ async fn codex_ticket_assistant_uses_project_network_permission_for_structured_p
policies,
events,
)
.with_permission_store(Arc::new(FakePermissionStore(permission_doc)))
.with_system_permission_store(Arc::new(FakeSystemPermissionStore(system_doc)));
open.execute(OpenTicketAssistantInput {
@ -678,3 +691,98 @@ async fn codex_ticket_assistant_uses_project_network_permission_for_structured_p
);
}
}
#[tokio::test]
async fn codex_ticket_assistant_uses_project_default_write_roots_without_overgranting() {
let issue = issue(7);
let profile_id = ProfileId::from_uuid(Uuid::from_u128(9));
let factory = Arc::new(FakeFactory::default());
let open = OpenTicketAssistant::new(
Arc::new(FakeIssues {
issue: issue.clone(),
}),
Arc::new(FakeProfiles {
profile: profile(profile_id).with_structured_adapter(StructuredAdapter::Codex),
}),
Arc::new(FakeAssistantContext::default()),
Arc::new(FakeEnvironmentPreparer::default()),
factory.clone(),
Arc::new(StructuredSessions::new()),
Arc::new(FakePolicies::default()),
Arc::new(SpyBus::default()),
)
.with_permission_store(Arc::new(FakePermissionStore(permission_doc(Some(
PermissionSet::new(
vec![file_rule(Capability::Write, Effect::Allow, &["out/**"])],
Posture::Deny,
),
)))));
open.execute(OpenTicketAssistantInput {
project: project(),
issue_ref: issue.reference(),
profile_id,
})
.await
.unwrap();
let starts = factory.starts.lock().unwrap();
assert_eq!(
starts[0].6,
Some(StructuredProviderLaunchPolicy::Codex {
sandbox_mode: "workspace-write".to_owned(),
writable_roots: vec!["/tmp/project/out".to_owned()],
network_access: false,
}),
"ticket assistant Codex policy must use permission-derived write roots"
);
}
#[tokio::test]
async fn codex_ticket_assistant_broad_write_with_subpath_deny_fails_closed() {
let issue = issue(7);
let profile_id = ProfileId::from_uuid(Uuid::from_u128(9));
let factory = Arc::new(FakeFactory::default());
let open = OpenTicketAssistant::new(
Arc::new(FakeIssues {
issue: issue.clone(),
}),
Arc::new(FakeProfiles {
profile: profile(profile_id).with_structured_adapter(StructuredAdapter::Codex),
}),
Arc::new(FakeAssistantContext::default()),
Arc::new(FakeEnvironmentPreparer::default()),
factory.clone(),
Arc::new(StructuredSessions::new()),
Arc::new(FakePolicies::default()),
Arc::new(SpyBus::default()),
)
.with_permission_store(Arc::new(FakePermissionStore(permission_doc(Some(
PermissionSet::new(
vec![
file_rule(Capability::Write, Effect::Allow, &["**"]),
file_rule(Capability::Write, Effect::Deny, &["secret/**"]),
],
Posture::Deny,
),
)))));
open.execute(OpenTicketAssistantInput {
project: project(),
issue_ref: issue.reference(),
profile_id,
})
.await
.unwrap();
let starts = factory.starts.lock().unwrap();
assert_eq!(
starts[0].6,
Some(StructuredProviderLaunchPolicy::Codex {
sandbox_mode: "read-only".to_owned(),
writable_roots: Vec::new(),
network_access: false,
}),
"ticket assistant must fail closed when Codex cannot represent a subpath write deny"
);
}