fix(permissions): validate network access flow
This commit is contained in:
@ -45,7 +45,7 @@ fn update_project_system_permissions_request_deserializes_allow_deny_ask() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolved_agent_system_permissions_dto_carries_runtime_lock_and_read_only_control() {
|
||||
fn resolved_agent_system_permissions_dto_separates_wanted_control_from_runtime_control() {
|
||||
let dto = ResolvedAgentSystemPermissionsDto(ResolvedAgentSystemPermissions {
|
||||
wanted: Some(NetworkPolicy::Allow),
|
||||
effective: NetworkPolicy::Deny,
|
||||
@ -55,8 +55,12 @@ fn resolved_agent_system_permissions_dto_carries_runtime_lock_and_read_only_cont
|
||||
reason: Some("not inspectable".to_owned()),
|
||||
},
|
||||
control: SystemPermissionControl {
|
||||
mode: SystemPermissionControlMode::Editable,
|
||||
reason: None,
|
||||
},
|
||||
runtime_control: SystemPermissionControl {
|
||||
mode: SystemPermissionControlMode::ReadOnly,
|
||||
reason: Some("not editable".to_owned()),
|
||||
reason: Some("runtime not editable".to_owned()),
|
||||
},
|
||||
});
|
||||
|
||||
@ -65,5 +69,6 @@ fn resolved_agent_system_permissions_dto_carries_runtime_lock_and_read_only_cont
|
||||
assert_eq!(value["wanted"], "allow");
|
||||
assert_eq!(value["effective"], "deny");
|
||||
assert_eq!(value["runtimeLock"]["state"], "locked");
|
||||
assert_eq!(value["control"]["mode"], "readOnly");
|
||||
assert_eq!(value["control"]["mode"], "editable");
|
||||
assert_eq!(value["runtimeControl"]["mode"], "readOnly");
|
||||
}
|
||||
|
||||
@ -18,17 +18,17 @@ use domain::ports::{
|
||||
AgentContextStore, AgentRuntime, AgentSessionFactory, ContextInjectionPlan, EventBus,
|
||||
FileSystem, FsError, IdGenerator, MemoryQuery, MemoryRecall, PermissionStore, PreparedContext,
|
||||
ProfileStore, ProjectStore, PtyPort, RemotePath, SecretStore, SessionPlan, SkillStore,
|
||||
SpawnSpec, StoreError,
|
||||
SpawnSpec, StoreError, StructuredProviderLaunchPolicy, SystemPermissionStore,
|
||||
};
|
||||
use domain::profile::{McpConfigStrategy, OpenCodeProviderConfig, StructuredAdapter};
|
||||
use domain::sandbox::{compile_sandbox_plan, SandboxContext, SandboxPlan};
|
||||
use domain::{
|
||||
bound_handoff_summary, Agent, AgentId, AgentManifest, AgentOrigin, AgentProfile,
|
||||
ContextInjection, ConversationId, ConversationParty, DomainEvent, EffectivePermissions,
|
||||
Handoff, HandoffStore, ManifestEntry, MarkdownDoc, MemoryIndexEntry, MemoryType, NodeId,
|
||||
PermissionProjector, ProfileId, Project, ProjectPath, ProjectedFile, ProjectionContext,
|
||||
ProjectorKey, ProviderSessionStore, PtySize, SessionId, SessionKind, SessionStatus, Skill,
|
||||
TerminalSession, HANDOFF_SUMMARY_MAX_CHARS,
|
||||
Handoff, HandoffStore, ManifestEntry, MarkdownDoc, MemoryIndexEntry, MemoryType, NetworkPolicy,
|
||||
NodeId, PermissionProjector, Posture, ProfileId, Project, ProjectPath, ProjectedFile,
|
||||
ProjectionContext, ProjectorKey, ProviderSessionStore, PtySize, SessionId, SessionKind,
|
||||
SessionStatus, Skill, TerminalSession, HANDOFF_SUMMARY_MAX_CHARS,
|
||||
};
|
||||
|
||||
use domain::live_state::WorkStatus;
|
||||
@ -1092,6 +1092,44 @@ 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>,
|
||||
project_root: &ProjectPath,
|
||||
) -> Option<StructuredProviderLaunchPolicy> {
|
||||
if !matches!(profile.structured_adapter, Some(StructuredAdapter::Codex)) {
|
||||
return None;
|
||||
}
|
||||
if select_projector_key(profile) != Some(ProjectorKey::Codex) {
|
||||
return None;
|
||||
}
|
||||
|
||||
let sandbox_mode = codex_structured_sandbox_mode(permissions).to_owned();
|
||||
let writable_roots = if sandbox_mode == "workspace-write" && !project_root.as_str().is_empty() {
|
||||
vec![project_root.as_str().to_owned()]
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
|
||||
Some(StructuredProviderLaunchPolicy::Codex {
|
||||
sandbox_mode,
|
||||
writable_roots,
|
||||
network_access: codex_network_access(network),
|
||||
})
|
||||
}
|
||||
|
||||
/// Launches an agent: resolve profile + context, prepare the invocation, apply
|
||||
/// the context-injection plan, open a PTY at the resolved `cwd`, spawn the CLI.
|
||||
///
|
||||
@ -1123,6 +1161,9 @@ pub struct LaunchAgent {
|
||||
/// historical hardcoded CLI permission seeds. When present and a project or
|
||||
/// agent policy is configured, the resolved policy is projected to the CLI.
|
||||
permissions: Option<Arc<dyn PermissionStore>>,
|
||||
/// Optional system permission store. Network policy is separate from
|
||||
/// filesystem/bash permissions, but it must still be projected into launch env.
|
||||
system_permissions: Option<Arc<dyn SystemPermissionStore>>,
|
||||
/// Fabrique des sessions **structurées** (IA, §17). Injectée au câblage
|
||||
/// (composition root) via [`Self::with_structured`]. `None` ⇒ le routage §17.4
|
||||
/// est désactivé et **tout** profil suit le chemin PTY historique (mode legacy /
|
||||
@ -1200,6 +1241,7 @@ impl LaunchAgent {
|
||||
recall,
|
||||
embedder_suggestion,
|
||||
permissions: None,
|
||||
system_permissions: None,
|
||||
session_factory: None,
|
||||
structured: None,
|
||||
handoffs: None,
|
||||
@ -1268,6 +1310,17 @@ impl LaunchAgent {
|
||||
self
|
||||
}
|
||||
|
||||
/// Injects the project system permission store used to resolve network policy
|
||||
/// at launch time.
|
||||
#[must_use]
|
||||
pub fn with_system_permission_store(
|
||||
mut self,
|
||||
permissions: Arc<dyn SystemPermissionStore>,
|
||||
) -> Self {
|
||||
self.system_permissions = Some(permissions);
|
||||
self
|
||||
}
|
||||
|
||||
/// Branche le provider de **store de sessions provider (lot P8b)** sur ce launcher :
|
||||
/// après un lancement structuré exposant un id de session moteur, range
|
||||
/// `(pair_conversation_id, provider_key) → engine_session_id` dans `providers.json`
|
||||
@ -1653,6 +1706,9 @@ impl LaunchAgent {
|
||||
let effective_permissions = self
|
||||
.resolve_effective_permissions(&input.project, agent.id)
|
||||
.await?;
|
||||
let network_permission = self
|
||||
.resolve_launch_network_permission(&input.project, agent.id)
|
||||
.await?;
|
||||
|
||||
// 3b. (Permission projection moved to step 5c, after the convention file and
|
||||
// the MCP config, so both the structured and PTY paths inherit it — see
|
||||
@ -1752,6 +1808,7 @@ impl LaunchAgent {
|
||||
&run_dir,
|
||||
&input.project.root,
|
||||
effective_permissions.as_ref(),
|
||||
network_permission,
|
||||
&mut spec,
|
||||
)
|
||||
.await?;
|
||||
@ -1771,6 +1828,12 @@ impl LaunchAgent {
|
||||
run_dir: run_dir.as_str(),
|
||||
},
|
||||
);
|
||||
let structured_policy = build_structured_launch_policy(
|
||||
&profile,
|
||||
effective_permissions.as_ref(),
|
||||
network_permission,
|
||||
&input.project.root,
|
||||
);
|
||||
|
||||
// 5b. ── POINT DE ROUTAGE §17.4 : IA structuré vs terminal brut ──
|
||||
// L'intention est explicite sur le launcher : les cellules humaines peuvent
|
||||
@ -1818,6 +1881,7 @@ impl LaunchAgent {
|
||||
size,
|
||||
&spec.env,
|
||||
spec.sandbox.as_ref(),
|
||||
structured_policy.as_ref(),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
@ -1895,6 +1959,7 @@ impl LaunchAgent {
|
||||
size: PtySize,
|
||||
env: &[(String, String)],
|
||||
sandbox: Option<&SandboxPlan>,
|
||||
structured_policy: Option<&StructuredProviderLaunchPolicy>,
|
||||
) -> Result<LaunchAgentOutput, AppError> {
|
||||
// Relaie le plan de sandbox OS (lot LP4-4) à la fabrique : `spec.sandbox`,
|
||||
// déjà compilé (pur, domaine) en step 5d. `None` ⇒ exécution native inchangée.
|
||||
@ -1907,6 +1972,7 @@ impl LaunchAgent {
|
||||
Some(&agent.id.to_string()),
|
||||
env,
|
||||
sandbox,
|
||||
structured_policy,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| AppError::Process(e.to_string()))?;
|
||||
@ -2114,9 +2180,9 @@ impl LaunchAgent {
|
||||
/// - [`ProjectedFile::MergeToml`] ⇒ merge only the **managed** tables/keys into
|
||||
/// any existing file (via the shared TOML helpers), preserving everything else.
|
||||
///
|
||||
/// No-op when: no registry is wired, the profile has no matching projector, or
|
||||
/// the resolved permissions are `None` (the projector returns an empty
|
||||
/// projection — native prompting preserved).
|
||||
/// No-op when: no registry is wired, or the profile has no matching projector.
|
||||
/// `permissions == None` only means no filesystem/bash policy is projected; the
|
||||
/// projector may still apply orthogonal system policy such as network env.
|
||||
///
|
||||
/// # Errors
|
||||
/// [`AppError::FileSystem`] if a plan file cannot be written.
|
||||
@ -2126,6 +2192,7 @@ impl LaunchAgent {
|
||||
run_dir: &ProjectPath,
|
||||
project_root: &ProjectPath,
|
||||
permissions: Option<&EffectivePermissions>,
|
||||
network: Option<NetworkPolicy>,
|
||||
spec: &mut SpawnSpec,
|
||||
) -> Result<(), AppError> {
|
||||
let Some(registry) = &self.projectors else {
|
||||
@ -2142,7 +2209,7 @@ impl LaunchAgent {
|
||||
project_root: project_root.as_str(),
|
||||
run_dir: run_dir.as_str(),
|
||||
};
|
||||
let projection = projector.project(permissions, &ctx);
|
||||
let projection = projector.project(permissions, network, &ctx);
|
||||
|
||||
for file in &projection.files {
|
||||
match file {
|
||||
@ -2172,9 +2239,12 @@ impl LaunchAgent {
|
||||
}
|
||||
|
||||
// Fold the plan's launch args/env into the spec, before the structured/PTY
|
||||
// split so both inherit them.
|
||||
// split so both inherit them. Env is upserted so stale runtime/parent values
|
||||
// cannot shadow the project policy for keys such as Codex network.
|
||||
spec.args.extend(projection.args.iter().cloned());
|
||||
spec.env.extend(projection.env.iter().cloned());
|
||||
for (key, value) in projection.env.iter() {
|
||||
upsert_env(&mut spec.env, key, value);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@ -2208,6 +2278,18 @@ impl LaunchAgent {
|
||||
Ok(doc.resolve_for(agent_id))
|
||||
}
|
||||
|
||||
async fn resolve_launch_network_permission(
|
||||
&self,
|
||||
project: &Project,
|
||||
agent_id: AgentId,
|
||||
) -> Result<Option<NetworkPolicy>, AppError> {
|
||||
let Some(store) = &self.system_permissions else {
|
||||
return Ok(None);
|
||||
};
|
||||
let doc = store.load_system_permissions(project).await?;
|
||||
Ok(doc.wanted_network_for(agent_id))
|
||||
}
|
||||
|
||||
/// Applies the context-injection plan that must happen *before* spawn:
|
||||
/// materialising a `conventionFile` context (write the `.md` to `<cwd>/target`)
|
||||
/// or attaching the on-disk context path to an environment variable. `Args` is
|
||||
@ -2877,6 +2959,14 @@ fn join(base: &ProjectPath, rel: &str) -> String {
|
||||
format!("{b}/{rel}")
|
||||
}
|
||||
|
||||
fn upsert_env(env: &mut Vec<(String, String)>, key: &str, value: &str) {
|
||||
if let Some((_, existing)) = env.iter_mut().find(|(candidate, _)| candidate == key) {
|
||||
*existing = value.to_owned();
|
||||
} else {
|
||||
env.push((key.to_owned(), value.to_owned()));
|
||||
}
|
||||
}
|
||||
|
||||
fn parent_rel(rel: &str) -> Option<&str> {
|
||||
rel.rsplit_once(['/', '\\']).map(|(parent, _)| parent)
|
||||
}
|
||||
|
||||
@ -2,11 +2,15 @@
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use domain::ports::{AgentSessionFactory, SessionPlan, StructuredSessionEnvironmentPreparer};
|
||||
use domain::ports::{
|
||||
AgentSessionFactory, SessionPlan, StructuredProviderLaunchPolicy,
|
||||
StructuredSessionEnvironmentPreparer, SystemPermissionStore,
|
||||
};
|
||||
use domain::profile::StructuredAdapter;
|
||||
use domain::AgentProfile;
|
||||
use domain::{
|
||||
AgentToolPolicy, AgentToolPolicyStore, AssistantContextProvider, DomainEvent, EventBus,
|
||||
IssueRef, IssueStore, ProfileId, ProfileStore, Project, SessionId,
|
||||
IssueRef, IssueStore, NetworkPolicy, ProfileId, ProfileStore, Project, SessionId,
|
||||
};
|
||||
|
||||
use crate::terminal::StructuredSessions;
|
||||
@ -22,6 +26,7 @@ pub struct OpenTicketAssistant {
|
||||
structured: Arc<StructuredSessions>,
|
||||
policies: Arc<dyn AgentToolPolicyStore>,
|
||||
events: Arc<dyn EventBus>,
|
||||
system_permissions: Option<Arc<dyn SystemPermissionStore>>,
|
||||
}
|
||||
|
||||
/// Input for [`OpenTicketAssistant`].
|
||||
@ -68,9 +73,17 @@ impl OpenTicketAssistant {
|
||||
structured,
|
||||
policies,
|
||||
events,
|
||||
system_permissions: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
self.system_permissions = Some(store);
|
||||
self
|
||||
}
|
||||
|
||||
/// Executes the open flow.
|
||||
///
|
||||
/// Reopening an already-live assistant for the same ticket replaces the old
|
||||
@ -136,6 +149,9 @@ impl OpenTicketAssistant {
|
||||
return Err(AppError::from(err));
|
||||
}
|
||||
};
|
||||
let structured_policy = self
|
||||
.build_ticket_assistant_structured_policy(&input.project, &profile, &environment)
|
||||
.await?;
|
||||
let session = self
|
||||
.factory
|
||||
.start(
|
||||
@ -146,6 +162,9 @@ impl OpenTicketAssistant {
|
||||
Some(&requester),
|
||||
&environment.env,
|
||||
None,
|
||||
structured_policy
|
||||
.as_ref()
|
||||
.or(environment.structured_policy.as_ref()),
|
||||
)
|
||||
.await
|
||||
.map_err(|err| {
|
||||
@ -180,6 +199,38 @@ impl OpenTicketAssistant {
|
||||
issue_ref: input.issue_ref,
|
||||
})
|
||||
}
|
||||
|
||||
async fn build_ticket_assistant_structured_policy(
|
||||
&self,
|
||||
project: &Project,
|
||||
profile: &AgentProfile,
|
||||
environment: &domain::ports::StructuredSessionEnvironment,
|
||||
) -> Result<Option<StructuredProviderLaunchPolicy>, AppError> {
|
||||
if profile.structured_adapter != Some(StructuredAdapter::Codex) {
|
||||
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),
|
||||
}))
|
||||
}
|
||||
|
||||
async fn resolve_project_default_network(
|
||||
&self,
|
||||
project: &Project,
|
||||
) -> Result<Option<NetworkPolicy>, AppError> {
|
||||
let Some(store) = &self.system_permissions else {
|
||||
return Ok(None);
|
||||
};
|
||||
let doc = store.load_system_permissions(project).await?;
|
||||
Ok(doc.project_default.and_then(|set| set.network))
|
||||
}
|
||||
}
|
||||
|
||||
fn codex_network_access(network: Option<NetworkPolicy>) -> bool {
|
||||
matches!(network, Some(NetworkPolicy::Allow))
|
||||
}
|
||||
|
||||
fn ticket_assistant_requester(project: &Project, issue_ref: IssueRef) -> String {
|
||||
|
||||
@ -32,7 +32,7 @@ use domain::ports::{
|
||||
ContextInjectionPlan, DirEntry, EventBus, EventStream, ExitStatus, FileSystem, FsError,
|
||||
IdGenerator, MemoryError, MemoryQuery, MemoryRecall, OutputStream, PermissionStore,
|
||||
PreparedContext, ProfileStore, PtyError, PtyHandle, PtyPort, RemotePath, ReplyStream,
|
||||
RuntimeError, SessionPlan, SkillStore, SpawnSpec, StoreError,
|
||||
RuntimeError, SessionPlan, SkillStore, SpawnSpec, StoreError, SystemPermissionStore,
|
||||
};
|
||||
use domain::profile::{
|
||||
AgentProfile, ContextInjection, McpCapability, McpConfigStrategy, McpTransport, OpenCodeConfig,
|
||||
@ -41,8 +41,8 @@ use domain::profile::{
|
||||
use domain::project::{Project, ProjectPath};
|
||||
use domain::remote::RemoteRef;
|
||||
use domain::skill::{Skill, SkillScope};
|
||||
use domain::{MemoryIndexEntry, MemorySlug, MemoryType};
|
||||
use domain::{PermissionSet, ProjectPermissions};
|
||||
use domain::{MemoryIndexEntry, MemorySlug, MemoryType, NetworkPolicy};
|
||||
use domain::{PermissionSet, ProjectPermissions, ProjectSystemPermissions, SystemPermissionSet};
|
||||
use domain::{PtySize, SessionId, SkillId, SkillRef};
|
||||
use uuid::Uuid;
|
||||
|
||||
@ -301,6 +301,7 @@ fn mem_entry(slug: &str, title: &str, hook: &str, kind: MemoryType) -> MemoryInd
|
||||
struct FakeRuntime {
|
||||
trace: Trace,
|
||||
plan: Option<ContextInjectionPlan>,
|
||||
env: Vec<(String, String)>,
|
||||
/// The last [`SessionPlan`] handed to `prepare_invocation`, captured so tests
|
||||
/// can assert the launch resolved the right Assign/Resume/None intention (T4).
|
||||
last_session: Arc<Mutex<Option<SessionPlan>>>,
|
||||
@ -311,9 +312,14 @@ impl FakeRuntime {
|
||||
Self {
|
||||
trace,
|
||||
plan,
|
||||
env: Vec::new(),
|
||||
last_session: Arc::new(Mutex::new(None)),
|
||||
}
|
||||
}
|
||||
fn with_env(mut self, env: Vec<(String, String)>) -> Self {
|
||||
self.env = env;
|
||||
self
|
||||
}
|
||||
/// Shared handle to inspect the captured session plan after a launch.
|
||||
fn session_probe(&self) -> Arc<Mutex<Option<SessionPlan>>> {
|
||||
Arc::clone(&self.last_session)
|
||||
@ -338,7 +344,7 @@ impl AgentRuntime for FakeRuntime {
|
||||
command: profile.command.clone(),
|
||||
args: profile.args.clone(),
|
||||
cwd: cwd.clone(),
|
||||
env: Vec::new(),
|
||||
env: self.env.clone(),
|
||||
context_plan: self.plan.clone(),
|
||||
sandbox: None,
|
||||
})
|
||||
@ -598,6 +604,8 @@ impl AgentSession for FakeSession {
|
||||
struct FakeStructuredFactory {
|
||||
trace: Trace,
|
||||
starts: Arc<Mutex<Vec<ProfileId>>>,
|
||||
envs: Arc<Mutex<Vec<Vec<(String, String)>>>>,
|
||||
policies: Arc<Mutex<Vec<Option<domain::ports::StructuredProviderLaunchPolicy>>>>,
|
||||
next_session: SessionId,
|
||||
}
|
||||
|
||||
@ -606,6 +614,8 @@ impl FakeStructuredFactory {
|
||||
Self {
|
||||
trace,
|
||||
starts: Arc::new(Mutex::new(Vec::new())),
|
||||
envs: Arc::new(Mutex::new(Vec::new())),
|
||||
policies: Arc::new(Mutex::new(Vec::new())),
|
||||
next_session,
|
||||
}
|
||||
}
|
||||
@ -613,6 +623,14 @@ impl FakeStructuredFactory {
|
||||
fn starts(&self) -> Vec<ProfileId> {
|
||||
self.starts.lock().unwrap().clone()
|
||||
}
|
||||
|
||||
fn envs(&self) -> Vec<Vec<(String, String)>> {
|
||||
self.envs.lock().unwrap().clone()
|
||||
}
|
||||
|
||||
fn policies(&self) -> Vec<Option<domain::ports::StructuredProviderLaunchPolicy>> {
|
||||
self.policies.lock().unwrap().clone()
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
@ -630,12 +648,18 @@ impl AgentSessionFactory for FakeStructuredFactory {
|
||||
_requester: Option<&str>,
|
||||
_env: &[(String, String)],
|
||||
_sandbox: Option<&domain::sandbox::SandboxPlan>,
|
||||
structured_policy: Option<&domain::ports::StructuredProviderLaunchPolicy>,
|
||||
) -> Result<Arc<dyn AgentSession>, AgentSessionError> {
|
||||
self.trace
|
||||
.lock()
|
||||
.unwrap()
|
||||
.push("structured.start".to_owned());
|
||||
self.starts.lock().unwrap().push(profile.id);
|
||||
self.envs.lock().unwrap().push(_env.to_vec());
|
||||
self.policies
|
||||
.lock()
|
||||
.unwrap()
|
||||
.push(structured_policy.cloned());
|
||||
Ok(Arc::new(FakeSession {
|
||||
id: self.next_session,
|
||||
conversation_id: Some("engine-session-1".to_owned()),
|
||||
@ -2994,6 +3018,26 @@ impl PermissionStore for FakePermissionStore {
|
||||
}
|
||||
}
|
||||
|
||||
struct FakeSystemPermissionStore(ProjectSystemPermissions);
|
||||
|
||||
#[async_trait]
|
||||
impl SystemPermissionStore for FakeSystemPermissionStore {
|
||||
async fn load_system_permissions(
|
||||
&self,
|
||||
_project: &Project,
|
||||
) -> Result<ProjectSystemPermissions, StoreError> {
|
||||
Ok(self.0.clone())
|
||||
}
|
||||
|
||||
async fn save_system_permissions(
|
||||
&self,
|
||||
_project: &Project,
|
||||
_permissions: &ProjectSystemPermissions,
|
||||
) -> Result<(), StoreError> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Faithful Claude projector double: emits a single owned `Replace` settings file
|
||||
/// whose `defaultMode` mirrors the real posture→mode mapping (Allow→bypass,
|
||||
/// Ask→acceptEdits, Deny→plan), and embeds the project root verbatim. `eff == None`
|
||||
@ -3007,6 +3051,7 @@ impl PermissionProjector for FakeClaudeProjector {
|
||||
fn project(
|
||||
&self,
|
||||
eff: Option<&EffectivePermissions>,
|
||||
_network: Option<NetworkPolicy>,
|
||||
ctx: &ProjectionContext,
|
||||
) -> PermissionProjection {
|
||||
let Some(eff) = eff else {
|
||||
@ -3038,8 +3083,9 @@ impl PermissionProjector for FakeClaudeProjector {
|
||||
/// Faithful Codex projector double: emits a co-owned `MergeToml` over the two
|
||||
/// managed keys + the matching `--sandbox`/`--ask-for-approval` args, both derived
|
||||
/// from the posture exactly like the real projector. Workspace-write postures also
|
||||
/// add the project root as a writable directory (`--add-dir`). `eff == None` ⇒
|
||||
/// empty.
|
||||
/// add the project root as a writable directory (`--add-dir`). `eff == None` ⇒ no
|
||||
/// filesystem/bash projection, but network env remains projected because system
|
||||
/// permissions are orthogonal.
|
||||
struct FakeCodexProjector;
|
||||
|
||||
impl FakeCodexProjector {
|
||||
@ -3059,13 +3105,37 @@ impl PermissionProjector for FakeCodexProjector {
|
||||
fn project(
|
||||
&self,
|
||||
eff: Option<&EffectivePermissions>,
|
||||
network: Option<NetworkPolicy>,
|
||||
ctx: &ProjectionContext,
|
||||
) -> PermissionProjection {
|
||||
let env = {
|
||||
let disabled = match network {
|
||||
Some(NetworkPolicy::Allow) => "0",
|
||||
Some(NetworkPolicy::Deny | NetworkPolicy::Ask) | None => "1",
|
||||
};
|
||||
vec![(
|
||||
"CODEX_SANDBOX_NETWORK_DISABLED".to_owned(),
|
||||
disabled.to_owned(),
|
||||
)]
|
||||
};
|
||||
let network_access = matches!(network, Some(NetworkPolicy::Allow));
|
||||
let network_file = ProjectedFile::MergeToml {
|
||||
rel_path: ".codex/config.toml".to_owned(),
|
||||
managed_tables: vec!["sandbox_workspace_write".to_owned()],
|
||||
managed_keys: Vec::new(),
|
||||
contents: format!("[sandbox_workspace_write]\nnetwork_access = {network_access}\n"),
|
||||
};
|
||||
let Some(eff) = eff else {
|
||||
return PermissionProjection::empty();
|
||||
return PermissionProjection {
|
||||
files: vec![network_file],
|
||||
env,
|
||||
..PermissionProjection::empty()
|
||||
};
|
||||
};
|
||||
let (sandbox, approval) = Self::modes(eff.fallback());
|
||||
let contents = format!("sandbox_mode = \"{sandbox}\"\napproval_policy = \"{approval}\"\n");
|
||||
let contents = format!(
|
||||
"sandbox_mode = \"{sandbox}\"\napproval_policy = \"{approval}\"\n\n[sandbox_workspace_write]\nnetwork_access = {network_access}\n"
|
||||
);
|
||||
let mut args = vec![
|
||||
"--sandbox".to_owned(),
|
||||
sandbox.to_owned(),
|
||||
@ -3079,12 +3149,12 @@ impl PermissionProjector for FakeCodexProjector {
|
||||
PermissionProjection {
|
||||
files: vec![ProjectedFile::MergeToml {
|
||||
rel_path: ".codex/config.toml".to_owned(),
|
||||
managed_tables: Vec::new(),
|
||||
managed_tables: vec!["sandbox_workspace_write".to_owned()],
|
||||
managed_keys: vec!["sandbox_mode".to_owned(), "approval_policy".to_owned()],
|
||||
contents,
|
||||
}],
|
||||
args,
|
||||
env: Vec::new(),
|
||||
env,
|
||||
}
|
||||
}
|
||||
fn owned_replace_paths(&self) -> Vec<String> {
|
||||
@ -3131,6 +3201,16 @@ fn launch_with_projection(
|
||||
plan: Option<ContextInjectionPlan>,
|
||||
registry: Option<Arc<PermissionProjectorRegistry>>,
|
||||
perm_doc: Option<ProjectPermissions>,
|
||||
) -> (LaunchAgent, Agent, FakeFs, FakePty, Arc<TerminalSessions>) {
|
||||
launch_with_projection_and_env(profile, plan, registry, perm_doc, Vec::new())
|
||||
}
|
||||
|
||||
fn launch_with_projection_and_env(
|
||||
profile: AgentProfile,
|
||||
plan: Option<ContextInjectionPlan>,
|
||||
registry: Option<Arc<PermissionProjectorRegistry>>,
|
||||
perm_doc: Option<ProjectPermissions>,
|
||||
env: Vec<(String, String)>,
|
||||
) -> (LaunchAgent, Agent, FakeFs, FakePty, Arc<TerminalSessions>) {
|
||||
let agent = scratch_agent(aid(1), "Backend", "agents/backend.md", profile.id);
|
||||
let contexts = FakeContexts::with_agent(&agent, "# ctx body");
|
||||
@ -3142,7 +3222,7 @@ fn launch_with_projection(
|
||||
let mut launch = LaunchAgent::new(
|
||||
Arc::new(contexts),
|
||||
Arc::new(profiles),
|
||||
Arc::new(FakeRuntime::new(Arc::clone(&tr), plan)),
|
||||
Arc::new(FakeRuntime::new(Arc::clone(&tr), plan).with_env(env)),
|
||||
Arc::new(fs.clone()),
|
||||
Arc::new(pty.clone()),
|
||||
Arc::new(FakeSkills::default()),
|
||||
@ -3270,6 +3350,221 @@ async fn projection_falls_back_to_codex_from_structured_adapter() {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn codex_projection_folds_network_env_into_pty_spawn() {
|
||||
for (policy, disabled) in [
|
||||
(Some(NetworkPolicy::Allow), "0"),
|
||||
(Some(NetworkPolicy::Deny), "1"),
|
||||
(Some(NetworkPolicy::Ask), "1"),
|
||||
(None, "1"),
|
||||
] {
|
||||
let profile = codex_profile().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(Posture::Allow)),
|
||||
);
|
||||
let system_doc =
|
||||
ProjectSystemPermissions::new(Some(SystemPermissionSet::new(policy)), Vec::new());
|
||||
launch =
|
||||
launch.with_system_permission_store(Arc::new(FakeSystemPermissionStore(system_doc)));
|
||||
|
||||
launch
|
||||
.execute(launch_input(agent.id))
|
||||
.await
|
||||
.expect("launch");
|
||||
|
||||
assert_eq!(
|
||||
pty.spawns()[0]
|
||||
.env
|
||||
.iter()
|
||||
.find(|(key, _)| key == "CODEX_SANDBOX_NETWORK_DISABLED")
|
||||
.map(|(_, value)| value.as_str()),
|
||||
Some(disabled),
|
||||
"network policy {policy:?} must be projected into the final child env"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn codex_network_allow_overrides_stale_disabled_env_in_pty_spawn() {
|
||||
let profile = codex_profile().with_structured_adapter(StructuredAdapter::Codex);
|
||||
let (mut launch, agent, _fs, pty, _s) = launch_with_projection_and_env(
|
||||
profile,
|
||||
Some(ContextInjectionPlan::File {
|
||||
target: "AGENTS.md".to_owned(),
|
||||
}),
|
||||
Some(full_registry()),
|
||||
Some(perm_doc(Posture::Allow)),
|
||||
vec![("CODEX_SANDBOX_NETWORK_DISABLED".to_owned(), "1".to_owned())],
|
||||
);
|
||||
let system_doc = ProjectSystemPermissions::new(
|
||||
Some(SystemPermissionSet::new(Some(NetworkPolicy::Allow))),
|
||||
Vec::new(),
|
||||
);
|
||||
launch = launch.with_system_permission_store(Arc::new(FakeSystemPermissionStore(system_doc)));
|
||||
|
||||
launch
|
||||
.execute(launch_input(agent.id))
|
||||
.await
|
||||
.expect("launch");
|
||||
|
||||
let spawns = pty.spawns();
|
||||
let values = spawns[0]
|
||||
.env
|
||||
.iter()
|
||||
.filter(|(key, _)| key == "CODEX_SANDBOX_NETWORK_DISABLED")
|
||||
.map(|(_, value)| value.as_str())
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(
|
||||
values,
|
||||
vec!["0"],
|
||||
"network Allow must replace stale disabled env instead of appending a duplicate"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn codex_network_env_is_projected_without_filesystem_permission_policy() {
|
||||
let profile = codex_profile().with_projector(ProjectorKey::Codex);
|
||||
let (mut launch, agent, fs, pty, _s) = launch_with_projection(
|
||||
profile,
|
||||
Some(ContextInjectionPlan::File {
|
||||
target: "AGENTS.md".to_owned(),
|
||||
}),
|
||||
Some(full_registry()),
|
||||
None,
|
||||
);
|
||||
let system_doc = ProjectSystemPermissions::new(
|
||||
Some(SystemPermissionSet::new(Some(NetworkPolicy::Allow))),
|
||||
Vec::new(),
|
||||
);
|
||||
launch = launch.with_system_permission_store(Arc::new(FakeSystemPermissionStore(system_doc)));
|
||||
|
||||
launch
|
||||
.execute(launch_input(agent.id))
|
||||
.await
|
||||
.expect("launch");
|
||||
|
||||
assert!(
|
||||
fs.writes_ending_with(CODEX_CONFIG_REL)
|
||||
.iter()
|
||||
.any(|(_, contents)| std::str::from_utf8(contents)
|
||||
.unwrap()
|
||||
.contains("[sandbox_workspace_write]\nnetwork_access = true")),
|
||||
"network policy must still update Codex config without filesystem/bash policy"
|
||||
);
|
||||
assert!(
|
||||
!pty.spawns()[0].args.contains(&"--sandbox".to_owned()),
|
||||
"no filesystem/bash policy means no Codex sandbox args are folded"
|
||||
);
|
||||
assert_eq!(
|
||||
pty.spawns()[0]
|
||||
.env
|
||||
.iter()
|
||||
.find(|(key, _)| key == "CODEX_SANDBOX_NETWORK_DISABLED")
|
||||
.map(|(_, value)| value.as_str()),
|
||||
Some("0"),
|
||||
"network Allow must still reach the final child env"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn codex_projection_forwards_network_env_to_structured_session_factory() {
|
||||
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()),
|
||||
None,
|
||||
);
|
||||
let system_doc = ProjectSystemPermissions::new(
|
||||
Some(SystemPermissionSet::new(Some(NetworkPolicy::Allow))),
|
||||
Vec::new(),
|
||||
);
|
||||
launch = launch.with_system_permission_store(Arc::new(FakeSystemPermissionStore(system_doc)));
|
||||
|
||||
let factory = FakeStructuredFactory::new(trace(), sid(888));
|
||||
let structured = Arc::new(StructuredSessions::new());
|
||||
launch = launch
|
||||
.with_structured_routing_mode(StructuredRoutingMode::RequireStructured)
|
||||
.with_structured(Arc::new(factory.clone()), Arc::clone(&structured));
|
||||
|
||||
let out = launch
|
||||
.execute(launch_input(agent.id))
|
||||
.await
|
||||
.expect("structured launch");
|
||||
|
||||
assert!(
|
||||
pty.spawns().is_empty(),
|
||||
"structured route must not spawn PTY"
|
||||
);
|
||||
assert!(
|
||||
out.structured.is_some(),
|
||||
"structured launch output is populated"
|
||||
);
|
||||
assert_eq!(factory.envs().len(), 1, "exactly one structured launch");
|
||||
assert_eq!(
|
||||
factory.envs()[0]
|
||||
.iter()
|
||||
.find(|(key, _)| key == "CODEX_SANDBOX_NETWORK_DISABLED")
|
||||
.map(|(_, value)| value.as_str()),
|
||||
Some("0"),
|
||||
"network Allow must reach the structured session factory env"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn codex_structured_policy_carries_exec_safe_sandbox_roots_and_network() {
|
||||
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(Posture::Deny)),
|
||||
);
|
||||
let system_doc = ProjectSystemPermissions::new(
|
||||
Some(SystemPermissionSet::new(Some(NetworkPolicy::Allow))),
|
||||
Vec::new(),
|
||||
);
|
||||
launch = launch.with_system_permission_store(Arc::new(FakeSystemPermissionStore(system_doc)));
|
||||
|
||||
let factory = FakeStructuredFactory::new(trace(), sid(889));
|
||||
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(agent.id))
|
||||
.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: true,
|
||||
})],
|
||||
"write-denied Codex structured policy must be exec-safe and omit writable roots"
|
||||
);
|
||||
}
|
||||
|
||||
/// (1d) A non-projectable profile (no projector, no CLAUDE.md, no Codex signal) ⇒
|
||||
/// no projection at all, even with a full registry and a posed policy.
|
||||
#[tokio::test]
|
||||
|
||||
@ -45,8 +45,8 @@ use domain::project::{Project, ProjectPath};
|
||||
use domain::remote::RemoteRef;
|
||||
use domain::skill::{Skill, SkillScope};
|
||||
use domain::{
|
||||
LayoutId, LayoutNode, LayoutTree, LeafCell, MemoryIndexEntry, NodeId, PermissionSet, Posture,
|
||||
ProjectPermissions, PtySize, SessionId, SessionKind, SkillId,
|
||||
LayoutId, LayoutNode, LayoutTree, LeafCell, MemoryIndexEntry, NetworkPolicy, NodeId,
|
||||
PermissionSet, Posture, ProjectPermissions, PtySize, SessionId, SessionKind, SkillId,
|
||||
};
|
||||
use uuid::Uuid;
|
||||
|
||||
@ -846,6 +846,7 @@ impl PermissionProjector for FakeClaudeProjector {
|
||||
fn project(
|
||||
&self,
|
||||
eff: Option<&EffectivePermissions>,
|
||||
_network: Option<NetworkPolicy>,
|
||||
ctx: &ProjectionContext,
|
||||
) -> PermissionProjection {
|
||||
if eff.is_none() {
|
||||
@ -880,6 +881,7 @@ impl PermissionProjector for FakeCodexProjector {
|
||||
fn project(
|
||||
&self,
|
||||
eff: Option<&EffectivePermissions>,
|
||||
_network: Option<NetworkPolicy>,
|
||||
_ctx: &ProjectionContext,
|
||||
) -> PermissionProjection {
|
||||
if eff.is_none() {
|
||||
|
||||
@ -1508,6 +1508,7 @@ impl AgentSessionFactory for CompletionFactory {
|
||||
_requester: Option<&str>,
|
||||
_env: &[(String, String)],
|
||||
_sandbox: Option<&domain::sandbox::SandboxPlan>,
|
||||
_structured_policy: Option<&domain::ports::StructuredProviderLaunchPolicy>,
|
||||
) -> Result<Arc<dyn AgentSession>, AgentSessionError> {
|
||||
let id = {
|
||||
let mut n = self.next_id.lock().unwrap();
|
||||
@ -4125,6 +4126,7 @@ impl AgentSessionFactory for CountingFactory {
|
||||
_requester: Option<&str>,
|
||||
_env: &[(String, String)],
|
||||
_sandbox: Option<&domain::sandbox::SandboxPlan>,
|
||||
_structured_policy: Option<&domain::ports::StructuredProviderLaunchPolicy>,
|
||||
) -> Result<Arc<dyn AgentSession>, AgentSessionError> {
|
||||
self.starts.fetch_add(1, Ordering::SeqCst);
|
||||
let id = {
|
||||
|
||||
@ -531,6 +531,7 @@ impl AgentSessionFactory for FakeFactory {
|
||||
_requester: Option<&str>,
|
||||
_env: &[(String, String)],
|
||||
_sandbox: Option<&domain::sandbox::SandboxPlan>,
|
||||
_structured_policy: Option<&domain::ports::StructuredProviderLaunchPolicy>,
|
||||
) -> Result<Arc<dyn AgentSession>, AgentSessionError> {
|
||||
self.starts
|
||||
.lock()
|
||||
@ -1158,7 +1159,16 @@ async fn swap_structured_live_session_shuts_down_then_relaunches() {
|
||||
let cwd = ProjectPath::new(ROOT).unwrap();
|
||||
let session = f
|
||||
.factory
|
||||
.start(&profile, &ctx, &cwd, &SessionPlan::None, None, &[], None)
|
||||
.start(
|
||||
&profile,
|
||||
&ctx,
|
||||
&cwd,
|
||||
&SessionPlan::None,
|
||||
None,
|
||||
&[],
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.expect("seed structured session");
|
||||
f.structured
|
||||
|
||||
@ -54,6 +54,19 @@ impl RuntimePermissionProbe for LockedProbe {
|
||||
}
|
||||
}
|
||||
|
||||
struct UnobservedProbe;
|
||||
|
||||
#[async_trait]
|
||||
impl RuntimePermissionProbe for UnobservedProbe {
|
||||
async fn probe_runtime_permissions(
|
||||
&self,
|
||||
_project: &Project,
|
||||
_agent_id: AgentId,
|
||||
) -> Result<RuntimePermissionSnapshot, RuntimeError> {
|
||||
Ok(RuntimePermissionSnapshot::unobserved())
|
||||
}
|
||||
}
|
||||
|
||||
fn project() -> Project {
|
||||
Project::new(
|
||||
ProjectId::new_random(),
|
||||
@ -125,7 +138,7 @@ async fn update_agent_system_permissions_adds_and_removes_sparse_override() {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn resolve_agent_system_permissions_exposes_locked_runtime_read_only() {
|
||||
async fn resolve_agent_system_permissions_exposes_locked_runtime_separately_from_wanted_control() {
|
||||
let agent = AgentId::new_random();
|
||||
let store = Arc::new(FakeSystemPermissionStore {
|
||||
doc: Mutex::new(ProjectSystemPermissions::new(
|
||||
@ -149,6 +162,43 @@ async fn resolve_agent_system_permissions_exposes_locked_runtime_read_only() {
|
||||
assert_eq!(out.permissions.runtime_lock.state, RuntimeLockState::Locked);
|
||||
assert_eq!(
|
||||
out.permissions.control.mode,
|
||||
SystemPermissionControlMode::Editable
|
||||
);
|
||||
assert_eq!(
|
||||
out.permissions.runtime_control.mode,
|
||||
SystemPermissionControlMode::ReadOnly
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn resolve_agent_system_permissions_unobserved_runtime_does_not_lock_form() {
|
||||
let agent = AgentId::new_random();
|
||||
let store = Arc::new(FakeSystemPermissionStore {
|
||||
doc: Mutex::new(ProjectSystemPermissions::new(
|
||||
Some(SystemPermissionSet::new(Some(NetworkPolicy::Allow))),
|
||||
vec![],
|
||||
)),
|
||||
saves: Mutex::new(0),
|
||||
});
|
||||
let use_case = ResolveAgentSystemPermissions::new(store, Arc::new(UnobservedProbe));
|
||||
|
||||
let out = use_case
|
||||
.execute(ResolveAgentSystemPermissionsInput {
|
||||
project: project(),
|
||||
agent_id: agent,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(out.permissions.wanted, Some(NetworkPolicy::Allow));
|
||||
assert_eq!(out.permissions.effective, NetworkPolicy::Allow);
|
||||
assert_eq!(out.permissions.runtime_lock.state, RuntimeLockState::None);
|
||||
assert_eq!(
|
||||
out.permissions.control.mode,
|
||||
SystemPermissionControlMode::Editable
|
||||
);
|
||||
assert_eq!(
|
||||
out.permissions.runtime_control.mode,
|
||||
SystemPermissionControlMode::ReadOnly
|
||||
);
|
||||
}
|
||||
|
||||
@ -9,15 +9,17 @@ use application::{
|
||||
use async_trait::async_trait;
|
||||
use domain::ports::{
|
||||
AgentSession, AgentSessionError, AgentSessionFactory, ReplyStream, RuntimeError, SessionPlan,
|
||||
StructuredSessionEnvironment, StructuredSessionEnvironmentPreparer,
|
||||
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, PreparedContext,
|
||||
ProfileId, ProfileStore, Project, ProjectId, ProjectPath, RemoteRef, SessionId, StoreError,
|
||||
IssueStatus, IssueStore, IssueStoreError, IssueVersion, MarkdownDoc, NetworkPolicy,
|
||||
PreparedContext, ProfileId, ProfileStore, Project, ProjectId, ProjectPath,
|
||||
ProjectSystemPermissions, RemoteRef, SessionId, SystemPermissionSet,
|
||||
};
|
||||
use uuid::Uuid;
|
||||
|
||||
@ -215,6 +217,7 @@ impl StructuredSessionEnvironmentPreparer for FakeEnvironmentPreparer {
|
||||
"CODEX_HOME".to_owned(),
|
||||
"/tmp/app-data/assistant/tickets/1/7/.codex".to_owned(),
|
||||
)],
|
||||
structured_policy: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
@ -254,6 +257,7 @@ struct FakeFactory {
|
||||
Option<String>,
|
||||
Vec<(String, String)>,
|
||||
Option<domain::SandboxPlan>,
|
||||
Option<StructuredProviderLaunchPolicy>,
|
||||
)>,
|
||||
>,
|
||||
shutdowns: Arc<Mutex<usize>>,
|
||||
@ -274,6 +278,7 @@ impl AgentSessionFactory for FakeFactory {
|
||||
requester: Option<&str>,
|
||||
env: &[(String, String)],
|
||||
sandbox: Option<&domain::SandboxPlan>,
|
||||
structured_policy: Option<&StructuredProviderLaunchPolicy>,
|
||||
) -> Result<Arc<dyn AgentSession>, AgentSessionError> {
|
||||
self.starts.lock().unwrap().push((
|
||||
ctx.clone(),
|
||||
@ -282,6 +287,7 @@ impl AgentSessionFactory for FakeFactory {
|
||||
requester.map(str::to_owned),
|
||||
env.to_vec(),
|
||||
sandbox.cloned(),
|
||||
structured_policy.cloned(),
|
||||
));
|
||||
Ok(Arc::new(FakeSession {
|
||||
id: SessionId::new_random(),
|
||||
@ -320,6 +326,26 @@ impl EventBus for SpyBus {
|
||||
}
|
||||
}
|
||||
|
||||
struct FakeSystemPermissionStore(ProjectSystemPermissions);
|
||||
|
||||
#[async_trait]
|
||||
impl SystemPermissionStore for FakeSystemPermissionStore {
|
||||
async fn load_system_permissions(
|
||||
&self,
|
||||
_project: &Project,
|
||||
) -> Result<ProjectSystemPermissions, StoreError> {
|
||||
Ok(self.0.clone())
|
||||
}
|
||||
|
||||
async fn save_system_permissions(
|
||||
&self,
|
||||
_project: &Project,
|
||||
_permissions: &ProjectSystemPermissions,
|
||||
) -> Result<(), StoreError> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn open_then_close_ticket_assistant_sets_policy_injects_context_and_emits_events() {
|
||||
let issue = issue(7);
|
||||
@ -415,3 +441,56 @@ async fn open_then_close_ticket_assistant_sets_policy_injects_context_and_emits_
|
||||
DomainEvent::TicketAssistantClosed { issue_ref } if issue_ref == issue.reference()
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn codex_ticket_assistant_uses_project_network_permission_for_structured_policy() {
|
||||
for (policy, network_access) in [
|
||||
(Some(NetworkPolicy::Allow), true),
|
||||
(Some(NetworkPolicy::Deny), false),
|
||||
(Some(NetworkPolicy::Ask), false),
|
||||
(None, false),
|
||||
] {
|
||||
let issue = issue(7);
|
||||
let profile_id = ProfileId::from_uuid(Uuid::from_u128(9));
|
||||
let structured = Arc::new(StructuredSessions::new());
|
||||
let policies = Arc::new(FakePolicies::default());
|
||||
let events = Arc::new(SpyBus::default());
|
||||
let factory = Arc::new(FakeFactory::default());
|
||||
let system_doc =
|
||||
ProjectSystemPermissions::new(Some(SystemPermissionSet::new(policy)), Vec::new());
|
||||
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(),
|
||||
structured,
|
||||
policies,
|
||||
events,
|
||||
)
|
||||
.with_system_permission_store(Arc::new(FakeSystemPermissionStore(system_doc)));
|
||||
|
||||
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".to_owned()],
|
||||
network_access,
|
||||
}),
|
||||
"policy {policy:?} must map to the Codex structured network flag"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -1612,6 +1612,9 @@ impl BackendCore {
|
||||
let template_tool_provider: Arc<dyn TemplateToolProvider> = template_tool_binder.clone();
|
||||
let tool_policy_registry = Arc::new(ToolPolicyRegistry::new());
|
||||
let tool_policy_store = Arc::clone(&tool_policy_registry) as Arc<dyn AgentToolPolicyStore>;
|
||||
let system_permission_store = Arc::new(FsSystemPermissionStore::new(Arc::clone(&fs_port)));
|
||||
let system_permission_store_port =
|
||||
Arc::clone(&system_permission_store) as Arc<dyn SystemPermissionStore>;
|
||||
let assistant_context_provider = Arc::new(FsAssistantContextStore::new(
|
||||
Arc::clone(&fs_port),
|
||||
app_data_dir.to_string_lossy().into_owned(),
|
||||
@ -1632,16 +1635,19 @@ impl BackendCore {
|
||||
}),
|
||||
Arc::clone(&secret_store_port),
|
||||
)) as Arc<dyn StructuredSessionEnvironmentPreparer>;
|
||||
let open_ticket_assistant = Arc::new(OpenTicketAssistant::new(
|
||||
Arc::clone(&issue_store_port),
|
||||
Arc::clone(&profile_store_port),
|
||||
assistant_context_provider,
|
||||
assistant_environment,
|
||||
Arc::clone(&session_factory),
|
||||
Arc::clone(&structured_sessions),
|
||||
Arc::clone(&tool_policy_store),
|
||||
Arc::clone(&events_port),
|
||||
));
|
||||
let open_ticket_assistant = Arc::new(
|
||||
OpenTicketAssistant::new(
|
||||
Arc::clone(&issue_store_port),
|
||||
Arc::clone(&profile_store_port),
|
||||
assistant_context_provider,
|
||||
assistant_environment,
|
||||
Arc::clone(&session_factory),
|
||||
Arc::clone(&structured_sessions),
|
||||
Arc::clone(&tool_policy_store),
|
||||
Arc::clone(&events_port),
|
||||
)
|
||||
.with_system_permission_store(Arc::clone(&system_permission_store_port)),
|
||||
);
|
||||
let close_ticket_assistant = Arc::new(CloseTicketAssistant::new(
|
||||
Arc::clone(&structured_sessions),
|
||||
tool_policy_store,
|
||||
@ -1651,9 +1657,6 @@ impl BackendCore {
|
||||
// --- Project permissions (LP1) ---
|
||||
let permission_store = Arc::new(FsPermissionStore::new(Arc::clone(&fs_port)));
|
||||
let permission_store_port = Arc::clone(&permission_store) as Arc<dyn PermissionStore>;
|
||||
let system_permission_store = Arc::new(FsSystemPermissionStore::new(Arc::clone(&fs_port)));
|
||||
let system_permission_store_port =
|
||||
Arc::clone(&system_permission_store) as Arc<dyn SystemPermissionStore>;
|
||||
let runtime_permission_probe =
|
||||
Arc::new(ReadOnlyRuntimePermissionProbe) as Arc<dyn RuntimePermissionProbe>;
|
||||
let get_project_system_permissions = Arc::new(GetProjectSystemPermissions::new(
|
||||
@ -1853,6 +1856,7 @@ impl BackendCore {
|
||||
)
|
||||
.with_structured_routing_mode(StructuredRoutingMode::HumanPtyFallback)
|
||||
.with_permission_store(Arc::clone(&permission_store_port))
|
||||
.with_system_permission_store(Arc::clone(&system_permission_store_port))
|
||||
// Reprise conversationnelle (lot P7) : à chaque (re)lancement, si la cellule
|
||||
// porte une conversation et qu'un handoff existe (`<root>/.ideai/conversations/`),
|
||||
// son résumé est réinjecté dans le convention file. Best-effort, additif :
|
||||
@ -1900,6 +1904,7 @@ impl BackendCore {
|
||||
)
|
||||
.with_structured_routing_mode(StructuredRoutingMode::RequireStructured)
|
||||
.with_permission_store(Arc::clone(&permission_store_port))
|
||||
.with_system_permission_store(Arc::clone(&system_permission_store_port))
|
||||
.with_handoff_provider(
|
||||
Arc::new(AppHandoffProvider) as Arc<dyn application::HandoffProvider>
|
||||
)
|
||||
|
||||
@ -922,11 +922,12 @@ pub trait PermissionProjector: Send + Sync {
|
||||
|
||||
/// Computes the projection.
|
||||
///
|
||||
/// `eff == None` ⇒ the **empty** projection ([`PermissionProjection::empty`]):
|
||||
/// we keep the CLI's native prompting (the product invariant of [`resolve`]).
|
||||
/// `eff == None` means no filesystem/bash policy is projected. Implementations
|
||||
/// may still project orthogonal system permissions, such as network env.
|
||||
fn project(
|
||||
&self,
|
||||
eff: Option<&EffectivePermissions>,
|
||||
network: Option<crate::system_permissions::NetworkPolicy>,
|
||||
ctx: &ProjectionContext,
|
||||
) -> PermissionProjection;
|
||||
|
||||
|
||||
@ -146,6 +146,28 @@ pub struct StructuredSessionEnvironment {
|
||||
pub cwd: ProjectPath,
|
||||
/// Environment variables to pass to the structured session.
|
||||
pub env: Vec<(String, String)>,
|
||||
/// Provider-specific launch policy to pass to the structured session.
|
||||
pub structured_policy: Option<StructuredProviderLaunchPolicy>,
|
||||
}
|
||||
|
||||
/// Provider-specific launch policy for structured/headless sessions.
|
||||
///
|
||||
/// This is intentionally separate from [`PermissionProjection`]: the projection is
|
||||
/// a generic CLI advisory plan (files/argv/env) while structured adapters may need
|
||||
/// a smaller, command-compatible contract. In particular, `codex exec` accepts
|
||||
/// `--sandbox` and `--add-dir`, but must never receive the interactive
|
||||
/// `--ask-for-approval` flag.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum StructuredProviderLaunchPolicy {
|
||||
/// Policy subset supported by `codex exec`.
|
||||
Codex {
|
||||
/// Codex sandbox mode (`read-only`, `workspace-write`, ...).
|
||||
sandbox_mode: String,
|
||||
/// Workspace roots to pass as repeated `--add-dir` values.
|
||||
writable_roots: Vec<String>,
|
||||
/// Whether Codex workspace-write sandbox network access must be enabled.
|
||||
network_access: bool,
|
||||
},
|
||||
}
|
||||
|
||||
/// Errors returned while preparing the IdeA-owned ticket assistant context.
|
||||
@ -1118,6 +1140,13 @@ pub trait AgentSessionFactory: Send + Sync {
|
||||
/// franchit le port en tant que **valeur domaine** ([`crate::sandbox::SandboxPlan`]) ;
|
||||
/// l'enforcer concret reste côté infra (injecté par instance dans la fabrique).
|
||||
///
|
||||
/// `structured_policy` est une projection optionnelle, provider-spécifique et
|
||||
/// compatible avec la commande structurée. Elle ne remplace pas `sandbox` :
|
||||
/// `sandbox` reste l'autorité OS, tandis que cette politique configure le CLI.
|
||||
/// Elle est volontairement séparée de [`PermissionProjector`] pour éviter de
|
||||
/// faire porter au projector le contrat exact de sous-commandes comme
|
||||
/// `codex exec`.
|
||||
///
|
||||
/// # Errors
|
||||
/// [`AgentSessionError::Start`] si la CLI/SDK est indisponible ou le mode
|
||||
/// structuré ne peut s'initialiser.
|
||||
@ -1130,6 +1159,7 @@ pub trait AgentSessionFactory: Send + Sync {
|
||||
requester: Option<&str>,
|
||||
env: &[(String, String)],
|
||||
sandbox: Option<&crate::sandbox::SandboxPlan>,
|
||||
structured_policy: Option<&StructuredProviderLaunchPolicy>,
|
||||
) -> Result<Arc<dyn AgentSession>, AgentSessionError>;
|
||||
}
|
||||
|
||||
|
||||
@ -1,8 +1,8 @@
|
||||
//! System-level agent permissions, distinct from filesystem/bash permissions.
|
||||
//!
|
||||
//! This model stores the policy the user wants IdeA to apply. It does not claim
|
||||
//! that an external assistant runtime can be elevated live: resolution combines
|
||||
//! the wanted policy with a read-only runtime probe.
|
||||
//! that an external assistant runtime can be elevated live: resolution exposes
|
||||
//! the wanted policy separately from the observed runtime state.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
@ -235,11 +235,22 @@ pub struct RuntimePermissionSnapshot {
|
||||
pub effective_network: NetworkPolicy,
|
||||
/// Runtime lock details.
|
||||
pub runtime_lock: RuntimeLock,
|
||||
/// Control state exposed to the UI.
|
||||
pub control: SystemPermissionControl,
|
||||
/// Runtime control state, distinct from editing the persisted wanted policy.
|
||||
pub runtime_control: SystemPermissionControl,
|
||||
}
|
||||
|
||||
impl RuntimePermissionSnapshot {
|
||||
/// Snapshot for an agent with no known active runtime lock.
|
||||
#[must_use]
|
||||
pub fn unobserved() -> Self {
|
||||
const REASON: &str = "No active runtime network permission state is currently observed.";
|
||||
Self {
|
||||
effective_network: NetworkPolicy::Deny,
|
||||
runtime_lock: RuntimeLock::none(),
|
||||
runtime_control: SystemPermissionControl::read_only(REASON),
|
||||
}
|
||||
}
|
||||
|
||||
/// Snapshot for a runtime that IdeA cannot inspect or elevate.
|
||||
#[must_use]
|
||||
pub fn locked_uninspectable() -> Self {
|
||||
@ -248,7 +259,7 @@ impl RuntimePermissionSnapshot {
|
||||
Self {
|
||||
effective_network: NetworkPolicy::Deny,
|
||||
runtime_lock: RuntimeLock::locked("external-runtime", REASON),
|
||||
control: SystemPermissionControl::read_only(REASON),
|
||||
runtime_control: SystemPermissionControl::read_only(REASON),
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -263,8 +274,10 @@ pub struct ResolvedAgentSystemPermissions {
|
||||
pub effective: NetworkPolicy,
|
||||
/// Runtime lock state.
|
||||
pub runtime_lock: RuntimeLock,
|
||||
/// Whether the UI can edit the policy live.
|
||||
/// Whether the UI can edit the persisted wanted policy.
|
||||
pub control: SystemPermissionControl,
|
||||
/// Whether IdeA can inspect or change the active runtime state.
|
||||
pub runtime_control: SystemPermissionControl,
|
||||
}
|
||||
|
||||
/// Resolves wanted system permissions against the runtime snapshot.
|
||||
@ -283,7 +296,8 @@ pub fn resolve_agent_system_permissions(
|
||||
wanted,
|
||||
effective,
|
||||
runtime_lock: runtime.runtime_lock,
|
||||
control: runtime.control,
|
||||
control: SystemPermissionControl::editable(),
|
||||
runtime_control: runtime.runtime_control,
|
||||
}
|
||||
}
|
||||
|
||||
@ -306,7 +320,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn locked_runtime_controls_effective_policy_without_fake_allow() {
|
||||
fn locked_runtime_controls_effective_policy_without_making_wanted_read_only() {
|
||||
let agent = AgentId::new_random();
|
||||
let doc = ProjectSystemPermissions::default();
|
||||
|
||||
@ -319,6 +333,31 @@ mod tests {
|
||||
assert_eq!(resolved.wanted, None);
|
||||
assert_eq!(resolved.effective, NetworkPolicy::Deny);
|
||||
assert_eq!(resolved.runtime_lock.state, RuntimeLockState::Locked);
|
||||
assert_eq!(resolved.control.mode, SystemPermissionControlMode::ReadOnly);
|
||||
assert_eq!(resolved.control.mode, SystemPermissionControlMode::Editable);
|
||||
assert_eq!(
|
||||
resolved.runtime_control.mode,
|
||||
SystemPermissionControlMode::ReadOnly
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unobserved_runtime_has_no_active_lock_and_preserves_wanted_effective_policy() {
|
||||
let agent = AgentId::new_random();
|
||||
let doc = ProjectSystemPermissions::new(
|
||||
Some(SystemPermissionSet::new(Some(NetworkPolicy::Allow))),
|
||||
vec![],
|
||||
);
|
||||
|
||||
let resolved =
|
||||
resolve_agent_system_permissions(&doc, agent, RuntimePermissionSnapshot::unobserved());
|
||||
|
||||
assert_eq!(resolved.wanted, Some(NetworkPolicy::Allow));
|
||||
assert_eq!(resolved.effective, NetworkPolicy::Allow);
|
||||
assert_eq!(resolved.runtime_lock.state, RuntimeLockState::None);
|
||||
assert_eq!(resolved.control.mode, SystemPermissionControlMode::Editable);
|
||||
assert_eq!(
|
||||
resolved.runtime_control.mode,
|
||||
SystemPermissionControlMode::ReadOnly
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -343,6 +343,7 @@ impl AgentSessionFactory for FakeFactory {
|
||||
_requester: Option<&str>,
|
||||
_env: &[(String, String)],
|
||||
_sandbox: Option<&domain::sandbox::SandboxPlan>,
|
||||
_structured_policy: Option<&domain::ports::StructuredProviderLaunchPolicy>,
|
||||
) -> Result<Arc<dyn AgentSession>, AgentSessionError> {
|
||||
Ok(Arc::new(FakeSession {
|
||||
id: SessionId::from_uuid(Uuid::from_u128(7)),
|
||||
@ -398,7 +399,16 @@ async fn fake_factory_supports_only_structured_profiles_and_starts() {
|
||||
};
|
||||
let cwd = ProjectPath::new("/srv/run").unwrap();
|
||||
let session = factory
|
||||
.start(&structured, &ctx, &cwd, &SessionPlan::None, None, &[], None)
|
||||
.start(
|
||||
&structured,
|
||||
&ctx,
|
||||
&cwd,
|
||||
&SessionPlan::None,
|
||||
None,
|
||||
&[],
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.expect("factory starts a session");
|
||||
assert_eq!(session.id(), SessionId::from_uuid(Uuid::from_u128(7)));
|
||||
|
||||
@ -314,7 +314,11 @@ impl StructuredSessionEnvironmentPreparer for TicketAssistantEnvironmentPreparer
|
||||
.await?;
|
||||
self.materialise_mcp(project, profile, &spec.cwd, requester, &mut env)
|
||||
.await?;
|
||||
Ok(StructuredSessionEnvironment { cwd: spec.cwd, env })
|
||||
Ok(StructuredSessionEnvironment {
|
||||
cwd: spec.cwd,
|
||||
env,
|
||||
structured_policy: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -31,6 +31,7 @@ impl PermissionProjector for ClaudePermissionProjector {
|
||||
fn project(
|
||||
&self,
|
||||
eff: Option<&EffectivePermissions>,
|
||||
_network: Option<domain::NetworkPolicy>,
|
||||
ctx: &ProjectionContext,
|
||||
) -> PermissionProjection {
|
||||
// Product invariant: nothing posed ⇒ nothing projected (native prompting).
|
||||
@ -222,7 +223,7 @@ mod tests {
|
||||
/// Projects and returns the parsed `settings.local.json` value, asserting the
|
||||
/// projection's structural contract (1 Replace file, no args/env) along the way.
|
||||
fn project_json(eff: &EffectivePermissions, root: &str) -> Value {
|
||||
let proj = ClaudePermissionProjector.project(Some(eff), &ctx(root, "/run/agent"));
|
||||
let proj = ClaudePermissionProjector.project(Some(eff), None, &ctx(root, "/run/agent"));
|
||||
assert!(proj.args.is_empty(), "Claude projection carries no args");
|
||||
assert!(proj.env.is_empty(), "Claude projection carries no env");
|
||||
assert_eq!(proj.files.len(), 1, "exactly one file projected");
|
||||
@ -248,7 +249,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn project_none_is_empty() {
|
||||
let proj = ClaudePermissionProjector.project(None, &ctx("/proj", "/run"));
|
||||
let proj = ClaudePermissionProjector.project(None, None, &ctx("/proj", "/run"));
|
||||
assert!(proj.files.is_empty());
|
||||
assert!(proj.args.is_empty());
|
||||
assert!(proj.env.is_empty());
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
//! Codex CLI permission projector (lot LP3-2).
|
||||
//!
|
||||
//! Produces the **permission-relevant** part of Codex's `config.toml`
|
||||
//! (`sandbox_mode` / `approval_policy`) plus the matching launch args
|
||||
//! (`sandbox_mode` / `approval_policy` / `sandbox_workspace_write.network_access`) plus the matching launch args
|
||||
//! (`--sandbox` / `--ask-for-approval`) and, for workspace-write postures, the
|
||||
//! project root as an additional writable directory (`--add-dir`). The
|
||||
//! posture→mode derivation is extracted verbatim from the former
|
||||
@ -11,7 +11,7 @@
|
||||
//! Unlike Claude's seed, Codex's `config.toml` is **co-owned** (it also carries the
|
||||
//! `mcp_servers.idea` table and the `projects.*` trust entries, which are MCP/trust
|
||||
//! concerns, not permissions). The projector therefore emits a
|
||||
//! [`ProjectedFile::MergeToml`] limited to the two permission keys it manages —
|
||||
//! [`ProjectedFile::MergeToml`] limited to the permission keys/table it manages —
|
||||
//! everything else in the file is preserved by the fold, and the file is **never**
|
||||
//! deleted on swap (hence an empty `owned_replace_paths`).
|
||||
|
||||
@ -19,6 +19,7 @@ use domain::permission::{
|
||||
EffectivePermissions, PermissionProjection, PermissionProjector, Posture, ProjectedFile,
|
||||
ProjectionContext, ProjectorKey,
|
||||
};
|
||||
use domain::NetworkPolicy;
|
||||
|
||||
use super::toml_string;
|
||||
|
||||
@ -27,10 +28,13 @@ use super::toml_string;
|
||||
/// the file lives at `.codex/config.toml` relative to the run dir.
|
||||
const CONFIG_REL_PATH: &str = ".codex/config.toml";
|
||||
|
||||
/// The two top-level keys this projector manages in `config.toml`. Everything else
|
||||
/// The top-level keys this projector manages in `config.toml`. Everything else
|
||||
/// (MCP table, trust entries, user keys) is preserved by the merge.
|
||||
const MANAGED_KEYS: [&str; 2] = ["sandbox_mode", "approval_policy"];
|
||||
|
||||
/// Codex workspace-write sandbox table owned by IdeA for network projection.
|
||||
const SANDBOX_WORKSPACE_WRITE_TABLE: &str = "sandbox_workspace_write";
|
||||
|
||||
/// Projects [`EffectivePermissions`] into Codex's sandbox/approval config + args.
|
||||
///
|
||||
/// Pure: `project` only computes the plan; the launch path merges the TOML fragment
|
||||
@ -46,23 +50,31 @@ impl PermissionProjector for CodexPermissionProjector {
|
||||
fn project(
|
||||
&self,
|
||||
eff: Option<&EffectivePermissions>,
|
||||
network: Option<NetworkPolicy>,
|
||||
ctx: &ProjectionContext,
|
||||
) -> PermissionProjection {
|
||||
// Product invariant: nothing posed ⇒ nothing projected. Codex keeps its
|
||||
// native sandbox/approval defaults (no args, no managed keys written).
|
||||
// No filesystem/bash policy ⇒ no sandbox/approval projection. Network is
|
||||
// orthogonal and still gets an explicit env override to avoid stale inheritance.
|
||||
let Some(permissions) = eff else {
|
||||
return PermissionProjection::empty();
|
||||
return PermissionProjection {
|
||||
files: vec![codex_network_file(network)],
|
||||
env: codex_network_env(network),
|
||||
..PermissionProjection::empty()
|
||||
};
|
||||
};
|
||||
let sandbox = codex_sandbox_mode(permissions);
|
||||
let approval = codex_approval_policy(permissions);
|
||||
let network_access = codex_network_access(network);
|
||||
|
||||
// Permission-only TOML fragment (escaped exactly like the former
|
||||
// `set_top_level_toml_value`). The mcp_servers/trust tables are NOT a
|
||||
// permission concern and stay with the MCP wiring (LP3-3).
|
||||
let contents = format!(
|
||||
"sandbox_mode = {}\napproval_policy = {}\n",
|
||||
"sandbox_mode = {}\napproval_policy = {}\n\n[{}]\nnetwork_access = {}\n",
|
||||
toml_string(sandbox),
|
||||
toml_string(approval),
|
||||
SANDBOX_WORKSPACE_WRITE_TABLE,
|
||||
network_access,
|
||||
);
|
||||
|
||||
let mut args = vec![
|
||||
@ -79,12 +91,12 @@ impl PermissionProjector for CodexPermissionProjector {
|
||||
PermissionProjection {
|
||||
files: vec![ProjectedFile::MergeToml {
|
||||
rel_path: CONFIG_REL_PATH.to_owned(),
|
||||
managed_tables: Vec::new(),
|
||||
managed_tables: vec![SANDBOX_WORKSPACE_WRITE_TABLE.to_owned()],
|
||||
managed_keys: MANAGED_KEYS.iter().map(|k| (*k).to_owned()).collect(),
|
||||
contents,
|
||||
}],
|
||||
args,
|
||||
env: Vec::new(),
|
||||
env: codex_network_env(network),
|
||||
}
|
||||
}
|
||||
|
||||
@ -94,6 +106,38 @@ impl PermissionProjector for CodexPermissionProjector {
|
||||
}
|
||||
}
|
||||
|
||||
fn codex_network_file(network: Option<NetworkPolicy>) -> ProjectedFile {
|
||||
ProjectedFile::MergeToml {
|
||||
rel_path: CONFIG_REL_PATH.to_owned(),
|
||||
managed_tables: vec![SANDBOX_WORKSPACE_WRITE_TABLE.to_owned()],
|
||||
managed_keys: Vec::new(),
|
||||
contents: format!(
|
||||
"[{}]\nnetwork_access = {}\n",
|
||||
SANDBOX_WORKSPACE_WRITE_TABLE,
|
||||
codex_network_access(network),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
fn codex_network_env(network: Option<NetworkPolicy>) -> Vec<(String, String)> {
|
||||
// Codex inherits the parent environment by default. Always set the variable for
|
||||
// Codex launches so a stale `CODEX_SANDBOX_NETWORK_DISABLED=1` in IdeA's own
|
||||
// environment cannot leak into a newly allowed child.
|
||||
let disabled = if codex_network_access(network) {
|
||||
"0"
|
||||
} else {
|
||||
"1"
|
||||
};
|
||||
vec![(
|
||||
"CODEX_SANDBOX_NETWORK_DISABLED".to_owned(),
|
||||
disabled.to_owned(),
|
||||
)]
|
||||
}
|
||||
|
||||
fn codex_network_access(network: Option<NetworkPolicy>) -> bool {
|
||||
matches!(network, Some(NetworkPolicy::Allow))
|
||||
}
|
||||
|
||||
fn codex_sandbox_mode(permissions: &EffectivePermissions) -> &'static str {
|
||||
match permissions.fallback() {
|
||||
Posture::Deny => "read-only",
|
||||
@ -130,10 +174,33 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn project_none_is_empty() {
|
||||
let proj = CodexPermissionProjector.project(None, &ctx());
|
||||
assert!(proj.files.is_empty());
|
||||
let proj = CodexPermissionProjector.project(None, None, &ctx());
|
||||
assert_eq!(proj.files.len(), 1);
|
||||
assert!(proj.args.is_empty());
|
||||
assert!(proj.env.is_empty());
|
||||
match &proj.files[0] {
|
||||
ProjectedFile::MergeToml {
|
||||
rel_path,
|
||||
managed_tables,
|
||||
managed_keys,
|
||||
contents,
|
||||
} => {
|
||||
assert_eq!(rel_path, CONFIG_REL_PATH);
|
||||
assert_eq!(
|
||||
managed_tables,
|
||||
&vec![SANDBOX_WORKSPACE_WRITE_TABLE.to_owned()]
|
||||
);
|
||||
assert!(managed_keys.is_empty());
|
||||
assert!(
|
||||
contents.contains("[sandbox_workspace_write]\nnetwork_access = false"),
|
||||
"network is denied by default: {contents:?}"
|
||||
);
|
||||
}
|
||||
ProjectedFile::Replace { .. } => panic!("Codex must emit a MergeToml file"),
|
||||
}
|
||||
assert_eq!(
|
||||
proj.env,
|
||||
vec![("CODEX_SANDBOX_NETWORK_DISABLED".to_owned(), "1".to_owned())]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@ -152,8 +219,12 @@ mod tests {
|
||||
(Posture::Ask, "workspace-write", "on-request"),
|
||||
(Posture::Allow, "workspace-write", "never"),
|
||||
] {
|
||||
let proj = CodexPermissionProjector.project(Some(&eff(posture)), &ctx());
|
||||
assert!(proj.env.is_empty(), "Codex projection carries no env");
|
||||
let proj = CodexPermissionProjector.project(Some(&eff(posture)), None, &ctx());
|
||||
assert_eq!(
|
||||
proj.env,
|
||||
vec![("CODEX_SANDBOX_NETWORK_DISABLED".to_owned(), "1".to_owned())],
|
||||
"Codex projection denies network by default"
|
||||
);
|
||||
|
||||
// -- The single MergeToml file, with the two managed permission keys.
|
||||
assert_eq!(proj.files.len(), 1, "exactly one file projected");
|
||||
@ -165,7 +236,10 @@ mod tests {
|
||||
contents,
|
||||
} => {
|
||||
assert_eq!(rel_path, CONFIG_REL_PATH);
|
||||
assert!(managed_tables.is_empty(), "no managed tables");
|
||||
assert_eq!(
|
||||
managed_tables,
|
||||
&vec![SANDBOX_WORKSPACE_WRITE_TABLE.to_owned()]
|
||||
);
|
||||
assert_eq!(
|
||||
managed_keys,
|
||||
&vec!["sandbox_mode".to_owned(), "approval_policy".to_owned()]
|
||||
@ -178,6 +252,10 @@ mod tests {
|
||||
contents.contains(&format!("approval_policy = \"{approval}\"")),
|
||||
"posture {posture:?}: contents={contents:?}"
|
||||
);
|
||||
assert!(
|
||||
contents.contains("[sandbox_workspace_write]\nnetwork_access = false"),
|
||||
"network is denied by default: {contents:?}"
|
||||
);
|
||||
}
|
||||
ProjectedFile::Replace { .. } => panic!("Codex must emit a MergeToml file"),
|
||||
}
|
||||
@ -210,4 +288,34 @@ mod tests {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn network_policy_maps_to_stale_proof_env() {
|
||||
for (network, disabled, network_access) in [
|
||||
(Some(NetworkPolicy::Allow), "0", true),
|
||||
(Some(NetworkPolicy::Deny), "1", false),
|
||||
(Some(NetworkPolicy::Ask), "1", false),
|
||||
(None, "1", false),
|
||||
] {
|
||||
let proj =
|
||||
CodexPermissionProjector.project(Some(&eff(Posture::Allow)), network, &ctx());
|
||||
assert_eq!(
|
||||
proj.env,
|
||||
vec![(
|
||||
"CODEX_SANDBOX_NETWORK_DISABLED".to_owned(),
|
||||
disabled.to_owned()
|
||||
)],
|
||||
"network={network:?}"
|
||||
);
|
||||
match &proj.files[0] {
|
||||
ProjectedFile::MergeToml { contents, .. } => assert!(
|
||||
contents.contains(&format!(
|
||||
"[sandbox_workspace_write]\nnetwork_access = {network_access}"
|
||||
)),
|
||||
"network={network:?}: {contents:?}"
|
||||
),
|
||||
ProjectedFile::Replace { .. } => panic!("Codex must emit a MergeToml file"),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -7,8 +7,7 @@ use async_trait::async_trait;
|
||||
use domain::ports::{RuntimeError, RuntimePermissionProbe};
|
||||
use domain::{AgentId, Project, RuntimePermissionSnapshot};
|
||||
|
||||
/// Conservative probe used when IdeA cannot inspect or pilot runtime network
|
||||
/// permissions.
|
||||
/// Passive probe used when IdeA has no active runtime network telemetry.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct ReadOnlyRuntimePermissionProbe;
|
||||
|
||||
@ -19,6 +18,6 @@ impl RuntimePermissionProbe for ReadOnlyRuntimePermissionProbe {
|
||||
_project: &Project,
|
||||
_agent_id: AgentId,
|
||||
) -> Result<RuntimePermissionSnapshot, RuntimeError> {
|
||||
Ok(RuntimePermissionSnapshot::locked_uninspectable())
|
||||
Ok(RuntimePermissionSnapshot::unobserved())
|
||||
}
|
||||
}
|
||||
|
||||
@ -154,8 +154,12 @@ pub struct CodexExecSession {
|
||||
command: String,
|
||||
/// Répertoire de travail (run dir isolé §14.1).
|
||||
cwd: String,
|
||||
/// Codex CLI sandbox mode passed to `codex exec --sandbox`.
|
||||
sandbox_mode: String,
|
||||
/// Project/workspace roots that must be writable in Codex's CLI sandbox.
|
||||
writable_roots: Vec<String>,
|
||||
/// Structured policy projection of Codex workspace-write sandbox network access.
|
||||
network_access: Option<bool>,
|
||||
/// Variables d'environnement préparées au lancement (ex. `CODEX_HOME` isolé).
|
||||
env: Vec<(String, String)>,
|
||||
/// Id de conversation **du moteur** Codex, capté au premier tour, `None` avant.
|
||||
@ -183,12 +187,44 @@ impl CodexExecSession {
|
||||
env: Vec<(String, String)>,
|
||||
sandbox: Option<SandboxPlan>,
|
||||
sandbox_enforcer: Option<Arc<dyn SandboxEnforcer>>,
|
||||
) -> Self {
|
||||
Self::new_with_policy(
|
||||
id,
|
||||
command,
|
||||
cwd,
|
||||
seed_conversation_id,
|
||||
"workspace-write",
|
||||
writable_roots,
|
||||
None,
|
||||
env,
|
||||
sandbox,
|
||||
sandbox_enforcer,
|
||||
)
|
||||
}
|
||||
|
||||
/// Construit l'adapter avec la politique compatible `codex exec` résolue par
|
||||
/// `LaunchAgent`.
|
||||
#[must_use]
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn new_with_policy(
|
||||
id: SessionId,
|
||||
command: impl Into<String>,
|
||||
cwd: impl Into<String>,
|
||||
seed_conversation_id: Option<String>,
|
||||
sandbox_mode: impl Into<String>,
|
||||
writable_roots: Vec<String>,
|
||||
network_access: Option<bool>,
|
||||
env: Vec<(String, String)>,
|
||||
sandbox: Option<SandboxPlan>,
|
||||
sandbox_enforcer: Option<Arc<dyn SandboxEnforcer>>,
|
||||
) -> Self {
|
||||
Self {
|
||||
id,
|
||||
command: command.into(),
|
||||
cwd: cwd.into(),
|
||||
sandbox_mode: sandbox_mode.into(),
|
||||
writable_roots,
|
||||
network_access,
|
||||
env,
|
||||
conversation_id: Mutex::new(seed_conversation_id),
|
||||
sandbox,
|
||||
@ -217,28 +253,54 @@ impl CodexExecSession {
|
||||
let conversation_id = self.conversation_id.lock().expect("mutex sain").clone();
|
||||
args.push("--json".to_owned());
|
||||
args.push("--skip-git-repo-check".to_owned());
|
||||
args.push("--sandbox".to_owned());
|
||||
args.push("workspace-write".to_owned());
|
||||
for root in self.writable_roots.iter().filter(|root| !root.is_empty()) {
|
||||
args.push("--add-dir".to_owned());
|
||||
args.push(root.clone());
|
||||
if !self.sandbox_mode.trim().is_empty() {
|
||||
args.push("--sandbox".to_owned());
|
||||
args.push(self.sandbox_mode.clone());
|
||||
}
|
||||
if self.sandbox_mode == "workspace-write" {
|
||||
for root in self.writable_roots.iter().filter(|root| !root.is_empty()) {
|
||||
args.push("--add-dir".to_owned());
|
||||
args.push(root.clone());
|
||||
}
|
||||
}
|
||||
if let Some(network_access) = self.network_access {
|
||||
args.push("-c".to_owned());
|
||||
args.push(format!(
|
||||
"sandbox_workspace_write.network_access={network_access}"
|
||||
));
|
||||
}
|
||||
if let Some(id) = conversation_id {
|
||||
args.push("resume".to_owned());
|
||||
args.push(id);
|
||||
}
|
||||
args.push(prompt.to_owned());
|
||||
let mut env = self.env.clone();
|
||||
if let Some(network_access) = self.network_access {
|
||||
upsert_env(
|
||||
&mut env,
|
||||
"CODEX_SANDBOX_NETWORK_DISABLED",
|
||||
if network_access { "0" } else { "1" },
|
||||
);
|
||||
}
|
||||
SpawnLine {
|
||||
command: self.command.clone(),
|
||||
args,
|
||||
cwd: self.cwd.clone(),
|
||||
env: self.env.clone(),
|
||||
env,
|
||||
stdin: None,
|
||||
sandbox: self.sandbox.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn upsert_env(env: &mut Vec<(String, String)>, key: &str, value: &str) {
|
||||
if let Some((_, existing)) = env.iter_mut().find(|(k, _)| k == key) {
|
||||
*existing = value.to_owned();
|
||||
} else {
|
||||
env.push((key.to_owned(), value.to_owned()));
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl AgentSession for CodexExecSession {
|
||||
fn id(&self) -> SessionId {
|
||||
|
||||
@ -14,7 +14,7 @@ use serde_json::Value;
|
||||
|
||||
use domain::ports::{
|
||||
AgentSession, AgentSessionError, AgentSessionFactory, PreparedContext, SessionPlan,
|
||||
ToolInvocationError, ToolInvoker, ToolSpec,
|
||||
StructuredProviderLaunchPolicy, ToolInvocationError, ToolInvoker, ToolSpec,
|
||||
};
|
||||
use domain::profile::{AgentProfile, StructuredAdapter};
|
||||
use domain::project::ProjectPath;
|
||||
@ -139,6 +139,7 @@ impl AgentSessionFactory for StructuredSessionFactory {
|
||||
requester: Option<&str>,
|
||||
env: &[(String, String)],
|
||||
sandbox: Option<&SandboxPlan>,
|
||||
structured_policy: Option<&StructuredProviderLaunchPolicy>,
|
||||
) -> Result<Arc<dyn AgentSession>, AgentSessionError> {
|
||||
let adapter = profile.structured_adapter.ok_or_else(|| {
|
||||
AgentSessionError::Start(format!(
|
||||
@ -171,16 +172,36 @@ impl AgentSessionFactory for StructuredSessionFactory {
|
||||
StructuredAdapter::Claude => Arc::new(ClaudeSdkSession::new(
|
||||
id, command, cwd, seed, plan, enforcer,
|
||||
)),
|
||||
StructuredAdapter::Codex => Arc::new(CodexExecSession::new(
|
||||
id,
|
||||
command,
|
||||
cwd,
|
||||
seed,
|
||||
vec![ctx.project_root.clone()],
|
||||
env.to_vec(),
|
||||
plan,
|
||||
enforcer,
|
||||
)),
|
||||
StructuredAdapter::Codex => {
|
||||
let policy = match structured_policy {
|
||||
Some(StructuredProviderLaunchPolicy::Codex {
|
||||
sandbox_mode,
|
||||
writable_roots,
|
||||
network_access,
|
||||
}) => (
|
||||
sandbox_mode.clone(),
|
||||
writable_roots.clone(),
|
||||
Some(*network_access),
|
||||
),
|
||||
None => (
|
||||
"workspace-write".to_owned(),
|
||||
vec![ctx.project_root.clone()],
|
||||
None,
|
||||
),
|
||||
};
|
||||
Arc::new(CodexExecSession::new_with_policy(
|
||||
id,
|
||||
command,
|
||||
cwd,
|
||||
seed,
|
||||
policy.0,
|
||||
policy.1,
|
||||
policy.2,
|
||||
env.to_vec(),
|
||||
plan,
|
||||
enforcer,
|
||||
))
|
||||
}
|
||||
StructuredAdapter::OpenCode => Arc::new(OpenCodeSession::new(
|
||||
id,
|
||||
profile.command.clone(),
|
||||
|
||||
@ -513,6 +513,7 @@ mod tests {
|
||||
None,
|
||||
&[],
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.expect("start Claude ok");
|
||||
@ -531,6 +532,7 @@ mod tests {
|
||||
None,
|
||||
&[],
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.expect("start Codex ok");
|
||||
@ -563,6 +565,7 @@ mod tests {
|
||||
None,
|
||||
&[],
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.expect("start OpenAI-compatible ok");
|
||||
@ -592,7 +595,16 @@ mod tests {
|
||||
};
|
||||
|
||||
let session = factory
|
||||
.start(&codex, &ctx, &cwd(), &SessionPlan::None, None, &[], None)
|
||||
.start(
|
||||
&codex,
|
||||
&ctx,
|
||||
&cwd(),
|
||||
&SessionPlan::None,
|
||||
None,
|
||||
&[],
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.expect("start Codex ok");
|
||||
let content = drain_final(session.as_ref()).await;
|
||||
@ -629,6 +641,7 @@ mod tests {
|
||||
None,
|
||||
&[],
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.expect("start resume ok");
|
||||
@ -1444,6 +1457,7 @@ mod tests {
|
||||
None,
|
||||
&[],
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.expect("start resume codex");
|
||||
@ -1469,6 +1483,7 @@ mod tests {
|
||||
None,
|
||||
&[],
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.expect("start assign");
|
||||
@ -1501,6 +1516,7 @@ mod tests {
|
||||
None,
|
||||
&[],
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
{
|
||||
@ -1667,8 +1683,7 @@ mod tests {
|
||||
// =====================================================================
|
||||
// DURCISSEMENT QA (lot D3, §17.9 D3 — fix codex 0.137) — autonomie
|
||||
// d'écriture Codex : la commande générée porte EXACTEMENT
|
||||
// [exec, --json, --skip-git-repo-check, --sandbox, workspace-write,
|
||||
// --add-dir, <project-root>, <prompt>]
|
||||
// [exec, --json, --skip-git-repo-check, --sandbox, <mode>, --add-dir, <project-root>, <prompt>]
|
||||
// (`resume <id>` après les options `exec` pour une reprise). Le flag `--ask-for-approval never`
|
||||
// a été RETIRÉ : `codex exec` 0.137 ne le connaît pas (`error: unexpected
|
||||
// argument`) et est déjà non-interactif. Ce test verrouille l'argv exact pour
|
||||
@ -1759,6 +1774,186 @@ mod tests {
|
||||
let _ = std::fs::remove_file(&argv);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn codex_read_only_policy_omits_add_dir_and_approval_flag() {
|
||||
let (cmd, argv) = make_recording_fake(&[
|
||||
r#"{"type":"item.completed","item":{"id":"i0","type":"agent_message","text":"ok"}}"#,
|
||||
]);
|
||||
let session = CodexExecSession::new_with_policy(
|
||||
SessionId::new_random(),
|
||||
cmd.clone(),
|
||||
"/",
|
||||
None,
|
||||
"read-only",
|
||||
vec!["/project/root".to_owned()],
|
||||
Some(false),
|
||||
Vec::new(),
|
||||
None,
|
||||
None,
|
||||
);
|
||||
let _ = session.send("analyse").await.expect("send ok");
|
||||
let recorded = std::fs::read_to_string(&argv).expect("argv");
|
||||
let args: Vec<&str> = recorded.lines().collect();
|
||||
|
||||
assert_eq!(
|
||||
args,
|
||||
vec![
|
||||
"exec",
|
||||
"--json",
|
||||
"--skip-git-repo-check",
|
||||
"--sandbox",
|
||||
"read-only",
|
||||
"-c",
|
||||
"sandbox_workspace_write.network_access=false",
|
||||
"analyse",
|
||||
],
|
||||
"read-only must not carry --add-dir or --ask-for-approval and must disable Codex network, got: {args:?}"
|
||||
);
|
||||
let _ = std::fs::remove_file(&cmd);
|
||||
let _ = std::fs::remove_file(&argv);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn codex_workspace_write_policy_carries_network_config_override() {
|
||||
let (cmd, argv) = make_recording_fake(&[
|
||||
r#"{"type":"item.completed","item":{"id":"i0","type":"agent_message","text":"ok"}}"#,
|
||||
]);
|
||||
let session = CodexExecSession::new_with_policy(
|
||||
SessionId::new_random(),
|
||||
cmd.clone(),
|
||||
"/",
|
||||
None,
|
||||
"workspace-write",
|
||||
vec!["/project/root".to_owned()],
|
||||
Some(true),
|
||||
Vec::new(),
|
||||
None,
|
||||
None,
|
||||
);
|
||||
let _ = session.send("analyse").await.expect("send ok");
|
||||
let recorded = std::fs::read_to_string(&argv).expect("argv");
|
||||
let args: Vec<&str> = recorded.lines().collect();
|
||||
|
||||
assert_eq!(
|
||||
args,
|
||||
vec![
|
||||
"exec",
|
||||
"--json",
|
||||
"--skip-git-repo-check",
|
||||
"--sandbox",
|
||||
"workspace-write",
|
||||
"--add-dir",
|
||||
"/project/root",
|
||||
"-c",
|
||||
"sandbox_workspace_write.network_access=true",
|
||||
"analyse",
|
||||
],
|
||||
"workspace-write Allow must pass the official Codex network override, got: {args:?}"
|
||||
);
|
||||
let _ = std::fs::remove_file(&cmd);
|
||||
let _ = std::fs::remove_file(&argv);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn codex_structured_session_forwards_network_env() {
|
||||
use std::io::Write as _;
|
||||
|
||||
let dir = std::env::current_dir()
|
||||
.expect("cwd")
|
||||
.join("target")
|
||||
.join("test-fakes")
|
||||
.join("session");
|
||||
std::fs::create_dir_all(&dir).expect("fake dir");
|
||||
let bin = dir.join(format!("idea-env-cli-{}", std::process::id()));
|
||||
let env_file = dir.join(format!("idea-env-rec-{}", std::process::id()));
|
||||
let script = format!(
|
||||
"#!/bin/sh\nprintf '%s\\n' \"$CODEX_SANDBOX_NETWORK_DISABLED\" > '{}'\nprintf '%s\\n' '{}'\n",
|
||||
env_file.display(),
|
||||
r#"{"type":"item.completed","item":{"id":"i0","type":"agent_message","text":"ok"}}"#
|
||||
);
|
||||
{
|
||||
let mut f = std::fs::File::create(&bin).expect("create fake");
|
||||
f.write_all(script.as_bytes()).expect("write fake");
|
||||
f.sync_all().expect("sync fake");
|
||||
}
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let mut p = std::fs::metadata(&bin).unwrap().permissions();
|
||||
p.set_mode(0o755);
|
||||
std::fs::set_permissions(&bin, p).unwrap();
|
||||
}
|
||||
super::conformance::wait_until_executable(&bin);
|
||||
|
||||
let session = CodexExecSession::new(
|
||||
SessionId::new_random(),
|
||||
bin.to_string_lossy().into_owned(),
|
||||
"/",
|
||||
None,
|
||||
Vec::new(),
|
||||
vec![("CODEX_SANDBOX_NETWORK_DISABLED".to_owned(), "0".to_owned())],
|
||||
None,
|
||||
None,
|
||||
);
|
||||
let _ = session.send("salut").await.expect("send ok");
|
||||
|
||||
let recorded = std::fs::read_to_string(&env_file).expect("env file");
|
||||
assert_eq!(recorded.trim(), "0");
|
||||
let _ = std::fs::remove_file(&bin);
|
||||
let _ = std::fs::remove_file(&env_file);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn codex_structured_policy_overrides_stale_network_env() {
|
||||
use std::io::Write as _;
|
||||
|
||||
let dir = std::env::current_dir()
|
||||
.expect("cwd")
|
||||
.join("target")
|
||||
.join("test-fakes")
|
||||
.join("session");
|
||||
std::fs::create_dir_all(&dir).expect("fake dir");
|
||||
let bin = dir.join(format!("idea-env-override-cli-{}", std::process::id()));
|
||||
let env_file = dir.join(format!("idea-env-override-rec-{}", std::process::id()));
|
||||
let script = format!(
|
||||
"#!/bin/sh\nprintf '%s\\n' \"$CODEX_SANDBOX_NETWORK_DISABLED\" > '{}'\nprintf '%s\\n' '{}'\n",
|
||||
env_file.display(),
|
||||
r#"{"type":"item.completed","item":{"id":"i0","type":"agent_message","text":"ok"}}"#
|
||||
);
|
||||
{
|
||||
let mut f = std::fs::File::create(&bin).expect("create fake");
|
||||
f.write_all(script.as_bytes()).expect("write fake");
|
||||
f.sync_all().expect("sync fake");
|
||||
}
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let mut p = std::fs::metadata(&bin).unwrap().permissions();
|
||||
p.set_mode(0o755);
|
||||
std::fs::set_permissions(&bin, p).unwrap();
|
||||
}
|
||||
super::conformance::wait_until_executable(&bin);
|
||||
|
||||
let session = CodexExecSession::new_with_policy(
|
||||
SessionId::new_random(),
|
||||
bin.to_string_lossy().into_owned(),
|
||||
"/",
|
||||
None,
|
||||
"workspace-write",
|
||||
Vec::new(),
|
||||
Some(true),
|
||||
vec![("CODEX_SANDBOX_NETWORK_DISABLED".to_owned(), "1".to_owned())],
|
||||
None,
|
||||
None,
|
||||
);
|
||||
let _ = session.send("salut").await.expect("send ok");
|
||||
|
||||
let recorded = std::fs::read_to_string(&env_file).expect("env file");
|
||||
assert_eq!(recorded.trim(), "0");
|
||||
let _ = std::fs::remove_file(&bin);
|
||||
let _ = std::fs::remove_file(&env_file);
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// LS2 — adapter Claude niveau 1 (§21) : `parse_reset_ms` (parseur ISO-8601
|
||||
// À LA MAIN + heuristique secondes/ms + days_from_civil) et le mapping
|
||||
|
||||
@ -478,6 +478,7 @@ async fn structured_sandboxed_turn_preserves_conversation_id() {
|
||||
None,
|
||||
&[],
|
||||
Some(&plan),
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.expect("start sandboxé ok");
|
||||
|
||||
@ -483,6 +483,7 @@ impl AgentSessionFactory for BlockingReplyFactory {
|
||||
_requester: Option<&str>,
|
||||
_env: &[(String, String)],
|
||||
_sandbox: Option<&domain::sandbox::SandboxPlan>,
|
||||
_structured_policy: Option<&domain::ports::StructuredProviderLaunchPolicy>,
|
||||
) -> Result<Arc<dyn AgentSession>, AgentSessionError> {
|
||||
Ok(Arc::new(BlockingReplySession {
|
||||
id: SessionId::from_uuid(Uuid::new_v4()),
|
||||
|
||||
89
crates/infrastructure/tests/system_permission_store.rs
Normal file
89
crates/infrastructure/tests/system_permission_store.rs
Normal file
@ -0,0 +1,89 @@
|
||||
//! Integration tests for [`FsSystemPermissionStore`] against a real temp project.
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
use domain::ids::{AgentId, ProjectId};
|
||||
use domain::ports::{FileSystem, SystemPermissionStore};
|
||||
use domain::project::{Project, ProjectPath};
|
||||
use domain::remote::RemoteRef;
|
||||
use domain::{
|
||||
AgentSystemPermissionOverride, NetworkPolicy, ProjectSystemPermissions, SystemPermissionSet,
|
||||
SYSTEM_PERMISSIONS_VERSION,
|
||||
};
|
||||
use infrastructure::{FsSystemPermissionStore, LocalFileSystem};
|
||||
use uuid::Uuid;
|
||||
|
||||
struct TempDir(PathBuf);
|
||||
|
||||
impl TempDir {
|
||||
fn new() -> Self {
|
||||
let p = std::env::temp_dir().join(format!("idea-system-permissions-{}", Uuid::new_v4()));
|
||||
std::fs::create_dir_all(&p).unwrap();
|
||||
Self(p)
|
||||
}
|
||||
|
||||
fn project_root(&self) -> String {
|
||||
self.0.to_string_lossy().into_owned()
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for TempDir {
|
||||
fn drop(&mut self) {
|
||||
let _ = std::fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
|
||||
fn store() -> FsSystemPermissionStore {
|
||||
let fs: Arc<dyn FileSystem> = Arc::new(LocalFileSystem::new());
|
||||
FsSystemPermissionStore::new(fs)
|
||||
}
|
||||
|
||||
fn project(tmp: &TempDir) -> Project {
|
||||
Project::new(
|
||||
ProjectId::new_random(),
|
||||
"system-permissions",
|
||||
ProjectPath::new(tmp.project_root()).unwrap(),
|
||||
RemoteRef::local(),
|
||||
1_700_000_000_000,
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn missing_system_permissions_file_returns_default_document() {
|
||||
let tmp = TempDir::new();
|
||||
let project = project(&tmp);
|
||||
|
||||
let loaded = store().load_system_permissions(&project).await.unwrap();
|
||||
|
||||
assert_eq!(loaded, ProjectSystemPermissions::default());
|
||||
assert_eq!(loaded.version, SYSTEM_PERMISSIONS_VERSION);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn save_then_load_roundtrips_project_defaults_and_agent_override() {
|
||||
let tmp = TempDir::new();
|
||||
let project = project(&tmp);
|
||||
let agent = AgentId::new_random();
|
||||
let doc = ProjectSystemPermissions::new(
|
||||
Some(SystemPermissionSet::new(Some(NetworkPolicy::Ask))),
|
||||
vec![AgentSystemPermissionOverride::new(
|
||||
agent,
|
||||
SystemPermissionSet::new(Some(NetworkPolicy::Deny)),
|
||||
)],
|
||||
);
|
||||
|
||||
let store = store();
|
||||
store.save_system_permissions(&project, &doc).await.unwrap();
|
||||
|
||||
let loaded = store.load_system_permissions(&project).await.unwrap();
|
||||
assert_eq!(loaded, doc);
|
||||
assert_eq!(loaded.wanted_network_for(agent), Some(NetworkPolicy::Deny));
|
||||
|
||||
let path = tmp.0.join(".ideai").join("system-permissions.json");
|
||||
assert!(
|
||||
path.exists(),
|
||||
"store writes under .ideai/system-permissions.json"
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user