import { isAbsolute, relative, resolve } from "node:path";
import type { PilotDeckToolDefinition, PilotDeckToolRuntimeContext } from "../../tool/index.js";
import { matchPermissionRule } from "../policy/matchPermissionRule.js";
import type {
PermissionContext,
PermissionDecision,
PermissionDecisionReason,
PermissionRequest,
PermissionResult,
PermissionRule,
} from "../protocol/types.js";
export class PermissionRuntime {
async decide(
tool: PilotDeckToolDefinition,
input: unknown,
context: PilotDeckToolRuntimeContext,
toolCallId: string,
): Promise<PermissionDecision> {
const permissionContext = context.permissionContext;
const sessionAllowRule = findMatchingRule(
permissionContext.rules.allow.filter((rule) => rule.source === "session"),
tool.name,
input,
);
const denyRule = findMatchingRule(permissionContext.rules.deny, tool.name, input);
if (denyRule) {
if (sessionAllowRule && denyRule.source === "user") {
return this.allowSessionRule(tool, input, context, toolCallId, sessionAllowRule);
}
return denyFromRule(denyRule);
}
const askRule = findMatchingRule(permissionContext.rules.ask, tool.name, input);
if (askRule) {
return finalizeAsk(askFromRule(tool, input, toolCallId, askRule), permissionContext);
}
if (sessionAllowRule) {
return this.allowSessionRule(tool, input, context, toolCallId, sessionAllowRule);
}
const allowRule = findMatchingRule(permissionContext.rules.allow, tool.name, input);
if (allowRule) {
if (
permissionContext.mode === "plan" &&
!tool.isReadOnly(input) &&
!isPlanDirectoryWrite(tool, input, permissionContext)
) {
} else {
return allow({
type: "rule",
behavior: "allow",
rule: allowRule,
message: `Allow rule permits ${tool.name}.`,
});
}
}
const toolPermission = await tool.checkPermissions?.(input, context);
const toolDecision = normalizeToolPermission(toolPermission, tool, input, toolCallId, permissionContext);
if (toolDecision) {
if (toolDecision.type === "ask") {
if (permissionContext.mode === "bypassPermissions") {
return allow({
type: "mode",
mode: permissionContext.mode,
message: `Permission mode ${permissionContext.mode} overrides ${tool.name}.checkPermissions ask.`,
});
}
if (permissionContext.mode === "plan" && tool.isReadOnly(input)) {
return allow({
type: "mode",
mode: "plan",
message: `Plan mode allows read-only tool ${tool.name} despite .checkPermissions ask.`,
});
}
if (permissionContext.mode === "plan" && !isPlanDirectoryWrite(tool, input, permissionContext)) {
return deny({
type: "mode",
mode: "plan",
message: `Plan mode denies side-effecting tool ${tool.name}.`,
});
}
return finalizeAsk(toolDecision, permissionContext);
}
return toolDecision;
}
if (permissionContext.mode === "bypassPermissions") {
return allow({
type: "mode",
mode: permissionContext.mode,
message: `Permission mode ${permissionContext.mode} allows ${tool.name}.`,
});
}
const modeDecision = decideByMode(tool, input, toolCallId, permissionContext);
return modeDecision.type === "ask" ? finalizeAsk(modeDecision, permissionContext) : modeDecision;
}
private async allowSessionRule(
tool: PilotDeckToolDefinition,
input: unknown,
context: PilotDeckToolRuntimeContext,
toolCallId: string,
rule: PermissionRule,
): Promise<PermissionDecision> {
const toolPermission = await tool.checkPermissions?.(input, context);
const toolDecision = normalizeToolPermission(toolPermission, tool, input, toolCallId, context.permissionContext);
if (toolDecision && toolDecision.type !== "ask") {
return toolDecision;
}
return allow({
type: "rule",
behavior: "allow",
rule,
message: `Session allow rule permits ${tool.name}.`,
});
}
}
function normalizeToolPermission(
result: PermissionResult | undefined,
tool: PilotDeckToolDefinition,
input: unknown,
toolCallId: string,
context: PermissionContext,
): PermissionDecision | undefined {
if (!result || result.type === "passthrough") {
return undefined;
}
if (result.type === "ask") {
return {
...result,
request: {
...result.request,
toolCallId,
toolName: tool.name,
},
};
}
if (result.type === "allow" || result.type === "deny" || result.type === "cancel") {
return result;
}
return ask(tool, input, toolCallId, {
type: "runtime",
message: `Permission result for ${tool.name} was not recognized in mode ${context.mode}.`,
});
}
function decideByMode(
tool: PilotDeckToolDefinition,
input: unknown,
toolCallId: string,
context: PermissionContext,
): PermissionDecision {
if (context.mode === "plan") {
if (tool.isReadOnly(input)) {
return allow({
type: "mode",
mode: "plan",
message: `Plan mode allows read-only tool ${tool.name}.`,
});
}
if (isPlanDirectoryWrite(tool, input, context)) {
return allow({
type: "mode",
mode: "plan",
message: `Plan mode allows writing markdown plans under the plan directory.`,
});
}
return deny({
type: "mode",
mode: "plan",
message: `Plan mode denies side-effecting tool ${tool.name}.`,
});
}
if (context.mode === "acceptEdits" && tool.kind === "filesystem" && !tool.isReadOnly(input)) {
return allow({
type: "mode",
mode: "acceptEdits",
message: `acceptEdits allows filesystem edit tool ${tool.name}.`,
});
}
if (tool.isReadOnly(input)) {
return allow({
type: "mode",
mode: context.mode,
message: `Mode ${context.mode} allows read-only tool ${tool.name}.`,
});
}
return ask(tool, input, toolCallId, {
type: "mode",
mode: context.mode,
message: `Mode ${context.mode} requires permission for ${tool.name}.`,
});
}
function findMatchingRule(rules: PermissionRule[], toolName: string, input: unknown): PermissionRule | undefined {
return rules.find((rule) => matchPermissionRule(rule, toolName, input));
}
function allow(reason: PermissionDecisionReason): PermissionDecision {
return { type: "allow", reason };
}
function deny(reason: PermissionDecisionReason): PermissionDecision {
return { type: "deny", reason, message: reason.message };
}
function denyFromRule(rule: PermissionRule): PermissionDecision {
return deny({
type: "rule",
behavior: "deny",
rule,
message: `Deny rule blocks ${rule.toolName}.`,
});
}
function askFromRule(
tool: PilotDeckToolDefinition,
input: unknown,
toolCallId: string,
rule: PermissionRule,
): PermissionDecision {
return ask(tool, input, toolCallId, {
type: "rule",
behavior: "ask",
rule,
message: `Ask rule requires confirmation for ${tool.name}.`,
});
}
function ask(
tool: PilotDeckToolDefinition,
input: unknown,
toolCallId: string,
reason: PermissionDecisionReason,
): PermissionDecision {
return {
type: "ask",
reason,
request: createPermissionRequest(tool, input, toolCallId, reason),
};
}
function createPermissionRequest(
tool: PilotDeckToolDefinition,
input: unknown,
toolCallId: string,
reason: PermissionDecisionReason,
): PermissionRequest {
return {
toolCallId,
toolName: tool.name,
inputSummary: summarizeInput(input),
reason,
options: [
{ id: "allow_once", label: "Allow once" },
{ id: "deny", label: "Deny" },
{ id: "cancel", label: "Cancel" },
],
};
}
function finalizeAsk(decision: PermissionDecision, context: PermissionContext): PermissionDecision {
if (decision.type !== "ask") {
return decision;
}
if (context.mode === "bypassPermissions") {
return {
type: "allow",
reason: {
type: "mode",
mode: "bypassPermissions",
message: "bypassPermissions mode skips permission prompts.",
},
};
}
if (context.mode === "dontAsk") {
return {
type: "deny",
reason: {
type: "mode",
mode: "dontAsk",
message: "dontAsk mode denies permission prompts.",
},
message: "Permission prompt denied because dontAsk mode is active.",
};
}
return decision;
}
function summarizeInput(input: unknown): string {
try {
const json = JSON.stringify(input);
if (!json) {
return String(input);
}
return json.length > 500 ? `${json.slice(0, 500)}...` : json;
} catch {
return "[unserializable input]";
}
}
* Returns true when a filesystem write tool (write_file / edit_file) targets
* a markdown file under the project-local `.pilotdeck/plans` directory.
* Resolves relative paths against the permission context cwd so `./foo.md`
* and the absolute path both match.
*/
function isPlanDirectoryWrite(
tool: PilotDeckToolDefinition,
input: unknown,
context: PermissionContext,
): boolean {
if (tool.kind !== "filesystem" || !context.planDirectoryPath) return false;
const record = input as Record<string, unknown> | null;
const filePath = record?.file_path ?? record?.filePath;
if (typeof filePath !== "string") return false;
const absolute = resolve(context.cwd, filePath);
if (!absolute.toLowerCase().endsWith(".md")) {
return false;
}
const relativeToPlanDir = relative(context.planDirectoryPath, absolute);
return (
relativeToPlanDir !== ""
&& !isAbsolute(relativeToPlanDir)
&& !relativeToPlanDir.startsWith("..")
&& !relativeToPlanDir.startsWith(`..${process.platform === "win32" ? "\\" : "/"}`)
);
}