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

@ -448,6 +448,7 @@ pub fn run() {
plugins::plugin_storage_delete,
plugins::plugin_workspace_list_dir,
plugins::plugin_workspace_stat,
plugins::plugin_workspace_exec_tool,
plugins::plugin_query_project_structure,
plugins::plugin_config_read_document,
plugins::plugin_config_update_document,
@ -488,6 +489,7 @@ fn plugin_workspace_invoke_handler<R: tauri::Runtime>(
plugins::plugin_storage_delete,
plugins::plugin_workspace_list_dir,
plugins::plugin_workspace_stat,
plugins::plugin_workspace_exec_tool,
plugins::plugin_query_project_structure,
plugins::plugin_config_read_document,
plugins::plugin_config_update_document,

View File

@ -17,10 +17,10 @@ use backend::dto::{
PluginRuntimeContributionCatalogDto, PluginStorageGetDto, PluginStorageSetDto, PluginTaskDto,
PluginTaskStatusDto, PluginToolchainDiagnosticDto, PluginToolchainDiagnosticRequestDto,
PluginUninstallResultDto, PluginWorkspaceBinaryFileDto, PluginWorkspaceDirectoryListingDto,
PluginWorkspacePathDto, PluginWorkspaceStatDto, PluginWorkspaceTextFileDto,
PluginWorkspaceWriteBinaryDto, PluginWorkspaceWriteTextDto, ProjectPluginSettingsDto,
ProjectPluginSettingsViewDto, ReloadPluginDto, ReviewPluginPackageDto,
SetProjectPluginEnabledRequestDto,
PluginWorkspaceExecDto, PluginWorkspaceExecResultDto, PluginWorkspacePathDto,
PluginWorkspaceStatDto, PluginWorkspaceTextFileDto, PluginWorkspaceWriteBinaryDto,
PluginWorkspaceWriteTextDto, ProjectPluginSettingsDto, ProjectPluginSettingsViewDto,
ReloadPluginDto, ReviewPluginPackageDto, SetProjectPluginEnabledRequestDto,
};
use domain::ports::{PluginManifestValidator, PluginPackageStore, PluginRegistryStore};
use domain::{PluginId, RelativePath};
@ -414,6 +414,19 @@ pub async fn plugin_workspace_stat(
.map_err(ErrorDto::from)
}
/// Executes a short, permission-gated tool in a project workspace.
#[tauri::command]
pub async fn plugin_workspace_exec_tool(
input: PluginWorkspaceExecDto,
state: State<'_, AppState>,
) -> Result<PluginWorkspaceExecResultDto, ErrorDto> {
state
.plugin_workspace_exec
.execute(input.into())
.await
.map_err(ErrorDto::from)
}
/// Queries a bounded generic project structure for the public plugin API.
#[tauri::command]
pub async fn plugin_query_project_structure(
@ -888,6 +901,7 @@ mod tests {
trust_level: PluginTrustLevel::Full,
capabilities: Vec::new(),
activation_scope: domain::PluginActivationScope::default(),
permissions: domain::PluginPermissions::default(),
contributes: PluginContributionSet::default(),
})
}

View File

@ -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,

View File

@ -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();

View File

@ -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 {

View File

@ -449,6 +449,59 @@ impl From<PluginWorkspacePathDto> for application::PluginWorkspacePathInput {
}
}
/// Permission-gated short workspace tool request DTO.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PluginWorkspaceExecDto {
/// Calling plugin id injected by the plugin runtime adapter.
pub plugin_id: String,
/// Project id.
pub project_id: String,
/// Bare executable name.
pub command: String,
/// Direct argument vector.
#[serde(default)]
pub args: Vec<String>,
/// Project-relative working directory.
#[serde(default)]
pub cwd: Option<String>,
/// Host-allowlisted environment overlay.
#[serde(default)]
pub env: std::collections::BTreeMap<String, String>,
/// Optional UTF-8 stdin.
#[serde(default)]
pub stdin: Option<String>,
/// Requested timeout.
#[serde(default)]
pub timeout_ms: Option<u64>,
/// Requested retained stdout size.
#[serde(default)]
pub max_stdout_bytes: Option<usize>,
/// Requested retained stderr size.
#[serde(default)]
pub max_stderr_bytes: Option<usize>,
}
impl From<PluginWorkspaceExecDto> for application::PluginWorkspaceExecInput {
fn from(value: PluginWorkspaceExecDto) -> Self {
Self {
plugin_id: value.plugin_id,
project_id: value.project_id,
command: value.command,
args: value.args,
cwd: value.cwd,
env: value.env.into_iter().collect(),
stdin: value.stdin,
timeout_ms: value.timeout_ms,
max_stdout_bytes: value.max_stdout_bytes,
max_stderr_bytes: value.max_stderr_bytes,
}
}
}
/// Result of a permission-gated short workspace tool invocation.
pub type PluginWorkspaceExecResultDto = application::PluginWorkspaceExecResult;
/// Plugin workspace text write request DTO.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
@ -6558,6 +6611,52 @@ mod tests {
);
}
#[test]
fn dto_plugin_workspace_exec_maps_manifest_scoped_transport_contract() {
let dto: PluginWorkspaceExecDto = serde_json::from_value(json!({
"pluginId": "dev.acme.tools",
"projectId": Uuid::from_u128(346).to_string(),
"command": "tool",
"args": ["status", "--short"],
"cwd": ".",
"env": {"NO_COLOR": "1"},
"stdin": "input",
"timeoutMs": 5000,
"maxStdoutBytes": 256000,
"maxStderrBytes": 64000
}))
.unwrap();
let input: application::PluginWorkspaceExecInput = dto.into();
assert_eq!(input.plugin_id, "dev.acme.tools");
assert_eq!(input.command, "tool");
assert_eq!(input.args, ["status", "--short"]);
assert_eq!(input.env, [("NO_COLOR".to_owned(), "1".to_owned())]);
let result = application::PluginWorkspaceExecResult {
exit_code: Some(0),
signal: None,
timed_out: false,
stdout: "ok\n".to_owned(),
stderr: String::new(),
stdout_truncated: false,
stderr_truncated: false,
duration_ms: 8,
};
assert_eq!(
serde_json::to_value(result).unwrap(),
json!({
"exitCode": 0,
"signal": null,
"timedOut": false,
"stdout": "ok\n",
"stderr": "",
"stdoutTruncated": false,
"stderrTruncated": false,
"durationMs": 8
})
);
}
#[test]
fn dto_plugins_project_structure_query_maps_to_application_input() {
let dto = PluginProjectStructureQueryDto {

View File

@ -41,16 +41,16 @@ use application::{
OpenPluginLayoutWindow, OpenProject, OpenTerminal, OpenTicketAssistant, OrchestratorService,
PairAttemptLimiter, PairDevice, PermissionProjectorRegistry, PluginCommandTasks,
PluginConfigDocuments, PluginEventSubscriptions, PluginStorageAccess,
PluginToolchainDiagnostics, PluginWorkspaceAccess, ProjectPluginResolver, ProposeContext,
PurgeManagedTemp, QueryProjectStructure, ReadAgentContext, ReadChatDraft, ReadContext,
ReadConversationPage, ReadIssue, ReadIssueAttachment, ReadIssueCarnet, ReadMcpToolPermissions,
ReadMemory, ReadMemoryIndex, ReadOpenProjectRestoreState, ReadProjectContext,
ReadProjectSettings, ReadSkill, ReadTemplate, ReadUserSettings, RecallMemory, ReconcileLayouts,
ReconcileLiveState, ReconcileLiveStateInput, ReconcilePluginMcpServers,
RecordProjectClosedForRestore, RecordProjectOpenedForRestore, RecordTurn, RecordTurnProvider,
ReferenceProfiles, ReleaseManagedTemp, ReloadPlugin, ReloadPluginInput,
RemoveProjectFromRegistry, RenameDevice, RenameLayout, RenameSprint, ReorderSprints,
ResizeTerminal, ResolveAgentCapabilities, ResolveAgentPermissions,
PluginToolchainDiagnostics, PluginWorkspaceAccess, PluginWorkspaceExecTool,
ProjectPluginResolver, ProposeContext, PurgeManagedTemp, QueryProjectStructure,
ReadAgentContext, ReadChatDraft, ReadContext, ReadConversationPage, ReadIssue,
ReadIssueAttachment, ReadIssueCarnet, ReadMcpToolPermissions, ReadMemory, ReadMemoryIndex,
ReadOpenProjectRestoreState, ReadProjectContext, ReadProjectSettings, ReadSkill, ReadTemplate,
ReadUserSettings, RecallMemory, ReconcileLayouts, ReconcileLiveState, ReconcileLiveStateInput,
ReconcilePluginMcpServers, RecordProjectClosedForRestore, RecordProjectOpenedForRestore,
RecordTurn, RecordTurnProvider, ReferenceProfiles, ReleaseManagedTemp, ReloadPlugin,
ReloadPluginInput, RemoveProjectFromRegistry, RenameDevice, RenameLayout, RenameSprint,
ReorderSprints, ResizeTerminal, ResolveAgentCapabilities, ResolveAgentPermissions,
ResolveAgentSystemPermissions, ResolveManagedTemp, ResolveMemoryLinks, RestartCellAgentRuntime,
RestoreOpenProjects, RestoreOpenProjectsOutput, RestoreOpenWindows, RetryBackgroundTask,
RetryOpenProjectRestore, ReviewPluginPackage, RevokeAllDevices, RevokeDevice,
@ -102,20 +102,21 @@ use infrastructure::{
embedder_from_profile, AdaptiveMemoryRecall, BackgroundCompletionSink,
BackgroundTaskReadyToDeliver, ClaudePermissionProjector, ClaudeTranscriptInspector,
CliAgentRuntime, CodexPermissionProjector, CommandBackgroundRunner,
EmbeddedCompatibilityMatrix, EmbedderEnvProbe, ExternalMcpPluginSupervisor,
FsAssistantContextStore, FsBackgroundTaskStore, FsChatAttachmentStore, FsChatDraftStore,
FsConversationLog, FsDeviceSessionStore, FsEmbedderProfileStore, FsEmbedderPromptStore,
FsHandoffStore, FsIssueNumberAllocator, FsIssueStore, FsLiveStateStore, FsManagedTempStore,
FsMcpToolPermissionStore, FsMemoryStore, FsModelServerRegistry, FsOpenProjectRestoreStore,
FsOrchestratorWatcher, FsPermissionStore, FsPluginPackageStore, FsPluginRegistryStore,
FsPluginStorageStore, FsProfileStore, FsProjectPluginSettingsStore, FsProjectSettingsStore,
FsProjectStore, FsProviderSessionStore, FsScheduledProjectTaskStore, FsSecretStore,
FsSkillStore, FsSprintStore, FsSystemPermissionStore, FsTemplateStore, FsUserSettingsStore,
FsWindowStateStore, FsWorkspaceToolProvider, Git2Repository, HeuristicHandoffSummarizer,
HfModelArtifactDownloader, HttpOpenAiCompatibleProbe, HttpProviderModelCatalogue,
IdeaiContextStore, InMemoryConversationRegistry, InMemoryMailbox, InMemoryPairAttemptLimiter,
LlamaCppRuntime, LocalEnvironmentReader, LocalFileSystem, LocalManagedProcess,
LocalProcessSpawner, ManagedTempToolProvider, McpServer, MediatedInbox, NaiveMemoryRecall,
DiagnosticPluginWorkspaceExecAudit, EmbeddedCompatibilityMatrix, EmbedderEnvProbe,
ExternalMcpPluginSupervisor, FsAssistantContextStore, FsBackgroundTaskStore,
FsChatAttachmentStore, FsChatDraftStore, FsConversationLog, FsDeviceSessionStore,
FsEmbedderProfileStore, FsEmbedderPromptStore, FsHandoffStore, FsIssueNumberAllocator,
FsIssueStore, FsLiveStateStore, FsManagedTempStore, FsMcpToolPermissionStore, FsMemoryStore,
FsModelServerRegistry, FsOpenProjectRestoreStore, FsOrchestratorWatcher, FsPermissionStore,
FsPluginPackageStore, FsPluginRegistryStore, FsPluginStorageStore, FsProfileStore,
FsProjectPluginSettingsStore, FsProjectSettingsStore, FsProjectStore, FsProviderSessionStore,
FsScheduledProjectTaskStore, FsSecretStore, FsSkillStore, FsSprintStore,
FsSystemPermissionStore, FsTemplateStore, FsUserSettingsStore, FsWindowStateStore,
FsWorkspaceToolProvider, Git2Repository, HeuristicHandoffSummarizer, HfModelArtifactDownloader,
HttpOpenAiCompatibleProbe, HttpProviderModelCatalogue, IdeaiContextStore,
InMemoryConversationRegistry, InMemoryMailbox, InMemoryPairAttemptLimiter, LlamaCppRuntime,
LocalEnvironmentReader, LocalFileSystem, LocalManagedProcess, LocalProcessSpawner,
LocalWorkspaceToolRunner, ManagedTempToolProvider, McpServer, MediatedInbox, NaiveMemoryRecall,
OrchestratorWatchHandle, PortablePtyAdapter, ProcessCliVersionReader,
ReadOnlyRuntimePermissionProbe, RwFileGuard, StructuredSessionFactory, SystemClock,
SystemMillisClock, TemplateToolProvider, TicketAssistantEnvironmentPreparer,
@ -2319,6 +2320,8 @@ pub struct BackendCore {
pub reconcile_plugin_mcp_servers: Arc<ReconcilePluginMcpServers>,
/// Public plugin workspace/file access facade.
pub plugin_workspace_access: Arc<PluginWorkspaceAccess>,
/// Permission-gated short workspace tool execution facade.
pub plugin_workspace_exec: Arc<PluginWorkspaceExecTool>,
/// Public plugin structured config document facade.
pub plugin_config_documents: Arc<PluginConfigDocuments>,
/// Public plugin-owned storage facade.
@ -3855,6 +3858,17 @@ impl BackendCore {
PluginWorkspaceAccess::new(Arc::clone(&store_port), Arc::clone(&fs_port))
.with_events(Arc::clone(&events_port)),
);
let plugin_workspace_exec = Arc::new(PluginWorkspaceExecTool::new(
Arc::clone(&store_port),
Arc::clone(&fs_port),
Arc::clone(&project_plugin_resolver),
Arc::new(LocalWorkspaceToolRunner::new(
infrastructure::default_enforcer(),
)),
Arc::clone(&environment_reader_port),
Arc::clone(&clock) as Arc<dyn Clock>,
Arc::new(DiagnosticPluginWorkspaceExecAudit),
));
let plugin_config_documents = Arc::new(
PluginConfigDocuments::new(Arc::clone(&store_port), Arc::clone(&fs_port))
.with_events(Arc::clone(&events_port)),
@ -4565,6 +4579,7 @@ impl BackendCore {
list_plugin_runtime_contributions,
reconcile_plugin_mcp_servers,
plugin_workspace_access,
plugin_workspace_exec,
plugin_config_documents,
plugin_storage_access,
query_project_structure,

View File

@ -249,11 +249,13 @@ pub use plugin::{
PluginError, PluginId, PluginInstallSource, PluginLayoutContribution, PluginLayoutType,
PluginLifecycleState, PluginManifest, PluginMcpServerContribution, PluginMcpServerId,
PluginMcpServerSpec, PluginMcpStatus, PluginMcpStatusSet, PluginMcpToolMetadata,
PluginMenuItemContribution, PluginPackageRef, PluginRegistry, PluginRegistryEntry,
PluginSkillContribution, PluginSkillId, PluginSkillRef, PluginSlashCommandContribution,
PluginToolRef, PluginTopLevelMenuContribution, PluginTrustLevel, PluginVersion,
ProjectPluginActivation, ProjectPluginSettings, RelativePath, RemovalOutcome,
StagedPluginPackage,
PluginMenuItemContribution, PluginPackageRef, PluginPermissions, PluginRegistry,
PluginRegistryEntry, PluginSkillContribution, PluginSkillId, PluginSkillRef,
PluginSlashCommandContribution, PluginToolRef, PluginTopLevelMenuContribution,
PluginTrustLevel, PluginVersion, PluginWorkspaceExecAuditEntry,
PluginWorkspaceExecAuditOutcome, PluginWorkspaceExecEffect, PluginWorkspaceExecPermission,
PluginWorkspaceExecRule, ProjectPluginActivation, ProjectPluginSettings, RelativePath,
RemovalOutcome, StagedPluginPackage,
};
pub use sandbox::{
@ -290,11 +292,12 @@ pub use ports::{
ModelArtifactResolution, Output, OutputStream, PermissionStore, PluginManifestBytes,
PluginManifestError, PluginManifestValidator, PluginMcpError, PluginMcpSupervisor,
PluginPackageStore, PluginRegistryError, PluginRegistryStore, PluginStorageError,
PluginStorageStore, PluginStoreError, PreparedContext, ProcessError, ProcessSpawner,
ProfileStore, ProjectPluginSettingsStore, ProjectSettingsStore, ProjectStore,
ProviderModelCatalogue, PtyError, PtyHandle, PtyPort, RemoteError, RemoteHost, RemotePath,
RuntimeError, RuntimePermissionProbe, ScheduledProjectTaskStore, ScheduledTask,
PluginStorageStore, PluginStoreError, PluginWorkspaceExecAuditSink, PreparedContext,
ProcessError, ProcessSpawner, ProfileStore, ProjectPluginSettingsStore, ProjectSettingsStore,
ProjectStore, ProviderModelCatalogue, PtyError, PtyHandle, PtyPort, RemoteError, RemoteHost,
RemotePath, RuntimeError, RuntimePermissionProbe, ScheduledProjectTaskStore, ScheduledTask,
ScheduledTaskStoreError, Scheduler, SpawnSpec, SprintStore, SprintStoreError, StoreError,
StructuredSessionEnvironment, StructuredSessionEnvironmentPreparer, SystemPermissionStore,
TemplateStore, UserSettingsStore, WindowStateStore,
TemplateStore, UserSettingsStore, WindowStateStore, WorkspaceToolExecError,
WorkspaceToolExecOutput, WorkspaceToolExecSpec, WorkspaceToolRunner,
};

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"));
}
}

View File

@ -1760,6 +1760,74 @@ pub trait ProcessSpawner: Send + Sync {
async fn run(&self, spec: SpawnSpec) -> Result<Output, ProcessError>;
}
/// Fully bounded request executed by the workspace-tool infrastructure adapter.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct WorkspaceToolExecSpec {
/// Bare executable name; no shell is involved.
pub command: String,
/// Exact argument vector.
pub args: Vec<String>,
/// Canonical working directory under the project root.
pub cwd: ProjectPath,
/// Minimal host-approved environment.
pub env: Vec<(String, String)>,
/// Optional UTF-8 standard input.
pub stdin: Option<String>,
/// Hard runtime deadline.
pub timeout_ms: u64,
/// Maximum retained stdout bytes.
pub max_stdout_bytes: usize,
/// Maximum retained stderr bytes.
pub max_stderr_bytes: usize,
/// Filesystem sandbox applied to the child.
pub sandbox: crate::sandbox::SandboxPlan,
}
/// Captured result of a bounded workspace-tool invocation.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct WorkspaceToolExecOutput {
/// Exit code, absent when killed by signal.
pub exit_code: Option<i32>,
/// Platform signal name/number when available.
pub signal: Option<String>,
/// Whether the host deadline killed the process.
pub timed_out: bool,
/// Retained stdout prefix.
pub stdout: Vec<u8>,
/// Retained stderr prefix.
pub stderr: Vec<u8>,
/// Whether stdout exceeded its retained limit.
pub stdout_truncated: bool,
/// Whether stderr exceeded its retained limit.
pub stderr_truncated: bool,
/// Wall-clock duration measured by the adapter.
pub duration_ms: u64,
}
/// Failure to start or supervise a bounded workspace tool.
#[derive(Debug, Clone, PartialEq, Eq, Error)]
pub enum WorkspaceToolExecError {
/// Process or pipe failure.
#[error("workspace tool execution failed: {0}")]
Execution(String),
}
/// Adapter for short, invisible, bounded external-tool invocations.
#[async_trait]
pub trait WorkspaceToolRunner: Send + Sync {
/// Runs one request to completion without creating a background-task record.
async fn run(
&self,
spec: WorkspaceToolExecSpec,
) -> Result<WorkspaceToolExecOutput, WorkspaceToolExecError>;
}
/// Metadata-only audit sink for plugin workspace tool invocations.
pub trait PluginWorkspaceExecAuditSink: Send + Sync {
/// Records one denied, failed, or completed request without output contents.
fn record(&self, entry: crate::PluginWorkspaceExecAuditEntry);
}
/// Reads host environment variables through an injected adapter.
pub trait EnvironmentReader: Send + Sync {
/// Returns one environment variable value, if present.

View File

@ -48,6 +48,7 @@ pub mod session;
pub mod sprints;
pub mod store;
pub mod timeparse;
pub mod workspace_tool;
pub use assistant::{FsAssistantContextStore, TicketAssistantEnvironmentPreparer};
pub use background_task::{
@ -126,3 +127,4 @@ pub use store::{
StubEmbedder, VectorMemoryRecall, DEFAULT_OLLAMA_BASE_URL, ONNX_CACHE_SUBDIR,
RECOMMENDED_ONNX_MODELS, VECTOR_HTTP_ENABLED, VECTOR_ONNX_ENABLED,
};
pub use workspace_tool::{DiagnosticPluginWorkspaceExecAudit, LocalWorkspaceToolRunner};

View File

@ -0,0 +1,266 @@
//! Bounded, sandboxed process adapter for the plugin workspace execution SDK.
use std::io::{Read, Write};
use std::process::{Command, Stdio};
use std::sync::Arc;
use std::time::{Duration, Instant};
use async_trait::async_trait;
use domain::ports::{
PluginWorkspaceExecAuditSink, WorkspaceToolExecError, WorkspaceToolExecOutput,
WorkspaceToolExecSpec, WorkspaceToolRunner,
};
use domain::sandbox::SandboxEnforcer;
use domain::PluginWorkspaceExecAuditEntry;
const MAX_CONCURRENT_WORKSPACE_TOOLS: usize = 4;
/// Local process runner with hard concurrency, time, output and sandbox bounds.
pub struct LocalWorkspaceToolRunner {
sandbox: Arc<dyn SandboxEnforcer>,
permits: Arc<tokio::sync::Semaphore>,
}
impl LocalWorkspaceToolRunner {
/// Builds the adapter with the selected platform sandbox enforcer.
#[must_use]
pub fn new(sandbox: Arc<dyn SandboxEnforcer>) -> Self {
Self {
sandbox,
permits: Arc::new(tokio::sync::Semaphore::new(MAX_CONCURRENT_WORKSPACE_TOOLS)),
}
}
}
#[async_trait]
impl WorkspaceToolRunner for LocalWorkspaceToolRunner {
async fn run(
&self,
spec: WorkspaceToolExecSpec,
) -> Result<WorkspaceToolExecOutput, WorkspaceToolExecError> {
let _permit = Arc::clone(&self.permits)
.acquire_owned()
.await
.map_err(|_| WorkspaceToolExecError::Execution("runner is shutting down".to_owned()))?;
let sandbox = Arc::clone(&self.sandbox);
let (tx, rx) = tokio::sync::oneshot::channel();
std::thread::spawn(move || {
let result = run_blocking(spec, sandbox.as_ref());
let _ = tx.send(result);
});
rx.await.map_err(|_| {
WorkspaceToolExecError::Execution("workspace tool runner thread stopped".to_owned())
})?
}
}
fn run_blocking(
spec: WorkspaceToolExecSpec,
sandbox: &dyn SandboxEnforcer,
) -> Result<WorkspaceToolExecOutput, WorkspaceToolExecError> {
let started = Instant::now();
sandbox
.enforce(&spec.sandbox)
.map_err(|error| WorkspaceToolExecError::Execution(format!("sandbox: {error}")))?;
let mut command = Command::new(&spec.command);
command
.args(&spec.args)
.current_dir(spec.cwd.as_str())
.env_clear()
.envs(spec.env.iter().cloned())
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped());
let mut child = command
.spawn()
.map_err(|error| WorkspaceToolExecError::Execution(format!("{}: {error}", spec.command)))?;
let stdout = child
.stdout
.take()
.ok_or_else(|| WorkspaceToolExecError::Execution("stdout pipe unavailable".to_owned()))?;
let stderr = child
.stderr
.take()
.ok_or_else(|| WorkspaceToolExecError::Execution("stderr pipe unavailable".to_owned()))?;
let stdout_limit = spec.max_stdout_bytes;
let stderr_limit = spec.max_stderr_bytes;
let stdout_reader = std::thread::spawn(move || read_bounded(stdout, stdout_limit));
let stderr_reader = std::thread::spawn(move || read_bounded(stderr, stderr_limit));
let stdin_writer = match spec.stdin {
Some(stdin) => {
let mut pipe = child.stdin.take().ok_or_else(|| {
WorkspaceToolExecError::Execution("stdin pipe unavailable".to_owned())
})?;
Some(std::thread::spawn(move || pipe.write_all(stdin.as_bytes())))
}
None => {
drop(child.stdin.take());
None
}
};
let deadline = Duration::from_millis(spec.timeout_ms);
let (status, timed_out) = loop {
if let Some(status) = child
.try_wait()
.map_err(|error| WorkspaceToolExecError::Execution(error.to_string()))?
{
break (status, false);
}
if started.elapsed() >= deadline {
child
.kill()
.map_err(|error| WorkspaceToolExecError::Execution(error.to_string()))?;
let status = child
.wait()
.map_err(|error| WorkspaceToolExecError::Execution(error.to_string()))?;
break (status, true);
}
std::thread::sleep(Duration::from_millis(5));
};
let (stdout, stdout_truncated) = stdout_reader.join().map_err(|_| {
WorkspaceToolExecError::Execution("stdout reader thread panicked".to_owned())
})??;
let (stderr, stderr_truncated) = stderr_reader.join().map_err(|_| {
WorkspaceToolExecError::Execution("stderr reader thread panicked".to_owned())
})??;
if let Some(writer) = stdin_writer {
writer
.join()
.map_err(|_| {
WorkspaceToolExecError::Execution("stdin writer thread panicked".to_owned())
})?
.or_else(|error| {
if error.kind() == std::io::ErrorKind::BrokenPipe {
Ok(())
} else {
Err(error)
}
})
.map_err(|error| WorkspaceToolExecError::Execution(error.to_string()))?;
}
Ok(WorkspaceToolExecOutput {
exit_code: status.code(),
signal: exit_signal(&status),
timed_out,
stdout,
stderr,
stdout_truncated,
stderr_truncated,
duration_ms: u64::try_from(started.elapsed().as_millis()).unwrap_or(u64::MAX),
})
}
fn read_bounded(
mut reader: impl Read,
limit: usize,
) -> Result<(Vec<u8>, bool), WorkspaceToolExecError> {
let mut retained = Vec::with_capacity(limit.min(8 * 1024));
let mut truncated = false;
let mut chunk = [0_u8; 8 * 1024];
loop {
let read = reader
.read(&mut chunk)
.map_err(|error| WorkspaceToolExecError::Execution(error.to_string()))?;
if read == 0 {
break;
}
let remaining = limit.saturating_sub(retained.len());
let keep = remaining.min(read);
retained.extend_from_slice(&chunk[..keep]);
truncated |= keep < read;
}
Ok((retained, truncated))
}
#[cfg(unix)]
fn exit_signal(status: &std::process::ExitStatus) -> Option<String> {
use std::os::unix::process::ExitStatusExt;
status.signal().map(|signal| signal.to_string())
}
#[cfg(not(unix))]
fn exit_signal(_status: &std::process::ExitStatus) -> Option<String> {
None
}
/// Structured diagnostic audit sink that never serializes captured output.
#[derive(Debug, Default, Clone, Copy)]
pub struct DiagnosticPluginWorkspaceExecAudit;
impl PluginWorkspaceExecAuditSink for DiagnosticPluginWorkspaceExecAudit {
fn record(&self, entry: PluginWorkspaceExecAuditEntry) {
match serde_json::to_string(&entry) {
Ok(json) => eprintln!("[plugin-workspace-exec] {json}"),
Err(error) => eprintln!("[plugin-workspace-exec] audit serialization failed: {error}"),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use domain::permission::Posture;
use domain::sandbox::SandboxPlan;
use domain::ProjectPath;
#[test]
fn bounded_reader_drains_but_retains_only_prefix() {
let (bytes, truncated) = read_bounded(&b"abcdef"[..], 3).unwrap();
assert_eq!(bytes, b"abc");
assert!(truncated);
}
fn spec(args: &[&str], timeout_ms: u64, stdout_limit: usize) -> WorkspaceToolExecSpec {
WorkspaceToolExecSpec {
command: "sh".to_owned(),
args: args.iter().map(|value| (*value).to_owned()).collect(),
cwd: ProjectPath::new("/").unwrap(),
env: vec![("PATH".to_owned(), "/usr/bin:/bin".to_owned())],
stdin: None,
timeout_ms,
max_stdout_bytes: stdout_limit,
max_stderr_bytes: 32,
sandbox: SandboxPlan {
allowed: Vec::new(),
default_posture: Posture::Allow,
},
}
}
#[tokio::test]
async fn local_runner_times_out_and_reaps_process() {
let runner = LocalWorkspaceToolRunner::new(Arc::new(crate::sandbox::NoopSandbox));
let result = runner.run(spec(&["-c", "sleep 1"], 20, 32)).await.unwrap();
assert!(result.timed_out);
assert!(result.duration_ms < 1_000);
}
#[tokio::test]
async fn local_runner_timeout_is_not_blocked_by_unread_stdin() {
let runner = LocalWorkspaceToolRunner::new(Arc::new(crate::sandbox::NoopSandbox));
let mut request = spec(&["-c", "sleep 1"], 20, 32);
request.stdin = Some("x".repeat(64_000));
let result = runner.run(request).await.unwrap();
assert!(result.timed_out);
assert!(result.duration_ms < 1_000);
}
#[tokio::test]
async fn local_runner_keeps_streams_separate_and_truncates_output() {
let runner = LocalWorkspaceToolRunner::new(Arc::new(crate::sandbox::NoopSandbox));
let result = runner
.run(spec(&["-c", "printf 123456; printf err >&2"], 1_000, 3))
.await
.unwrap();
assert_eq!(result.stdout, b"123");
assert_eq!(result.stderr, b"err");
assert!(result.stdout_truncated);
assert!(!result.stderr_truncated);
}
}