diff --git a/docs/development/task-backlog.md b/docs/development/task-backlog.md index 4de7aa9..e5d8a93 100644 --- a/docs/development/task-backlog.md +++ b/docs/development/task-backlog.md @@ -356,10 +356,10 @@ Status markers: **Tasks:** -- [ ] Allow only configured safe commands. -- [ ] Stream stdout and stderr events. -- [ ] Reject arbitrary commands unless permission policy allows them. -- [ ] Commit with message `feat: add safe shell runtime adapter`. +- [x] Allow only configured safe commands. +- [x] Stream stdout and stderr events. +- [x] Reject arbitrary commands unless permission policy allows them. +- [x] Commit with message `feat: add safe shell runtime adapter`. **Acceptance Criteria:** diff --git a/packages/runtime-adapters/src/execution/shell-adapter.test.ts b/packages/runtime-adapters/src/execution/shell-adapter.test.ts new file mode 100644 index 0000000..bdb4a09 --- /dev/null +++ b/packages/runtime-adapters/src/execution/shell-adapter.test.ts @@ -0,0 +1,128 @@ +import { describe, expect, it } from "vitest"; + +import { createSafeShellAdapter } from "./shell-adapter.js"; + +describe("createSafeShellAdapter", () => { + it("executes configured safe commands and streams stdout and stderr events", async () => { + const adapter = createSafeShellAdapter({ + allowedCommands: ["echo hello"], + runCommand: async (command) => { + expect(command).toBe("echo hello"); + return { + stdout: "hello", + stderr: "warn", + exitCode: 0, + }; + }, + now: () => "2026-05-29T00:00:00.000Z", + }); + + const result = await adapter.executeTask({ + taskId: "task-1", + prompt: "echo hello", + workspaceRoot: "/tmp/agentdeck", + }); + + expect(result.status).toBe("completed"); + expect(result.events).toEqual([ + { + id: "task-1-stdout-1", + taskId: "task-1", + type: "task.stdout", + createdAt: "2026-05-29T00:00:00.000Z", + payload: { text: "hello" }, + }, + { + id: "task-1-stderr-1", + taskId: "task-1", + type: "task.stderr", + createdAt: "2026-05-29T00:00:00.000Z", + payload: { text: "warn" }, + }, + { + id: "task-1-completed", + taskId: "task-1", + type: "task.completed", + createdAt: "2026-05-29T00:00:00.000Z", + payload: { exitCode: 0 }, + }, + ]); + }); + + it("rejects arbitrary commands before execution unless policy allows them", async () => { + let executed = false; + const adapter = createSafeShellAdapter({ + allowedCommands: ["echo hello"], + runCommand: async () => { + executed = true; + return { + stdout: "", + stderr: "", + exitCode: 0, + }; + }, + now: () => "2026-05-29T00:00:00.000Z", + }); + + const result = await adapter.executeTask({ + taskId: "task-1", + prompt: "rm -rf /tmp/example", + workspaceRoot: "/tmp/agentdeck", + }); + + expect(executed).toBe(false); + expect(result.status).toBe("failed"); + expect(result.events).toEqual([ + { + id: "task-1-failed", + taskId: "task-1", + type: "task.failed", + createdAt: "2026-05-29T00:00:00.000Z", + payload: { message: 'Command "rm -rf /tmp/example" is not allowed.' }, + }, + ]); + }); + + it("allows arbitrary commands when permission policy explicitly allows them", async () => { + const adapter = createSafeShellAdapter({ + allowedCommands: [], + allowArbitraryCommands: true, + runCommand: async () => ({ + stdout: "ok", + stderr: "", + exitCode: 0, + }), + now: () => "2026-05-29T00:00:00.000Z", + }); + + const result = await adapter.executeTask({ + taskId: "task-1", + prompt: "pwd", + workspaceRoot: "/tmp/agentdeck", + }); + + expect(result.status).toBe("completed"); + expect(result.events.map((event) => event.type)).toEqual(["task.stdout", "task.completed"]); + }); + + it("reports non-zero command exits as failures", async () => { + const adapter = createSafeShellAdapter({ + allowedCommands: ["false"], + runCommand: async () => ({ + stdout: "", + stderr: "bad", + exitCode: 1, + }), + now: () => "2026-05-29T00:00:00.000Z", + }); + + const result = await adapter.executeTask({ + taskId: "task-1", + prompt: "false", + workspaceRoot: "/tmp/agentdeck", + }); + + expect(result.status).toBe("failed"); + expect(result.events.map((event) => event.type)).toEqual(["task.stderr", "task.failed"]); + }); +}); diff --git a/packages/runtime-adapters/src/execution/shell-adapter.ts b/packages/runtime-adapters/src/execution/shell-adapter.ts new file mode 100644 index 0000000..0dd87d0 --- /dev/null +++ b/packages/runtime-adapters/src/execution/shell-adapter.ts @@ -0,0 +1,159 @@ +import { runProbe } from "../probe/run-probe.js"; +import { + createRuntimeAdapterCapabilities, + type RuntimeAdapter, + type RuntimeTaskResult, +} from "./runtime-adapter.js"; +import { createTaskEvent, type TaskEvent } from "./task-events.js"; + +export interface ShellCommandResult { + readonly stdout: string; + readonly stderr: string; + readonly exitCode: number | null; +} + +export interface SafeShellAdapterOptions { + readonly allowedCommands: readonly string[]; + readonly allowArbitraryCommands?: boolean; + readonly runCommand?: (command: string, cwd: string) => Promise; + readonly now?: () => string; +} + +export function createSafeShellAdapter(options: SafeShellAdapterOptions): RuntimeAdapter { + const now = options.now ?? (() => new Date().toISOString()); + const allowedCommands = new Set(options.allowedCommands); + + return { + id: "shell", + name: "Safe Shell", + runtimeType: "node", + async executeTask(request) { + const command = request.prompt.trim(); + if (!options.allowArbitraryCommands && !allowedCommands.has(command)) { + return { + taskId: request.taskId, + status: "failed", + events: [ + createTaskEvent({ + id: `${request.taskId}-failed`, + taskId: request.taskId, + type: "task.failed", + createdAt: now(), + payload: { + message: `Command "${command}" is not allowed.`, + }, + }), + ], + }; + } + + const commandResult = await (options.runCommand ?? runShellCommand)( + command, + request.workspaceRoot, + ); + return createRuntimeTaskResult({ + taskId: request.taskId, + commandResult, + now, + }); + }, + async cancelTask() { + return { + cancelled: false, + reason: "Safe shell commands are not cancellable after dispatch.", + }; + }, + getCapabilities() { + return createRuntimeAdapterCapabilities({ + streaming: true, + tools: ["shell"], + }); + }, + }; +} + +async function runShellCommand(command: string, cwd: string): Promise { + const result = await runProbe(command, { + cwd, + }); + + return { + stdout: result.stdout, + stderr: result.stderr, + exitCode: result.exitCode, + }; +} + +function createRuntimeTaskResult(input: { + readonly taskId: string; + readonly commandResult: ShellCommandResult; + readonly now: () => string; +}): RuntimeTaskResult { + const events: TaskEvent[] = []; + + if (input.commandResult.stdout.length > 0) { + events.push( + createTaskEvent({ + id: `${input.taskId}-stdout-1`, + taskId: input.taskId, + type: "task.stdout", + createdAt: input.now(), + payload: { + text: input.commandResult.stdout, + }, + }), + ); + } + + if (input.commandResult.stderr.length > 0) { + events.push( + createTaskEvent({ + id: `${input.taskId}-stderr-1`, + taskId: input.taskId, + type: "task.stderr", + createdAt: input.now(), + payload: { + text: input.commandResult.stderr, + }, + }), + ); + } + + if (input.commandResult.exitCode === 0) { + events.push( + createTaskEvent({ + id: `${input.taskId}-completed`, + taskId: input.taskId, + type: "task.completed", + createdAt: input.now(), + payload: { + exitCode: 0, + }, + }), + ); + + return { + taskId: input.taskId, + status: "completed", + events, + }; + } + + events.push( + createTaskEvent({ + id: `${input.taskId}-failed`, + taskId: input.taskId, + type: "task.failed", + createdAt: input.now(), + payload: { + message: `Command failed with exit code ${input.commandResult.exitCode ?? "unknown"}.`, + }, + }), + ); + + return { + taskId: input.taskId, + status: "failed", + events, + }; +} diff --git a/packages/runtime-adapters/src/index.ts b/packages/runtime-adapters/src/index.ts index 3576c8a..0bcea5d 100644 --- a/packages/runtime-adapters/src/index.ts +++ b/packages/runtime-adapters/src/index.ts @@ -52,3 +52,6 @@ export type { TaskStdoutEvent, TaskToolEvent, } from "./execution/task-events.js"; + +export { createSafeShellAdapter } from "./execution/shell-adapter.js"; +export type { SafeShellAdapterOptions, ShellCommandResult } from "./execution/shell-adapter.js";