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:
@ -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"));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user