fix(backend): projeter les EffectivePermissions dans le bloc permission d'opencode.json

Les 4 générateurs opencode.json/opencode_provider.json codaient en dur
{"bash":"ask","edit":"ask"}, ignorant les permissions configurées côté
IdeA pour l'agent. Claude et Codex appliquaient déjà PermissionProjector,
seul OpenCode passait à côté.

Ajoute domain::opencode_permission_block(eff: Option<&EffectivePermissions>)
qui mappe bash ← posture bash effective, edit ← posture Write effective
(Read/Delete non exprimables dans le schéma OpenCode, déjà enforcées par
le sandbox Landlock). eff == None omet la clé permission entièrement,
préservant le prompting natif OpenCode — même invariant que Claude/Codex.

Câble eff jusqu'aux 4 sites d'appel (lifecycle.rs + assistant/mod.rs,
variantes llamacpp et provider cloud). Le chemin ticket-assistant
(assistant/mod.rs) n'a pas de PermissionStore par agent pour l'instant,
donc eff y reste None (comportement inchangé, pas de régression).

Réf ticket #94.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-23 18:14:20 +02:00
parent c9fffd7c47
commit 56631bd3c8
4 changed files with 280 additions and 43 deletions

View File

@ -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");
}
}