feat(wave): #119/#122/#131/#132 verts + sprint plugins ESM/persistance #135/#136/#139

État d'intégration confiné à la branche batch. Les tickets #119 (skills →
capacités agent découvrables), #122 (override permissions par défaut), #131
(effort par agent/presets) et #132 (outil MCP d'édition du contexte projet)
sont verts en périmètre. Le sprint plugins multi-fichiers ESM / persistance
plugin-owned (#135/#136/#139) est co-implémenté dans les MÊMES fichiers de
câblage (frontend/src/ports/index.ts, backend/src/lib.rs, domain/ports.rs,
backend/dto.rs), inséparable sans staging interactif (indisponible ici).

Commit unique volontaire : préserve l'état vert QA sans découpe hunk risquée.
NON mergé vers develop tant que #137 (QA e2e plugins) n'est pas vert.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-08-03 11:06:23 +02:00
parent 22c6bd803d
commit 171c6c923c
59 changed files with 3654 additions and 291 deletions

View File

@ -542,6 +542,36 @@ impl ProjectPermissions {
self.agent_permissions(agent_id),
)
}
/// Reports agent-level blanket allows that are shadowed by project-level
/// blanket denies for `agent_id`.
///
/// This is a diagnostic companion to [`Self::resolve_for`]. It does not
/// participate in permission resolution and does not change deny-wins.
#[must_use]
pub fn shadow_for(&self, agent_id: AgentId) -> PermissionShadowReport {
shadow_report(
self.project_defaults.as_ref(),
self.agent_permissions(agent_id),
)
}
}
/// Diagnostic report for agent overrides that cannot loosen the project policy.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PermissionShadowReport {
/// Agent blanket read allow is shadowed by a project blanket read deny.
pub read: bool,
/// Agent blanket write allow is shadowed by a project blanket write deny.
pub write: bool,
/// Agent blanket delete allow is shadowed by a project blanket delete deny.
pub delete: bool,
/// Agent blanket bash allow is shadowed by a project blanket bash deny.
pub execute_bash: bool,
/// Agent fallback choice is looser than the resolved project-tightened
/// fallback.
pub fallback: bool,
}
/// The normalised, flattened output of [`resolve`] — the **sole input** of the
@ -713,6 +743,72 @@ pub fn resolve(
Some(EffectivePermissions { rules, fallback })
}
/// Reports agent-level blanket allows shadowed by project-level blanket denies.
///
/// This is deliberately **not** a general glob-overlap solver. It is shaped to
/// the current UI contract: file capabilities are considered blanket only when
/// the rule has exactly one glob, `"**"`; bash is considered blanket only when
/// the rule has no command matchers. Scoped rules are ignored by this diagnostic
/// even though normal [`resolve`] and decision methods still honour them.
#[must_use]
pub fn shadow_report(
project: Option<&PermissionSet>,
agent: Option<&PermissionSet>,
) -> PermissionShadowReport {
let Some(agent) = agent else {
return PermissionShadowReport::default();
};
let shadowed = |capability| {
blanket_effect(project, capability, BlanketLookupMode::DenyWins) == Some(Effect::Deny)
&& blanket_effect(Some(agent), capability, BlanketLookupMode::AllowWins)
== Some(Effect::Allow)
};
let fallback = resolve(project, Some(agent))
.is_some_and(|resolved| agent.fallback() != resolved.fallback());
PermissionShadowReport {
read: shadowed(Capability::Read),
write: shadowed(Capability::Write),
delete: shadowed(Capability::Delete),
execute_bash: shadowed(Capability::ExecuteBash),
fallback,
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum BlanketLookupMode {
DenyWins,
AllowWins,
}
fn blanket_effect(
set: Option<&PermissionSet>,
capability: Capability,
mode: BlanketLookupMode,
) -> Option<Effect> {
let mut found = None;
for rule in set?.rules() {
if rule.capability() != capability || !is_blanket_rule(rule) {
continue;
}
match (mode, rule.effect()) {
(BlanketLookupMode::DenyWins, Effect::Deny) => return Some(Effect::Deny),
(BlanketLookupMode::AllowWins, Effect::Allow) => return Some(Effect::Allow),
_ => found = Some(rule.effect()),
}
}
found
}
fn is_blanket_rule(rule: &PermissionRule) -> bool {
if rule.capability().is_bash() {
return rule.commands().is_empty();
}
let globs = rule.paths().globs();
globs.len() == 1 && globs[0].pattern() == "**"
}
/// Renders a human-readable Markdown **summary** of the resolved policy, suitable
/// for injection into an agent's context (lot LP4-0).
///
@ -1118,6 +1214,10 @@ mod tests {
PathScope::new(patterns.iter().map(|s| s.to_string())).unwrap()
}
fn blanket_file(capability: Capability, effect: Effect) -> PermissionRule {
PermissionRule::file(capability, effect, path_scope(&["**"])).unwrap()
}
// ---- VO construction & invariants -----------------------------------
#[test]
@ -1398,6 +1498,155 @@ mod tests {
);
}
// ---- shadow diagnostics (ticket #122) -------------------------------
#[test]
fn shadow_report_flags_read_write_delete_bash_when_project_blanket_deny_beats_agent_blanket_allow(
) {
let project = PermissionSet::new(
vec![
blanket_file(Capability::Read, Effect::Deny),
blanket_file(Capability::Write, Effect::Deny),
blanket_file(Capability::Delete, Effect::Deny),
PermissionRule::bash(Effect::Deny, vec![]),
],
Posture::Ask,
);
let agent = PermissionSet::new(
vec![
blanket_file(Capability::Read, Effect::Allow),
blanket_file(Capability::Write, Effect::Allow),
blanket_file(Capability::Delete, Effect::Allow),
PermissionRule::bash(Effect::Allow, vec![]),
],
Posture::Ask,
);
assert_eq!(
shadow_report(Some(&project), Some(&agent)),
PermissionShadowReport {
read: true,
write: true,
delete: true,
execute_bash: true,
fallback: false,
}
);
}
#[test]
fn shadow_report_false_when_project_has_no_deny_rule_for_capability() {
let project = PermissionSet::new(
vec![blanket_file(Capability::Read, Effect::Allow)],
Posture::Ask,
);
let agent = PermissionSet::new(
vec![blanket_file(Capability::Read, Effect::Allow)],
Posture::Ask,
);
assert_eq!(shadow_report(Some(&project), Some(&agent)).read, false);
}
#[test]
fn shadow_report_false_when_agent_rule_is_scoped_not_blanket() {
let project = PermissionSet::new(
vec![blanket_file(Capability::Write, Effect::Deny)],
Posture::Ask,
);
let agent = PermissionSet::new(
vec![
PermissionRule::file(Capability::Write, Effect::Allow, path_scope(&["src/**"]))
.unwrap(),
],
Posture::Ask,
);
assert_eq!(shadow_report(Some(&project), Some(&agent)).write, false);
}
#[test]
fn shadow_report_false_when_agent_also_denies() {
let project = PermissionSet::new(
vec![PermissionRule::bash(Effect::Deny, vec![])],
Posture::Ask,
);
let agent = PermissionSet::new(
vec![PermissionRule::bash(Effect::Deny, vec![])],
Posture::Ask,
);
assert_eq!(
shadow_report(Some(&project), Some(&agent)).execute_bash,
false
);
}
#[test]
fn shadow_report_fallback_true_when_project_fallback_stricter_than_agent_chosen_fallback() {
let project = PermissionSet::new(vec![], Posture::Deny);
let agent = PermissionSet::new(vec![], Posture::Allow);
assert!(shadow_report(Some(&project), Some(&agent)).fallback);
}
#[test]
fn shadow_report_all_false_when_agent_is_none() {
let project = PermissionSet::new(
vec![PermissionRule::bash(Effect::Deny, vec![])],
Posture::Deny,
);
assert_eq!(
shadow_report(Some(&project), None),
PermissionShadowReport::default()
);
}
#[test]
fn shadow_for_matches_free_function_via_project_permissions() {
let agent = AgentId::new_random();
let project = PermissionSet::new(
vec![PermissionRule::bash(Effect::Deny, vec![])],
Posture::Ask,
);
let custom = PermissionSet::new(
vec![PermissionRule::bash(Effect::Allow, vec![])],
Posture::Ask,
);
let doc = ProjectPermissions::new(
Some(project.clone()),
vec![AgentPermissionOverride::new(agent, custom.clone())],
);
assert_eq!(
doc.shadow_for(agent),
shadow_report(Some(&project), Some(&custom))
);
}
#[test]
fn permission_shadow_report_serialises_camel_case() {
let report = PermissionShadowReport {
read: false,
write: false,
delete: false,
execute_bash: true,
fallback: true,
};
assert_eq!(
serde_json::to_value(report).unwrap(),
serde_json::json!({
"read": false,
"write": false,
"delete": false,
"executeBash": true,
"fallback": true
})
);
}
// ---- LP3: ProjectorKey serde + PermissionProjection invariant ------
#[test]