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:
@ -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]
|
||||
|
||||
Reference in New Issue
Block a user