src / policy / gateRuntime.ts
src / policy / gateRuntime.ts
/**
* The runtime half of the permission gate. `permissions.ts` decides; this
* module does the I/O the decision needs (approval-store lookups, staging) and
* turns a "stage" decision into the pending-approval tool envelope. Tools must
* never re-implement the rule order — they call `applyGate` and either proceed
* or return the staged envelope untouched.
*/
import type { ToolCallContext } from "@lmstudio/sdk";
import type { ArtifactRef } from "../core/artifacts";
import { AgenticError, asError } from "../core/errors";
import { sha256Text } from "../core/hash";
import { okResult, type ToolEnvelope } from "../core/result";
import type { PluginRuntime } from "../runtime";
import type { ApprovalRecord, ApprovalReference, ApprovalResume } from "./approvalStore";
import {
decide,
describePermissionMode,
gateReasonText,
type MutationKind,
} from "./permissions";
export interface GateExtra {
data?: Record<string, unknown>;
artifacts?: ArtifactRef[];
facts?: string[];
omit?: string[];
}
export interface GateRequest {
kind: MutationKind;
/** Envelope operation name used when the request is staged (e.g. "edit.apply"). */
operation: string;
operationHash: string;
title: string;
description: string;
destructive: boolean;
/**
* Appended to the refusal when the destructive ceiling is what blocked the
* call. `gateReasonText` can only name the setting, which is true for every
* kind of mutation and actionable for none: the caller knows *which*
* operation and path made this request destructive, so it says so here.
*/
destructiveDetail?: string;
reference: ApprovalReference;
resume: ApprovalResume;
/**
* Extra envelope content for a staged result. Pass a thunk when building it
* costs I/O (artifact hashing): it is resolved only on the staging path, so
* an allowed mutation never pays for content nobody sees.
*/
extra?: GateExtra | (() => GateExtra | Promise<GateExtra>);
}
export type GateOutcome =
| { outcome: "allow"; record?: ApprovalRecord }
| { outcome: "stage"; record: ApprovalRecord; result: ToolEnvelope };
export function hashOperation(kind: MutationKind, payload: unknown): string {
return sha256Text(`${kind}\u0000${JSON.stringify(payload)}`);
}
/**
* Drops undefined-valued keys before an object becomes `resume.arguments`, so
* a staged approval's resume call round-trips through JSON (and equality
* checks) identically to the call that staged it.
*/
export function compact(obj: Record<string, unknown>): Record<string, unknown> {
return Object.fromEntries(Object.entries(obj).filter(([, v]) => v !== undefined));
}
export function modeFacts(runtime: PluginRuntime): string[] {
return [`permission mode ${describePermissionMode(runtime.settings.permissionMode)}`];
}
export function stagedResult(
runtime: PluginRuntime,
operation: string,
record: ApprovalRecord,
extra: GateExtra = {},
): ToolEnvelope {
const resume = record.resume;
// The compressor keeps only summary/facts after compaction, so the summary
// must spell out the exact resume call rather than "the same arguments".
const resumeText = resume
? `After approval, call ${resume.tool} with ${JSON.stringify(resume.arguments)}.`
: "After approval, issue the same call again.";
return okResult({
operation,
summary: `Awaiting approval ${record.id}: ${record.title}. The user must reply /accept ${record.id} (or /deny ${record.id} <reason>). ${resumeText}`,
data: {
...(extra.data ?? {}),
status: "pending_approval",
mode: runtime.settings.permissionMode,
approval: {
id: record.id,
kind: record.kind,
title: record.title,
destructive: record.destructive,
resume,
},
},
artifacts: extra.artifacts,
importance: "critical",
facts: [
`approval ${record.id} pending: ${record.title}`,
...modeFacts(runtime),
...(extra.facts ?? []),
],
omit: extra.omit,
});
}
export async function applyGate(
runtime: PluginRuntime,
ctx: ToolCallContext | undefined,
request: GateRequest,
): Promise<GateOutcome> {
const mode = runtime.settings.permissionMode;
const approval = mode === "auto" ? undefined : await runtime.approvals.find(request.operationHash);
const activePlan = mode === "plan" ? await runtime.approvals.activePlan() : undefined;
const decision = decide({
mode,
kind: request.kind,
destructive: request.destructive,
allowDestructiveEdits: runtime.settings.allowDestructiveEdits,
planApproved: activePlan !== undefined,
approval:
approval &&
(approval.status === "pending" ||
approval.status === "approved" ||
approval.status === "denied")
? { status: approval.status }
: undefined,
});
const explain = (reason: Parameters<typeof gateReasonText>[0]) =>
`${request.title} was not executed because ${gateReasonText(reason)}.`;
const details = {
mode,
...(request.reference.transactionId
? { transactionId: request.reference.transactionId }
: {}),
};
if (decision.outcome === "refuse") {
const detail =
decision.reason === "destructive_disabled" && request.destructiveDetail
? ` ${request.destructiveDetail}`
: "";
throw new AgenticError(
decision.reason === "destructive_disabled" ? "PROTECTED_PATH" : "APPROVAL_DENIED",
`${explain(decision.reason)}${detail}`,
{ ...details, ...(approval ? { approvalId: approval.id } : {}) },
);
}
if (decision.outcome === "allow") {
return {
outcome: "allow",
...(decision.reason === "approval_consumed" && approval ? { record: approval } : {}),
};
}
if (decision.reason === "plan_requires_plan") {
throw new AgenticError("APPROVAL_REQUIRED", explain(decision.reason), details);
}
const record =
approval?.status === "pending"
? approval
: await runtime.approvals.stage({
kind: request.kind,
operationHash: request.operationHash,
title: request.title,
description: request.description,
destructive: request.destructive,
reference: request.reference,
resume: request.resume,
});
ctx?.warn(
`Approval ${record.id} required: ${record.title}. Reply /accept ${record.id} or /deny ${record.id} <reason>.`,
);
const extra = typeof request.extra === "function" ? await request.extra() : request.extra;
return {
outcome: "stage",
record,
result: stagedResult(runtime, request.operation, record, extra),
};
}
/**
* Marks an approval spent after the mutation it authorised has already
* succeeded. It never throws: a store I/O failure, or a concurrent commit that
* consumed the record first, must not turn a completed edit into an error
* envelope. The user is warned instead.
*/
export async function consumeApproval(
runtime: PluginRuntime,
ctx: ToolCallContext | undefined,
outcome: GateOutcome,
reference: ApprovalReference,
): Promise<void> {
if (outcome.outcome !== "allow" || !outcome.record) return;
try {
await runtime.approvals.consume(outcome.record.id, reference);
} catch (error) {
ctx?.warn(
`Approval ${outcome.record.id} could not be marked consumed: ${asError(error).message}`,
);
}
}
/**
* The runtime half of the permission gate. `permissions.ts` decides; this
* module does the I/O the decision needs (approval-store lookups, staging) and
* turns a "stage" decision into the pending-approval tool envelope. Tools must
* never re-implement the rule order — they call `applyGate` and either proceed
* or return the staged envelope untouched.
*/
import type { ToolCallContext } from "@lmstudio/sdk";
import type { ArtifactRef } from "../core/artifacts";
import { AgenticError, asError } from "../core/errors";
import { sha256Text } from "../core/hash";
import { okResult, type ToolEnvelope } from "../core/result";
import type { PluginRuntime } from "../runtime";
import type { ApprovalRecord, ApprovalReference, ApprovalResume } from "./approvalStore";
import {
decide,
describePermissionMode,
gateReasonText,
type MutationKind,
} from "./permissions";
export interface GateExtra {
data?: Record<string, unknown>;
artifacts?: ArtifactRef[];
facts?: string[];
omit?: string[];
}
export interface GateRequest {
kind: MutationKind;
/** Envelope operation name used when the request is staged (e.g. "edit.apply"). */
operation: string;
operationHash: string;
title: string;
description: string;
destructive: boolean;
/**
* Appended to the refusal when the destructive ceiling is what blocked the
* call. `gateReasonText` can only name the setting, which is true for every
* kind of mutation and actionable for none: the caller knows *which*
* operation and path made this request destructive, so it says so here.
*/
destructiveDetail?: string;
reference: ApprovalReference;
resume: ApprovalResume;
/**
* Extra envelope content for a staged result. Pass a thunk when building it
* costs I/O (artifact hashing): it is resolved only on the staging path, so
* an allowed mutation never pays for content nobody sees.
*/
extra?: GateExtra | (() => GateExtra | Promise<GateExtra>);
}
export type GateOutcome =
| { outcome: "allow"; record?: ApprovalRecord }
| { outcome: "stage"; record: ApprovalRecord; result: ToolEnvelope };
export function hashOperation(kind: MutationKind, payload: unknown): string {
return sha256Text(`${kind}\u0000${JSON.stringify(payload)}`);
}
/**
* Drops undefined-valued keys before an object becomes `resume.arguments`, so
* a staged approval's resume call round-trips through JSON (and equality
* checks) identically to the call that staged it.
*/
export function compact(obj: Record<string, unknown>): Record<string, unknown> {
return Object.fromEntries(Object.entries(obj).filter(([, v]) => v !== undefined));
}
export function modeFacts(runtime: PluginRuntime): string[] {
return [`permission mode ${describePermissionMode(runtime.settings.permissionMode)}`];
}
export function stagedResult(
runtime: PluginRuntime,
operation: string,
record: ApprovalRecord,
extra: GateExtra = {},
): ToolEnvelope {
const resume = record.resume;
// The compressor keeps only summary/facts after compaction, so the summary
// must spell out the exact resume call rather than "the same arguments".
const resumeText = resume
? `After approval, call ${resume.tool} with ${JSON.stringify(resume.arguments)}.`
: "After approval, issue the same call again.";
return okResult({
operation,
summary: `Awaiting approval ${record.id}: ${record.title}. The user must reply /accept ${record.id} (or /deny ${record.id} <reason>). ${resumeText}`,
data: {
...(extra.data ?? {}),
status: "pending_approval",
mode: runtime.settings.permissionMode,
approval: {
id: record.id,
kind: record.kind,
title: record.title,
destructive: record.destructive,
resume,
},
},
artifacts: extra.artifacts,
importance: "critical",
facts: [
`approval ${record.id} pending: ${record.title}`,
...modeFacts(runtime),
...(extra.facts ?? []),
],
omit: extra.omit,
});
}
export async function applyGate(
runtime: PluginRuntime,
ctx: ToolCallContext | undefined,
request: GateRequest,
): Promise<GateOutcome> {
const mode = runtime.settings.permissionMode;
const approval = mode === "auto" ? undefined : await runtime.approvals.find(request.operationHash);
const activePlan = mode === "plan" ? await runtime.approvals.activePlan() : undefined;
const decision = decide({
mode,
kind: request.kind,
destructive: request.destructive,
allowDestructiveEdits: runtime.settings.allowDestructiveEdits,
planApproved: activePlan !== undefined,
approval:
approval &&
(approval.status === "pending" ||
approval.status === "approved" ||
approval.status === "denied")
? { status: approval.status }
: undefined,
});
const explain = (reason: Parameters<typeof gateReasonText>[0]) =>
`${request.title} was not executed because ${gateReasonText(reason)}.`;
const details = {
mode,
...(request.reference.transactionId
? { transactionId: request.reference.transactionId }
: {}),
};
if (decision.outcome === "refuse") {
const detail =
decision.reason === "destructive_disabled" && request.destructiveDetail
? ` ${request.destructiveDetail}`
: "";
throw new AgenticError(
decision.reason === "destructive_disabled" ? "PROTECTED_PATH" : "APPROVAL_DENIED",
`${explain(decision.reason)}${detail}`,
{ ...details, ...(approval ? { approvalId: approval.id } : {}) },
);
}
if (decision.outcome === "allow") {
return {
outcome: "allow",
...(decision.reason === "approval_consumed" && approval ? { record: approval } : {}),
};
}
if (decision.reason === "plan_requires_plan") {
throw new AgenticError("APPROVAL_REQUIRED", explain(decision.reason), details);
}
const record =
approval?.status === "pending"
? approval
: await runtime.approvals.stage({
kind: request.kind,
operationHash: request.operationHash,
title: request.title,
description: request.description,
destructive: request.destructive,
reference: request.reference,
resume: request.resume,
});
ctx?.warn(
`Approval ${record.id} required: ${record.title}. Reply /accept ${record.id} or /deny ${record.id} <reason>.`,
);
const extra = typeof request.extra === "function" ? await request.extra() : request.extra;
return {
outcome: "stage",
record,
result: stagedResult(runtime, request.operation, record, extra),
};
}
/**
* Marks an approval spent after the mutation it authorised has already
* succeeded. It never throws: a store I/O failure, or a concurrent commit that
* consumed the record first, must not turn a completed edit into an error
* envelope. The user is warned instead.
*/
export async function consumeApproval(
runtime: PluginRuntime,
ctx: ToolCallContext | undefined,
outcome: GateOutcome,
reference: ApprovalReference,
): Promise<void> {
if (outcome.outcome !== "allow" || !outcome.record) return;
try {
await runtime.approvals.consume(outcome.record.id, reference);
} catch (error) {
ctx?.warn(
`Approval ${outcome.record.id} could not be marked consumed: ${asError(error).message}`,
);
}
}