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