mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-06-06 19:35:44 +02:00
Add run-level auto permission mode
- add LLM-based auto permission classifier for permission-gated tool calls - store run-level permission mode and auto permission decision events - auto-approve low-risk calls, and bubble auto-denied calls to manual approval - show auto-denied reasons in chat and auto-approved labels below tool cards - add BYOK setting for the auto-permission decision model
This commit is contained in:
parent
8a8b78071d
commit
d47cab6a0f
15 changed files with 641 additions and 85 deletions
|
|
@ -36,6 +36,7 @@ import { getRaw as getLabelingAgentRaw } from "../knowledge/labeling_agent.js";
|
|||
import { getRaw as getNoteTaggingAgentRaw } from "../knowledge/note_tagging_agent.js";
|
||||
import { getRaw as getInlineTaskAgentRaw } from "../knowledge/inline_task_agent.js";
|
||||
import { getRaw as getAgentNotesAgentRaw } from "../knowledge/agent_notes_agent.js";
|
||||
import { classifyToolPermissions, type AutoPermissionCandidate } from "../security/auto-permission-classifier.js";
|
||||
|
||||
const AGENT_NOTES_DIR = path.join(WorkDir, 'knowledge', 'Agent Notes');
|
||||
|
||||
|
|
@ -901,6 +902,7 @@ export class AgentState {
|
|||
agentName: string | null = null;
|
||||
runModel: string | null = null;
|
||||
runProvider: string | null = null;
|
||||
permissionMode: "manual" | "auto" = "manual";
|
||||
runUseCase: UseCase | null = null;
|
||||
runSubUseCase: string | null = null;
|
||||
messages: z.infer<typeof MessageList> = [];
|
||||
|
|
@ -912,6 +914,8 @@ export class AgentState {
|
|||
pendingAskHumanRequests: Record<string, z.infer<typeof AskHumanRequestEvent>> = {};
|
||||
allowedToolCallIds: Record<string, true> = {};
|
||||
deniedToolCallIds: Record<string, true> = {};
|
||||
autoAllowedToolCalls: Record<string, { reason: string }> = {};
|
||||
autoDeniedToolCalls: Record<string, { reason: string }> = {};
|
||||
sessionAllowedCommands: Set<string> = new Set();
|
||||
sessionAllowedFileAccess: FileAccessGrant[] = [];
|
||||
|
||||
|
|
@ -1019,6 +1023,7 @@ export class AgentState {
|
|||
this.agentName = event.agentName;
|
||||
this.runModel = event.model;
|
||||
this.runProvider = event.provider;
|
||||
this.permissionMode = event.permissionMode ?? "manual";
|
||||
this.runUseCase = event.useCase ?? null;
|
||||
this.runSubUseCase = event.subUseCase ?? null;
|
||||
break;
|
||||
|
|
@ -1031,6 +1036,7 @@ export class AgentState {
|
|||
this.subflowStates[event.toolCallId].agentName = event.agentName;
|
||||
this.subflowStates[event.toolCallId].runModel = this.runModel;
|
||||
this.subflowStates[event.toolCallId].runProvider = this.runProvider;
|
||||
this.subflowStates[event.toolCallId].permissionMode = this.permissionMode;
|
||||
this.subflowStates[event.toolCallId].runUseCase = this.runUseCase;
|
||||
this.subflowStates[event.toolCallId].runSubUseCase = this.runSubUseCase;
|
||||
break;
|
||||
|
|
@ -1081,10 +1087,22 @@ export class AgentState {
|
|||
break;
|
||||
case "deny":
|
||||
this.deniedToolCallIds[event.toolCallId] = true;
|
||||
delete this.autoDeniedToolCalls[event.toolCallId];
|
||||
break;
|
||||
}
|
||||
delete this.pendingToolPermissionRequests[event.toolCallId];
|
||||
break;
|
||||
case "tool-permission-auto-decision":
|
||||
switch (event.decision) {
|
||||
case "allow":
|
||||
this.allowedToolCallIds[event.toolCallId] = true;
|
||||
this.autoAllowedToolCalls[event.toolCallId] = { reason: event.reason };
|
||||
break;
|
||||
case "deny":
|
||||
this.autoDeniedToolCalls[event.toolCallId] = { reason: event.reason };
|
||||
break;
|
||||
}
|
||||
break;
|
||||
case "ask-human-request":
|
||||
this.pendingAskHumanRequests[event.toolCallId] = event;
|
||||
break;
|
||||
|
|
@ -1190,13 +1208,19 @@ export async function* streamAgent({
|
|||
// if tool has been denied, deny
|
||||
if (state.deniedToolCallIds[toolCallId]) {
|
||||
_logger.log('returning denied tool message, reason: tool has been denied');
|
||||
const autoDenied = state.autoDeniedToolCalls[toolCallId];
|
||||
yield* processEvent({
|
||||
runId,
|
||||
messageId: await idGenerator.next(),
|
||||
type: "message",
|
||||
message: {
|
||||
role: "tool",
|
||||
content: "Unable to execute this tool: Permission was denied.",
|
||||
content: autoDenied
|
||||
? JSON.stringify({
|
||||
success: false,
|
||||
error: `Auto-permission denied: ${autoDenied.reason}`,
|
||||
})
|
||||
: "Unable to execute this tool: Permission was denied.",
|
||||
toolCallId: toolCallId,
|
||||
toolName: toolCall.toolName,
|
||||
},
|
||||
|
|
@ -1493,6 +1517,7 @@ If the user's message is clearly NOT a coding request (small talk, an unrelated
|
|||
|
||||
// if there were any ask-human calls, emit those events
|
||||
if (message.content instanceof Array) {
|
||||
const permissionCandidates: AutoPermissionCandidate[] = [];
|
||||
for (const part of message.content) {
|
||||
if (part.type === "tool-call") {
|
||||
const underlyingTool = agent.tools![part.toolName];
|
||||
|
|
@ -1518,14 +1543,7 @@ If the user's message is clearly NOT a coding request (small talk, an unrelated
|
|||
state.sessionAllowedFileAccess,
|
||||
);
|
||||
if (permission) {
|
||||
loopLogger.log('emitting tool-permission-request, toolCallId:', part.toolCallId);
|
||||
yield* processEvent({
|
||||
runId,
|
||||
type: "tool-permission-request",
|
||||
toolCall: part,
|
||||
permission,
|
||||
subflow: [],
|
||||
});
|
||||
permissionCandidates.push({ toolCall: part, permission });
|
||||
}
|
||||
if (underlyingTool.type === "agent" && underlyingTool.name) {
|
||||
loopLogger.log('emitting spawn-subflow, toolCallId:', part.toolCallId);
|
||||
|
|
@ -1549,6 +1567,87 @@ If the user's message is clearly NOT a coding request (small talk, an unrelated
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (permissionCandidates.length > 0) {
|
||||
if (state.permissionMode === "auto") {
|
||||
let decisionsByToolCallId = new Map<string, { decision: "allow" | "deny"; reason: string }>();
|
||||
try {
|
||||
const decisions = await classifyToolPermissions({
|
||||
runId,
|
||||
agentName: state.agentName,
|
||||
messages: convertFromMessages(state.messages),
|
||||
candidates: permissionCandidates,
|
||||
useCase: state.runUseCase ?? "copilot_chat",
|
||||
subUseCase: state.runSubUseCase,
|
||||
});
|
||||
decisionsByToolCallId = new Map(decisions.map((decision) => [
|
||||
decision.toolCallId,
|
||||
{ decision: decision.decision, reason: decision.reason },
|
||||
]));
|
||||
} catch (error) {
|
||||
loopLogger.log(
|
||||
'auto-permission classifier failed:',
|
||||
error instanceof Error ? error.message : String(error),
|
||||
);
|
||||
}
|
||||
|
||||
for (const candidate of permissionCandidates) {
|
||||
const decision = decisionsByToolCallId.get(candidate.toolCall.toolCallId);
|
||||
if (!decision) {
|
||||
loopLogger.log('auto-permission missing decision, falling back to prompt:', candidate.toolCall.toolCallId);
|
||||
yield* processEvent({
|
||||
runId,
|
||||
type: "tool-permission-request",
|
||||
toolCall: candidate.toolCall,
|
||||
permission: candidate.permission,
|
||||
subflow: [],
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
loopLogger.log(
|
||||
'emitting tool-permission-auto-decision, toolCallId:',
|
||||
candidate.toolCall.toolCallId,
|
||||
'decision:',
|
||||
decision.decision,
|
||||
);
|
||||
yield* processEvent({
|
||||
runId,
|
||||
type: "tool-permission-auto-decision",
|
||||
toolCallId: candidate.toolCall.toolCallId,
|
||||
toolCall: candidate.toolCall,
|
||||
permission: candidate.permission,
|
||||
decision: decision.decision,
|
||||
reason: decision.reason,
|
||||
subflow: [],
|
||||
});
|
||||
if (decision.decision === "deny") {
|
||||
loopLogger.log(
|
||||
'auto-permission denied, falling back to prompt:',
|
||||
candidate.toolCall.toolCallId,
|
||||
);
|
||||
yield* processEvent({
|
||||
runId,
|
||||
type: "tool-permission-request",
|
||||
toolCall: candidate.toolCall,
|
||||
permission: candidate.permission,
|
||||
subflow: [],
|
||||
});
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for (const candidate of permissionCandidates) {
|
||||
loopLogger.log('emitting tool-permission-request, toolCallId:', candidate.toolCall.toolCallId);
|
||||
yield* processEvent({
|
||||
runId,
|
||||
type: "tool-permission-request",
|
||||
toolCall: candidate.toolCall,
|
||||
permission: candidate.permission,
|
||||
subflow: [],
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ const SIGNED_IN_DEFAULT_MODEL = "gpt-5.4";
|
|||
const SIGNED_IN_DEFAULT_PROVIDER = "rowboat";
|
||||
const SIGNED_IN_KG_MODEL = "google/gemini-3.1-flash-lite";
|
||||
const SIGNED_IN_LIVE_NOTE_AGENT_MODEL = "google/gemini-3.1-flash-lite";
|
||||
const SIGNED_IN_AUTO_PERMISSION_DECISION_MODEL = "google/gemini-3.1-flash-lite";
|
||||
|
||||
/**
|
||||
* The single source of truth for "what model+provider should we use when
|
||||
|
|
@ -76,6 +77,17 @@ export async function getLiveNoteAgentModel(): Promise<string> {
|
|||
return cfg.liveNoteAgentModel ?? cfg.model;
|
||||
}
|
||||
|
||||
/**
|
||||
* Model used by the auto-permission classifier.
|
||||
* Signed-in: curated default. BYOK: user override
|
||||
* (`autoPermissionDecisionModel`) or assistant model.
|
||||
*/
|
||||
export async function getAutoPermissionDecisionModel(): Promise<string> {
|
||||
if (await isSignedIn()) return SIGNED_IN_AUTO_PERMISSION_DECISION_MODEL;
|
||||
const cfg = await container.resolve<IModelConfigRepo>("modelConfigRepo").getConfig();
|
||||
return cfg.autoPermissionDecisionModel ?? cfg.model;
|
||||
}
|
||||
|
||||
/**
|
||||
* Model used by the meeting-notes summarizer. No special signed-in default —
|
||||
* historically meetings used the assistant model. BYOK: user override
|
||||
|
|
|
|||
|
|
@ -53,6 +53,7 @@ export class FSModelConfigRepo implements IModelConfigRepo {
|
|||
knowledgeGraphModel: config.knowledgeGraphModel,
|
||||
meetingNotesModel: config.meetingNotesModel,
|
||||
liveNoteAgentModel: config.liveNoteAgentModel,
|
||||
autoPermissionDecisionModel: config.autoPermissionDecisionModel,
|
||||
};
|
||||
|
||||
const toWrite = { ...config, providers: existingProviders };
|
||||
|
|
|
|||
|
|
@ -35,6 +35,7 @@ export type CreateRunRepoOptions = {
|
|||
agentId: string;
|
||||
model: string;
|
||||
provider: string;
|
||||
permissionMode: "manual" | "auto";
|
||||
useCase: z.infer<typeof UseCase>;
|
||||
subUseCase?: string;
|
||||
};
|
||||
|
|
@ -204,6 +205,7 @@ export class FSRunsRepo implements IRunsRepo {
|
|||
agentName: options.agentId,
|
||||
model: options.model,
|
||||
provider: options.provider,
|
||||
permissionMode: options.permissionMode,
|
||||
useCase: options.useCase,
|
||||
...(options.subUseCase ? { subUseCase: options.subUseCase } : {}),
|
||||
subflow: [],
|
||||
|
|
@ -216,6 +218,7 @@ export class FSRunsRepo implements IRunsRepo {
|
|||
agentId: options.agentId,
|
||||
model: options.model,
|
||||
provider: options.provider,
|
||||
permissionMode: options.permissionMode,
|
||||
useCase: options.useCase,
|
||||
...(options.subUseCase ? { subUseCase: options.subUseCase } : {}),
|
||||
log: [start],
|
||||
|
|
@ -251,6 +254,7 @@ export class FSRunsRepo implements IRunsRepo {
|
|||
agentId: start.agentName,
|
||||
model: start.model,
|
||||
provider: start.provider,
|
||||
permissionMode: start.permissionMode ?? "manual",
|
||||
...(start.useCase ? { useCase: start.useCase } : {}),
|
||||
...(start.subUseCase ? { subUseCase: start.subUseCase } : {}),
|
||||
log: events,
|
||||
|
|
@ -320,4 +324,4 @@ export class FSRunsRepo implements IRunsRepo {
|
|||
async delete(id: string): Promise<void> {
|
||||
await fsp.unlink(runLogPath(id));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -32,6 +32,7 @@ export async function createRun(opts: z.infer<typeof CreateRunOptions>): Promise
|
|||
agentId: opts.agentId,
|
||||
model,
|
||||
provider,
|
||||
permissionMode: opts.permissionMode ?? "manual",
|
||||
useCase,
|
||||
...(opts.subUseCase ? { subUseCase: opts.subUseCase } : {}),
|
||||
});
|
||||
|
|
|
|||
112
apps/x/packages/core/src/security/auto-permission-classifier.ts
Normal file
112
apps/x/packages/core/src/security/auto-permission-classifier.ts
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
import { generateObject, type ModelMessage } from "ai";
|
||||
import z from "zod";
|
||||
import { ToolPermissionMetadata } from "@x/shared/dist/runs.js";
|
||||
import { ToolCallPart } from "@x/shared/dist/message.js";
|
||||
import { captureLlmUsage } from "../analytics/usage.js";
|
||||
import { withUseCase, type UseCase } from "../analytics/use_case.js";
|
||||
import { getAutoPermissionDecisionModel, getDefaultModelAndProvider, resolveProviderConfig } from "../models/defaults.js";
|
||||
import { createProvider } from "../models/models.js";
|
||||
|
||||
const DecisionSchema = z.object({
|
||||
decisions: z.array(z.object({
|
||||
toolCallId: z.string(),
|
||||
decision: z.enum(["allow", "deny"]),
|
||||
reason: z.string().min(1),
|
||||
})),
|
||||
});
|
||||
|
||||
export type AutoPermissionCandidate = {
|
||||
toolCall: z.infer<typeof ToolCallPart>;
|
||||
permission: z.infer<typeof ToolPermissionMetadata>;
|
||||
};
|
||||
|
||||
export type AutoPermissionDecision = {
|
||||
toolCallId: string;
|
||||
decision: "allow" | "deny";
|
||||
reason: string;
|
||||
};
|
||||
|
||||
const SYSTEM_PROMPT = `You decide whether a personal productivity app may run tool calls without interrupting the user.
|
||||
|
||||
You only receive tool calls that already require permission under deterministic rules.
|
||||
|
||||
Allow a tool call only when it is clearly consistent with the user's request and low risk.
|
||||
Deny tool calls that are destructive, credential-sensitive, privacy-sensitive, broad in scope, likely irreversible, or not clearly requested.
|
||||
|
||||
Command examples to deny unless explicitly requested: deleting data, force pushing, deploying, running migrations, changing permissions, reading secrets, exfiltrating tokens, or modifying files outside the user's workspace.
|
||||
File examples to deny unless explicitly requested: deleting paths, writing outside the workspace, reading secrets or credentials, or broad access to private directories.
|
||||
|
||||
Return one decision for every toolCallId. Use the exact toolCallId values provided.`;
|
||||
|
||||
function compact(value: unknown, max = 8_000): string {
|
||||
const text = typeof value === "string" ? value : JSON.stringify(value, null, 2);
|
||||
if (text.length <= max) return text;
|
||||
return `${text.slice(0, max)}\n...<truncated>`;
|
||||
}
|
||||
|
||||
function recentContext(messages: ModelMessage[]): unknown[] {
|
||||
return messages.slice(-8).map((message) => {
|
||||
if (typeof message.content === "string") {
|
||||
return { role: message.role, content: compact(message.content, 2_000) };
|
||||
}
|
||||
return { role: message.role, content: compact(message.content, 3_000) };
|
||||
});
|
||||
}
|
||||
|
||||
function buildPrompt(input: {
|
||||
agentName: string | null;
|
||||
messages: ModelMessage[];
|
||||
candidates: AutoPermissionCandidate[];
|
||||
}) {
|
||||
return compact({
|
||||
agentName: input.agentName,
|
||||
recentConversation: recentContext(input.messages),
|
||||
toolCalls: input.candidates.map(({ toolCall, permission }) => ({
|
||||
toolCallId: toolCall.toolCallId,
|
||||
toolName: toolCall.toolName,
|
||||
arguments: toolCall.arguments,
|
||||
permission,
|
||||
})),
|
||||
}, 24_000);
|
||||
}
|
||||
|
||||
export async function classifyToolPermissions(input: {
|
||||
runId: string;
|
||||
agentName: string | null;
|
||||
messages: ModelMessage[];
|
||||
candidates: AutoPermissionCandidate[];
|
||||
useCase: UseCase;
|
||||
subUseCase?: string | null;
|
||||
}): Promise<AutoPermissionDecision[]> {
|
||||
if (input.candidates.length === 0) return [];
|
||||
|
||||
const modelId = await getAutoPermissionDecisionModel();
|
||||
const { provider: providerName } = await getDefaultModelAndProvider();
|
||||
const providerConfig = await resolveProviderConfig(providerName);
|
||||
const model = createProvider(providerConfig).languageModel(modelId);
|
||||
|
||||
const result = await withUseCase(
|
||||
{
|
||||
useCase: input.useCase,
|
||||
subUseCase: "auto_permission_classifier",
|
||||
...(input.agentName ? { agentName: input.agentName } : {}),
|
||||
},
|
||||
() => generateObject({
|
||||
model,
|
||||
system: SYSTEM_PROMPT,
|
||||
prompt: buildPrompt(input),
|
||||
schema: DecisionSchema,
|
||||
}),
|
||||
);
|
||||
|
||||
captureLlmUsage({
|
||||
useCase: input.useCase,
|
||||
subUseCase: "auto_permission_classifier",
|
||||
model: modelId,
|
||||
provider: providerName,
|
||||
usage: result.usage,
|
||||
});
|
||||
|
||||
const allowedIds = new Set(input.candidates.map((candidate) => candidate.toolCall.toolCallId));
|
||||
return result.object.decisions.filter((decision) => allowedIds.has(decision.toolCallId));
|
||||
}
|
||||
|
|
@ -17,10 +17,15 @@ export const LlmModelConfig = z.object({
|
|||
headers: z.record(z.string(), z.string()).optional(),
|
||||
model: z.string().optional(),
|
||||
models: z.array(z.string()).optional(),
|
||||
knowledgeGraphModel: z.string().optional(),
|
||||
meetingNotesModel: z.string().optional(),
|
||||
liveNoteAgentModel: z.string().optional(),
|
||||
autoPermissionDecisionModel: z.string().optional(),
|
||||
})).optional(),
|
||||
// Per-category model overrides (BYOK only — signed-in users always get
|
||||
// the curated gateway defaults). Read by helpers in core/models/defaults.ts.
|
||||
knowledgeGraphModel: z.string().optional(),
|
||||
meetingNotesModel: z.string().optional(),
|
||||
liveNoteAgentModel: z.string().optional(),
|
||||
autoPermissionDecisionModel: z.string().optional(),
|
||||
});
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ export const StartEvent = BaseRunEvent.extend({
|
|||
agentName: z.string(),
|
||||
model: z.string(),
|
||||
provider: z.string(),
|
||||
permissionMode: z.enum(["manual", "auto"]).optional(),
|
||||
// useCase/subUseCase tag the run for analytics. Optional on read so legacy
|
||||
// run files written before these fields existed still parse cleanly.
|
||||
useCase: z.enum([
|
||||
|
|
@ -110,6 +111,15 @@ export const ToolPermissionResponseEvent = BaseRunEvent.extend({
|
|||
scope: z.enum(["once", "session", "always"]).optional(),
|
||||
});
|
||||
|
||||
export const ToolPermissionAutoDecisionEvent = BaseRunEvent.extend({
|
||||
type: z.literal("tool-permission-auto-decision"),
|
||||
toolCallId: z.string(),
|
||||
toolCall: ToolCallPart,
|
||||
permission: ToolPermissionMetadata.optional(),
|
||||
decision: z.enum(["allow", "deny"]),
|
||||
reason: z.string(),
|
||||
});
|
||||
|
||||
export const RunErrorEvent = BaseRunEvent.extend({
|
||||
type: z.literal("error"),
|
||||
error: z.string(),
|
||||
|
|
@ -134,6 +144,7 @@ export const RunEvent = z.union([
|
|||
AskHumanResponseEvent,
|
||||
ToolPermissionRequestEvent,
|
||||
ToolPermissionResponseEvent,
|
||||
ToolPermissionAutoDecisionEvent,
|
||||
RunErrorEvent,
|
||||
RunStoppedEvent,
|
||||
]);
|
||||
|
|
@ -166,6 +177,7 @@ export const Run = z.object({
|
|||
agentId: z.string(),
|
||||
model: z.string(),
|
||||
provider: z.string(),
|
||||
permissionMode: z.enum(["manual", "auto"]).optional(),
|
||||
useCase: UseCase.optional(),
|
||||
subUseCase: z.string().optional(),
|
||||
log: z.array(RunEvent),
|
||||
|
|
@ -185,6 +197,7 @@ export const CreateRunOptions = z.object({
|
|||
agentId: z.string(),
|
||||
model: z.string().optional(),
|
||||
provider: z.string().optional(),
|
||||
permissionMode: z.enum(["manual", "auto"]).optional(),
|
||||
useCase: UseCase.optional(),
|
||||
subUseCase: z.string().optional(),
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue