fix(permissions): validate network access flow
This commit is contained in:
@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user