mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-04-27 17:36:25 +02:00
add session and always permission scopes for command execution
Session-scoped permissions are stored in the run log and rebuilt by the state-builder, scoping them to a single run. Always-scoped permissions persist to security.json. The backend derives command names from the run log instead of receiving them from the frontend. Uses regex-based command parsing with subshell/parenthesis support. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
1f85bcf8ae
commit
53d48ab4f3
8 changed files with 126 additions and 38 deletions
|
|
@ -12,7 +12,7 @@ import { execTool } from "../application/lib/exec-tool.js";
|
|||
import { AskHumanRequestEvent, RunEvent, ToolPermissionRequestEvent } from "@x/shared/dist/runs.js";
|
||||
import { BuiltinTools } from "../application/lib/builtin-tools.js";
|
||||
import { CopilotAgent } from "../application/assistant/agent.js";
|
||||
import { isBlocked } from "../application/lib/command-executor.js";
|
||||
import { isBlocked, extractCommandNames } from "../application/lib/command-executor.js";
|
||||
import container from "../di/container.js";
|
||||
import { IModelConfigRepo } from "../models/repo.js";
|
||||
import { createProvider } from "../models/models.js";
|
||||
|
|
@ -462,6 +462,7 @@ export class AgentState {
|
|||
pendingAskHumanRequests: Record<string, z.infer<typeof AskHumanRequestEvent>> = {};
|
||||
allowedToolCallIds: Record<string, true> = {};
|
||||
deniedToolCallIds: Record<string, true> = {};
|
||||
sessionAllowedCommands: Set<string> = new Set();
|
||||
|
||||
getPendingPermissions(): z.infer<typeof ToolPermissionRequestEvent>[] {
|
||||
const response: z.infer<typeof ToolPermissionRequestEvent>[] = [];
|
||||
|
|
@ -598,6 +599,16 @@ export class AgentState {
|
|||
switch (event.response) {
|
||||
case "approve":
|
||||
this.allowedToolCallIds[event.toolCallId] = true;
|
||||
// For session scope, extract command names and add to session allowlist
|
||||
if (event.scope === "session") {
|
||||
const toolCall = this.toolCallIdMap[event.toolCallId];
|
||||
if (toolCall && typeof toolCall.arguments === 'object' && toolCall.arguments !== null && 'command' in toolCall.arguments) {
|
||||
const names = extractCommandNames(String(toolCall.arguments.command));
|
||||
for (const name of names) {
|
||||
this.sessionAllowedCommands.add(name);
|
||||
}
|
||||
}
|
||||
}
|
||||
break;
|
||||
case "deny":
|
||||
this.deniedToolCallIds[event.toolCallId] = true;
|
||||
|
|
@ -882,7 +893,7 @@ export async function* streamAgent({
|
|||
}
|
||||
if (underlyingTool.type === "builtin" && underlyingTool.name === "executeCommand") {
|
||||
// if command is blocked, then seek permission
|
||||
if (isBlocked(part.arguments.command)) {
|
||||
if (isBlocked(part.arguments.command, state.sessionAllowedCommands)) {
|
||||
loopLogger.log('emitting tool-permission-request, toolCallId:', part.toolCallId);
|
||||
yield* processEvent({
|
||||
runId,
|
||||
|
|
|
|||
|
|
@ -3,15 +3,15 @@ import { promisify } from 'util';
|
|||
import { getSecurityAllowList } from '../../config/security.js';
|
||||
|
||||
const execPromise = promisify(exec);
|
||||
const COMMAND_SPLIT_REGEX = /(?:\|\||&&|;|\||\n|`|\$\(|\))/;
|
||||
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[] {
|
||||
export function extractCommandNames(command: string): string[] {
|
||||
const discovered = new Set<string>();
|
||||
const segments = command.split(COMMAND_SPLIT_REGEX);
|
||||
|
||||
|
|
@ -42,27 +42,21 @@ function extractCommandNames(command: string): string[] {
|
|||
return Array.from(discovered);
|
||||
}
|
||||
|
||||
function findBlockedCommands(command: string): string[] {
|
||||
function findBlockedCommands(command: string, sessionAllowedCommands?: Set<string>): string[] {
|
||||
const invoked = extractCommandNames(command);
|
||||
if (!invoked.length) return [];
|
||||
|
||||
const allowList = getSecurityAllowList();
|
||||
if (!allowList.length) return invoked;
|
||||
if (!allowList.length && (!sessionAllowedCommands || sessionAllowedCommands.size === 0)) return invoked;
|
||||
|
||||
const allowSet = new Set(allowList);
|
||||
if (allowSet.has('*')) return [];
|
||||
|
||||
return invoked.filter((cmd) => !allowSet.has(cmd));
|
||||
return invoked.filter((cmd) => !allowSet.has(cmd) && !sessionAllowedCommands?.has(cmd));
|
||||
}
|
||||
|
||||
// export const BlockedResult = {
|
||||
// stdout: '',
|
||||
// stderr: `Command blocked by security policy. Update ${SECURITY_CONFIG_PATH} to allow them before retrying.`,
|
||||
// exitCode: 126,
|
||||
// };
|
||||
|
||||
export function isBlocked(command: string): boolean {
|
||||
const blocked = findBlockedCommands(command);
|
||||
export function isBlocked(command: string, sessionAllowedCommands?: Set<string>): boolean {
|
||||
const blocked = findBlockedCommands(command, sessionAllowedCommands);
|
||||
return blocked.length > 0;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -20,6 +20,23 @@ const DEFAULT_ALLOW_LIST = [
|
|||
let cachedAllowList: string[] | null = null;
|
||||
let cachedMtimeMs: number | null = null;
|
||||
|
||||
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().
|
||||
|
|
@ -102,11 +119,9 @@ export function getSecurityAllowList(): string[] {
|
|||
if (cachedAllowList && cachedMtimeMs === stats.mtimeMs) {
|
||||
return cachedAllowList;
|
||||
}
|
||||
|
||||
const allowList = readAllowList();
|
||||
cachedAllowList = allowList;
|
||||
cachedAllowList = readAllowList();
|
||||
cachedMtimeMs = stats.mtimeMs;
|
||||
return allowList;
|
||||
return cachedAllowList;
|
||||
} catch {
|
||||
cachedAllowList = null;
|
||||
cachedMtimeMs = null;
|
||||
|
|
|
|||
|
|
@ -1,13 +1,15 @@
|
|||
import z from "zod";
|
||||
import container from "../di/container.js";
|
||||
import { IMessageQueue } from "../application/lib/message-queue.js";
|
||||
import { AskHumanResponseEvent, ToolPermissionResponseEvent, CreateRunOptions, Run, ListRunsResponse, ToolPermissionAuthorizePayload, AskHumanResponsePayload } from "@x/shared/dist/runs.js";
|
||||
import { AskHumanResponseEvent, ToolPermissionRequestEvent, ToolPermissionResponseEvent, CreateRunOptions, Run, ListRunsResponse, ToolPermissionAuthorizePayload, AskHumanResponsePayload } from "@x/shared/dist/runs.js";
|
||||
import { IRunsRepo } from "./repo.js";
|
||||
import { IAgentRuntime } from "../agents/runtime.js";
|
||||
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 { 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,11 +28,32 @@ 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, ...rest } = ev;
|
||||
|
||||
// For "always" scope, derive command from the run log and persist to security config
|
||||
if (rest.response === "approve" && scope === "always") {
|
||||
const repo = container.resolve<IRunsRepo>('runsRepo');
|
||||
const run = await repo.fetch(runId);
|
||||
const permReqEvent = run.log.find(
|
||||
(e): e is z.infer<typeof ToolPermissionRequestEvent> =>
|
||||
e.type === "tool-permission-request"
|
||||
&& e.toolCall.toolCallId === rest.toolCallId
|
||||
&& JSON.stringify(e.subflow) === JSON.stringify(rest.subflow)
|
||||
);
|
||||
if (permReqEvent && typeof permReqEvent.toolCall.arguments === 'object' && permReqEvent.toolCall.arguments !== null && 'command' in permReqEvent.toolCall.arguments) {
|
||||
const commandNames = extractCommandNames(String(permReqEvent.toolCall.arguments.command));
|
||||
if (commandNames.length > 0) {
|
||||
await addToSecurityConfig(commandNames);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const repo = container.resolve<IRunsRepo>('runsRepo');
|
||||
const event: z.infer<typeof ToolPermissionResponseEvent> = {
|
||||
...ev,
|
||||
...rest,
|
||||
runId,
|
||||
type: "tool-permission-response",
|
||||
scope,
|
||||
};
|
||||
await repo.appendEvents(runId, [event]);
|
||||
const runtime = container.resolve<IAgentRuntime>('agentRuntime');
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue