fix(permissions): validate network access flow

This commit is contained in:
2026-07-26 10:52:46 +02:00
parent 3047dc9195
commit 13fb538880
70 changed files with 4784 additions and 158 deletions

View File

@ -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)
}

View File

@ -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 {

View File

@ -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]

View File

@ -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() {

View File

@ -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 = {

View File

@ -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

View File

@ -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
);
}

View File

@ -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"
);
}
}