diff --git a/README.md b/README.md index 90f036a..bda7292 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/docs/manifest.md b/docs/manifest.md index bac8bbb..54dc60c 100644 --- a/docs/manifest.md +++ b/docs/manifest.md @@ -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 diff --git a/docs/services.md b/docs/services.md index 7268d31..2872bc3 100644 --- a/docs/services.md +++ b/docs/services.md @@ -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 diff --git a/scripts/check-manifest-tools.mjs b/scripts/check-manifest-tools.mjs index b1ed4fa..ae990a3 100644 --- a/scripts/check-manifest-tools.mjs +++ b/scripts/check-manifest-tools.mjs @@ -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"); +} diff --git a/src/index.ts b/src/index.ts index 4304a60..3c96ff7 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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, diff --git a/src/manifest.ts b/src/manifest.ts index 06ce975..a0b1802 100644 --- a/src/manifest.ts +++ b/src/manifest.ts @@ -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; } diff --git a/src/runtime.ts b/src/runtime.ts index a61922d..efc6152 100644 --- a/src/runtime.ts +++ b/src/runtime.ts @@ -137,6 +137,31 @@ export interface WorkspaceService { watch(path: string, handler: WorkspaceWatchHandler, projectId?: string): Promise; /** Queries a bounded, generic project structure read model. */ queryStructure(query?: WorkspaceStructureQuery): Promise; + /** Runs one manifest-allowlisted binary directly, without a visible task. */ + execTool(request: WorkspaceExecToolRequest): Promise; +} + +export interface WorkspaceExecToolRequest { + projectId?: string; + command: string; + args?: string[]; + cwd?: string; + env?: Record; + 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 {