claw-web-v2 / server /runtime /permissions.ts
Claw Web
feat: complete P0 fixes + Manus UI overhaul phase 2
6aeba60
// ─── permissions.ts β€” Matches original rust/crates/runtime/src/permissions.rs ─
// PermissionPolicy, PermissionEvaluator, glob matching, prompt flow
// ─── Types ───────────────────────────────────────────────────────────────────
export enum PermissionMode {
ReadOnly = "read-only",
WorkspaceWrite = "workspace-write",
DangerFullAccess = "danger-full-access",
Prompt = "prompt",
Allow = "allow",
}
export interface PermissionRequest {
toolName: string;
input: string;
currentMode: PermissionMode;
requiredMode: PermissionMode;
}
export type PermissionOutcome =
| { type: "allow" }
| { type: "deny"; reason: string };
export type PermissionPromptDecision =
| { type: "allow" }
| { type: "deny"; reason: string };
export interface PermissionPrompter {
decide(request: PermissionRequest): PermissionPromptDecision;
}
// ─── Mode ordering (for comparison) ──────────────────────────────────────────
// Mode ordering matches original Rust enum derive(PartialOrd, Ord):
// ReadOnly < WorkspaceWrite < DangerFullAccess < Prompt < Allow
const MODE_ORDER: Record<PermissionMode, number> = {
[PermissionMode.ReadOnly]: 0,
[PermissionMode.WorkspaceWrite]: 1,
[PermissionMode.DangerFullAccess]: 2,
[PermissionMode.Prompt]: 3,
[PermissionMode.Allow]: 4,
};
function modeGte(a: PermissionMode, b: PermissionMode): boolean {
return MODE_ORDER[a] >= MODE_ORDER[b];
}
// ─── Default tool requirements ───────────────────────────────────────────────
const DEFAULT_TOOL_REQUIREMENTS: Record<string, PermissionMode> = {
// Read-only tools
read_file: PermissionMode.ReadOnly,
glob_search: PermissionMode.ReadOnly,
grep_search: PermissionMode.ReadOnly,
WebSearch: PermissionMode.ReadOnly,
WebFetch: PermissionMode.ReadOnly,
ToolSearch: PermissionMode.ReadOnly,
TodoRead: PermissionMode.ReadOnly,
lsp: PermissionMode.ReadOnly,
// Workspace write tools
write_file: PermissionMode.WorkspaceWrite,
edit_file: PermissionMode.WorkspaceWrite,
NotebookEdit: PermissionMode.WorkspaceWrite,
TodoWrite: PermissionMode.WorkspaceWrite,
Config: PermissionMode.WorkspaceWrite,
// Danger/full access tools
bash: PermissionMode.DangerFullAccess,
REPL: PermissionMode.DangerFullAccess,
powershell: PermissionMode.DangerFullAccess,
Agent: PermissionMode.DangerFullAccess,
mcp_tool: PermissionMode.DangerFullAccess,
remote_trigger: PermissionMode.DangerFullAccess,
};
// ─── PermissionPolicy ────────────────────────────────────────────────────────
export class PermissionPolicy {
private activeMode: PermissionMode;
private toolRequirements: Map<string, PermissionMode>;
constructor(mode: PermissionMode) {
this.activeMode = mode;
this.toolRequirements = new Map(Object.entries(DEFAULT_TOOL_REQUIREMENTS));
}
withToolRequirement(
toolName: string,
mode: PermissionMode
): PermissionPolicy {
this.toolRequirements.set(toolName, mode);
return this;
}
getActiveMode(): PermissionMode {
return this.activeMode;
}
setActiveMode(mode: PermissionMode): void {
this.activeMode = mode;
}
requiredModeFor(toolName: string): PermissionMode {
return (
this.toolRequirements.get(toolName) || PermissionMode.DangerFullAccess
);
}
authorize(
toolName: string,
input: string,
prompter?: PermissionPrompter
): PermissionOutcome {
const currentMode = this.activeMode;
const requiredMode = this.requiredModeFor(toolName);
// Allow mode permits everything
if (currentMode === PermissionMode.Allow || modeGte(currentMode, requiredMode)) {
return { type: "allow" };
}
const request: PermissionRequest = {
toolName,
input,
currentMode,
requiredMode,
};
// Prompt mode or workspace-write escalating to danger-full-access
if (
currentMode === PermissionMode.Prompt ||
(currentMode === PermissionMode.WorkspaceWrite &&
requiredMode === PermissionMode.DangerFullAccess)
) {
if (prompter) {
const decision = prompter.decide(request);
if (decision.type === "allow") {
return { type: "allow" };
}
return { type: "deny", reason: decision.reason };
}
return {
type: "deny",
reason: `tool '${toolName}' requires approval to escalate from ${currentMode} to ${requiredMode}`,
};
}
return {
type: "deny",
reason: `tool '${toolName}' requires ${requiredMode} permission; current mode is ${currentMode}`,
};
}
}
import micromatch from "micromatch";
// ─── Glob matching for tool names ────────────────────────────────────────────
export function globMatchToolName(
pattern: string,
toolName: string
): boolean {
return micromatch.isMatch(toolName, pattern);
}
// ─── Glob matching for file paths ────────────────────────────────────────────
export function globMatchPath(pattern: string, filePath: string): boolean {
// Normalize: remove trailing slashes
const normalizedPath = filePath.replace(/\/+$/, "");
const normalizedPattern = pattern.replace(/\/+$/, "");
return micromatch.isMatch(normalizedPath, normalizedPattern, { dot: true });
}
// ─── Permission prompt text ──────────────────────────────────────────────────
export function permissionPromptText(request: PermissionRequest): string {
return `Tool "${request.toolName}" requires ${request.requiredMode} permission.\nCurrent mode: ${request.currentMode}\nInput: ${request.input.substring(0, 200)}${request.input.length > 200 ? "..." : ""}`;
}
// ─── Permission rule (for config-based rules) ────────────────────────────────
export interface PermissionRule {
toolPattern: string;
pathPattern?: string;
action: "allow" | "deny";
scope: "session" | "persistent";
}
export class PermissionEvaluator {
private rules: PermissionRule[] = [];
addRule(rule: PermissionRule): void {
this.rules.push(rule);
}
removeRule(index: number): void {
if (index >= 0 && index < this.rules.length) {
this.rules.splice(index, 1);
}
}
getRules(): PermissionRule[] {
return [...this.rules];
}
clearSessionRules(): void {
this.rules = this.rules.filter((r) => r.scope === "persistent");
}
evaluate(
toolName: string,
filePath?: string
): "allow" | "deny" | "no-match" {
// Rules are evaluated in order, last match wins
let result: "allow" | "deny" | "no-match" = "no-match";
for (const rule of this.rules) {
if (!globMatchToolName(rule.toolPattern, toolName)) {
continue;
}
if (rule.pathPattern && filePath) {
if (!globMatchPath(rule.pathPattern, filePath)) {
continue;
}
}
result = rule.action;
}
return result;
}
toJSON(): PermissionRule[] {
return this.rules;
}
static fromJSON(rules: PermissionRule[]): PermissionEvaluator {
const evaluator = new PermissionEvaluator();
evaluator.rules = rules;
return evaluator;
}
}