fix(sandbox): writable roots Codex dérivées des permissions effectives du menu agent — #197 (QA verte)
This commit is contained in:
@ -11,8 +11,7 @@
|
||||
//! [`AgentRuntime`], [`PtyPort`], [`FileSystem`], [`EventBus`]); none knows about
|
||||
//! a concrete adapter or Tauri.
|
||||
|
||||
use std::collections::{BTreeSet, HashMap};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use domain::ports::{
|
||||
@ -30,13 +29,16 @@ use domain::{
|
||||
bound_handoff_summary, Agent, AgentId, AgentManifest, AgentOrigin, AgentProfile,
|
||||
ContextInjection, ConversationId, ConversationParty, DomainEvent, EffectivePermissions,
|
||||
Handoff, HandoffStore, ManifestEntry, MarkdownDoc, MemoryIndexEntry, MemoryType, NetworkPolicy,
|
||||
NodeId, OrchestrationCapabilitySnapshot, PermissionProjector, Posture, ProfileId, Project,
|
||||
ProjectPath, ProjectedFile, ProjectionContext, ProjectorKey, ProviderSessionStore, PtySize,
|
||||
SessionId, SessionKind, SessionStatus, SkillKind, TerminalSession, HANDOFF_SUMMARY_MAX_CHARS,
|
||||
NodeId, OrchestrationCapabilitySnapshot, PermissionProjector, ProfileId, Project, ProjectPath,
|
||||
ProjectedFile, ProjectionContext, ProjectorKey, ProviderSessionStore, PtySize, SessionId,
|
||||
SessionKind, SessionStatus, SkillKind, TerminalSession, HANDOFF_SUMMARY_MAX_CHARS,
|
||||
};
|
||||
|
||||
use domain::live_state::WorkStatus;
|
||||
|
||||
use crate::codex_permissions::{
|
||||
codex_permission_writable_roots, codex_sandbox_mode, compile_codex_launch_policy,
|
||||
};
|
||||
use crate::error::AppError;
|
||||
use crate::layout::{persist_doc, resolve_doc};
|
||||
use crate::managed_temp::{ResolveManagedTemp, ResolveManagedTempInput};
|
||||
@ -1338,22 +1340,13 @@ fn select_projector_key(profile: &AgentProfile) -> Option<ProjectorKey> {
|
||||
None
|
||||
}
|
||||
|
||||
fn codex_structured_sandbox_mode(permissions: Option<&EffectivePermissions>) -> &'static str {
|
||||
match permissions.map(EffectivePermissions::fallback) {
|
||||
Some(Posture::Deny) => "read-only",
|
||||
Some(Posture::Ask | Posture::Allow) | None => "workspace-write",
|
||||
}
|
||||
}
|
||||
|
||||
fn codex_network_access(network: Option<NetworkPolicy>) -> bool {
|
||||
matches!(network, Some(NetworkPolicy::Allow))
|
||||
}
|
||||
|
||||
fn build_structured_launch_policy(
|
||||
profile: &AgentProfile,
|
||||
permissions: Option<&EffectivePermissions>,
|
||||
network: Option<NetworkPolicy>,
|
||||
codex_git_writable_roots: &[String],
|
||||
project_root: &str,
|
||||
run_dir: &str,
|
||||
extra_writable_roots: &[String],
|
||||
) -> Option<StructuredProviderLaunchPolicy> {
|
||||
if !matches!(profile.structured_adapter, Some(StructuredAdapter::Codex)) {
|
||||
return None;
|
||||
@ -1362,70 +1355,13 @@ fn build_structured_launch_policy(
|
||||
return None;
|
||||
}
|
||||
|
||||
let sandbox_mode = codex_structured_sandbox_mode(permissions).to_owned();
|
||||
let writable_roots = if sandbox_mode == "workspace-write" {
|
||||
codex_git_writable_roots.to_vec()
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
|
||||
Some(StructuredProviderLaunchPolicy::Codex {
|
||||
sandbox_mode,
|
||||
writable_roots,
|
||||
network_access: codex_network_access(network),
|
||||
})
|
||||
}
|
||||
|
||||
fn codex_git_writable_roots(project_root: &ProjectPath) -> Vec<String> {
|
||||
git_writable_roots_for_project(Path::new(project_root.as_str()))
|
||||
}
|
||||
|
||||
fn git_writable_roots_for_project(project_root: &Path) -> Vec<String> {
|
||||
let git_marker = project_root.join(".git");
|
||||
let Some(git_dir) = resolve_git_dir(&git_marker) else {
|
||||
return Vec::new();
|
||||
};
|
||||
let Ok(canonical) = git_dir.canonicalize() else {
|
||||
return Vec::new();
|
||||
};
|
||||
if !canonical.is_dir() {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
let mut roots = BTreeSet::new();
|
||||
roots.insert(canonical.to_string_lossy().into_owned());
|
||||
roots.into_iter().collect()
|
||||
}
|
||||
|
||||
fn resolve_git_dir(git_marker: &Path) -> Option<PathBuf> {
|
||||
if git_marker.is_dir() {
|
||||
return Some(git_marker.to_path_buf());
|
||||
}
|
||||
if !git_marker.is_file() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let contents = std::fs::read_to_string(git_marker).ok()?;
|
||||
let gitdir = contents
|
||||
.lines()
|
||||
.find_map(|line| line.trim().strip_prefix("gitdir:").map(str::trim))?;
|
||||
if gitdir.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let path = PathBuf::from(gitdir);
|
||||
if path.is_absolute() {
|
||||
Some(path)
|
||||
} else {
|
||||
git_marker.parent().map(|parent| parent.join(path))
|
||||
}
|
||||
}
|
||||
|
||||
fn enrich_sandbox_plan_with_codex_git_roots(
|
||||
plan: Option<SandboxPlan>,
|
||||
codex_git_writable_roots: &[String],
|
||||
) -> Option<SandboxPlan> {
|
||||
enrich_sandbox_plan_with_rw_roots(plan, codex_git_writable_roots)
|
||||
Some(compile_codex_launch_policy(
|
||||
project_root,
|
||||
run_dir,
|
||||
permissions,
|
||||
network,
|
||||
extra_writable_roots,
|
||||
))
|
||||
}
|
||||
|
||||
fn enrich_sandbox_plan_with_managed_temp_roots(
|
||||
@ -1461,14 +1397,6 @@ fn enrich_sandbox_plan_with_rw_roots(
|
||||
Some(plan)
|
||||
}
|
||||
|
||||
fn append_unique_paths(target: &mut Vec<String>, roots: &[String]) {
|
||||
for root in roots {
|
||||
if !target.iter().any(|existing| existing == root) {
|
||||
target.push(root.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn dedupe_paths(paths: impl IntoIterator<Item = LocalPath>) -> Vec<String> {
|
||||
let mut roots = Vec::new();
|
||||
for path in paths {
|
||||
@ -1533,7 +1461,7 @@ fn append_codex_pty_managed_temp_roots(
|
||||
if managed_temp_roots.is_empty() || !is_codex_profile(profile) {
|
||||
return;
|
||||
}
|
||||
if codex_structured_sandbox_mode(permissions) != "workspace-write" {
|
||||
if codex_sandbox_mode(permissions, managed_temp_roots) != "workspace-write" {
|
||||
return;
|
||||
}
|
||||
for root in managed_temp_roots {
|
||||
@ -2338,17 +2266,9 @@ impl LaunchAgent {
|
||||
// remplir `spec.sandbox`. `eff == None` (rien posé) ⇒ `None` : aucun plan,
|
||||
// comportement natif conservé (invariant produit). L'enforcer concret
|
||||
// (Landlock/Noop) est injecté côté PTY au composition root.
|
||||
let codex_git_writable_roots =
|
||||
if matches!(profile.structured_adapter, Some(StructuredAdapter::Codex)) {
|
||||
codex_git_writable_roots(&input.project.root)
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
let managed_temp_writable_roots = self
|
||||
.resolve_managed_temp_exposure(&input.managed_temp_exposure)
|
||||
.await?;
|
||||
let mut codex_writable_roots = codex_git_writable_roots.clone();
|
||||
append_unique_paths(&mut codex_writable_roots, &managed_temp_writable_roots);
|
||||
spec.sandbox = compile_sandbox_plan(
|
||||
effective_permissions.as_ref(),
|
||||
&SandboxContext {
|
||||
@ -2356,8 +2276,13 @@ impl LaunchAgent {
|
||||
run_dir: run_dir.as_str(),
|
||||
},
|
||||
);
|
||||
let codex_permission_writable_roots = codex_permission_writable_roots(
|
||||
input.project.root.as_str(),
|
||||
run_dir.as_str(),
|
||||
effective_permissions.as_ref(),
|
||||
);
|
||||
spec.sandbox =
|
||||
enrich_sandbox_plan_with_codex_git_roots(spec.sandbox, &codex_git_writable_roots);
|
||||
enrich_sandbox_plan_with_rw_roots(spec.sandbox, &codex_permission_writable_roots);
|
||||
spec.sandbox =
|
||||
enrich_sandbox_plan_with_managed_temp_roots(spec.sandbox, &managed_temp_writable_roots);
|
||||
append_codex_pty_managed_temp_roots(
|
||||
@ -2370,7 +2295,9 @@ impl LaunchAgent {
|
||||
&profile,
|
||||
effective_permissions.as_ref(),
|
||||
network_permission,
|
||||
&codex_writable_roots,
|
||||
input.project.root.as_str(),
|
||||
run_dir.as_str(),
|
||||
&managed_temp_writable_roots,
|
||||
);
|
||||
let resolved_effort = resolve_effort(
|
||||
profile.model_reasoning_effort.as_deref(),
|
||||
|
||||
101
crates/application/src/codex_permissions.rs
Normal file
101
crates/application/src/codex_permissions.rs
Normal file
@ -0,0 +1,101 @@
|
||||
use domain::permission::{Capability, Effect, EffectivePermissions, Posture};
|
||||
use domain::ports::StructuredProviderLaunchPolicy;
|
||||
use domain::sandbox::{compile_sandbox_plan, PathAccess, SandboxContext};
|
||||
use domain::NetworkPolicy;
|
||||
|
||||
pub(crate) fn compile_codex_launch_policy(
|
||||
project_root: &str,
|
||||
run_dir: &str,
|
||||
permissions: Option<&EffectivePermissions>,
|
||||
network: Option<NetworkPolicy>,
|
||||
extra_writable_roots: &[String],
|
||||
) -> StructuredProviderLaunchPolicy {
|
||||
let mut writable_roots = codex_permission_writable_roots(project_root, run_dir, permissions);
|
||||
append_unique_paths(&mut writable_roots, extra_writable_roots);
|
||||
let sandbox_mode = codex_sandbox_mode(permissions, &writable_roots).to_owned();
|
||||
if sandbox_mode != "workspace-write" {
|
||||
writable_roots.clear();
|
||||
}
|
||||
|
||||
StructuredProviderLaunchPolicy::Codex {
|
||||
sandbox_mode,
|
||||
writable_roots,
|
||||
network_access: codex_network_access(network),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn codex_permission_writable_roots(
|
||||
project_root: &str,
|
||||
run_dir: &str,
|
||||
permissions: Option<&EffectivePermissions>,
|
||||
) -> Vec<String> {
|
||||
let Some(permissions) = permissions else {
|
||||
return Vec::new();
|
||||
};
|
||||
|
||||
let mut roots = Vec::new();
|
||||
if !has_rw_deny(permissions)
|
||||
&& permissions.fallback() != Posture::Deny
|
||||
&& permissions.decide_file(Capability::Write, "") != Posture::Deny
|
||||
{
|
||||
roots.push(project_root.to_owned());
|
||||
}
|
||||
|
||||
if let Some(plan) = compile_sandbox_plan(
|
||||
Some(permissions),
|
||||
&SandboxContext {
|
||||
project_root,
|
||||
run_dir,
|
||||
},
|
||||
) {
|
||||
for grant in plan
|
||||
.allowed
|
||||
.iter()
|
||||
.filter(|grant| grant.access.contains(PathAccess::RW))
|
||||
{
|
||||
append_unique_path(&mut roots, &grant.abs_root);
|
||||
}
|
||||
}
|
||||
roots.sort();
|
||||
roots
|
||||
}
|
||||
|
||||
pub(crate) fn codex_sandbox_mode(
|
||||
permissions: Option<&EffectivePermissions>,
|
||||
writable_roots: &[String],
|
||||
) -> &'static str {
|
||||
if !writable_roots.is_empty() {
|
||||
return "workspace-write";
|
||||
}
|
||||
if permissions.is_some_and(has_rw_deny) {
|
||||
return "read-only";
|
||||
}
|
||||
match permissions.map(EffectivePermissions::fallback) {
|
||||
Some(Posture::Ask | Posture::Allow) | None => "workspace-write",
|
||||
Some(Posture::Deny) => "read-only",
|
||||
}
|
||||
}
|
||||
|
||||
fn has_rw_deny(permissions: &EffectivePermissions) -> bool {
|
||||
permissions.rules().iter().any(|rule| {
|
||||
rule.effect() == Effect::Deny
|
||||
&& matches!(rule.capability(), Capability::Write | Capability::Delete)
|
||||
})
|
||||
}
|
||||
|
||||
fn codex_network_access(network: Option<NetworkPolicy>) -> bool {
|
||||
matches!(network, Some(NetworkPolicy::Allow))
|
||||
}
|
||||
|
||||
fn append_unique_paths(target: &mut Vec<String>, roots: &[String]) {
|
||||
for root in roots {
|
||||
append_unique_path(target, root);
|
||||
}
|
||||
}
|
||||
|
||||
fn append_unique_path(target: &mut Vec<String>, root: &str) {
|
||||
if root.is_empty() || target.iter().any(|existing| existing == root) {
|
||||
return;
|
||||
}
|
||||
target.push(root.to_owned());
|
||||
}
|
||||
@ -15,6 +15,7 @@ pub mod agent;
|
||||
pub mod background;
|
||||
pub mod chat_attachments;
|
||||
pub mod chat_drafts;
|
||||
mod codex_permissions;
|
||||
pub mod conversation;
|
||||
pub mod device;
|
||||
pub mod diag;
|
||||
|
||||
@ -14,6 +14,7 @@ use domain::{
|
||||
ProfileId, ProfileStore, Project, SessionId,
|
||||
};
|
||||
|
||||
use crate::codex_permissions::compile_codex_launch_policy;
|
||||
use crate::terminal::StructuredSessions;
|
||||
use crate::AppError;
|
||||
|
||||
@ -229,11 +230,14 @@ impl OpenTicketAssistant {
|
||||
return Ok(environment.structured_policy.clone());
|
||||
}
|
||||
let network = self.resolve_project_default_network(project).await?;
|
||||
Ok(Some(StructuredProviderLaunchPolicy::Codex {
|
||||
sandbox_mode: "workspace-write".to_owned(),
|
||||
writable_roots: vec![project.root.as_str().to_owned()],
|
||||
network_access: codex_network_access(network),
|
||||
}))
|
||||
let permissions = self.resolve_project_default_permissions(project).await?;
|
||||
Ok(Some(compile_codex_launch_policy(
|
||||
project.root.as_str(),
|
||||
environment.cwd.as_str(),
|
||||
permissions.as_ref(),
|
||||
network,
|
||||
&[],
|
||||
)))
|
||||
}
|
||||
|
||||
async fn resolve_project_default_network(
|
||||
@ -262,10 +266,6 @@ impl OpenTicketAssistant {
|
||||
}
|
||||
}
|
||||
|
||||
fn codex_network_access(network: Option<NetworkPolicy>) -> bool {
|
||||
matches!(network, Some(NetworkPolicy::Allow))
|
||||
}
|
||||
|
||||
fn ticket_assistant_requester(project: &Project, issue_ref: IssueRef) -> String {
|
||||
format!(
|
||||
"ticket-assistant:{}:{}",
|
||||
|
||||
Reference in New Issue
Block a user