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:
Ramnique Singh 2026-06-03 07:57:50 +05:30
parent 8a8b78071d
commit d47cab6a0f
15 changed files with 641 additions and 85 deletions

View file

@ -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: [],
});
}
}
}
}
}
}

View file

@ -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

View file

@ -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 };

View file

@ -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));
}
}
}

View file

@ -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 } : {}),
});

View 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));
}

View file

@ -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(),
});

View file

@ -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(),
});