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