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
|
//! [`AgentRuntime`], [`PtyPort`], [`FileSystem`], [`EventBus`]); none knows about
|
||||||
//! a concrete adapter or Tauri.
|
//! a concrete adapter or Tauri.
|
||||||
|
|
||||||
use std::collections::{BTreeSet, HashMap};
|
use std::collections::HashMap;
|
||||||
use std::path::{Path, PathBuf};
|
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use domain::ports::{
|
use domain::ports::{
|
||||||
@ -30,13 +29,16 @@ use domain::{
|
|||||||
bound_handoff_summary, Agent, AgentId, AgentManifest, AgentOrigin, AgentProfile,
|
bound_handoff_summary, Agent, AgentId, AgentManifest, AgentOrigin, AgentProfile,
|
||||||
ContextInjection, ConversationId, ConversationParty, DomainEvent, EffectivePermissions,
|
ContextInjection, ConversationId, ConversationParty, DomainEvent, EffectivePermissions,
|
||||||
Handoff, HandoffStore, ManifestEntry, MarkdownDoc, MemoryIndexEntry, MemoryType, NetworkPolicy,
|
Handoff, HandoffStore, ManifestEntry, MarkdownDoc, MemoryIndexEntry, MemoryType, NetworkPolicy,
|
||||||
NodeId, OrchestrationCapabilitySnapshot, PermissionProjector, Posture, ProfileId, Project,
|
NodeId, OrchestrationCapabilitySnapshot, PermissionProjector, ProfileId, Project, ProjectPath,
|
||||||
ProjectPath, ProjectedFile, ProjectionContext, ProjectorKey, ProviderSessionStore, PtySize,
|
ProjectedFile, ProjectionContext, ProjectorKey, ProviderSessionStore, PtySize, SessionId,
|
||||||
SessionId, SessionKind, SessionStatus, SkillKind, TerminalSession, HANDOFF_SUMMARY_MAX_CHARS,
|
SessionKind, SessionStatus, SkillKind, TerminalSession, HANDOFF_SUMMARY_MAX_CHARS,
|
||||||
};
|
};
|
||||||
|
|
||||||
use domain::live_state::WorkStatus;
|
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::error::AppError;
|
||||||
use crate::layout::{persist_doc, resolve_doc};
|
use crate::layout::{persist_doc, resolve_doc};
|
||||||
use crate::managed_temp::{ResolveManagedTemp, ResolveManagedTempInput};
|
use crate::managed_temp::{ResolveManagedTemp, ResolveManagedTempInput};
|
||||||
@ -1338,22 +1340,13 @@ fn select_projector_key(profile: &AgentProfile) -> Option<ProjectorKey> {
|
|||||||
None
|
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(
|
fn build_structured_launch_policy(
|
||||||
profile: &AgentProfile,
|
profile: &AgentProfile,
|
||||||
permissions: Option<&EffectivePermissions>,
|
permissions: Option<&EffectivePermissions>,
|
||||||
network: Option<NetworkPolicy>,
|
network: Option<NetworkPolicy>,
|
||||||
codex_git_writable_roots: &[String],
|
project_root: &str,
|
||||||
|
run_dir: &str,
|
||||||
|
extra_writable_roots: &[String],
|
||||||
) -> Option<StructuredProviderLaunchPolicy> {
|
) -> Option<StructuredProviderLaunchPolicy> {
|
||||||
if !matches!(profile.structured_adapter, Some(StructuredAdapter::Codex)) {
|
if !matches!(profile.structured_adapter, Some(StructuredAdapter::Codex)) {
|
||||||
return None;
|
return None;
|
||||||
@ -1362,70 +1355,13 @@ fn build_structured_launch_policy(
|
|||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
|
|
||||||
let sandbox_mode = codex_structured_sandbox_mode(permissions).to_owned();
|
Some(compile_codex_launch_policy(
|
||||||
let writable_roots = if sandbox_mode == "workspace-write" {
|
project_root,
|
||||||
codex_git_writable_roots.to_vec()
|
run_dir,
|
||||||
} else {
|
permissions,
|
||||||
Vec::new()
|
network,
|
||||||
};
|
extra_writable_roots,
|
||||||
|
))
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn enrich_sandbox_plan_with_managed_temp_roots(
|
fn enrich_sandbox_plan_with_managed_temp_roots(
|
||||||
@ -1461,14 +1397,6 @@ fn enrich_sandbox_plan_with_rw_roots(
|
|||||||
Some(plan)
|
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> {
|
fn dedupe_paths(paths: impl IntoIterator<Item = LocalPath>) -> Vec<String> {
|
||||||
let mut roots = Vec::new();
|
let mut roots = Vec::new();
|
||||||
for path in paths {
|
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) {
|
if managed_temp_roots.is_empty() || !is_codex_profile(profile) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if codex_structured_sandbox_mode(permissions) != "workspace-write" {
|
if codex_sandbox_mode(permissions, managed_temp_roots) != "workspace-write" {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
for root in managed_temp_roots {
|
for root in managed_temp_roots {
|
||||||
@ -2338,17 +2266,9 @@ impl LaunchAgent {
|
|||||||
// remplir `spec.sandbox`. `eff == None` (rien posé) ⇒ `None` : aucun plan,
|
// remplir `spec.sandbox`. `eff == None` (rien posé) ⇒ `None` : aucun plan,
|
||||||
// comportement natif conservé (invariant produit). L'enforcer concret
|
// comportement natif conservé (invariant produit). L'enforcer concret
|
||||||
// (Landlock/Noop) est injecté côté PTY au composition root.
|
// (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
|
let managed_temp_writable_roots = self
|
||||||
.resolve_managed_temp_exposure(&input.managed_temp_exposure)
|
.resolve_managed_temp_exposure(&input.managed_temp_exposure)
|
||||||
.await?;
|
.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(
|
spec.sandbox = compile_sandbox_plan(
|
||||||
effective_permissions.as_ref(),
|
effective_permissions.as_ref(),
|
||||||
&SandboxContext {
|
&SandboxContext {
|
||||||
@ -2356,8 +2276,13 @@ impl LaunchAgent {
|
|||||||
run_dir: run_dir.as_str(),
|
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 =
|
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 =
|
spec.sandbox =
|
||||||
enrich_sandbox_plan_with_managed_temp_roots(spec.sandbox, &managed_temp_writable_roots);
|
enrich_sandbox_plan_with_managed_temp_roots(spec.sandbox, &managed_temp_writable_roots);
|
||||||
append_codex_pty_managed_temp_roots(
|
append_codex_pty_managed_temp_roots(
|
||||||
@ -2370,7 +2295,9 @@ impl LaunchAgent {
|
|||||||
&profile,
|
&profile,
|
||||||
effective_permissions.as_ref(),
|
effective_permissions.as_ref(),
|
||||||
network_permission,
|
network_permission,
|
||||||
&codex_writable_roots,
|
input.project.root.as_str(),
|
||||||
|
run_dir.as_str(),
|
||||||
|
&managed_temp_writable_roots,
|
||||||
);
|
);
|
||||||
let resolved_effort = resolve_effort(
|
let resolved_effort = resolve_effort(
|
||||||
profile.model_reasoning_effort.as_deref(),
|
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 background;
|
||||||
pub mod chat_attachments;
|
pub mod chat_attachments;
|
||||||
pub mod chat_drafts;
|
pub mod chat_drafts;
|
||||||
|
mod codex_permissions;
|
||||||
pub mod conversation;
|
pub mod conversation;
|
||||||
pub mod device;
|
pub mod device;
|
||||||
pub mod diag;
|
pub mod diag;
|
||||||
|
|||||||
@ -14,6 +14,7 @@ use domain::{
|
|||||||
ProfileId, ProfileStore, Project, SessionId,
|
ProfileId, ProfileStore, Project, SessionId,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
use crate::codex_permissions::compile_codex_launch_policy;
|
||||||
use crate::terminal::StructuredSessions;
|
use crate::terminal::StructuredSessions;
|
||||||
use crate::AppError;
|
use crate::AppError;
|
||||||
|
|
||||||
@ -229,11 +230,14 @@ impl OpenTicketAssistant {
|
|||||||
return Ok(environment.structured_policy.clone());
|
return Ok(environment.structured_policy.clone());
|
||||||
}
|
}
|
||||||
let network = self.resolve_project_default_network(project).await?;
|
let network = self.resolve_project_default_network(project).await?;
|
||||||
Ok(Some(StructuredProviderLaunchPolicy::Codex {
|
let permissions = self.resolve_project_default_permissions(project).await?;
|
||||||
sandbox_mode: "workspace-write".to_owned(),
|
Ok(Some(compile_codex_launch_policy(
|
||||||
writable_roots: vec![project.root.as_str().to_owned()],
|
project.root.as_str(),
|
||||||
network_access: codex_network_access(network),
|
environment.cwd.as_str(),
|
||||||
}))
|
permissions.as_ref(),
|
||||||
|
network,
|
||||||
|
&[],
|
||||||
|
)))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn resolve_project_default_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 {
|
fn ticket_assistant_requester(project: &Project, issue_ref: IssueRef) -> String {
|
||||||
format!(
|
format!(
|
||||||
"ticket-assistant:{}:{}",
|
"ticket-assistant:{}:{}",
|
||||||
|
|||||||
@ -25,8 +25,8 @@ use domain::events::DomainEvent;
|
|||||||
use domain::ids::{AgentId, ProfileId, ProjectId};
|
use domain::ids::{AgentId, ProfileId, ProjectId};
|
||||||
use domain::markdown::MarkdownDoc;
|
use domain::markdown::MarkdownDoc;
|
||||||
use domain::permission::{
|
use domain::permission::{
|
||||||
EffectivePermissions, PermissionProjection, PermissionProjector, Posture, ProjectedFile,
|
Capability, Effect, EffectivePermissions, PathScope, PermissionProjection, PermissionProjector,
|
||||||
ProjectionContext, ProjectorKey,
|
PermissionRule, Posture, ProjectedFile, ProjectionContext, ProjectorKey,
|
||||||
};
|
};
|
||||||
use domain::ports::{
|
use domain::ports::{
|
||||||
AgentContextStore, AgentRuntime, AgentSession, AgentSessionError, AgentSessionFactory, Clock,
|
AgentContextStore, AgentRuntime, AgentSession, AgentSessionError, AgentSessionFactory, Clock,
|
||||||
@ -3744,6 +3744,19 @@ fn perm_doc(posture: Posture) -> ProjectPermissions {
|
|||||||
ProjectPermissions::new(Some(PermissionSet::new(vec![], posture)), Vec::new())
|
ProjectPermissions::new(Some(PermissionSet::new(vec![], posture)), Vec::new())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn perm_doc_with_rules(rules: Vec<PermissionRule>, posture: Posture) -> ProjectPermissions {
|
||||||
|
ProjectPermissions::new(Some(PermissionSet::new(rules, posture)), 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()
|
||||||
|
}
|
||||||
|
|
||||||
/// Builds a `codex` profile (convention `AGENTS.md`) with the given customiser, so
|
/// Builds a `codex` profile (convention `AGENTS.md`) with the given customiser, so
|
||||||
/// the structured-adapter / projector / mcp variants can be exercised.
|
/// the structured-adapter / projector / mcp variants can be exercised.
|
||||||
fn codex_profile() -> AgentProfile {
|
fn codex_profile() -> AgentProfile {
|
||||||
@ -4179,8 +4192,7 @@ async fn launch_codex_structured_for_project(
|
|||||||
(factory.clone(), factory.policies(), factory.sandboxes())
|
(factory.clone(), factory.policies(), factory.sandboxes())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn assert_codex_git_roots(
|
fn assert_codex_writable_roots(
|
||||||
project_root: &Path,
|
|
||||||
policies: Vec<Option<domain::ports::StructuredProviderLaunchPolicy>>,
|
policies: Vec<Option<domain::ports::StructuredProviderLaunchPolicy>>,
|
||||||
sandboxes: Vec<Option<domain::sandbox::SandboxPlan>>,
|
sandboxes: Vec<Option<domain::sandbox::SandboxPlan>>,
|
||||||
expected_roots: Vec<String>,
|
expected_roots: Vec<String>,
|
||||||
@ -4197,11 +4209,7 @@ fn assert_codex_git_roots(
|
|||||||
assert_eq!(sandbox_mode, "workspace-write");
|
assert_eq!(sandbox_mode, "workspace-write");
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
writable_roots, expected_roots,
|
writable_roots, expected_roots,
|
||||||
"Codex writable_roots must be the resolved Git roots only"
|
"Codex writable_roots must mirror resolved write permission roots"
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
!writable_roots.contains(&project_root.to_string_lossy().into_owned()),
|
|
||||||
"project root must not be granted as a Codex writable root"
|
|
||||||
);
|
);
|
||||||
|
|
||||||
assert_eq!(sandboxes.len(), 1, "exactly one structured sandbox plan");
|
assert_eq!(sandboxes.len(), 1, "exactly one structured sandbox plan");
|
||||||
@ -4213,67 +4221,67 @@ fn assert_codex_git_roots(
|
|||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
sandbox_roots, writable_roots,
|
sandbox_roots, writable_roots,
|
||||||
"OS sandbox Git enrichment must match Codex writable_roots"
|
"OS sandbox write roots must match Codex writable_roots"
|
||||||
);
|
);
|
||||||
assert!(
|
assert!(
|
||||||
sandbox
|
sandbox
|
||||||
.allowed
|
.allowed
|
||||||
.iter()
|
.iter()
|
||||||
.all(|grant| grant.access == domain::sandbox::PathAccess::RW),
|
.all(|grant| grant.access == domain::sandbox::PathAccess::RW),
|
||||||
"Git roots must be added as write grants only"
|
"Codex writable roots must be added as write grants"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn codex_git_enrichment_adds_standard_git_dir_without_project_root_blanket() {
|
async fn codex_structured_project_write_grant_includes_project_root() {
|
||||||
let root = fresh_temp_dir("standard-git");
|
let root = fresh_temp_dir("standard-git");
|
||||||
std::fs::create_dir(root.join(".git")).unwrap();
|
std::fs::create_dir(root.join(".git")).unwrap();
|
||||||
let expected_git = root.join(".git").canonicalize().unwrap();
|
|
||||||
|
|
||||||
let (_factory, policies, sandboxes) = launch_codex_structured_for_project(&root).await;
|
let (_factory, policies, sandboxes) = launch_codex_structured_for_project(&root).await;
|
||||||
|
|
||||||
assert_codex_git_roots(
|
assert_codex_writable_roots(
|
||||||
&root,
|
|
||||||
policies,
|
policies,
|
||||||
sandboxes,
|
sandboxes,
|
||||||
vec![expected_git.to_string_lossy().into_owned()],
|
vec![root.to_string_lossy().into_owned()],
|
||||||
);
|
);
|
||||||
let _ = std::fs::remove_dir_all(&root);
|
let _ = std::fs::remove_dir_all(&root);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn codex_git_enrichment_adds_no_roots_for_non_git_project() {
|
async fn codex_structured_project_write_grant_does_not_require_git_project() {
|
||||||
let root = fresh_temp_dir("non-git");
|
let root = fresh_temp_dir("non-git");
|
||||||
|
|
||||||
let (_factory, policies, sandboxes) = launch_codex_structured_for_project(&root).await;
|
let (_factory, policies, sandboxes) = launch_codex_structured_for_project(&root).await;
|
||||||
|
|
||||||
assert_codex_git_roots(&root, policies, sandboxes, Vec::new());
|
assert_codex_writable_roots(
|
||||||
|
policies,
|
||||||
|
sandboxes,
|
||||||
|
vec![root.to_string_lossy().into_owned()],
|
||||||
|
);
|
||||||
let _ = std::fs::remove_dir_all(&root);
|
let _ = std::fs::remove_dir_all(&root);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn codex_git_enrichment_resolves_worktree_gitdir_file() {
|
async fn codex_structured_project_write_grant_does_not_add_worktree_gitdir() {
|
||||||
let base = fresh_temp_dir("worktree");
|
let base = fresh_temp_dir("worktree");
|
||||||
let root = base.join("worktree");
|
let root = base.join("worktree");
|
||||||
let git_dir = base.join("common").join("worktrees").join("wt");
|
let git_dir = base.join("common").join("worktrees").join("wt");
|
||||||
std::fs::create_dir_all(&root).unwrap();
|
std::fs::create_dir_all(&root).unwrap();
|
||||||
std::fs::create_dir_all(&git_dir).unwrap();
|
std::fs::create_dir_all(&git_dir).unwrap();
|
||||||
std::fs::write(root.join(".git"), "gitdir: ../common/worktrees/wt\n").unwrap();
|
std::fs::write(root.join(".git"), "gitdir: ../common/worktrees/wt\n").unwrap();
|
||||||
let expected_git = git_dir.canonicalize().unwrap();
|
|
||||||
|
|
||||||
let (_factory, policies, sandboxes) = launch_codex_structured_for_project(&root).await;
|
let (_factory, policies, sandboxes) = launch_codex_structured_for_project(&root).await;
|
||||||
|
|
||||||
assert_codex_git_roots(
|
assert_codex_writable_roots(
|
||||||
&root,
|
|
||||||
policies,
|
policies,
|
||||||
sandboxes,
|
sandboxes,
|
||||||
vec![expected_git.to_string_lossy().into_owned()],
|
vec![root.to_string_lossy().into_owned()],
|
||||||
);
|
);
|
||||||
let _ = std::fs::remove_dir_all(&base);
|
let _ = std::fs::remove_dir_all(&base);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn codex_git_enrichment_canonicalizes_and_avoids_parent_grants() {
|
async fn codex_structured_project_write_grant_uses_launch_root_without_parent_grants() {
|
||||||
let base = fresh_temp_dir("canonical");
|
let base = fresh_temp_dir("canonical");
|
||||||
let real_root = base.join("real");
|
let real_root = base.join("real");
|
||||||
let linked_root = base.join("linked");
|
let linked_root = base.join("linked");
|
||||||
@ -4284,33 +4292,218 @@ async fn codex_git_enrichment_canonicalizes_and_avoids_parent_grants() {
|
|||||||
std::fs::create_dir_all(&linked_root).unwrap();
|
std::fs::create_dir_all(&linked_root).unwrap();
|
||||||
|
|
||||||
let launch_root = if cfg!(unix) { &linked_root } else { &real_root };
|
let launch_root = if cfg!(unix) { &linked_root } else { &real_root };
|
||||||
let expected_git = real_root.join(".git").canonicalize().unwrap();
|
|
||||||
|
|
||||||
let (_factory, policies, sandboxes) = launch_codex_structured_for_project(launch_root).await;
|
let (_factory, policies, sandboxes) = launch_codex_structured_for_project(launch_root).await;
|
||||||
|
|
||||||
assert_codex_git_roots(
|
assert_codex_writable_roots(
|
||||||
launch_root,
|
|
||||||
policies.clone(),
|
policies.clone(),
|
||||||
sandboxes,
|
sandboxes,
|
||||||
vec![expected_git.to_string_lossy().into_owned()],
|
vec![launch_root.to_string_lossy().into_owned()],
|
||||||
);
|
);
|
||||||
let Some(domain::ports::StructuredProviderLaunchPolicy::Codex { writable_roots, .. }) =
|
let Some(domain::ports::StructuredProviderLaunchPolicy::Codex { writable_roots, .. }) =
|
||||||
policies.into_iter().next().unwrap()
|
policies.into_iter().next().unwrap()
|
||||||
else {
|
else {
|
||||||
panic!("expected Codex policy");
|
panic!("expected Codex policy");
|
||||||
};
|
};
|
||||||
assert_eq!(writable_roots.len(), 1, "Git roots must be deduplicated");
|
assert_eq!(
|
||||||
|
writable_roots.len(),
|
||||||
|
1,
|
||||||
|
"permission roots must be deduplicated"
|
||||||
|
);
|
||||||
assert!(
|
assert!(
|
||||||
!writable_roots.contains(&base.to_string_lossy().into_owned()),
|
!writable_roots.contains(&base.to_string_lossy().into_owned()),
|
||||||
"common parent must not be granted"
|
"common parent must not be granted"
|
||||||
);
|
);
|
||||||
assert!(
|
assert!(
|
||||||
!writable_roots.contains(&real_root.to_string_lossy().into_owned()),
|
!writable_roots.contains(&real_root.to_string_lossy().into_owned()),
|
||||||
"repo parent must not be granted"
|
"canonical repo parent must not be granted when the launch root differs"
|
||||||
);
|
);
|
||||||
let _ = std::fs::remove_dir_all(&base);
|
let _ = std::fs::remove_dir_all(&base);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn codex_structured_fallback_deny_with_explicit_write_allow_grants_only_subtree() {
|
||||||
|
let root = fresh_temp_dir("explicit-write-subtree");
|
||||||
|
let allowed = root.join("out");
|
||||||
|
std::fs::create_dir_all(&allowed).unwrap();
|
||||||
|
let profile = codex_profile()
|
||||||
|
.with_projector(ProjectorKey::Codex)
|
||||||
|
.with_structured_adapter(StructuredAdapter::Codex);
|
||||||
|
let (mut launch, agent, _fs, pty, _s) = launch_with_projection(
|
||||||
|
profile,
|
||||||
|
Some(ContextInjectionPlan::File {
|
||||||
|
target: "AGENTS.md".to_owned(),
|
||||||
|
}),
|
||||||
|
Some(full_registry()),
|
||||||
|
Some(perm_doc_with_rules(
|
||||||
|
vec![file_rule(Capability::Write, Effect::Allow, &["out/**"])],
|
||||||
|
Posture::Deny,
|
||||||
|
)),
|
||||||
|
);
|
||||||
|
let factory = FakeStructuredFactory::new(trace(), sid(892));
|
||||||
|
let structured = Arc::new(StructuredSessions::new());
|
||||||
|
launch = launch
|
||||||
|
.with_structured_routing_mode(StructuredRoutingMode::RequireStructured)
|
||||||
|
.with_structured(Arc::new(factory.clone()), Arc::clone(&structured));
|
||||||
|
|
||||||
|
launch
|
||||||
|
.execute(launch_input_with_project_root(agent.id, &root))
|
||||||
|
.await
|
||||||
|
.expect("structured launch");
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
pty.spawns().is_empty(),
|
||||||
|
"structured route must not spawn PTY"
|
||||||
|
);
|
||||||
|
assert_codex_writable_roots(
|
||||||
|
factory.policies(),
|
||||||
|
factory.sandboxes(),
|
||||||
|
vec![allowed.to_string_lossy().into_owned()],
|
||||||
|
);
|
||||||
|
let _ = std::fs::remove_dir_all(&root);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn codex_structured_broad_write_with_subpath_deny_does_not_grant_project_root() {
|
||||||
|
let root = fresh_temp_dir("broad-write-subpath-deny");
|
||||||
|
let profile = codex_profile()
|
||||||
|
.with_projector(ProjectorKey::Codex)
|
||||||
|
.with_structured_adapter(StructuredAdapter::Codex);
|
||||||
|
let (mut launch, agent, _fs, pty, _s) = launch_with_projection(
|
||||||
|
profile,
|
||||||
|
Some(ContextInjectionPlan::File {
|
||||||
|
target: "AGENTS.md".to_owned(),
|
||||||
|
}),
|
||||||
|
Some(full_registry()),
|
||||||
|
Some(perm_doc_with_rules(
|
||||||
|
vec![
|
||||||
|
file_rule(Capability::Write, Effect::Allow, &["**"]),
|
||||||
|
file_rule(Capability::Write, Effect::Deny, &["secret/**"]),
|
||||||
|
],
|
||||||
|
Posture::Deny,
|
||||||
|
)),
|
||||||
|
);
|
||||||
|
let factory = FakeStructuredFactory::new(trace(), sid(893));
|
||||||
|
let structured = Arc::new(StructuredSessions::new());
|
||||||
|
launch = launch
|
||||||
|
.with_structured_routing_mode(StructuredRoutingMode::RequireStructured)
|
||||||
|
.with_structured(Arc::new(factory.clone()), Arc::clone(&structured));
|
||||||
|
|
||||||
|
launch
|
||||||
|
.execute(launch_input_with_project_root(agent.id, &root))
|
||||||
|
.await
|
||||||
|
.expect("structured launch");
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
pty.spawns().is_empty(),
|
||||||
|
"structured route must not spawn PTY"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
factory.policies(),
|
||||||
|
vec![Some(domain::ports::StructuredProviderLaunchPolicy::Codex {
|
||||||
|
sandbox_mode: "read-only".to_owned(),
|
||||||
|
writable_roots: Vec::new(),
|
||||||
|
network_access: false,
|
||||||
|
})],
|
||||||
|
"Codex must not receive project-root write when a subpath write deny cannot be carved out"
|
||||||
|
);
|
||||||
|
let sandbox = factory.sandboxes().remove(0).expect("sandbox plan");
|
||||||
|
assert!(
|
||||||
|
sandbox.allowed.is_empty(),
|
||||||
|
"OS sandbox must not grant a broad root that would include the denied subpath"
|
||||||
|
);
|
||||||
|
let _ = std::fs::remove_dir_all(&root);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn codex_structured_fallback_write_with_subpath_deny_fails_closed() {
|
||||||
|
let root = fresh_temp_dir("fallback-write-subpath-deny");
|
||||||
|
let profile = codex_profile()
|
||||||
|
.with_projector(ProjectorKey::Codex)
|
||||||
|
.with_structured_adapter(StructuredAdapter::Codex);
|
||||||
|
let (mut launch, agent, _fs, pty, _s) = launch_with_projection(
|
||||||
|
profile,
|
||||||
|
Some(ContextInjectionPlan::File {
|
||||||
|
target: "AGENTS.md".to_owned(),
|
||||||
|
}),
|
||||||
|
Some(full_registry()),
|
||||||
|
Some(perm_doc_with_rules(
|
||||||
|
vec![file_rule(Capability::Write, Effect::Deny, &["secret/**"])],
|
||||||
|
Posture::Allow,
|
||||||
|
)),
|
||||||
|
);
|
||||||
|
let factory = FakeStructuredFactory::new(trace(), sid(894));
|
||||||
|
let structured = Arc::new(StructuredSessions::new());
|
||||||
|
launch = launch
|
||||||
|
.with_structured_routing_mode(StructuredRoutingMode::RequireStructured)
|
||||||
|
.with_structured(Arc::new(factory.clone()), Arc::clone(&structured));
|
||||||
|
|
||||||
|
launch
|
||||||
|
.execute(launch_input_with_project_root(agent.id, &root))
|
||||||
|
.await
|
||||||
|
.expect("structured launch");
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
pty.spawns().is_empty(),
|
||||||
|
"structured route must not spawn PTY"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
factory.policies(),
|
||||||
|
vec![Some(domain::ports::StructuredProviderLaunchPolicy::Codex {
|
||||||
|
sandbox_mode: "read-only".to_owned(),
|
||||||
|
writable_roots: Vec::new(),
|
||||||
|
network_access: false,
|
||||||
|
})],
|
||||||
|
"fallback-wide write plus a subpath deny cannot be represented by Codex writable roots"
|
||||||
|
);
|
||||||
|
let _ = std::fs::remove_dir_all(&root);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn codex_structured_disjoint_write_allow_survives_unrelated_subpath_deny() {
|
||||||
|
let root = fresh_temp_dir("disjoint-write-deny");
|
||||||
|
let allowed = root.join("out");
|
||||||
|
std::fs::create_dir_all(&allowed).unwrap();
|
||||||
|
let profile = codex_profile()
|
||||||
|
.with_projector(ProjectorKey::Codex)
|
||||||
|
.with_structured_adapter(StructuredAdapter::Codex);
|
||||||
|
let (mut launch, agent, _fs, pty, _s) = launch_with_projection(
|
||||||
|
profile,
|
||||||
|
Some(ContextInjectionPlan::File {
|
||||||
|
target: "AGENTS.md".to_owned(),
|
||||||
|
}),
|
||||||
|
Some(full_registry()),
|
||||||
|
Some(perm_doc_with_rules(
|
||||||
|
vec![
|
||||||
|
file_rule(Capability::Write, Effect::Allow, &["out/**"]),
|
||||||
|
file_rule(Capability::Write, Effect::Deny, &["secret/**"]),
|
||||||
|
],
|
||||||
|
Posture::Deny,
|
||||||
|
)),
|
||||||
|
);
|
||||||
|
let factory = FakeStructuredFactory::new(trace(), sid(895));
|
||||||
|
let structured = Arc::new(StructuredSessions::new());
|
||||||
|
launch = launch
|
||||||
|
.with_structured_routing_mode(StructuredRoutingMode::RequireStructured)
|
||||||
|
.with_structured(Arc::new(factory.clone()), Arc::clone(&structured));
|
||||||
|
|
||||||
|
launch
|
||||||
|
.execute(launch_input_with_project_root(agent.id, &root))
|
||||||
|
.await
|
||||||
|
.expect("structured launch");
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
pty.spawns().is_empty(),
|
||||||
|
"structured route must not spawn PTY"
|
||||||
|
);
|
||||||
|
assert_codex_writable_roots(
|
||||||
|
factory.policies(),
|
||||||
|
factory.sandboxes(),
|
||||||
|
vec![allowed.to_string_lossy().into_owned()],
|
||||||
|
);
|
||||||
|
let _ = std::fs::remove_dir_all(&root);
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn managed_temp_exposure_enriches_codex_policy_and_os_sandbox_from_same_resolution() {
|
async fn managed_temp_exposure_enriches_codex_policy_and_os_sandbox_from_same_resolution() {
|
||||||
let root = fresh_temp_dir("managed-temp-exposure");
|
let root = fresh_temp_dir("managed-temp-exposure");
|
||||||
@ -4368,18 +4561,24 @@ async fn managed_temp_exposure_enriches_codex_policy_and_os_sandbox_from_same_re
|
|||||||
factory.policies(),
|
factory.policies(),
|
||||||
vec![Some(domain::ports::StructuredProviderLaunchPolicy::Codex {
|
vec![Some(domain::ports::StructuredProviderLaunchPolicy::Codex {
|
||||||
sandbox_mode: "workspace-write".to_owned(),
|
sandbox_mode: "workspace-write".to_owned(),
|
||||||
writable_roots: vec![managed_root.clone()],
|
writable_roots: vec![root.to_string_lossy().into_owned(), managed_root.clone()],
|
||||||
network_access: false,
|
network_access: false,
|
||||||
})],
|
})],
|
||||||
"Codex writable_roots must come from the resolved managed-temp path"
|
"Codex writable_roots must include permission roots plus the resolved managed-temp path"
|
||||||
);
|
);
|
||||||
let sandbox = factory.sandboxes().remove(0).expect("sandbox plan");
|
let sandbox = factory.sandboxes().remove(0).expect("sandbox plan");
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
sandbox.allowed,
|
sandbox.allowed,
|
||||||
vec![domain::sandbox::PathGrant {
|
vec![
|
||||||
|
domain::sandbox::PathGrant {
|
||||||
|
abs_root: root.to_string_lossy().into_owned(),
|
||||||
|
access: domain::sandbox::PathAccess::RW,
|
||||||
|
},
|
||||||
|
domain::sandbox::PathGrant {
|
||||||
abs_root: managed_root,
|
abs_root: managed_root,
|
||||||
access: domain::sandbox::PathAccess::RW,
|
access: domain::sandbox::PathAccess::RW,
|
||||||
}],
|
},
|
||||||
|
],
|
||||||
"OS sandbox grants must match the Codex writable roots"
|
"OS sandbox grants must match the Codex writable roots"
|
||||||
);
|
);
|
||||||
let _ = std::fs::remove_dir_all(&root);
|
let _ = std::fs::remove_dir_all(&root);
|
||||||
|
|||||||
@ -7,7 +7,9 @@ use application::{
|
|||||||
StructuredSessions,
|
StructuredSessions,
|
||||||
};
|
};
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use domain::permission::{EffectivePermissions, Posture};
|
use domain::permission::{
|
||||||
|
Capability, Effect, EffectivePermissions, PathScope, PermissionRule, Posture,
|
||||||
|
};
|
||||||
use domain::ports::{
|
use domain::ports::{
|
||||||
AgentSession, AgentSessionError, AgentSessionFactory, PermissionStore, ReplyStream,
|
AgentSession, AgentSessionError, AgentSessionFactory, PermissionStore, ReplyStream,
|
||||||
RuntimeError, SessionPlan, StoreError, StructuredProviderLaunchPolicy,
|
RuntimeError, SessionPlan, StoreError, StructuredProviderLaunchPolicy,
|
||||||
@ -415,6 +417,15 @@ fn permission_doc(project_defaults: Option<PermissionSet>) -> ProjectPermissions
|
|||||||
ProjectPermissions::new(project_defaults, Vec::new())
|
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]
|
#[tokio::test]
|
||||||
async fn open_then_close_ticket_assistant_sets_policy_injects_context_and_emits_events() {
|
async fn open_then_close_ticket_assistant_sets_policy_injects_context_and_emits_events() {
|
||||||
let issue = issue(7);
|
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 factory = Arc::new(FakeFactory::default());
|
||||||
let system_doc =
|
let system_doc =
|
||||||
ProjectSystemPermissions::new(Some(SystemPermissionSet::new(policy)), Vec::new());
|
ProjectSystemPermissions::new(Some(SystemPermissionSet::new(policy)), Vec::new());
|
||||||
|
let permission_doc = permission_doc(Some(PermissionSet::new(Vec::new(), Posture::Allow)));
|
||||||
let open = OpenTicketAssistant::new(
|
let open = OpenTicketAssistant::new(
|
||||||
Arc::new(FakeIssues {
|
Arc::new(FakeIssues {
|
||||||
issue: issue.clone(),
|
issue: issue.clone(),
|
||||||
@ -656,6 +668,7 @@ async fn codex_ticket_assistant_uses_project_network_permission_for_structured_p
|
|||||||
policies,
|
policies,
|
||||||
events,
|
events,
|
||||||
)
|
)
|
||||||
|
.with_permission_store(Arc::new(FakePermissionStore(permission_doc)))
|
||||||
.with_system_permission_store(Arc::new(FakeSystemPermissionStore(system_doc)));
|
.with_system_permission_store(Arc::new(FakeSystemPermissionStore(system_doc)));
|
||||||
|
|
||||||
open.execute(OpenTicketAssistantInput {
|
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"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user