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

@ -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::{

View File

@ -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<serde_json::Value> {
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"));
}
}