diff --git a/apps/cli/src/application/assistant/instructions.ts b/apps/cli/src/application/assistant/instructions.ts index a99e9d4b..3fd32af6 100644 --- a/apps/cli/src/application/assistant/instructions.ts +++ b/apps/cli/src/application/assistant/instructions.ts @@ -26,4 +26,19 @@ Always consult this catalog first so you load the right skills before taking act - Explore existing files and structure before creating new assets. - Use relative paths (no \${BASE_DIR} prefixes) when running commands or referencing files. - Keep user data safe—double-check before editing or deleting important resources. + +## Builtin Tools vs Shell Commands + +**IMPORTANT**: Rowboat provides builtin tools that are internal and do NOT require security allowlist entries: +- \`deleteFile\`, \`createFile\`, \`updateFile\`, \`readFile\` - File operations +- \`listFiles\`, \`exploreDirectory\` - Directory exploration +- \`analyzeAgent\` - Agent analysis +- \`listMcpServers\`, \`listMcpTools\` - MCP server management +- \`loadSkill\` - Skill loading + +These tools work directly and are NOT filtered by \`.rowboat/config/security.json\`. + +**Only \`executeCommand\` (shell/bash commands) is filtered** by the security allowlist. If you need to delete a file, use the \`deleteFile\` builtin tool, not \`executeCommand\` with \`rm\`. If you need to create a file, use \`createFile\`, not \`executeCommand\` with \`touch\` or \`echo >\`. + +The security allowlist in \`security.json\` only applies to shell commands executed via \`executeCommand\`, not to Rowboat's internal builtin tools. `; diff --git a/apps/cli/src/application/assistant/skills/builtin-tools/skill.ts b/apps/cli/src/application/assistant/skills/builtin-tools/skill.ts index 217d7f91..2467367b 100644 --- a/apps/cli/src/application/assistant/skills/builtin-tools/skill.ts +++ b/apps/cli/src/application/assistant/skills/builtin-tools/skill.ts @@ -10,6 +10,8 @@ Agents can use builtin tools by declaring them in the \`"tools"\` object with \` ### executeCommand **The most powerful and versatile builtin tool** - Execute any bash/shell command and get the output. +**Security note:** Commands are filtered through \`.rowboat/config/security.json\`. Populate this file with allowed command names (array or dictionary entries). Any command not present is blocked and returns exit code 126 so the agent knows it violated the policy. + **Agent tool declaration:** \`\`\`json "tools": { @@ -176,4 +178,3 @@ There are no separate "workflow" files - everything is an agent! `; export default skill; - diff --git a/apps/cli/src/application/config/security.ts b/apps/cli/src/application/config/security.ts new file mode 100644 index 00000000..35809f14 --- /dev/null +++ b/apps/cli/src/application/config/security.ts @@ -0,0 +1,90 @@ +import path from "path"; +import fs from "fs"; +import { WorkDir } from "./config.js"; + +export const SECURITY_CONFIG_PATH = path.join(WorkDir, "config", "security.json"); + +const DEFAULT_ALLOW_LIST = ["ls", "pwd", "cat", "echo", "whoami"]; + +let cachedAllowList: string[] | null = null; +let cachedMtimeMs: number | null = null; + +function ensureSecurityConfig() { + if (!fs.existsSync(SECURITY_CONFIG_PATH)) { + fs.writeFileSync( + SECURITY_CONFIG_PATH, + JSON.stringify(DEFAULT_ALLOW_LIST, null, 2) + "\n", + "utf8", + ); + } +} + +function normalizeList(commands: unknown[]): string[] { + const seen = new Set(); + for (const entry of commands) { + if (typeof entry !== "string") continue; + const normalized = entry.trim().toLowerCase(); + if (!normalized) continue; + seen.add(normalized); + } + + return Array.from(seen); +} + +function parseSecurityPayload(payload: unknown): string[] { + if (Array.isArray(payload)) { + return normalizeList(payload); + } + + if (payload && typeof payload === "object") { + const maybeObject = payload as Record; + if (Array.isArray(maybeObject.allowedCommands)) { + return normalizeList(maybeObject.allowedCommands); + } + + const dynamicList = Object.entries(maybeObject) + .filter(([, value]) => Boolean(value)) + .map(([key]) => key); + + return normalizeList(dynamicList); + } + + return []; +} + +function readAllowList(): string[] { + ensureSecurityConfig(); + + try { + const configContent = fs.readFileSync(SECURITY_CONFIG_PATH, "utf8"); + const parsed = JSON.parse(configContent); + return parseSecurityPayload(parsed); + } catch (error) { + console.warn(`Failed to read security config at ${SECURITY_CONFIG_PATH}: ${error instanceof Error ? error.message : error}`); + return DEFAULT_ALLOW_LIST; + } +} + +export function getSecurityAllowList(): string[] { + ensureSecurityConfig(); + try { + const stats = fs.statSync(SECURITY_CONFIG_PATH); + if (cachedAllowList && cachedMtimeMs === stats.mtimeMs) { + return cachedAllowList; + } + + const allowList = readAllowList(); + cachedAllowList = allowList; + cachedMtimeMs = stats.mtimeMs; + return allowList; + } catch { + cachedAllowList = null; + cachedMtimeMs = null; + return readAllowList(); + } +} + +export function resetSecurityAllowListCache() { + cachedAllowList = null; + cachedMtimeMs = null; +} diff --git a/apps/cli/src/application/lib/command-executor.ts b/apps/cli/src/application/lib/command-executor.ts index c8ba6939..369dca0a 100644 --- a/apps/cli/src/application/lib/command-executor.ts +++ b/apps/cli/src/application/lib/command-executor.ts @@ -1,7 +1,73 @@ import { exec, execSync } from 'child_process'; import { promisify } from 'util'; +import { getSecurityAllowList, SECURITY_CONFIG_PATH } from '../config/security.js'; const execPromise = promisify(exec); +const COMMAND_SPLIT_REGEX = /(?:\|\||&&|;|\||\n)/; +const ENV_ASSIGNMENT_REGEX = /^[A-Za-z_][A-Za-z0-9_]*=.*/; +const WRAPPER_COMMANDS = new Set(['sudo', 'env', 'time', 'command']); + +function sanitizeToken(token: string): string { + return token.trim().replace(/^['"]+|['"]+$/g, ''); +} + +function extractCommandNames(command: string): string[] { + const discovered = new Set(); + const segments = command.split(COMMAND_SPLIT_REGEX); + + for (const segment of segments) { + const tokens = segment.trim().split(/\s+/).filter(Boolean); + if (!tokens.length) continue; + + let index = 0; + while (index < tokens.length && ENV_ASSIGNMENT_REGEX.test(tokens[index])) { + index++; + } + + if (index >= tokens.length) continue; + + const primary = sanitizeToken(tokens[index]).toLowerCase(); + if (!primary) continue; + + discovered.add(primary); + + if (WRAPPER_COMMANDS.has(primary) && index + 1 < tokens.length) { + const wrapped = sanitizeToken(tokens[index + 1]).toLowerCase(); + if (wrapped) { + discovered.add(wrapped); + } + } + } + + return Array.from(discovered); +} + +function findBlockedCommands(command: string): string[] { + const invoked = extractCommandNames(command); + if (!invoked.length) return []; + + const allowList = getSecurityAllowList(); + if (!allowList.length) return invoked; + + const allowSet = new Set(allowList); + if (allowSet.has('*')) return []; + + return invoked.filter((cmd) => !allowSet.has(cmd)); +} + +function enforceSecurity(command: string): CommandResult | null { + const blocked = findBlockedCommands(command); + + if (!blocked.length) { + return null; + } + + return { + stdout: '', + stderr: `Command blocked by security policy. Blocked command(s): ${blocked.join(', ')}. Update ${SECURITY_CONFIG_PATH} to allow them before retrying.`, + exitCode: 126, + }; +} export interface CommandResult { stdout: string; @@ -23,6 +89,11 @@ export async function executeCommand( maxBuffer?: number; // max buffer size in bytes } ): Promise { + const securityResult = enforceSecurity(command); + if (securityResult) { + return securityResult; + } + try { const { stdout, stderr } = await execPromise(command, { cwd: options?.cwd, @@ -57,6 +128,11 @@ export function executeCommandSync( timeout?: number; } ): CommandResult { + const securityResult = enforceSecurity(command); + if (securityResult) { + return securityResult; + } + try { const stdout = execSync(command, { cwd: options?.cwd,