feat(backend,frontend): lot utilisateur #346 #347 #348 — exécution workspace plugin contrôlée par l'hôte, réordonnancement des onglets projet par glisser-déposer, Final restauré après stop d'agent CLI custom (QA verte)

#346 — capacités plugin SCM: le manifeste permissions.workspaceExec
(contrat SDK 8ff0f47) est appliqué côté hôte — domain PluginWorkspaceExec
Rule/Effect + audit sink, port WorkspaceToolRunner, adapter
LocalWorkspaceToolRunner sandboxé (concurrence 4, timeout, bornes
stdout/stderr, résolution bare executable sans shell), service
application + DTO backend + commandes app-tauri, gateway frontend
execTool exposée au runtime plugin — fin des lectures ad hoc du store
interne .git par les plugins.
#347 — onglets projet réordonnables par glisser-déposer (Tabs DnD,
onReorder, persistance de l'ordre via useProjects).
#348 — stop d'agent CLI custom: le message Final vert réapparaît au tour
suivant (override terminal stop armé correctement, reset de
l'interaction, refresh de la délégation requester).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-09-13 12:23:44 +02:00
parent 2b0d82efa3
commit 561c50f6ed
28 changed files with 1761 additions and 51 deletions

View File

@ -289,6 +289,132 @@ pub enum PluginCapability {
Tooling,
}
/// Declared impact of one generic workspace tool invocation.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum PluginWorkspaceExecEffect {
/// The command is expected to inspect state only.
Read,
/// The command may modify project-owned files.
Write,
/// The command may use the network.
Network,
/// The command may perform destructive project-owned mutations.
Destructive,
}
/// One manifest-declared allow rule for short, hidden workspace tool execution.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PluginWorkspaceExecRule {
/// Bare executable name resolved by the host without a shell.
pub command: String,
/// Exact argv tokens. `*` matches one token, or the remaining suffix when last.
#[serde(default)]
pub args: Vec<String>,
/// Allowed normalized project-relative cwd. `*` allows any confined cwd.
#[serde(default = "default_workspace_exec_cwd")]
pub cwd: String,
/// Declared impact used by host policy and audit.
pub effect: PluginWorkspaceExecEffect,
}
fn default_workspace_exec_cwd() -> String {
".".to_owned()
}
impl PluginWorkspaceExecRule {
/// Whether this rule authorizes the exact executable, argv, and confined cwd.
#[must_use]
pub fn matches(&self, command: &str, args: &[String], cwd: &str) -> bool {
self.command == command
&& (self.cwd == "*" || self.cwd == cwd)
&& workspace_exec_args_match(&self.args, args)
}
}
fn workspace_exec_args_match(pattern: &[String], args: &[String]) -> bool {
let Some((last, prefix)) = pattern.split_last() else {
return args.is_empty();
};
if last == "*" {
return args.len() >= prefix.len()
&& prefix
.iter()
.zip(args)
.all(|(expected, actual)| expected == "*" || expected == actual);
}
pattern.len() == args.len()
&& pattern
.iter()
.zip(args)
.all(|(expected, actual)| expected == "*" || expected == actual)
}
/// Manifest permission block for generic project-scoped executable access.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PluginWorkspaceExecPermission {
/// Explicit allow rules. An empty list denies every invocation.
#[serde(default)]
pub allow: Vec<PluginWorkspaceExecRule>,
}
/// Security-sensitive permissions requested by a plugin manifest.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PluginPermissions {
/// Short, hidden external-tool execution permissions.
#[serde(default)]
pub workspace_exec: Option<PluginWorkspaceExecPermission>,
}
/// Audited terminal outcome of a plugin workspace execution request.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum PluginWorkspaceExecAuditOutcome {
/// A manifest/project/runtime permission rejected the request.
Denied,
/// The allowed process was supervised to a terminal result.
Completed,
/// The allowed process could not be started or supervised.
Failed,
}
/// Metadata-only audit event. Captured stdout/stderr contents are never included.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PluginWorkspaceExecAuditEntry {
/// Calling plugin identity injected by the host runtime.
pub plugin_id: PluginId,
/// Project that confined the invocation.
pub project_id: crate::ProjectId,
/// Bare executable name.
pub command: String,
/// Argument count; argument values are intentionally omitted.
pub arg_count: usize,
/// Normalized project-relative cwd.
pub cwd: String,
/// Manifest-declared effect, absent for a request denied before rule matching.
pub effect: Option<PluginWorkspaceExecEffect>,
/// Index of the manifest allow rule used, when allowed.
pub permission_rule_index: Option<usize>,
/// Terminal audit outcome.
pub outcome: PluginWorkspaceExecAuditOutcome,
/// Exit code when available.
pub exit_code: Option<i32>,
/// Whether the process hit the host timeout.
pub timed_out: bool,
/// Retained stdout byte count, never content.
pub stdout_bytes: usize,
/// Retained stderr byte count, never content.
pub stderr_bytes: usize,
/// Execution duration.
pub duration_ms: u64,
/// Audit timestamp from the host clock.
pub occurred_at_ms: i64,
}
/// Manifest-declared runtime activation scope.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
@ -677,6 +803,9 @@ pub struct PluginManifest {
/// Activation scope. Missing in older manifests means app-level activation.
#[serde(default)]
pub activation_scope: PluginActivationScope,
/// Security-sensitive host permissions requested by the plugin.
#[serde(default)]
pub permissions: PluginPermissions,
/// Contributions.
pub contributes: PluginContributionSet,
}
@ -982,4 +1111,19 @@ mod tests {
serde_json::json!("project")
);
}
#[test]
fn workspace_exec_rule_matches_exact_tokens_and_trailing_suffix_wildcard() {
let rule = PluginWorkspaceExecRule {
command: "tool".to_owned(),
args: vec!["status".to_owned(), "*".to_owned()],
cwd: ".".to_owned(),
effect: PluginWorkspaceExecEffect::Read,
};
assert!(rule.matches("tool", &["status".to_owned()], "."));
assert!(rule.matches("tool", &["status".to_owned(), "--short".to_owned()], "."));
assert!(!rule.matches("tool", &["commit".to_owned()], "."));
assert!(!rule.matches("other", &["status".to_owned()], "."));
assert!(!rule.matches("tool", &["status".to_owned()], "nested"));
}
}