Compare commits

..

6 Commits

Author SHA1 Message Date
2a3c582ec8 merge(sdk): fix #329 — feedbacks menu host génériques et doc SDK alignée (QA verte: npm run check, git diff --check) — docs only, aucun push 2026-09-19 00:33:57 +02:00
32d33b330f docs(sdk): feedbacks menu host génériques — #329 ; README + docs commands-and-feedback/menus/services : failed/skipped sans message Git codé en dur ni payload brut, opened/alreadyOpen succès silencieux, logs host pluginId/commandId/status/reason/message, vue de diagnostic reste accessible quand Git/ADB/Gradle ou autre outil optionnel manque, workspace.execTool rappelé ; QA verte : npm run check, git diff --check 2026-09-19 00:33:57 +02:00
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
720cd53924 merge(sdk): fix #336 — contrat docs commandes/menus apres hot reload (erreur retryable, feedback host-owned, contrat manifest/registration, reload sans refresh auto)
# Conflicts:
#	docs/menus.md
2026-09-12 14:06:54 +02:00
9efb781636 docs(sdk): rejet de promesse windows.open remonte au handler + rejet des commandes stale d'un runtime remplace (identite plugin) — #336 (docs only, aucun push) 2026-09-12 14:06:00 +02:00
116b75a024 docs(sdk): contrat commandes/menus apres reload — handler manquant = erreur retryable jamais no-op silencieux, feedback skipped/failed host-owned, pas de contributes.commands separe, reload sans refresh auto par le plugin — #336 (docs only, aucun push) 2026-09-12 14:05:52 +02:00
11 changed files with 279 additions and 20 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. - [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. - [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. - [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. - [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. - [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. - [Project Plugin Assignments](docs/project-plugin-assignments.md): enable plugins per project and assign skills and MCP tools to agents.
@ -23,6 +24,18 @@ The installable example lives in [`examples/hello-plugin`](examples/hello-plugin
It demonstrates a menu command, storage, background-task feedback, a React layout It demonstrates a menu command, storage, background-task feedback, a React layout
component and `services.windows.open(...)`. 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.
Menu commands that open or focus a plugin view return `status: "opened"`
(including `alreadyOpen: true`) and remain UI-silent on success. Keep diagnostic
views accessible when optional tools are missing, and use the structured
`skipped`/`failed` feedback contract for genuinely impossible actions; see
[Commands And Feedback](docs/commands-and-feedback.md) and [Menus](docs/menus.md).
## Install ## Install
```sh ```sh

View File

@ -1,7 +1,7 @@
# Commands And Feedback # Commands And Feedback
This document is the normative SDK contract for plugin commands that may launch This document is the normative SDK contract for plugin commands, including
background work. commands that open a view or launch background work.
## Contract ## Contract
@ -25,14 +25,17 @@ manifest contribution -> command id -> registered command handler -> optional ta
not by a fake task. not by a fake task.
- Feedback objects must be stable enough for agents and programmatic callers to - Feedback objects must be stable enough for agents and programmatic callers to
parse, and their messages must be readable by humans. parse, and their messages must be readable by humans.
- The current human menu-click UI does not guarantee display of a command - The human menu-click UI presents minimal host-owned feedback for `skipped`
handler return value. When a human must see feedback from a menu action, open and `failed` results. It intentionally does not expose the raw result payload
or focus a plugin layout/window from the handler with or assume which tool (Git, ADB, Gradle, and so on) the plugin uses.
For successful actions and richer status, open or focus a plugin layout/window
from the handler with
`ctx.services.windows.open({ layoutType, state })`. `ctx.services.windows.open({ layoutType, state })`.
## Preconditions ## Preconditions
Check preconditions before calling `runCommand()`: Check preconditions before calling `runCommand()`. These are task-launch
preconditions, not prerequisites for opening an explanatory view:
- `ctx.services` exists. Plugins need the `ui` or `tooling` capability for the - `ctx.services` exists. Plugins need the `ui` or `tooling` capability for the
service facade. service facade.
@ -49,6 +52,12 @@ Check preconditions before calling `runCommand()`:
- Do not use `ctx.pluginRoot` as `cwd`; `cwd` remains relative to the project root. - Do not use `ctx.pluginRoot` as `cwd`; `cwd` remains relative to the project root.
- `command` and `args` are separate values. Do not shell-join user input. - `command` and `args` are separate values. Do not shell-join user input.
A command whose purpose is to open or focus a diagnostic/configuration view
should call `services.windows.open(...)` before probing optional executables.
Missing Git, ADB, Gradle, or another tool must not block that view when the view
can explain the degraded state and the next step. Probe tools from the view (or
from its data loader) and render the degraded state there.
If any required precondition fails, return a skipped result: If any required precondition fails, return a skipped result:
```ts ```ts
@ -66,21 +75,30 @@ with equivalent fields:
```ts ```ts
type CommandFeedback = type CommandFeedback =
| { status: "opened"; message?: string; alreadyOpen: boolean }
| { status: "skipped"; reason: string; message: string } | { status: "skipped"; reason: string; message: string }
| { status: "failed"; reason?: string; message?: string }
| { status: "launched"; taskId: string; state: string; message: string }; | { status: "launched"; taskId: string; state: string; message: string };
``` ```
Use the same `status` vocabulary consistently: Use the same `status` vocabulary consistently:
- `opened`: the requested view was opened or focused. `alreadyOpen: true` is a
successful focus operation, not a failure or skip.
- `skipped`: no background task was created. - `skipped`: no background task was created.
- `launched`: a task was created; inspect the task state for later progress. - `launched`: a task was created; inspect the task state for later progress.
- `failed`: the handler itself failed before it could return normally. - `failed`: the handler caught a failure and returned structured failure
feedback. An uncaught/rejected handler is also treated as a failure by the
host.
Visible surfaces are intentionally distinct: Visible surfaces are intentionally distinct:
- Command return value: immediate feedback for programmatic callers, agents and - Command return value: immediate feedback for programmatic callers and agents.
future host surfaces. It is not a guaranteed visible UI surface for current The human menu host maps `skipped` and `failed` results to concise feedback;
human menu clicks. plugin-specific fields and messages are not displayed verbatim. `opened`
results, including `alreadyOpen: true`, succeed without an alert. The host
logs structured diagnostics with `pluginId`, `commandId`, `status`, `reason`
and `message`; raw objects, stack traces and internal ids are not user-facing.
- Plugin layout/window: the normative visible feedback surface for menu actions - Plugin layout/window: the normative visible feedback surface for menu actions
that need to show status, results or next steps to a human. The layout must be that need to show status, results or next steps to a human. The layout must be
declared in `idea-plugin.json` and registered during activation. declared in `idea-plugin.json` and registered during activation.
@ -145,6 +163,11 @@ const plugin: IdeAPluginModule = {
export default plugin; export default plugin;
``` ```
The `opened` branch above must remain successful even when an optional tool
diagnosed by `HealthView` is absent. If `windows.open(...)` itself rejects, log
the technical cause and either let the promise reject or return `failed`; the
host will show generic, plugin-neutral feedback rather than the raw message.
Background task feedback: Background task feedback:
```ts ```ts

View File

@ -141,6 +141,39 @@ development loop.
- `activationScope`: `"app"` by default, or `"project"` when activation needs a - `activationScope`: `"app"` by default, or `"project"` when activation needs a
focused project immediately. focused project immediately.
- `capabilities`: `"ui"`, `"tooling"` and/or `"mcp"`. - `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 ## Contributions
@ -243,7 +276,9 @@ public id used by both `ctx.layouts.register({ type, component })` and
Contribution ids are part of the runtime contract: Contribution ids are part of the runtime contract:
- commands can only register ids declared by `contributes.menuItems[*].command`; - there is no separate `contributes.commands` collection; commands can only
register ids declared by `contributes.menuItems[*].command` or
`contributes.slashCommands[*].command`;
- layouts can only register `type` values declared by `contributes.layouts`; - layouts can only register `type` values declared by `contributes.layouts`;
- `services.windows.open({ layoutType })` only accepts a layout type declared by - `services.windows.open({ layoutType })` only accepts a layout type declared by
the calling plugin. the calling plugin.

View File

@ -33,7 +33,10 @@ Then register the command id:
```ts ```ts
export function activate(ctx: ActivateContext): void { export function activate(ctx: ActivateContext): void {
const disposable = ctx.commands?.registerCommand("hello-plugin.open", async () => { const disposable = ctx.commands?.registerCommand("hello-plugin.open", async () => {
await ctx.services?.windows.open({ layoutType: "hello-plugin.dashboard" }); const win = await ctx.services?.windows.open({ layoutType: "hello-plugin.dashboard" });
return win
? { status: "opened", alreadyOpen: win.alreadyOpen }
: { status: "skipped", reason: "ui-service-unavailable", message: "UI service unavailable." };
}); });
if (disposable) ctx.subscriptions.push(disposable); if (disposable) ctx.subscriptions.push(disposable);
} }
@ -41,9 +44,22 @@ export function activate(ctx: ActivateContext): void {
## Rules ## Rules
- A command can only be registered if at least one manifest menu item declares - There is no separate `contributes.commands` collection. A command can only be
that exact `command` id. registered if at least one manifest `menuItems[].command` or
- Missing command handlers are a no-op when the user clicks the menu item. `slashCommands[].command` entry declares that exact id.
- A missing command handler is surfaced by the host as a retryable reload error;
menu clicks must never fail silently.
- A registered handler's rejected promise is not swallowed: the host logs the
plugin id, command id, status, reason and message, then presents a generic
retryable error without raw payloads. In
particular, return/await `services.windows.open(...)` so native-window
failures follow this path.
- An open/focus handler returns `status: "opened"` whether `alreadyOpen` is
`false` (new window) or `true` (existing window focused). Neither success
produces an alert.
- Keep diagnostic views reachable when optional executables such as Git, ADB or
Gradle are missing. Let the view explain that degraded state instead of
skipping the menu action before opening it.
- A handler owns precondition checks and feedback. See - A handler owns precondition checks and feedback. See
[Commands And Feedback](commands-and-feedback.md). [Commands And Feedback](commands-and-feedback.md).
- Menu item `when` expressions are evaluated by the host; unsupported or false - Menu item `when` expressions are evaluated by the host; unsupported or false

View File

@ -150,6 +150,8 @@ These actions solve different problems:
- Reload the plugin after rebuilding or replacing its installed package. Reload - Reload the plugin after rebuilding or replacing its installed package. Reload
refreshes the plugin manifest, runtime bundle, contributions, and packaged refreshes the plugin manifest, runtime bundle, contributions, and packaged
skill Markdown. It does not change project activation or agent assignments. skill Markdown. It does not change project activation or agent assignments.
Plugins do not call a contribution-refresh API themselves: rebuild or replace
the installed package, then use the host reload action.
- Relaunch the agent after enabling or disabling a project plugin, changing its - Relaunch the agent after enabling or disabling a project plugin, changing its
skill/tool assignments, or manually editing `.ideai/project-plugins.json`. skill/tool assignments, or manually editing `.ideai/project-plugins.json`.
Agent capabilities are resolved into a launch-time snapshot, so an already Agent capabilities are resolved into a launch-time snapshot, so an already

View File

@ -29,6 +29,38 @@ Key APIs:
- `stat(path, projectId?)` - `stat(path, projectId?)`
- `watch(path, handler, projectId?)` - `watch(path, handler, projectId?)`
- `queryStructure(query?)` - `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 ## Tasks
@ -93,6 +125,13 @@ locations. Use it for configuration the user expects to review/version.
one of the calling plugin's declared layout contributions. See one of the calling plugin's declared layout contributions. See
[Windows](windows.md). [Windows](windows.md).
Resolving this promise means the window was opened or focused. Its
`alreadyOpen` flag reports which happened; both outcomes are successful and a
menu handler should return `status: "opened"`. Do not gate a diagnostic window
on optional tool availability when that window can present the unavailable
state. A rejection means the window operation itself was impossible and may be
logged or returned as `status: "failed"`; do not expose its raw error to users.
## Scheduled tasks ## Scheduled tasks
Project scheduled tasks are exposed through native Tauri/HTTP commands and the Project scheduled tasks are exposed through native Tauri/HTTP commands and the

View File

@ -24,6 +24,13 @@ do not currently guarantee that return value is displayed.
existing `contributes.layouts` layout. existing `contributes.layouts` layout.
- The host validates the plugin is runtime-active and the layout exists before - The host validates the plugin is runtime-active and the layout exists before
opening or focusing the window. opening or focusing the window.
- The calling plugin package must still be installed, enabled globally and
enabled for the focused project. A stale command from a replaced runtime is
rejected instead of opening a window under another plugin's identity.
- `windows.open(...)` rejects its promise when any validation or native-window
operation fails. Let that rejection reach the command handler (or catch it,
log it and return explicit failed feedback); the IdeA menu host logs the
rejection and shows a retryable error to the user.
- Reopening the same plugin/layout pair focuses the existing window. - Reopening the same plugin/layout pair focuses the existing window.
- `state` is JSON-serializable initial window state. The mounted component can - `state` is JSON-serializable initial window state. The mounted component can
later call `setState(next)` to update its local host state. later call `setState(next)` to update its local host state.
@ -47,13 +54,14 @@ The returned `label` is a host-owned window identity. Treat it as opaque.
```ts ```ts
ctx.commands?.registerCommand("hello-plugin.open-dashboard", async () => { ctx.commands?.registerCommand("hello-plugin.open-dashboard", async () => {
const win = await ctx.services?.windows.open({ if (!ctx.services) {
return { status: "skipped", reason: "ui-service-unavailable", message: "UI service unavailable." };
}
const win = await ctx.services.windows.open({
layoutType: "hello-plugin.dashboard", layoutType: "hello-plugin.dashboard",
state: { source: "menu" } state: { source: "menu" }
}); });
return win return { status: "opened", message: "Opened dashboard.", alreadyOpen: win.alreadyOpen };
? { status: "opened", message: "Opened dashboard.", alreadyOpen: win.alreadyOpen }
: { status: "skipped", reason: "ui-service-unavailable", message: "UI service unavailable." };
}); });
``` ```

View File

@ -36,3 +36,26 @@ duplicate.contributes.mcpServers[0].tools.push({ name: "read_scene" });
const duplicateResult = validatePluginManifest(duplicate); const duplicateResult = validatePluginManifest(duplicate);
assert.equal(duplicateResult.success, false, "duplicate tool metadata is rejected"); assert.equal(duplicateResult.success, false, "duplicate tool metadata is rejected");
assert.ok(duplicateResult.errors.some((error) => error.includes("must be unique"))); 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, IdeAPluginActivationScope,
IdeAPluginCapability, IdeAPluginCapability,
IdeAPluginManifest, IdeAPluginManifest,
IdeAPluginPermissions,
IdeAPluginWorkspaceExecEffect,
IdeAPluginWorkspaceExecRule,
IdeAPluginEngineConstraints, IdeAPluginEngineConstraints,
IdeAPluginLayoutContribution, IdeAPluginLayoutContribution,
IdeAPluginMcpServerContribution, IdeAPluginMcpServerContribution,
@ -78,6 +81,8 @@ export type {
WorkspaceBinaryFile, WorkspaceBinaryFile,
WorkspaceDirEntry, WorkspaceDirEntry,
WorkspaceDirectoryListing, WorkspaceDirectoryListing,
WorkspaceExecToolRequest,
WorkspaceExecToolResult,
WorkspaceFileChangedEvent, WorkspaceFileChangedEvent,
WorkspaceProject, WorkspaceProject,
WorkspaceResolvedPath, WorkspaceResolvedPath,

View File

@ -13,6 +13,7 @@ export interface IdeAPluginManifest {
* Defaults to "app". Use "project" when activate(ctx) requires a focused project. * Defaults to "app". Use "project" when activate(ctx) requires a focused project.
*/ */
activationScope?: IdeAPluginActivationScope; activationScope?: IdeAPluginActivationScope;
permissions?: IdeAPluginPermissions;
contributes?: { contributes?: {
skills?: IdeAPluginSkillContribution[]; skills?: IdeAPluginSkillContribution[];
menus?: IdeAPluginTopLevelMenuContribution[]; 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 IdeAPluginCapability = "ui" | "mcp" | "tooling";
export type IdeAPluginActivationScope = "app" | "project"; export type IdeAPluginActivationScope = "app" | "project";
@ -145,6 +164,7 @@ export function validatePluginManifest(input: unknown): PluginManifestValidation
validateEngines(input.engines, errors); validateEngines(input.engines, errors);
validateCapabilities(input.capabilities, errors); validateCapabilities(input.capabilities, errors);
validateActivationScope(input.activationScope, errors); validateActivationScope(input.activationScope, errors);
validatePermissions(input.permissions, errors);
validateContributes(input.contributes, errors); validateContributes(input.contributes, errors);
if (errors.length > 0) { if (errors.length > 0) {
@ -154,6 +174,56 @@ export function validatePluginManifest(input: unknown): PluginManifestValidation
return { success: true, data: input as unknown as IdeAPluginManifest, errors: [] }; 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 { export function isPluginManifest(input: unknown): input is IdeAPluginManifest {
return validatePluginManifest(input).success; return validatePluginManifest(input).success;
} }

View File

@ -137,6 +137,31 @@ export interface WorkspaceService {
watch(path: string, handler: WorkspaceWatchHandler, projectId?: string): Promise<WorkspaceWatch>; watch(path: string, handler: WorkspaceWatchHandler, projectId?: string): Promise<WorkspaceWatch>;
/** Queries a bounded, generic project structure read model. */ /** Queries a bounded, generic project structure read model. */
queryStructure(query?: WorkspaceStructureQuery): Promise<ProjectStructure>; 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 { export interface WorkspaceResolvedPath {