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
83 changes: 83 additions & 0 deletions src/request-serialization.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
export type RequestSerializationAccess = "exclusive" | "shared-read";

interface QueuedRequest<T = unknown> {
access: RequestSerializationAccess;
run: () => Promise<T> | T;
resolve: (value: T) => void;
reject: (error: unknown) => void;
}

export class RequestSerializationQueues {
private readonly queues = new Map<string, QueuedRequest[]>();
private readonly draining = new Set<string>();

enqueue<T>(
key: string,
access: RequestSerializationAccess,
run: () => Promise<T> | T,
): Promise<T> {
return new Promise<T>((resolve, reject) => {
const queue = this.queues.get(key) ?? [];
queue.push({ access, run, resolve, reject });
this.queues.set(key, queue);
this.drain(key);
});
}

private drain(key: string): void {
if (this.draining.has(key)) return;
this.draining.add(key);

void this.drainLoop(key).finally(() => {
this.draining.delete(key);
const queue = this.queues.get(key);
if (queue && queue.length > 0) this.drain(key);
});
}

private async drainLoop(key: string): Promise<void> {
while (true) {
const batch = this.takeNextBatch(key);
if (batch.length === 0) return;

await Promise.all(
batch.map(async (request) => {
try {
request.resolve(await request.run());
} catch (error) {
request.reject(error);
}
}),
);
}
}

private takeNextBatch(key: string): QueuedRequest[] {
const queue = this.queues.get(key);
if (!queue || queue.length === 0) {
this.queues.delete(key);
return [];
}

const first = queue.shift();
if (!first) return [];

if (first.access === "exclusive") {
if (queue.length === 0) this.queues.delete(key);
return [first];
}

const batch = [first];
while (queue[0]?.access === "shared-read") {
const next = queue.shift();
if (next) batch.push(next);
}

if (queue.length === 0) this.queues.delete(key);
return batch;
}
}

export function workspaceQueueKey(workspaceId: string): string {
return `workspace:${workspaceId}`;
}
126 changes: 126 additions & 0 deletions src/shell-policy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
import type { ShellMode } from "./config.js";

export interface ShellPolicyDecision {
allowed: boolean;
mode: ShellMode;
reason?: string;
}

const READ_ONLY_COMMANDS = new Set([
"cat",
"df",
"du",
"file",
"find",
"git",
"grep",
"head",
"ls",
"pwd",
"rg",
"stat",
"tail",
"wc",
]);

const READ_ONLY_GIT_SUBCOMMANDS = new Set([
"branch",
"diff",
"grep",
"log",
"ls-files",
"remote",
"rev-parse",
"show",
"status",
]);

const SHELL_CONTROL_PATTERNS = [/&&/, /\|\|/, /;/, /\|/, />/, /</, /`/, /\$\(/];
const DESTRUCTIVE_FIND_FLAGS = new Set(["-delete", "-exec", "-execdir", "-ok", "-okdir"]);

export function validateShellCommand(mode: ShellMode, command: string): ShellPolicyDecision {
const trimmed = command.trim();

if (mode === "off") {
return deny(mode, "Shell execution is disabled by DEVSPACE_SHELL_MODE=off.");
}

if (!trimmed) {
return deny(mode, "Shell command is empty.");
}

if (mode === "full") {
return allow(mode);
}

if (hasShellControlOperator(trimmed)) {
return deny(
mode,
"DEVSPACE_SHELL_MODE=read-only allows a single inspection command without pipes, redirects, or shell control operators.",
);
}

const words = trimmed.split(/\s+/);
const commandName = basename(words[0] ?? "");
if (!READ_ONLY_COMMANDS.has(commandName)) {
return deny(
mode,
`DEVSPACE_SHELL_MODE=read-only blocked '${commandName}'. Allowed commands: ${Array.from(READ_ONLY_COMMANDS).join(", ")}.`,
);
}

if (commandName === "git") {
return validateGitCommand(words, mode);
}

if (commandName === "find") {
return validateFindCommand(words, mode);
}

return allow(mode);
}

function validateGitCommand(words: string[], mode: ShellMode): ShellPolicyDecision {
const subcommand = words[1];
if (!subcommand) return allow(mode);

if (subcommand.startsWith("-")) {
return deny(mode, "DEVSPACE_SHELL_MODE=read-only only allows direct read-only git subcommands.");
}

if (!READ_ONLY_GIT_SUBCOMMANDS.has(subcommand)) {
return deny(
mode,
`DEVSPACE_SHELL_MODE=read-only blocked 'git ${subcommand}'. Allowed git subcommands: ${Array.from(
READ_ONLY_GIT_SUBCOMMANDS,
).join(", ")}.`,
);
}

return allow(mode);
}

function validateFindCommand(words: string[], mode: ShellMode): ShellPolicyDecision {
const destructiveFlag = words.find((word) => DESTRUCTIVE_FIND_FLAGS.has(word));
if (destructiveFlag) {
return deny(mode, `DEVSPACE_SHELL_MODE=read-only blocked find flag '${destructiveFlag}'.`);
}

return allow(mode);
}

function hasShellControlOperator(command: string): boolean {
return SHELL_CONTROL_PATTERNS.some((pattern) => pattern.test(command));
}

function basename(command: string): string {
return (command.split(/[\\/]/).pop() ?? command).toLowerCase();
}

function allow(mode: ShellMode): ShellPolicyDecision {
return { allowed: true, mode };
}

function deny(mode: ShellMode, reason: string): ShellPolicyDecision {
return { allowed: false, mode, reason };
}
75 changes: 75 additions & 0 deletions src/tool-result.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
export type ToolContent =
| { type: "text"; text: string }
| { type: "image"; data: string; mimeType: string };

export interface ToolResponse<TDetails = unknown> {
content: ToolContent[];
details?: TDetails;
isError?: boolean;
}

export interface ToolResultEnvelope<TSummary = Record<string, unknown>, TDetails = unknown> {
ok: boolean;
tool: string;
workspaceId?: string;
path?: string;
summary?: TSummary;
content: ToolContent[];
details?: TDetails;
diff?: string;
diagnostics?: string[];
truncated?: boolean;
durationMs: number;
}

export function textContent(text: string): ToolContent[] {
return [{ type: "text", text }];
}

export function toolError(message: string): ToolResponse {
return { content: textContent(message), isError: true };
}

export function contentText(content: ToolContent[]): string {
return content.map((item) => (item.type === "text" ? item.text : `[image:${item.mimeType}]`)).join("\n");
}

export function textSummary(text: string, maxLength = 220): string {
const compact = text.replace(/\s+/g, " ").trim();
if (compact.length <= maxLength) return compact;
return `${compact.slice(0, maxLength - 1)}…`;
}

export function contentLineCount(content: ToolContent[]): number | undefined {
const text = contentText(content);
if (!text) return undefined;
return text.split("\n").length;
}

export function makeToolResultEnvelope<TSummary = Record<string, unknown>, TDetails = unknown>(input: {
ok: boolean;
tool: string;
workspaceId?: string;
path?: string;
summary?: TSummary;
content: ToolContent[];
details?: TDetails;
diff?: string;
diagnostics?: string[];
truncated?: boolean;
startedAt: number;
}): ToolResultEnvelope<TSummary, TDetails> {
return {
ok: input.ok,
tool: input.tool,
workspaceId: input.workspaceId,
path: input.path,
summary: input.summary,
content: input.content,
details: input.details,
diff: input.diff,
diagnostics: input.diagnostics,
truncated: input.truncated,
durationMs: Date.now() - input.startedAt,
};
}