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:
@ -196,12 +196,17 @@ pub use plugin::{
|
||||
PluginToolDiagnostic, PluginToolRequirement, PluginToolchainDiagnostic,
|
||||
PluginToolchainDiagnosticInput, PluginToolchainDiagnostics, PluginWorkspaceAccess,
|
||||
PluginWorkspaceBinaryFile, PluginWorkspaceDirEntry, PluginWorkspaceDirectoryListing,
|
||||
PluginWorkspaceExecInput, PluginWorkspaceExecResult, PluginWorkspaceExecTool,
|
||||
PluginWorkspacePathInput, PluginWorkspaceStat, PluginWorkspaceTextFile,
|
||||
PluginWorkspaceWriteBinaryInput, PluginWorkspaceWriteTextInput, ProjectConvention,
|
||||
ProjectModule, ProjectStructureEntry, ProjectStructureQuery, QueryProjectStructure,
|
||||
QueryProjectStructureInput, ReconcilePluginMcpServers, ReloadPlugin, ReloadPluginInput,
|
||||
ReviewPluginPackage, ReviewPluginPackageInput, SetPluginEnabled, SetPluginEnabledInput,
|
||||
UninstallPlugin, UninstallPluginInput, UninstallPluginResult,
|
||||
PLUGIN_WORKSPACE_EXEC_DEFAULT_STDERR_BYTES, PLUGIN_WORKSPACE_EXEC_DEFAULT_STDOUT_BYTES,
|
||||
PLUGIN_WORKSPACE_EXEC_DEFAULT_TIMEOUT_MS, PLUGIN_WORKSPACE_EXEC_MAX_STDERR_BYTES,
|
||||
PLUGIN_WORKSPACE_EXEC_MAX_STDIN_BYTES, PLUGIN_WORKSPACE_EXEC_MAX_STDOUT_BYTES,
|
||||
PLUGIN_WORKSPACE_EXEC_MAX_TIMEOUT_MS,
|
||||
};
|
||||
pub use project::{
|
||||
CloseProject, CloseProjectInput, CloseProjectOutput, CloseTab, CloseTabInput, CreateProject,
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
//! Plugin application use cases.
|
||||
|
||||
use std::collections::{HashMap, HashSet, VecDeque};
|
||||
use std::collections::{BTreeMap, HashMap, HashSet, VecDeque};
|
||||
use std::path::Path;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use domain::ports::EnvironmentReader;
|
||||
@ -8,19 +9,22 @@ use domain::ports::{
|
||||
BackgroundTaskStore, Clock, DirEntry, EventBus, FileMetadata, FileSystem, IdGenerator,
|
||||
LocalPath, Output, PluginManifestBytes, PluginManifestError, PluginManifestValidator,
|
||||
PluginMcpError, PluginMcpSupervisor, PluginPackageStore, PluginRegistryError,
|
||||
PluginRegistryStore, PluginStorageError, PluginStorageStore, PluginStoreError, ProcessError,
|
||||
ProcessSpawner, ProjectStore, RemotePath, SpawnSpec,
|
||||
PluginRegistryStore, PluginStorageError, PluginStorageStore, PluginStoreError,
|
||||
PluginWorkspaceExecAuditSink, ProcessError, ProcessSpawner, ProjectStore, RemotePath,
|
||||
SpawnSpec, WorkspaceToolExecError, WorkspaceToolExecSpec, WorkspaceToolRunner,
|
||||
};
|
||||
use domain::{
|
||||
AgentId, BackgroundTask, BackgroundTaskState, BackgroundTaskWakePolicy, ContentHash,
|
||||
DomainEvent, PluginContributionSet, PluginDescriptor, PluginId, PluginInstallSource,
|
||||
PluginLayoutType, PluginLifecycleState, PluginManifest, PluginMcpServerSpec,
|
||||
PluginRegistryEntry, PluginTrustLevel, Project, ProjectId, ProjectPath,
|
||||
PluginRegistryEntry, PluginTrustLevel, PluginWorkspaceExecAuditEntry,
|
||||
PluginWorkspaceExecAuditOutcome, PluginWorkspaceExecEffect, Project, ProjectId, ProjectPath,
|
||||
ProjectPluginSettingsStore, RemovalOutcome, StagedPluginPackage, TaskId,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::ProjectPluginResolver;
|
||||
use crate::{AppError, SpawnBackgroundCommand, SpawnBackgroundCommandInput};
|
||||
|
||||
/// Contribution counts for admin display.
|
||||
@ -2070,6 +2074,421 @@ impl PluginCommandTasks {
|
||||
}
|
||||
}
|
||||
|
||||
/// Default deadline for a short plugin workspace tool.
|
||||
pub const PLUGIN_WORKSPACE_EXEC_DEFAULT_TIMEOUT_MS: u64 = 5_000;
|
||||
/// Host maximum deadline for a short plugin workspace tool.
|
||||
pub const PLUGIN_WORKSPACE_EXEC_MAX_TIMEOUT_MS: u64 = 30_000;
|
||||
/// Default retained stdout size.
|
||||
pub const PLUGIN_WORKSPACE_EXEC_DEFAULT_STDOUT_BYTES: usize = 256_000;
|
||||
/// Default retained stderr size.
|
||||
pub const PLUGIN_WORKSPACE_EXEC_DEFAULT_STDERR_BYTES: usize = 64_000;
|
||||
/// Host maximum retained stdout size.
|
||||
pub const PLUGIN_WORKSPACE_EXEC_MAX_STDOUT_BYTES: usize = 1_000_000;
|
||||
/// Host maximum retained stderr size.
|
||||
pub const PLUGIN_WORKSPACE_EXEC_MAX_STDERR_BYTES: usize = 256_000;
|
||||
/// Host maximum UTF-8 stdin size.
|
||||
pub const PLUGIN_WORKSPACE_EXEC_MAX_STDIN_BYTES: usize = 64_000;
|
||||
|
||||
const WORKSPACE_EXEC_INHERITED_ENV: &[&str] = &[
|
||||
"HOME", "LANG", "LC_ALL", "LC_CTYPE", "PATH", "TERM", "TMPDIR", "TMP", "TEMP", "TZ",
|
||||
];
|
||||
const WORKSPACE_EXEC_OVERLAY_ENV: &[&str] =
|
||||
&["LANG", "LC_ALL", "LC_CTYPE", "NO_COLOR", "TERM", "TZ"];
|
||||
|
||||
/// Host-injected request for one short, invisible plugin tool invocation.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PluginWorkspaceExecInput {
|
||||
/// Calling plugin id, injected by the runtime service facade.
|
||||
pub plugin_id: String,
|
||||
/// Project id owning and confining the invocation.
|
||||
pub project_id: String,
|
||||
/// Bare executable name.
|
||||
pub command: String,
|
||||
/// Arguments passed directly, never through a shell.
|
||||
#[serde(default)]
|
||||
pub args: Vec<String>,
|
||||
/// Project-relative cwd, defaulting to the root.
|
||||
#[serde(default)]
|
||||
pub cwd: Option<String>,
|
||||
/// Host-allowlisted environment overlay.
|
||||
#[serde(default)]
|
||||
pub env: Vec<(String, String)>,
|
||||
/// Optional UTF-8 stdin.
|
||||
#[serde(default)]
|
||||
pub stdin: Option<String>,
|
||||
/// Requested deadline, clamped by the host maximum.
|
||||
#[serde(default)]
|
||||
pub timeout_ms: Option<u64>,
|
||||
/// Requested retained stdout size, clamped by the host maximum.
|
||||
#[serde(default)]
|
||||
pub max_stdout_bytes: Option<usize>,
|
||||
/// Requested retained stderr size, clamped by the host maximum.
|
||||
#[serde(default)]
|
||||
pub max_stderr_bytes: Option<usize>,
|
||||
}
|
||||
|
||||
/// Public result of one short plugin workspace tool invocation.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PluginWorkspaceExecResult {
|
||||
/// Exit code, absent for a signalled process.
|
||||
pub exit_code: Option<i32>,
|
||||
/// Platform signal when available.
|
||||
pub signal: Option<String>,
|
||||
/// Whether the host deadline killed the process.
|
||||
pub timed_out: bool,
|
||||
/// Lossy UTF-8 rendering of retained stdout.
|
||||
pub stdout: String,
|
||||
/// Lossy UTF-8 rendering of retained stderr.
|
||||
pub stderr: String,
|
||||
/// Whether stdout exceeded its retained limit.
|
||||
pub stdout_truncated: bool,
|
||||
/// Whether stderr exceeded its retained limit.
|
||||
pub stderr_truncated: bool,
|
||||
/// Measured wall-clock duration.
|
||||
pub duration_ms: u64,
|
||||
}
|
||||
|
||||
/// Generic, permission-gated, project-confined executable service for plugins.
|
||||
pub struct PluginWorkspaceExecTool {
|
||||
projects: Arc<dyn ProjectStore>,
|
||||
fs: Arc<dyn FileSystem>,
|
||||
plugins: Arc<ProjectPluginResolver>,
|
||||
runner: Arc<dyn WorkspaceToolRunner>,
|
||||
env: Arc<dyn EnvironmentReader>,
|
||||
clock: Arc<dyn Clock>,
|
||||
audit: Arc<dyn PluginWorkspaceExecAuditSink>,
|
||||
}
|
||||
|
||||
impl PluginWorkspaceExecTool {
|
||||
/// Builds the use case from replaceable ports.
|
||||
#[must_use]
|
||||
pub fn new(
|
||||
projects: Arc<dyn ProjectStore>,
|
||||
fs: Arc<dyn FileSystem>,
|
||||
plugins: Arc<ProjectPluginResolver>,
|
||||
runner: Arc<dyn WorkspaceToolRunner>,
|
||||
env: Arc<dyn EnvironmentReader>,
|
||||
clock: Arc<dyn Clock>,
|
||||
audit: Arc<dyn PluginWorkspaceExecAuditSink>,
|
||||
) -> Self {
|
||||
Self {
|
||||
projects,
|
||||
fs,
|
||||
plugins,
|
||||
runner,
|
||||
env,
|
||||
clock,
|
||||
audit,
|
||||
}
|
||||
}
|
||||
|
||||
/// Executes an allowlisted binary without creating a visible task.
|
||||
pub async fn execute(
|
||||
&self,
|
||||
input: PluginWorkspaceExecInput,
|
||||
) -> Result<PluginWorkspaceExecResult, AppError> {
|
||||
let plugin_id = PluginId::new(input.plugin_id.clone())
|
||||
.map_err(|err| AppError::Invalid(err.to_string()))?;
|
||||
let project_id = parse_project_id(&input.project_id)?;
|
||||
let project = self.projects.load_project(project_id).await?;
|
||||
validate_workspace_exec_command(&input.command, &input.args, input.stdin.as_deref())?;
|
||||
let cwd_relative = normalize_exec_cwd(input.cwd.as_deref())?;
|
||||
let cwd = canonical_exec_cwd(self.fs.as_ref(), &project, &cwd_relative).await?;
|
||||
let manifest = self
|
||||
.plugins
|
||||
.enabled_manifest(&project.root, &plugin_id)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::Invalid("plugin is not active for this project".to_owned()))?;
|
||||
let matched = manifest
|
||||
.permissions
|
||||
.workspace_exec
|
||||
.as_ref()
|
||||
.and_then(|permission| {
|
||||
permission
|
||||
.allow
|
||||
.iter()
|
||||
.enumerate()
|
||||
.find(|(_, rule)| rule.matches(&input.command, &input.args, &cwd_relative))
|
||||
});
|
||||
let Some((rule_index, rule)) = matched else {
|
||||
self.audit.record(exec_audit(
|
||||
plugin_id,
|
||||
project_id,
|
||||
&input,
|
||||
&cwd_relative,
|
||||
None,
|
||||
None,
|
||||
PluginWorkspaceExecAuditOutcome::Denied,
|
||||
None,
|
||||
false,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
self.clock.now_millis(),
|
||||
));
|
||||
return Err(AppError::Invalid(
|
||||
"workspace tool invocation is not allowed by the active plugin manifest".to_owned(),
|
||||
));
|
||||
};
|
||||
let effect = rule.effect;
|
||||
let env = workspace_exec_environment(self.env.as_ref(), &input.env)?;
|
||||
let timeout_ms = bounded_nonzero(
|
||||
"timeoutMs",
|
||||
input.timeout_ms,
|
||||
PLUGIN_WORKSPACE_EXEC_DEFAULT_TIMEOUT_MS,
|
||||
PLUGIN_WORKSPACE_EXEC_MAX_TIMEOUT_MS,
|
||||
)?;
|
||||
let max_stdout_bytes = bounded_nonzero_usize(
|
||||
"maxStdoutBytes",
|
||||
input.max_stdout_bytes,
|
||||
PLUGIN_WORKSPACE_EXEC_DEFAULT_STDOUT_BYTES,
|
||||
PLUGIN_WORKSPACE_EXEC_MAX_STDOUT_BYTES,
|
||||
)?;
|
||||
let max_stderr_bytes = bounded_nonzero_usize(
|
||||
"maxStderrBytes",
|
||||
input.max_stderr_bytes,
|
||||
PLUGIN_WORKSPACE_EXEC_DEFAULT_STDERR_BYTES,
|
||||
PLUGIN_WORKSPACE_EXEC_MAX_STDERR_BYTES,
|
||||
)?;
|
||||
let spec = WorkspaceToolExecSpec {
|
||||
command: input.command.clone(),
|
||||
args: input.args.clone(),
|
||||
cwd,
|
||||
env,
|
||||
stdin: input.stdin.clone(),
|
||||
timeout_ms,
|
||||
max_stdout_bytes,
|
||||
max_stderr_bytes,
|
||||
sandbox: workspace_exec_sandbox(&project.root, effect),
|
||||
};
|
||||
match self.runner.run(spec).await {
|
||||
Ok(output) => {
|
||||
self.audit.record(exec_audit(
|
||||
plugin_id,
|
||||
project_id,
|
||||
&input,
|
||||
&cwd_relative,
|
||||
Some(effect),
|
||||
Some(rule_index),
|
||||
PluginWorkspaceExecAuditOutcome::Completed,
|
||||
output.exit_code,
|
||||
output.timed_out,
|
||||
output.stdout.len(),
|
||||
output.stderr.len(),
|
||||
output.duration_ms,
|
||||
self.clock.now_millis(),
|
||||
));
|
||||
Ok(PluginWorkspaceExecResult {
|
||||
exit_code: output.exit_code,
|
||||
signal: output.signal,
|
||||
timed_out: output.timed_out,
|
||||
stdout: String::from_utf8_lossy(&output.stdout).into_owned(),
|
||||
stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
|
||||
stdout_truncated: output.stdout_truncated,
|
||||
stderr_truncated: output.stderr_truncated,
|
||||
duration_ms: output.duration_ms,
|
||||
})
|
||||
}
|
||||
Err(WorkspaceToolExecError::Execution(message)) => {
|
||||
self.audit.record(exec_audit(
|
||||
plugin_id,
|
||||
project_id,
|
||||
&input,
|
||||
&cwd_relative,
|
||||
Some(effect),
|
||||
Some(rule_index),
|
||||
PluginWorkspaceExecAuditOutcome::Failed,
|
||||
None,
|
||||
false,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
self.clock.now_millis(),
|
||||
));
|
||||
Err(AppError::Process(message))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_workspace_exec_command(
|
||||
command: &str,
|
||||
args: &[String],
|
||||
stdin: Option<&str>,
|
||||
) -> Result<(), AppError> {
|
||||
if command.is_empty()
|
||||
|| command.len() > 128
|
||||
|| !command
|
||||
.bytes()
|
||||
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'+' | b'-'))
|
||||
{
|
||||
return Err(AppError::Invalid(
|
||||
"command must be a bare ASCII executable name".to_owned(),
|
||||
));
|
||||
}
|
||||
if args.len() > 64
|
||||
|| args
|
||||
.iter()
|
||||
.any(|arg| arg.len() > 4096 || arg.contains('\0'))
|
||||
{
|
||||
return Err(AppError::Invalid(
|
||||
"args must contain at most 64 bounded NUL-free tokens".to_owned(),
|
||||
));
|
||||
}
|
||||
if stdin.is_some_and(|value| value.len() > PLUGIN_WORKSPACE_EXEC_MAX_STDIN_BYTES) {
|
||||
return Err(AppError::Invalid(format!(
|
||||
"stdin exceeds {PLUGIN_WORKSPACE_EXEC_MAX_STDIN_BYTES} bytes"
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn normalize_exec_cwd(raw: Option<&str>) -> Result<String, AppError> {
|
||||
let normalized = normalize_workspace_relative_path(raw.unwrap_or("."))?;
|
||||
Ok(if normalized.is_empty() {
|
||||
".".to_owned()
|
||||
} else {
|
||||
normalized
|
||||
})
|
||||
}
|
||||
|
||||
async fn canonical_exec_cwd(
|
||||
fs: &dyn FileSystem,
|
||||
project: &Project,
|
||||
relative: &str,
|
||||
) -> Result<ProjectPath, AppError> {
|
||||
let root = fs
|
||||
.canonicalize(&RemotePath::new(project.root.as_str().to_owned()))
|
||||
.await
|
||||
.map_err(|_| AppError::NotFound("project root is inaccessible".to_owned()))?;
|
||||
let candidate = resolve_workspace_path(project, relative)?;
|
||||
let candidate = fs
|
||||
.canonicalize(&candidate.absolute)
|
||||
.await
|
||||
.map_err(|_| AppError::NotFound("workspace tool cwd is inaccessible".to_owned()))?;
|
||||
if !Path::new(candidate.as_str()).starts_with(Path::new(root.as_str())) {
|
||||
return Err(AppError::Invalid(
|
||||
"workspace tool cwd escapes the project root through a symlink".to_owned(),
|
||||
));
|
||||
}
|
||||
ProjectPath::new(candidate.as_str().to_owned())
|
||||
.map_err(|err| AppError::Invalid(err.to_string()))
|
||||
}
|
||||
|
||||
fn workspace_exec_environment(
|
||||
env_reader: &dyn EnvironmentReader,
|
||||
overlay: &[(String, String)],
|
||||
) -> Result<Vec<(String, String)>, AppError> {
|
||||
let mut env = BTreeMap::new();
|
||||
for name in WORKSPACE_EXEC_INHERITED_ENV {
|
||||
if let Some(value) = env_reader.get(name) {
|
||||
env.insert((*name).to_owned(), value);
|
||||
}
|
||||
}
|
||||
for (name, value) in overlay {
|
||||
if !WORKSPACE_EXEC_OVERLAY_ENV.contains(&name.as_str())
|
||||
|| value.contains('\0')
|
||||
|| value.len() > 4096
|
||||
{
|
||||
return Err(AppError::Invalid(format!(
|
||||
"environment variable `{name}` is not allowed or is invalid"
|
||||
)));
|
||||
}
|
||||
env.insert(name.clone(), value.clone());
|
||||
}
|
||||
Ok(env.into_iter().collect())
|
||||
}
|
||||
|
||||
fn bounded_nonzero(
|
||||
name: &str,
|
||||
value: Option<u64>,
|
||||
default: u64,
|
||||
max: u64,
|
||||
) -> Result<u64, AppError> {
|
||||
let value = value.unwrap_or(default);
|
||||
if value == 0 {
|
||||
return Err(AppError::Invalid(format!("{name} must be positive")));
|
||||
}
|
||||
Ok(value.min(max))
|
||||
}
|
||||
|
||||
fn bounded_nonzero_usize(
|
||||
name: &str,
|
||||
value: Option<usize>,
|
||||
default: usize,
|
||||
max: usize,
|
||||
) -> Result<usize, AppError> {
|
||||
let value = value.unwrap_or(default);
|
||||
if value == 0 {
|
||||
return Err(AppError::Invalid(format!("{name} must be positive")));
|
||||
}
|
||||
Ok(value.min(max))
|
||||
}
|
||||
|
||||
fn workspace_exec_sandbox(
|
||||
root: &ProjectPath,
|
||||
effect: PluginWorkspaceExecEffect,
|
||||
) -> domain::SandboxPlan {
|
||||
let project_access = match effect {
|
||||
PluginWorkspaceExecEffect::Read => domain::PathAccess::RO | domain::PathAccess::EXEC,
|
||||
PluginWorkspaceExecEffect::Write
|
||||
| PluginWorkspaceExecEffect::Network
|
||||
| PluginWorkspaceExecEffect::Destructive => {
|
||||
domain::PathAccess::RO | domain::PathAccess::RW | domain::PathAccess::EXEC
|
||||
}
|
||||
};
|
||||
let mut allowed = vec![domain::PathGrant {
|
||||
abs_root: root.as_str().to_owned(),
|
||||
access: project_access,
|
||||
}];
|
||||
for system_root in ["/bin", "/usr", "/lib", "/lib64", "/etc"] {
|
||||
allowed.push(domain::PathGrant {
|
||||
abs_root: system_root.to_owned(),
|
||||
access: domain::PathAccess::RO | domain::PathAccess::EXEC,
|
||||
});
|
||||
}
|
||||
domain::SandboxPlan {
|
||||
allowed,
|
||||
default_posture: domain::permission::Posture::Deny,
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn exec_audit(
|
||||
plugin_id: PluginId,
|
||||
project_id: ProjectId,
|
||||
input: &PluginWorkspaceExecInput,
|
||||
cwd: &str,
|
||||
effect: Option<PluginWorkspaceExecEffect>,
|
||||
permission_rule_index: Option<usize>,
|
||||
outcome: PluginWorkspaceExecAuditOutcome,
|
||||
exit_code: Option<i32>,
|
||||
timed_out: bool,
|
||||
stdout_bytes: usize,
|
||||
stderr_bytes: usize,
|
||||
duration_ms: u64,
|
||||
occurred_at_ms: i64,
|
||||
) -> PluginWorkspaceExecAuditEntry {
|
||||
PluginWorkspaceExecAuditEntry {
|
||||
plugin_id,
|
||||
project_id,
|
||||
command: input.command.clone(),
|
||||
arg_count: input.args.len(),
|
||||
cwd: cwd.to_owned(),
|
||||
effect,
|
||||
permission_rule_index,
|
||||
outcome,
|
||||
exit_code,
|
||||
timed_out,
|
||||
stdout_bytes,
|
||||
stderr_bytes,
|
||||
duration_ms,
|
||||
occurred_at_ms,
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_agent_id(raw: &str) -> Result<AgentId, AppError> {
|
||||
Uuid::parse_str(raw)
|
||||
.map(AgentId::from_uuid)
|
||||
@ -2267,6 +2686,7 @@ impl ListPlugins {
|
||||
trust_level: PluginTrustLevel::Full,
|
||||
capabilities: Vec::new(),
|
||||
activation_scope: domain::PluginActivationScope::default(),
|
||||
permissions: domain::PluginPermissions::default(),
|
||||
contributes: PluginContributionSet::default(),
|
||||
};
|
||||
out.push(admin_from_descriptor(
|
||||
@ -3334,9 +3754,39 @@ struct RawManifest {
|
||||
capabilities: Vec<String>,
|
||||
#[serde(default)]
|
||||
activation_scope: domain::PluginActivationScope,
|
||||
#[serde(default)]
|
||||
permissions: RawPluginPermissions,
|
||||
contributes: RawContributes,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct RawPluginPermissions {
|
||||
#[serde(default)]
|
||||
workspace_exec: Option<RawWorkspaceExecPermission>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Deserialize)]
|
||||
struct RawWorkspaceExecPermission {
|
||||
#[serde(default)]
|
||||
allow: Vec<RawWorkspaceExecRule>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct RawWorkspaceExecRule {
|
||||
command: String,
|
||||
#[serde(default)]
|
||||
args: Vec<String>,
|
||||
#[serde(default = "default_workspace_exec_cwd")]
|
||||
cwd: String,
|
||||
effect: domain::PluginWorkspaceExecEffect,
|
||||
}
|
||||
|
||||
fn default_workspace_exec_cwd() -> String {
|
||||
".".to_owned()
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct RawEngines {
|
||||
@ -3510,6 +3960,7 @@ impl PluginManifestValidator for JsonPluginManifestValidator {
|
||||
))),
|
||||
})
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
let permissions = validate_plugin_permissions(raw.permissions)?;
|
||||
let contributes = validate_contributes(raw.contributes)?;
|
||||
Ok(PluginManifest {
|
||||
idea_plugin_manifest_version: 1,
|
||||
@ -3524,11 +3975,82 @@ impl PluginManifestValidator for JsonPluginManifestValidator {
|
||||
trust_level: PluginTrustLevel::Full,
|
||||
capabilities,
|
||||
activation_scope: raw.activation_scope,
|
||||
permissions,
|
||||
contributes,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_plugin_permissions(
|
||||
raw: RawPluginPermissions,
|
||||
) -> Result<domain::PluginPermissions, PluginManifestError> {
|
||||
let workspace_exec = raw
|
||||
.workspace_exec
|
||||
.map(|permission| {
|
||||
if permission.allow.len() > 64 {
|
||||
return Err(PluginManifestError::Invalid(
|
||||
"permissions.workspaceExec.allow must contain at most 64 rules".to_owned(),
|
||||
));
|
||||
}
|
||||
let allow = permission
|
||||
.allow
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.map(|(index, rule)| validate_workspace_exec_rule(index, rule))
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
Ok(domain::PluginWorkspaceExecPermission { allow })
|
||||
})
|
||||
.transpose()?;
|
||||
Ok(domain::PluginPermissions { workspace_exec })
|
||||
}
|
||||
|
||||
fn validate_workspace_exec_rule(
|
||||
index: usize,
|
||||
rule: RawWorkspaceExecRule,
|
||||
) -> Result<domain::PluginWorkspaceExecRule, PluginManifestError> {
|
||||
let field = format!("permissions.workspaceExec.allow[{index}]");
|
||||
let command = rule.command.trim();
|
||||
if command.is_empty()
|
||||
|| command.len() > 128
|
||||
|| !command
|
||||
.bytes()
|
||||
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'+' | b'-'))
|
||||
{
|
||||
return Err(PluginManifestError::Invalid(format!(
|
||||
"{field}.command must be a bare ASCII executable name"
|
||||
)));
|
||||
}
|
||||
if rule.args.len() > 64
|
||||
|| rule.args.iter().any(|arg| {
|
||||
arg.is_empty()
|
||||
|| arg.len() > 4096
|
||||
|| arg.contains('\0')
|
||||
|| (arg.contains('*') && arg != "*")
|
||||
})
|
||||
{
|
||||
return Err(PluginManifestError::Invalid(format!(
|
||||
"{field}.args must contain at most 64 non-empty bounded tokens; only a whole `*` token is a wildcard"
|
||||
)));
|
||||
}
|
||||
let cwd = if rule.cwd == "*" {
|
||||
"*".to_owned()
|
||||
} else {
|
||||
let normalized = normalize_workspace_relative_path(&rule.cwd)
|
||||
.map_err(|err| PluginManifestError::Invalid(format!("{field}.cwd: {err}")))?;
|
||||
if normalized.is_empty() {
|
||||
".".to_owned()
|
||||
} else {
|
||||
normalized
|
||||
}
|
||||
};
|
||||
Ok(domain::PluginWorkspaceExecRule {
|
||||
command: command.to_owned(),
|
||||
args: rule.args,
|
||||
cwd,
|
||||
effect: rule.effect,
|
||||
})
|
||||
}
|
||||
|
||||
fn validate_contributes(raw: RawContributes) -> Result<PluginContributionSet, PluginManifestError> {
|
||||
let mut seen = HashSet::new();
|
||||
let mut insert = |id: &str| {
|
||||
@ -3808,6 +4330,13 @@ mod tests {
|
||||
"main": "dist/index.js",
|
||||
"trustLevel": "full",
|
||||
"capabilities": ["ui", "mcp", "tooling"],
|
||||
"permissions": {
|
||||
"workspaceExec": {
|
||||
"allow": [
|
||||
{"command":"tool","args":["status","*"],"cwd":".","effect":"read"}
|
||||
]
|
||||
}
|
||||
},
|
||||
"contributes": {
|
||||
"skills": [{"id":"build-debug","name":"build-debug","description":"Diagnose build failures.","kind":"workflow","path":"skills/build-debug/SKILL.md"}],
|
||||
"menus": [{"id":"dev.acme.menu","label":"Graph","topLevel":true}],
|
||||
@ -4187,6 +4716,13 @@ mod tests {
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(m.id.as_str(), "dev.acme.gitgraph");
|
||||
let workspace_exec = m.permissions.workspace_exec.as_ref().unwrap();
|
||||
assert_eq!(workspace_exec.allow.len(), 1);
|
||||
assert_eq!(workspace_exec.allow[0].command, "tool");
|
||||
assert_eq!(
|
||||
workspace_exec.allow[0].effect,
|
||||
PluginWorkspaceExecEffect::Read
|
||||
);
|
||||
assert_eq!(
|
||||
m.capabilities,
|
||||
vec![
|
||||
@ -4224,6 +4760,29 @@ mod tests {
|
||||
assert_eq!(m.contributes.slash_commands.len(), 1);
|
||||
assert_eq!(m.contributes.slash_commands[0].name, "/graph");
|
||||
assert!(m.contributes.slash_commands[0].requires_confirmation);
|
||||
let exec = m.permissions.workspace_exec.unwrap();
|
||||
assert_eq!(exec.allow.len(), 1);
|
||||
assert!(exec.allow[0].matches("tool", &["status".to_owned()], "."));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workspace_exec_manifest_rejects_shell_paths_and_partial_wildcards() {
|
||||
for command_and_args in [
|
||||
serde_json::json!({"command":"/bin/tool","args":["status"],"cwd":".","effect":"read"}),
|
||||
serde_json::json!({"command":"tool","args":["--format=*"],"cwd":".","effect":"read"}),
|
||||
] {
|
||||
let mut value: serde_json::Value = serde_json::from_slice(&valid_manifest()).unwrap();
|
||||
value["permissions"]["workspaceExec"]["allow"] = serde_json::json!([command_and_args]);
|
||||
assert!(validator()
|
||||
.validate(
|
||||
&serde_json::to_vec(&value).unwrap(),
|
||||
&domain::PluginPackageRef {
|
||||
plugin_id: None,
|
||||
root: "x".into()
|
||||
},
|
||||
)
|
||||
.is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
@ -5267,6 +5826,33 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
struct FakeWorkspaceToolRunner {
|
||||
output: Mutex<Result<domain::WorkspaceToolExecOutput, WorkspaceToolExecError>>,
|
||||
specs: Mutex<Vec<WorkspaceToolExecSpec>>,
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl WorkspaceToolRunner for FakeWorkspaceToolRunner {
|
||||
async fn run(
|
||||
&self,
|
||||
spec: WorkspaceToolExecSpec,
|
||||
) -> Result<domain::WorkspaceToolExecOutput, WorkspaceToolExecError> {
|
||||
self.specs.lock().unwrap().push(spec);
|
||||
self.output.lock().unwrap().clone()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct FakeWorkspaceExecAudit {
|
||||
entries: Mutex<Vec<PluginWorkspaceExecAuditEntry>>,
|
||||
}
|
||||
|
||||
impl PluginWorkspaceExecAuditSink for FakeWorkspaceExecAudit {
|
||||
fn record(&self, entry: PluginWorkspaceExecAuditEntry) {
|
||||
self.entries.lock().unwrap().push(entry);
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct FakeEnvironmentReader {
|
||||
values: Mutex<HashMap<String, String>>,
|
||||
@ -5405,6 +5991,140 @@ mod tests {
|
||||
(project_id, store, Arc::new(FakeWorkspaceFs::default()))
|
||||
}
|
||||
|
||||
fn workspace_exec_fixture() -> (
|
||||
ProjectId,
|
||||
PluginWorkspaceExecTool,
|
||||
Arc<FakeWorkspaceToolRunner>,
|
||||
Arc<FakeWorkspaceExecAudit>,
|
||||
) {
|
||||
let (project_id, projects, fs) = workspace_fixture();
|
||||
fs.seed_file("/workspace/example/.marker", b"ok".to_vec());
|
||||
let packages = Arc::new(FakePackages::with_manifest(valid_manifest()));
|
||||
let registry = Arc::new(FakeRegistry {
|
||||
registry: Mutex::new(registry_with(PluginLifecycleState::Enabled)),
|
||||
});
|
||||
let settings = Arc::new(FakeRuntimeProjectSettings {
|
||||
settings: domain::ProjectPluginSettings {
|
||||
plugins: vec![domain::ProjectPluginActivation {
|
||||
plugin_id: plugin_id(),
|
||||
enabled: true,
|
||||
}],
|
||||
..domain::ProjectPluginSettings::default()
|
||||
},
|
||||
});
|
||||
let resolver = Arc::new(ProjectPluginResolver::new(
|
||||
settings,
|
||||
packages,
|
||||
registry,
|
||||
Arc::new(validator()),
|
||||
));
|
||||
let runner = Arc::new(FakeWorkspaceToolRunner {
|
||||
output: Mutex::new(Ok(domain::WorkspaceToolExecOutput {
|
||||
exit_code: Some(0),
|
||||
signal: None,
|
||||
timed_out: false,
|
||||
stdout: b"clean\n".to_vec(),
|
||||
stderr: Vec::new(),
|
||||
stdout_truncated: false,
|
||||
stderr_truncated: false,
|
||||
duration_ms: 12,
|
||||
})),
|
||||
specs: Mutex::new(Vec::new()),
|
||||
});
|
||||
let env = Arc::new(FakeEnvironmentReader::default());
|
||||
env.set("PATH", "/usr/bin:/bin");
|
||||
let audit = Arc::new(FakeWorkspaceExecAudit::default());
|
||||
let tool = PluginWorkspaceExecTool::new(
|
||||
projects,
|
||||
fs,
|
||||
resolver,
|
||||
runner.clone(),
|
||||
env,
|
||||
Arc::new(FixedClock(1234)),
|
||||
audit.clone(),
|
||||
);
|
||||
(project_id, tool, runner, audit)
|
||||
}
|
||||
|
||||
fn workspace_exec_input(project_id: ProjectId) -> PluginWorkspaceExecInput {
|
||||
PluginWorkspaceExecInput {
|
||||
plugin_id: plugin_id().as_str().to_owned(),
|
||||
project_id: project_id.to_string(),
|
||||
command: "tool".to_owned(),
|
||||
args: vec!["status".to_owned(), "--short".to_owned()],
|
||||
cwd: Some(".".to_owned()),
|
||||
env: vec![("NO_COLOR".to_owned(), "1".to_owned())],
|
||||
stdin: None,
|
||||
timeout_ms: Some(u64::MAX),
|
||||
max_stdout_bytes: Some(usize::MAX),
|
||||
max_stderr_bytes: Some(usize::MAX),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn workspace_exec_allows_matching_rule_clamps_limits_and_audits_metadata() {
|
||||
let (project_id, tool, runner, audit) = workspace_exec_fixture();
|
||||
let result = tool
|
||||
.execute(workspace_exec_input(project_id))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(result.stdout, "clean\n");
|
||||
let specs = runner.specs.lock().unwrap();
|
||||
assert_eq!(specs.len(), 1);
|
||||
assert_eq!(specs[0].timeout_ms, PLUGIN_WORKSPACE_EXEC_MAX_TIMEOUT_MS);
|
||||
assert_eq!(
|
||||
specs[0].max_stdout_bytes,
|
||||
PLUGIN_WORKSPACE_EXEC_MAX_STDOUT_BYTES
|
||||
);
|
||||
assert_eq!(
|
||||
specs[0].max_stderr_bytes,
|
||||
PLUGIN_WORKSPACE_EXEC_MAX_STDERR_BYTES
|
||||
);
|
||||
assert!(specs[0]
|
||||
.env
|
||||
.contains(&("PATH".to_owned(), "/usr/bin:/bin".to_owned())));
|
||||
assert!(specs[0]
|
||||
.env
|
||||
.contains(&("NO_COLOR".to_owned(), "1".to_owned())));
|
||||
drop(specs);
|
||||
let entries = audit.entries.lock().unwrap();
|
||||
assert_eq!(entries.len(), 1);
|
||||
assert_eq!(
|
||||
entries[0].outcome,
|
||||
PluginWorkspaceExecAuditOutcome::Completed
|
||||
);
|
||||
assert_eq!(entries[0].arg_count, 2);
|
||||
assert_eq!(entries[0].stdout_bytes, 6);
|
||||
assert_eq!(entries[0].permission_rule_index, Some(0));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn workspace_exec_denies_unmatched_args_and_never_runs_process() {
|
||||
let (project_id, tool, runner, audit) = workspace_exec_fixture();
|
||||
let mut input = workspace_exec_input(project_id);
|
||||
input.args = vec!["commit".to_owned()];
|
||||
let error = tool.execute(input).await.unwrap_err();
|
||||
assert_eq!(error.code(), "INVALID");
|
||||
assert!(runner.specs.lock().unwrap().is_empty());
|
||||
assert_eq!(
|
||||
audit.entries.lock().unwrap()[0].outcome,
|
||||
PluginWorkspaceExecAuditOutcome::Denied
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn workspace_exec_rejects_cwd_escape_and_non_allowlisted_env() {
|
||||
let (project_id, tool, runner, _) = workspace_exec_fixture();
|
||||
let mut escape = workspace_exec_input(project_id);
|
||||
escape.cwd = Some("../outside".to_owned());
|
||||
assert_eq!(tool.execute(escape).await.unwrap_err().code(), "INVALID");
|
||||
|
||||
let mut secret = workspace_exec_input(project_id);
|
||||
secret.env = vec![("AWS_SECRET_ACCESS_KEY".to_owned(), "secret".to_owned())];
|
||||
assert_eq!(tool.execute(secret).await.unwrap_err().code(), "INVALID");
|
||||
assert!(runner.specs.lock().unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn plugin_workspace_rejects_paths_outside_project_root() {
|
||||
let (project_id, projects, fs) = workspace_fixture();
|
||||
|
||||
@ -803,6 +803,7 @@ mod tests {
|
||||
trust_level: PluginTrustLevel::Full,
|
||||
capabilities: vec![PluginCapability::Mcp],
|
||||
activation_scope: PluginActivationScope::Project,
|
||||
permissions: domain::PluginPermissions::default(),
|
||||
contributes: PluginContributionSet {
|
||||
mcp_servers: vec![
|
||||
PluginMcpServerContribution {
|
||||
|
||||
Reference in New Issue
Block a user