mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-05-06 05:42:37 +02:00
added session and always permissions
This commit is contained in:
parent
b238089e2d
commit
71a3d2ff91
7 changed files with 200 additions and 27 deletions
|
|
@ -3,17 +3,78 @@ import { promisify } from 'util';
|
|||
import { getSecurityAllowList } 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, '');
|
||||
return token.trim().replace(/^['"()]+|['"()]+$/g, '');
|
||||
}
|
||||
|
||||
function extractCommandNames(command: string): string[] {
|
||||
/**
|
||||
* Split a shell command string on command separators (||, &&, ;, |, \n)
|
||||
* while respecting single and double quotes.
|
||||
*/
|
||||
function splitCommandSegments(command: string): string[] {
|
||||
const segments: string[] = [];
|
||||
let current = '';
|
||||
let inSingle = false;
|
||||
let inDouble = false;
|
||||
|
||||
for (let i = 0; i < command.length; i++) {
|
||||
const ch = command[i];
|
||||
|
||||
if (ch === "'" && !inDouble) {
|
||||
inSingle = !inSingle;
|
||||
current += ch;
|
||||
continue;
|
||||
}
|
||||
if (ch === '"' && !inSingle) {
|
||||
inDouble = !inDouble;
|
||||
current += ch;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (inSingle || inDouble) {
|
||||
current += ch;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Outside quotes — check for separators
|
||||
if (ch === '\n' || ch === ';') {
|
||||
segments.push(current);
|
||||
current = '';
|
||||
continue;
|
||||
}
|
||||
if (ch === '|') {
|
||||
if (command[i + 1] === '|') {
|
||||
// ||
|
||||
segments.push(current);
|
||||
current = '';
|
||||
i++; // skip second |
|
||||
} else {
|
||||
// single pipe
|
||||
segments.push(current);
|
||||
current = '';
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (ch === '&' && command[i + 1] === '&') {
|
||||
segments.push(current);
|
||||
current = '';
|
||||
i++; // skip second &
|
||||
continue;
|
||||
}
|
||||
|
||||
current += ch;
|
||||
}
|
||||
|
||||
if (current) segments.push(current);
|
||||
return segments;
|
||||
}
|
||||
|
||||
export function extractCommandNames(command: string): string[] {
|
||||
const discovered = new Set<string>();
|
||||
const segments = command.split(COMMAND_SPLIT_REGEX);
|
||||
const segments = splitCommandSegments(command);
|
||||
|
||||
for (const segment of segments) {
|
||||
const tokens = segment.trim().split(/\s+/).filter(Boolean);
|
||||
|
|
|
|||
|
|
@ -20,6 +20,33 @@ const DEFAULT_ALLOW_LIST = [
|
|||
let cachedAllowList: string[] | null = null;
|
||||
let cachedMtimeMs: number | null = null;
|
||||
|
||||
/** In-memory session allowlist — resets on app restart */
|
||||
const sessionAllowSet = new Set<string>();
|
||||
|
||||
export function addToSessionAllowList(commands: string[]): void {
|
||||
for (const cmd of commands) {
|
||||
const normalized = cmd.trim().toLowerCase();
|
||||
if (normalized) sessionAllowSet.add(normalized);
|
||||
}
|
||||
}
|
||||
|
||||
export async function addToSecurityConfig(commands: string[]): Promise<void> {
|
||||
ensureSecurityConfigSync();
|
||||
const current = readAllowList();
|
||||
const merged = new Set(current);
|
||||
for (const cmd of commands) {
|
||||
const normalized = cmd.trim().toLowerCase();
|
||||
if (normalized) merged.add(normalized);
|
||||
}
|
||||
await fsPromises.writeFile(
|
||||
SECURITY_CONFIG_PATH,
|
||||
JSON.stringify(Array.from(merged).sort(), null, 2) + "\n",
|
||||
"utf8",
|
||||
);
|
||||
// Reset cache so next read picks up the new file
|
||||
resetSecurityAllowListCache();
|
||||
}
|
||||
|
||||
/**
|
||||
* Async function to ensure security config file exists.
|
||||
* Called explicitly at app startup via initConfigs().
|
||||
|
|
@ -99,18 +126,28 @@ export function getSecurityAllowList(): string[] {
|
|||
ensureSecurityConfigSync();
|
||||
try {
|
||||
const stats = fs.statSync(SECURITY_CONFIG_PATH);
|
||||
let fileList: string[];
|
||||
if (cachedAllowList && cachedMtimeMs === stats.mtimeMs) {
|
||||
return cachedAllowList;
|
||||
fileList = cachedAllowList;
|
||||
} else {
|
||||
fileList = readAllowList();
|
||||
cachedAllowList = fileList;
|
||||
cachedMtimeMs = stats.mtimeMs;
|
||||
}
|
||||
|
||||
const allowList = readAllowList();
|
||||
cachedAllowList = allowList;
|
||||
cachedMtimeMs = stats.mtimeMs;
|
||||
return allowList;
|
||||
// Merge session allowlist
|
||||
if (sessionAllowSet.size === 0) return fileList;
|
||||
const merged = new Set(fileList);
|
||||
for (const cmd of sessionAllowSet) merged.add(cmd);
|
||||
return Array.from(merged);
|
||||
} catch {
|
||||
cachedAllowList = null;
|
||||
cachedMtimeMs = null;
|
||||
return readAllowList();
|
||||
const fileList = readAllowList();
|
||||
if (sessionAllowSet.size === 0) return fileList;
|
||||
const merged = new Set(fileList);
|
||||
for (const cmd of sessionAllowSet) merged.add(cmd);
|
||||
return Array.from(merged);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -8,6 +8,8 @@ import { IBus } from "../application/lib/bus.js";
|
|||
import { IAbortRegistry } from "./abort-registry.js";
|
||||
import { IRunsLock } from "./lock.js";
|
||||
import { forceCloseAllMcpClients } from "../mcp/mcp.js";
|
||||
import { extractCommandNames } from "../application/lib/command-executor.js";
|
||||
import { addToSessionAllowList, addToSecurityConfig } from "../config/security.js";
|
||||
|
||||
export async function createRun(opts: z.infer<typeof CreateRunOptions>): Promise<z.infer<typeof Run>> {
|
||||
const repo = container.resolve<IRunsRepo>('runsRepo');
|
||||
|
|
@ -26,9 +28,23 @@ export async function createMessage(runId: string, message: string): Promise<str
|
|||
}
|
||||
|
||||
export async function authorizePermission(runId: string, ev: z.infer<typeof ToolPermissionAuthorizePayload>): Promise<void> {
|
||||
const { scope, command, ...rest } = ev;
|
||||
|
||||
// Handle scope side-effects when approving
|
||||
if (rest.response === "approve" && command && scope && scope !== "once") {
|
||||
const commandNames = extractCommandNames(command);
|
||||
if (commandNames.length > 0) {
|
||||
if (scope === "session") {
|
||||
addToSessionAllowList(commandNames);
|
||||
} else if (scope === "always") {
|
||||
await addToSecurityConfig(commandNames);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const repo = container.resolve<IRunsRepo>('runsRepo');
|
||||
const event: z.infer<typeof ToolPermissionResponseEvent> = {
|
||||
...ev,
|
||||
...rest,
|
||||
runId,
|
||||
type: "tool-permission-response",
|
||||
};
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue