É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>
197 lines
5.5 KiB
Rust
197 lines
5.5 KiB
Rust
use std::sync::{Arc, Mutex};
|
|
|
|
use application::{
|
|
ResolveAgentPermissions, ResolveAgentPermissionsInput, UpdateAgentPermissions,
|
|
UpdateAgentPermissionsInput, UpdateProjectPermissions, UpdateProjectPermissionsInput,
|
|
};
|
|
use async_trait::async_trait;
|
|
use domain::ids::{AgentId, ProjectId};
|
|
use domain::ports::{PermissionStore, StoreError};
|
|
use domain::project::{Project, ProjectPath};
|
|
use domain::remote::RemoteRef;
|
|
use domain::{
|
|
Capability, Effect, PermissionRule, PermissionSet, PermissionShadowReport, Posture,
|
|
ProjectPermissions,
|
|
};
|
|
|
|
#[derive(Default)]
|
|
struct FakePermissionStore {
|
|
doc: Mutex<ProjectPermissions>,
|
|
saves: Mutex<usize>,
|
|
}
|
|
|
|
#[async_trait]
|
|
impl PermissionStore for FakePermissionStore {
|
|
async fn load_permissions(&self, _project: &Project) -> Result<ProjectPermissions, StoreError> {
|
|
Ok(self.doc.lock().unwrap().clone())
|
|
}
|
|
|
|
async fn save_permissions(
|
|
&self,
|
|
_project: &Project,
|
|
permissions: &ProjectPermissions,
|
|
) -> Result<(), StoreError> {
|
|
*self.doc.lock().unwrap() = permissions.clone();
|
|
*self.saves.lock().unwrap() += 1;
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
fn project() -> Project {
|
|
Project::new(
|
|
ProjectId::new_random(),
|
|
"permissions",
|
|
ProjectPath::new("/home/me/proj").unwrap(),
|
|
RemoteRef::local(),
|
|
1_700_000_000_000,
|
|
)
|
|
.unwrap()
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn update_project_permissions_replaces_defaults_and_persists() {
|
|
let store = Arc::new(FakePermissionStore::default());
|
|
let use_case = UpdateProjectPermissions::new(store.clone());
|
|
|
|
let out = use_case
|
|
.execute(UpdateProjectPermissionsInput {
|
|
project: project(),
|
|
permissions: Some(PermissionSet::new(vec![], Posture::Ask)),
|
|
})
|
|
.await
|
|
.unwrap();
|
|
|
|
assert_eq!(
|
|
out.permissions.project_defaults.unwrap().fallback(),
|
|
Posture::Ask
|
|
);
|
|
assert_eq!(*store.saves.lock().unwrap(), 1);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn update_agent_permissions_adds_and_removes_sparse_override() {
|
|
let store = Arc::new(FakePermissionStore::default());
|
|
let use_case = UpdateAgentPermissions::new(store.clone());
|
|
let agent = AgentId::new_random();
|
|
|
|
use_case
|
|
.execute(UpdateAgentPermissionsInput {
|
|
project: project(),
|
|
agent_id: agent,
|
|
permissions: Some(PermissionSet::new(vec![], Posture::Deny)),
|
|
})
|
|
.await
|
|
.unwrap();
|
|
|
|
assert_eq!(
|
|
store
|
|
.doc
|
|
.lock()
|
|
.unwrap()
|
|
.agent_permissions(agent)
|
|
.unwrap()
|
|
.fallback(),
|
|
Posture::Deny
|
|
);
|
|
|
|
let out = use_case
|
|
.execute(UpdateAgentPermissionsInput {
|
|
project: project(),
|
|
agent_id: agent,
|
|
permissions: None,
|
|
})
|
|
.await
|
|
.unwrap();
|
|
|
|
assert!(out.permissions.agent_permissions(agent).is_none());
|
|
assert_eq!(*store.saves.lock().unwrap(), 2);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn resolve_agent_permissions_returns_effective_policy() {
|
|
let agent = AgentId::new_random();
|
|
let store = Arc::new(FakePermissionStore {
|
|
doc: Mutex::new(ProjectPermissions::new(
|
|
Some(PermissionSet::new(vec![], Posture::Ask)),
|
|
vec![],
|
|
)),
|
|
saves: Mutex::new(0),
|
|
});
|
|
let use_case = ResolveAgentPermissions::new(store);
|
|
|
|
let out = use_case
|
|
.execute(ResolveAgentPermissionsInput {
|
|
project: project(),
|
|
agent_id: agent,
|
|
})
|
|
.await
|
|
.unwrap();
|
|
|
|
assert_eq!(out.effective.unwrap().fallback(), Posture::Ask);
|
|
assert_eq!(out.shadowed, PermissionShadowReport::default());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn resolve_agent_permissions_reports_shadowed_alongside_unchanged_effective() {
|
|
let agent = AgentId::new_random();
|
|
let store = Arc::new(FakePermissionStore {
|
|
doc: Mutex::new(ProjectPermissions::new(
|
|
Some(PermissionSet::new(
|
|
vec![PermissionRule::bash(Effect::Deny, vec![])],
|
|
Posture::Ask,
|
|
)),
|
|
vec![domain::AgentPermissionOverride::new(
|
|
agent,
|
|
PermissionSet::new(
|
|
vec![PermissionRule::bash(Effect::Allow, vec![])],
|
|
Posture::Ask,
|
|
),
|
|
)],
|
|
)),
|
|
saves: Mutex::new(0),
|
|
});
|
|
let use_case = ResolveAgentPermissions::new(store);
|
|
|
|
let out = use_case
|
|
.execute(ResolveAgentPermissionsInput {
|
|
project: project(),
|
|
agent_id: agent,
|
|
})
|
|
.await
|
|
.unwrap();
|
|
|
|
assert!(out.shadowed.execute_bash);
|
|
assert_eq!(out.effective.unwrap().decide_bash("ls"), Posture::Deny);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn resolve_agent_permissions_shadowed_defaults_when_no_agent_override() {
|
|
let agent = AgentId::new_random();
|
|
let store = Arc::new(FakePermissionStore {
|
|
doc: Mutex::new(ProjectPermissions::new(
|
|
Some(PermissionSet::new(
|
|
vec![PermissionRule::file(
|
|
Capability::Read,
|
|
Effect::Deny,
|
|
domain::PathScope::new(["**".to_owned()]).unwrap(),
|
|
)
|
|
.unwrap()],
|
|
Posture::Deny,
|
|
)),
|
|
vec![],
|
|
)),
|
|
saves: Mutex::new(0),
|
|
});
|
|
let use_case = ResolveAgentPermissions::new(store);
|
|
|
|
let out = use_case
|
|
.execute(ResolveAgentPermissionsInput {
|
|
project: project(),
|
|
agent_id: agent,
|
|
})
|
|
.await
|
|
.unwrap();
|
|
|
|
assert_eq!(out.shadowed, PermissionShadowReport::default());
|
|
}
|