Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions docs/development/task-backlog.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:**

Expand Down
128 changes: 128 additions & 0 deletions packages/runtime-adapters/src/execution/shell-adapter.test.ts
Original file line number Diff line number Diff line change
@@ -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"]);
});
});
159 changes: 159 additions & 0 deletions packages/runtime-adapters/src/execution/shell-adapter.ts
Original file line number Diff line number Diff line change
@@ -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<ShellCommandResult>;
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<ShellCommandResult> {
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,
};
}
3 changes: 3 additions & 0 deletions packages/runtime-adapters/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";