Compare commits

..

1 Commits

Author SHA1 Message Date
8ff0f475de feat(sdk): contrat permissions.workspaceExec pour l'exécution contrôlée via l'hôte — #346
Cadrage capacités plugin SCM (#346): le manifeste plugin gagne
permissions.workspaceExec.allow — règles d'exécution contrôlée par l'hôte
(commande bare ASCII résolue sans shell, argv exact avec jeton `*` en
suffixe, cwd project-relative sûr, effect read/write/network/destructive,
64 règles max) — au lieu de lectures ad hoc du store interne .git.
Validation manifeste + exports runtime/index + docs README/manifest/
services + auto-check script étendu (règles valides acceptées, règles
unsafe rejetées).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-13 12:07:56 +02:00
7 changed files with 196 additions and 1 deletions

View File

@ -14,7 +14,8 @@ exports `activate(ctx)`.
- [Commands And Feedback](docs/commands-and-feedback.md): command return values, skipped work and background tasks.
- [Layouts With React](docs/layouts-react.md): React/JSX/hooks layout authoring with host-provided React.
- [Windows](docs/windows.md): opening plugin layouts in detached OS windows.
- [Services](docs/services.md): workspace, tasks, tooling, events, config, terminal and windows facades.
- [Services](docs/services.md): workspace files and bounded `workspace.execTool`, tasks,
tooling, events, config, terminal and windows facades.
- [Packaging And Distribution](docs/packaging-distribution.md): build output, archive layout, hot reload and dependency rules.
- [Codex Skills And IdeA Plugins](docs/codex-skills-and-idea-plugins.md): separate IdeA-native plugin skills from Codex plugin discovery.
- [Project Plugin Assignments](docs/project-plugin-assignments.md): enable plugins per project and assign skills and MCP tools to agents.
@ -23,6 +24,12 @@ The installable example lives in [`examples/hello-plugin`](examples/hello-plugin
It demonstrates a menu command, storage, background-task feedback, a React layout
component and `services.windows.open(...)`.
For short project-scoped external tools, use
`ctx.services.workspace.execTool()` with an explicit manifest
`permissions.workspaceExec` allow rule. See [Services](docs/services.md) and
[Manifest](docs/manifest.md). The primitive is generic and does not expose a
native Git/SCM API.
## Install
```sh

View File

@ -141,6 +141,39 @@ development loop.
- `activationScope`: `"app"` by default, or `"project"` when activation needs a
focused project immediately.
- `capabilities`: `"ui"`, `"tooling"` and/or `"mcp"`.
- `permissions.workspaceExec`: explicit generic executable rules used by
`ctx.services.workspace.execTool()`.
## Workspace execution permissions
Short project-scoped executable access is denied unless the active plugin
manifest contains a matching rule:
```json
{
"permissions": {
"workspaceExec": {
"allow": [
{
"command": "tool",
"args": ["status", "*"],
"cwd": ".",
"effect": "read"
}
]
}
}
}
```
`command` is a bare ASCII executable name; paths and shell command lines are
rejected. `args` is an argv pattern: literal tokens match exactly, `*` matches
one token, and a final `*` matches the remaining suffix (including no token).
`cwd` is an exact normalized project-relative directory; `*` permits any cwd
that the host still proves is under the project root. `effect` is one of
`read`, `write`, `network`, or `destructive` and drives sandbox/audit policy.
The manifest declares generic process access only; it does not add native SCM
semantics to IdeA.
## Contributions

View File

@ -29,6 +29,38 @@ Key APIs:
- `stat(path, projectId?)`
- `watch(path, handler, projectId?)`
- `queryStructure(query?)`
- `execTool(request)`
### Short external tools
`services.workspace.execTool()` runs a short external executable already available
to the host. It is generic: IdeA does not expose Git, SVN, formatter, or linter
semantics. The plugin owns the arguments and parses the returned output.
```ts
const result = await ctx.services!.workspace.execTool({
projectId,
command: "tool",
args: ["status", "--short"],
cwd: ".",
timeoutMs: 5_000,
maxStdoutBytes: 256_000,
maxStderrBytes: 64_000
});
```
The host passes `command` and `args` directly without a shell. The plugin must
declare a matching `permissions.workspaceExec.allow` rule, be active for the
project, and use a project-relative `cwd`. IdeA canonicalizes the cwd, bounds
time/stdin/output/concurrency, exposes only an allowlisted environment, applies
the project filesystem sandbox where supported, and audits metadata without
captured stdout/stderr contents. This API returns output directly and never
creates a visible Work/background-task entry.
Current host maxima are 30 seconds, 1,000,000 stdout bytes, 256,000 stderr bytes,
and 64,000 stdin bytes. Requested output/time values above a maximum are clamped;
zero is rejected. Output is returned as lossy UTF-8 with explicit truncation
flags.
## Tasks

View File

@ -36,3 +36,26 @@ duplicate.contributes.mcpServers[0].tools.push({ name: "read_scene" });
const duplicateResult = validatePluginManifest(duplicate);
assert.equal(duplicateResult.success, false, "duplicate tool metadata is rejected");
assert.ok(duplicateResult.errors.some((error) => error.includes("must be unique")));
const withWorkspaceExec = structuredClone(base);
withWorkspaceExec.permissions = {
workspaceExec: {
allow: [{ command: "tool", args: ["status", "*"], cwd: ".", effect: "read" }]
}
};
assert.equal(
validatePluginManifest(withWorkspaceExec).success,
true,
"valid generic workspace execution permissions are accepted"
);
for (const invalidRule of [
{ command: "sh -c", args: [], cwd: ".", effect: "read" },
{ command: "tool", args: ["sta*"], cwd: ".", effect: "read" },
{ command: "tool", args: [], cwd: "../outside", effect: "read" },
{ command: "tool", args: [], cwd: ".", effect: "git" }
]) {
const invalid = structuredClone(base);
invalid.permissions = { workspaceExec: { allow: [invalidRule] } };
assert.equal(validatePluginManifest(invalid).success, false, "unsafe workspace exec rule is rejected");
}

View File

@ -2,6 +2,9 @@ export type {
IdeAPluginActivationScope,
IdeAPluginCapability,
IdeAPluginManifest,
IdeAPluginPermissions,
IdeAPluginWorkspaceExecEffect,
IdeAPluginWorkspaceExecRule,
IdeAPluginEngineConstraints,
IdeAPluginLayoutContribution,
IdeAPluginMcpServerContribution,
@ -78,6 +81,8 @@ export type {
WorkspaceBinaryFile,
WorkspaceDirEntry,
WorkspaceDirectoryListing,
WorkspaceExecToolRequest,
WorkspaceExecToolResult,
WorkspaceFileChangedEvent,
WorkspaceProject,
WorkspaceResolvedPath,

View File

@ -13,6 +13,7 @@ export interface IdeAPluginManifest {
* Defaults to "app". Use "project" when activate(ctx) requires a focused project.
*/
activationScope?: IdeAPluginActivationScope;
permissions?: IdeAPluginPermissions;
contributes?: {
skills?: IdeAPluginSkillContribution[];
menus?: IdeAPluginTopLevelMenuContribution[];
@ -23,6 +24,24 @@ export interface IdeAPluginManifest {
};
}
export type IdeAPluginWorkspaceExecEffect = "read" | "write" | "network" | "destructive";
export interface IdeAPluginWorkspaceExecRule {
/** Bare executable name resolved by the host without a shell. */
command: string;
/** Exact argv tokens. `*` matches one token, or the remaining suffix when last. */
args?: string[];
/** Exact project-relative cwd; `*` permits any host-confined cwd. Defaults to `.`. */
cwd?: string;
effect: IdeAPluginWorkspaceExecEffect;
}
export interface IdeAPluginPermissions {
workspaceExec?: {
allow: IdeAPluginWorkspaceExecRule[];
};
}
export type IdeAPluginCapability = "ui" | "mcp" | "tooling";
export type IdeAPluginActivationScope = "app" | "project";
@ -145,6 +164,7 @@ export function validatePluginManifest(input: unknown): PluginManifestValidation
validateEngines(input.engines, errors);
validateCapabilities(input.capabilities, errors);
validateActivationScope(input.activationScope, errors);
validatePermissions(input.permissions, errors);
validateContributes(input.contributes, errors);
if (errors.length > 0) {
@ -154,6 +174,56 @@ export function validatePluginManifest(input: unknown): PluginManifestValidation
return { success: true, data: input as unknown as IdeAPluginManifest, errors: [] };
}
function validatePermissions(value: unknown, errors: string[]): void {
if (value === undefined) return;
if (!isRecord(value)) {
errors.push("permissions must be an object when provided");
return;
}
const workspaceExec = value.workspaceExec;
if (workspaceExec === undefined) return;
if (!isRecord(workspaceExec) || !Array.isArray(workspaceExec.allow)) {
errors.push("permissions.workspaceExec.allow must be an array");
return;
}
if (workspaceExec.allow.length > 64) {
errors.push("permissions.workspaceExec.allow must contain at most 64 rules");
}
workspaceExec.allow.forEach((rule, index) => {
const label = `permissions.workspaceExec.allow[${index}]`;
if (!isRecord(rule)) {
errors.push(`${label} must be an object`);
return;
}
requireString(rule, "command", errors, `${label}.command`);
if (typeof rule.command === "string" && !/^[A-Za-z0-9._+-]{1,128}$/.test(rule.command)) {
errors.push(`${label}.command must be a bare ASCII executable name`);
}
optionalStringArray(rule, "args", errors, `${label}.args`);
if (Array.isArray(rule.args)) {
if (rule.args.length > 64 || rule.args.some((arg) =>
typeof arg === "string" && (arg.length === 0 || arg.length > 4096 || (arg.includes("*") && arg !== "*")))) {
errors.push(`${label}.args contains an invalid token`);
}
}
optionalString(rule, "cwd", errors, `${label}.cwd`);
if (typeof rule.cwd === "string" && rule.cwd !== "*" && !isSafeWorkspaceCwd(rule.cwd)) {
errors.push(`${label}.cwd must be project-relative without empty, dot, or parent segments`);
}
if (!["read", "write", "network", "destructive"].includes(String(rule.effect))) {
errors.push(`${label}.effect must be "read", "write", "network" or "destructive"`);
}
});
}
function isSafeWorkspaceCwd(value: string): boolean {
if (value === ".") return true;
if (value.length === 0 || value.startsWith("/") || value.startsWith("\\") || value.includes(":")) {
return false;
}
return value.replaceAll("\\", "/").split("/").every((part) => part !== "" && part !== "." && part !== "..");
}
export function isPluginManifest(input: unknown): input is IdeAPluginManifest {
return validatePluginManifest(input).success;
}

View File

@ -137,6 +137,31 @@ export interface WorkspaceService {
watch(path: string, handler: WorkspaceWatchHandler, projectId?: string): Promise<WorkspaceWatch>;
/** Queries a bounded, generic project structure read model. */
queryStructure(query?: WorkspaceStructureQuery): Promise<ProjectStructure>;
/** Runs one manifest-allowlisted binary directly, without a visible task. */
execTool(request: WorkspaceExecToolRequest): Promise<WorkspaceExecToolResult>;
}
export interface WorkspaceExecToolRequest {
projectId?: string;
command: string;
args?: string[];
cwd?: string;
env?: Record<string, string>;
timeoutMs?: number;
maxStdoutBytes?: number;
maxStderrBytes?: number;
stdin?: string;
}
export interface WorkspaceExecToolResult {
exitCode: number | null;
signal?: string;
timedOut: boolean;
stdout: string;
stderr: string;
stdoutTruncated: boolean;
stderrTruncated: boolean;
durationMs: number;
}
export interface WorkspaceResolvedPath {