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