diff --git a/crates/application/src/agent/lifecycle.rs b/crates/application/src/agent/lifecycle.rs index 93ddcfb..85a1dad 100644 --- a/crates/application/src/agent/lifecycle.rs +++ b/crates/application/src/agent/lifecycle.rs @@ -1713,6 +1713,7 @@ impl LaunchAgent { &run_dir, &input.project.root, input.mcp_runtime.as_ref(), + effective_permissions.as_ref(), &mut spec, ) .await?; @@ -2271,6 +2272,7 @@ impl LaunchAgent { run_dir: &ProjectPath, project_root: &ProjectPath, runtime: Option<&McpRuntime>, + eff: Option<&EffectivePermissions>, spec: &mut SpawnSpec, ) -> Result<(), AppError> { let Some(mcp) = &profile.mcp else { @@ -2363,7 +2365,7 @@ impl LaunchAgent { return Ok(()); } let body = if let Some(opencode) = profile.opencode.as_ref() { - opencode_config_json(opencode, project_root.as_str(), runtime).to_string() + opencode_config_json(opencode, project_root.as_str(), runtime, eff).to_string() } else if let Some(provider) = profile.opencode_provider.as_ref() { let api_key = self.resolve_opencode_provider_api_key(provider).await?; opencode_provider_config_json( @@ -2371,6 +2373,7 @@ impl LaunchAgent { &api_key, project_root.as_str(), runtime, + eff, ) .to_string() } else { @@ -2646,6 +2649,7 @@ fn opencode_config_json( config: &domain::profile::OpenCodeConfig, project_root: &str, runtime: Option<&McpRuntime>, + eff: Option<&EffectivePermissions>, ) -> serde_json::Value { let model = config.model.as_str(); let opencode_model = format!("llamacpp/{model}"); @@ -2725,13 +2729,9 @@ fn opencode_config_json( } }), ); - root.insert( - "permission".to_owned(), - serde_json::json!({ - "bash": "ask", - "edit": "ask" - }), - ); + if let Some(permission) = domain::opencode_permission_block(eff) { + root.insert("permission".to_owned(), permission); + } root.insert( "disabled_providers".to_owned(), serde_json::json!(["anthropic", "openai", "gemini", "ollama"]), @@ -2751,6 +2751,7 @@ fn opencode_provider_config_json( api_key: &str, project_root: &str, runtime: Option<&McpRuntime>, + eff: Option<&EffectivePermissions>, ) -> serde_json::Value { let mut root = serde_json::Map::new(); root.insert( @@ -2811,13 +2812,9 @@ fn opencode_provider_config_json( } }), ); - root.insert( - "permission".to_owned(), - serde_json::json!({ - "bash": "ask", - "edit": "ask" - }), - ); + if let Some(permission) = domain::opencode_permission_block(eff) { + root.insert("permission".to_owned(), permission); + } serde_json::Value::Object(root) } @@ -4410,7 +4407,7 @@ command = "idea-mcp" domain::ports::SecretRef::new("secret-ref"), ) .unwrap(); - let body = opencode_provider_config_json(&config, "sk-live", "/project", None); + let body = opencode_provider_config_json(&config, "sk-live", "/project", None, None); let provider = &body["provider"]["anthropic"]; assert_eq!(provider["options"]["apiKey"], "sk-live"); assert!(provider["options"].get("baseURL").is_none()); @@ -4434,7 +4431,7 @@ command = "idea-mcp" .unwrap() .with_custom(custom); - let body = opencode_provider_config_json(&config, "sk-live", "/project", None); + let body = opencode_provider_config_json(&config, "sk-live", "/project", None, None); let provider = &body["provider"]["my-custom"]; assert_eq!(provider["options"]["apiKey"], "sk-live"); assert_eq!(provider["options"]["baseURL"], "https://my-endpoint/v1"); @@ -4458,10 +4455,90 @@ command = "idea-mcp" .unwrap() .with_custom(custom); - let body = opencode_provider_config_json(&config, "sk-live", "/project", None); + let body = opencode_provider_config_json(&config, "sk-live", "/project", None, None); assert_eq!( body["provider"]["my-custom"]["models"]["my-model"]["name"], "My Model" ); } + + // ---- permission projection wiring (ticket #94) ----------------------- + + /// Builds an [`EffectivePermissions`] with the given fallback posture (only + /// the fallback drives the blanket bash/edit verdicts checked here). + fn eff(fallback: domain::permission::Posture) -> EffectivePermissions { + domain::permission::resolve( + Some(&domain::permission::PermissionSet::new(vec![], fallback)), + None, + ) + .unwrap() + } + + #[test] + fn opencode_config_json_omits_permission_key_when_eff_is_none() { + let config = + domain::profile::OpenCodeConfig::new("http://localhost:8080", None, "m", None, None) + .unwrap(); + let body = opencode_config_json(&config, "/project", None, None); + assert!(body.get("permission").is_none()); + } + + #[test] + fn opencode_config_json_projects_allow_and_deny() { + let config = + domain::profile::OpenCodeConfig::new("http://localhost:8080", None, "m", None, None) + .unwrap(); + + use domain::permission::Posture; + let allowed = + opencode_config_json(&config, "/project", None, Some(&eff(Posture::Allow))); + assert_eq!(allowed["permission"]["bash"], "allow"); + assert_eq!(allowed["permission"]["edit"], "allow"); + + let denied = opencode_config_json(&config, "/project", None, Some(&eff(Posture::Deny))); + assert_eq!(denied["permission"]["bash"], "deny"); + assert_eq!(denied["permission"]["edit"], "deny"); + } + + #[test] + fn opencode_provider_config_json_omits_permission_key_when_eff_is_none() { + let config = OpenCodeProviderConfig::new( + "anthropic", + "claude-sonnet-5", + domain::ports::SecretRef::new("secret-ref"), + ) + .unwrap(); + let body = opencode_provider_config_json(&config, "sk-live", "/project", None, None); + assert!(body.get("permission").is_none()); + } + + #[test] + fn opencode_provider_config_json_projects_allow_and_deny() { + let config = OpenCodeProviderConfig::new( + "anthropic", + "claude-sonnet-5", + domain::ports::SecretRef::new("secret-ref"), + ) + .unwrap(); + + let allowed = opencode_provider_config_json( + &config, + "sk-live", + "/project", + None, + Some(&eff(domain::permission::Posture::Allow)), + ); + assert_eq!(allowed["permission"]["bash"], "allow"); + assert_eq!(allowed["permission"]["edit"], "allow"); + + let denied = opencode_provider_config_json( + &config, + "sk-live", + "/project", + None, + Some(&eff(domain::permission::Posture::Deny)), + ); + assert_eq!(denied["permission"]["bash"], "deny"); + assert_eq!(denied["permission"]["edit"], "deny"); + } } diff --git a/crates/domain/src/lib.rs b/crates/domain/src/lib.rs index 1267076..6b924e9 100644 --- a/crates/domain/src/lib.rs +++ b/crates/domain/src/lib.rs @@ -188,10 +188,11 @@ pub use layout::{ pub use events::{DomainEvent, OrchestrationSource}; pub use permission::{ - render_permission_summary, resolve as resolve_permissions, AgentPermissionOverride, Capability, - CommandMatcher, CommandRule, Effect, EffectivePermissions, Glob, PathScope, PermissionError, - PermissionProjection, PermissionProjector, PermissionRule, PermissionSet, Posture, - ProjectPermissions, ProjectedFile, ProjectionContext, ProjectorKey, PERMISSIONS_VERSION, + opencode_permission_block, render_permission_summary, resolve as resolve_permissions, + AgentPermissionOverride, Capability, CommandMatcher, CommandRule, Effect, EffectivePermissions, + Glob, PathScope, PermissionError, PermissionProjection, PermissionProjector, PermissionRule, + PermissionSet, Posture, ProjectPermissions, ProjectedFile, ProjectionContext, ProjectorKey, + PERMISSIONS_VERSION, }; pub use plugin::{ diff --git a/crates/domain/src/permission.rs b/crates/domain/src/permission.rs index 89fbb23..6f0aa64 100644 --- a/crates/domain/src/permission.rs +++ b/crates/domain/src/permission.rs @@ -640,6 +640,36 @@ impl EffectivePermissions { } } +fn posture_to_opencode(posture: Posture) -> &'static str { + match posture { + Posture::Allow => "allow", + Posture::Ask => "ask", + Posture::Deny => "deny", + } +} + +/// Builds the OpenCode `permission` config block from `eff` (ticket #94). +/// +/// OpenCode's schema only exposes `bash`/`edit` keys (no `read`/`delete`: those +/// two capabilities are not expressible in it and are already enforced by the +/// Landlock sandbox). Mapping: `bash` ← [`EffectivePermissions::decide_bash`] on +/// the blanket command `""`; `edit` ← [`EffectivePermissions::decide_file`] on +/// [`Capability::Write`] with the blanket glob `"**"`. +/// +/// `eff == None` (nothing posed) ⇒ [`None`]: the caller must omit the +/// `permission` key entirely, preserving OpenCode's native prompting — the same +/// invariant already enforced for Claude/Codex projection. +#[must_use] +pub fn opencode_permission_block(eff: Option<&EffectivePermissions>) -> Option { + let eff = eff?; + let bash = posture_to_opencode(eff.decide_bash("")); + let edit = posture_to_opencode(eff.decide_file(Capability::Write, "**")); + Some(serde_json::json!({ + "bash": bash, + "edit": edit + })) +} + /// Resolves a project-level and an agent-level [`PermissionSet`] into the /// normalised [`EffectivePermissions`]. /// @@ -1488,4 +1518,54 @@ mod tests { assert!(md.contains("OS-enforced") && md.contains("NOT OS-locked")); assert!(md.contains("**Default posture:** Deny")); } + + // ---- opencode_permission_block (ticket #94) -------------------------- + + #[test] + fn opencode_permission_block_none_when_nothing_posed() { + assert!(opencode_permission_block(None).is_none()); + } + + #[test] + fn opencode_permission_block_maps_bash_from_decide_bash_and_edit_from_write() { + // Distinct rules per capability so bash/edit can't be conflated: bash is + // allowed, Write is denied, fallback stays Ask (never consulted here). + let set = PermissionSet::new( + vec![ + PermissionRule::bash(Effect::Allow, vec![]), + PermissionRule::file(Capability::Write, Effect::Deny, path_scope(&["**"])).unwrap(), + ], + Posture::Ask, + ); + let eff = resolve(Some(&set), None).unwrap(); + let block = opencode_permission_block(Some(&eff)).unwrap(); + assert_eq!(block["bash"], "allow"); + assert_eq!(block["edit"], "deny"); + } + + #[test] + fn opencode_permission_block_falls_back_when_no_rule_matches() { + for (fallback, expected) in [ + (Posture::Allow, "allow"), + (Posture::Ask, "ask"), + (Posture::Deny, "deny"), + ] { + let set = PermissionSet::new(vec![], fallback); + let eff = resolve(Some(&set), None).unwrap(); + let block = opencode_permission_block(Some(&eff)).unwrap(); + assert_eq!(block["bash"], expected); + assert_eq!(block["edit"], expected); + } + } + + #[test] + fn opencode_permission_block_omits_read_and_delete_keys() { + let set = PermissionSet::new(vec![], Posture::Deny); + let eff = resolve(Some(&set), None).unwrap(); + let block = opencode_permission_block(Some(&eff)).unwrap(); + let obj = block.as_object().unwrap(); + assert_eq!(obj.len(), 2, "only bash/edit are opencode-expressible"); + assert!(!obj.contains_key("read")); + assert!(!obj.contains_key("delete")); + } } diff --git a/crates/infrastructure/src/assistant/mod.rs b/crates/infrastructure/src/assistant/mod.rs index 2319c72..7540f7a 100644 --- a/crates/infrastructure/src/assistant/mod.rs +++ b/crates/infrastructure/src/assistant/mod.rs @@ -8,9 +8,9 @@ use domain::ports::{SecretStore, SessionPlan}; use domain::profile::{McpConfigStrategy, OpenCodeProviderConfig, StructuredAdapter}; use domain::{ AgentProfile, AgentRuntime, AssistantContextError, AssistantContextProvider, - ContextInjectionPlan, FileSystem, FsError, Issue, IssueRef, MarkdownDoc, McpServerWiring, - PreparedContext, Project, ProjectPath, RemotePath, RuntimeError, StructuredSessionEnvironment, - StructuredSessionEnvironmentPreparer, + ContextInjectionPlan, EffectivePermissions, FileSystem, FsError, Issue, IssueRef, MarkdownDoc, + McpServerWiring, PreparedContext, Project, ProjectPath, RemotePath, RuntimeError, + StructuredSessionEnvironment, StructuredSessionEnvironmentPreparer, }; use serde_json::{json, Map, Value}; @@ -243,8 +243,14 @@ impl TicketAssistantEnvironmentPreparer { if profile.structured_adapter != Some(StructuredAdapter::OpenCode) { return Ok(()); } + // No `PermissionStore` is wired for ticket-assistant sessions (no + // per-agent policy exists in this path, for any CLI) — `eff` is + // always `None` here, which per `opencode_permission_block`'s + // contract omits the `permission` key entirely and preserves + // OpenCode's native prompting, matching Claude/Codex's behaviour + // on this same path. let body = if let Some(opencode) = profile.opencode.as_ref() { - opencode_config_json(opencode, project.root.as_str(), runtime.as_ref()) + opencode_config_json(opencode, project.root.as_str(), runtime.as_ref(), None) .to_string() } else if let Some(provider) = profile.opencode_provider.as_ref() { let api_key = self.resolve_opencode_provider_api_key(provider).await?; @@ -253,6 +259,7 @@ impl TicketAssistantEnvironmentPreparer { &api_key, project.root.as_str(), runtime.as_ref(), + None, ) .to_string() } else { @@ -337,6 +344,7 @@ fn opencode_config_json( config: &domain::profile::OpenCodeConfig, project_root: &str, runtime: Option<&McpRuntime>, + eff: Option<&EffectivePermissions>, ) -> Value { let model = config.model.as_str(); let opencode_model = format!("llamacpp/{model}"); @@ -403,13 +411,9 @@ fn opencode_config_json( } }), ); - root.insert( - "permission".to_owned(), - json!({ - "bash": "ask", - "edit": "ask" - }), - ); + if let Some(permission) = domain::opencode_permission_block(eff) { + root.insert("permission".to_owned(), permission); + } root.insert( "disabled_providers".to_owned(), json!(["anthropic", "openai", "gemini", "ollama"]), @@ -426,6 +430,7 @@ fn opencode_provider_config_json( api_key: &str, project_root: &str, runtime: Option<&McpRuntime>, + eff: Option<&EffectivePermissions>, ) -> Value { let mut root = Map::new(); root.insert( @@ -491,13 +496,9 @@ fn opencode_provider_config_json( } }), ); - root.insert( - "permission".to_owned(), - json!({ - "bash": "ask", - "edit": "ask" - }), - ); + if let Some(permission) = domain::opencode_permission_block(eff) { + root.insert("permission".to_owned(), permission); + } Value::Object(root) } @@ -636,7 +637,7 @@ mod opencode_provider_config_json_tests { #[test] fn known_provider_emits_only_the_api_key_option() { let config = known_provider_config(); - let body = opencode_provider_config_json(&config, "sk-live", "/project", None); + let body = opencode_provider_config_json(&config, "sk-live", "/project", None, None); let provider = &body["provider"]["anthropic"]; assert_eq!(provider["options"]["apiKey"], "sk-live"); assert!(provider["options"].get("baseURL").is_none()); @@ -647,7 +648,7 @@ mod opencode_provider_config_json_tests { #[test] fn custom_provider_emits_npm_base_url_and_models() { let config = custom_provider_config(); - let body = opencode_provider_config_json(&config, "sk-live", "/project", None); + let body = opencode_provider_config_json(&config, "sk-live", "/project", None, None); let provider = &body["provider"]["my-custom"]; assert_eq!(provider["options"]["apiKey"], "sk-live"); assert_eq!(provider["options"]["baseURL"], "https://my-endpoint/v1"); @@ -666,10 +667,88 @@ mod opencode_provider_config_json_tests { let config = OpenCodeProviderConfig::new("my-custom", "my-model", SecretRef::new("ref")) .unwrap() .with_custom(custom); - let body = opencode_provider_config_json(&config, "sk-live", "/project", None); + let body = opencode_provider_config_json(&config, "sk-live", "/project", None, None); assert_eq!( body["provider"]["my-custom"]["models"]["my-model"]["name"], "My Model" ); } + + // ---- permission projection wiring (ticket #94) ----------------------- + + /// Builds an [`EffectivePermissions`] with the given fallback posture (only + /// the fallback drives the blanket bash/edit verdicts checked here). + fn eff(fallback: domain::permission::Posture) -> EffectivePermissions { + domain::permission::resolve( + Some(&domain::permission::PermissionSet::new(vec![], fallback)), + None, + ) + .unwrap() + } + + #[test] + fn opencode_config_json_omits_permission_key_when_eff_is_none() { + let config = + domain::profile::OpenCodeConfig::new("http://localhost:8080", None, "m", None, None) + .unwrap(); + let body = opencode_config_json(&config, "/project", None, None); + assert!(body.get("permission").is_none()); + } + + #[test] + fn opencode_config_json_projects_allow_and_deny() { + let config = + domain::profile::OpenCodeConfig::new("http://localhost:8080", None, "m", None, None) + .unwrap(); + + let allowed = opencode_config_json( + &config, + "/project", + None, + Some(&eff(domain::permission::Posture::Allow)), + ); + assert_eq!(allowed["permission"]["bash"], "allow"); + assert_eq!(allowed["permission"]["edit"], "allow"); + + let denied = opencode_config_json( + &config, + "/project", + None, + Some(&eff(domain::permission::Posture::Deny)), + ); + assert_eq!(denied["permission"]["bash"], "deny"); + assert_eq!(denied["permission"]["edit"], "deny"); + } + + #[test] + fn provider_config_json_omits_permission_key_when_eff_is_none() { + let config = known_provider_config(); + let body = opencode_provider_config_json(&config, "sk-live", "/project", None, None); + assert!(body.get("permission").is_none()); + } + + #[test] + fn provider_config_json_projects_allow_and_deny() { + let config = known_provider_config(); + + let allowed = opencode_provider_config_json( + &config, + "sk-live", + "/project", + None, + Some(&eff(domain::permission::Posture::Allow)), + ); + assert_eq!(allowed["permission"]["bash"], "allow"); + assert_eq!(allowed["permission"]["edit"], "allow"); + + let denied = opencode_provider_config_json( + &config, + "sk-live", + "/project", + None, + Some(&eff(domain::permission::Posture::Deny)), + ); + assert_eq!(denied["permission"]["bash"], "deny"); + assert_eq!(denied["permission"]["edit"], "deny"); + } }