Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
317 lines
9.0 KiB
TypeScript
317 lines
9.0 KiB
TypeScript
import cattyToolSpecs from './generated/cattyToolSpecs.json';
|
|
import {
|
|
buildAlwaysAllowCommandPatterns,
|
|
extractGrantableShellCommandSegments,
|
|
} from '../shared/shellCommandGrant';
|
|
|
|
export interface PermissionGrantRule {
|
|
id: string;
|
|
capabilityId: string;
|
|
sessionPattern: string;
|
|
commandPattern?: string;
|
|
argsPattern?: Record<string, string>;
|
|
createdAt: number;
|
|
note?: string;
|
|
}
|
|
|
|
export interface PermissionGrantMatchContext {
|
|
capabilityId: string;
|
|
sessionId?: string;
|
|
chatSessionId?: string;
|
|
hostname?: string;
|
|
args?: Record<string, unknown>;
|
|
}
|
|
|
|
type CattyToolSpecRef = {
|
|
capabilityId: string;
|
|
toolName: string;
|
|
rpcMethod: string | null;
|
|
policy?: {
|
|
write?: boolean;
|
|
bypassesApproval?: boolean;
|
|
};
|
|
};
|
|
|
|
const TOOL_NAME_TO_CAPABILITY = new Map<string, string>();
|
|
const RPC_METHOD_TO_CAPABILITY = new Map<string, string>();
|
|
|
|
for (const spec of cattyToolSpecs as CattyToolSpecRef[]) {
|
|
TOOL_NAME_TO_CAPABILITY.set(spec.toolName, spec.capabilityId);
|
|
if (spec.rpcMethod) {
|
|
RPC_METHOD_TO_CAPABILITY.set(spec.rpcMethod, spec.capabilityId);
|
|
}
|
|
}
|
|
|
|
export function resolveCapabilityId(toolOrRpcName: string): string {
|
|
return TOOL_NAME_TO_CAPABILITY.get(toolOrRpcName)
|
|
?? RPC_METHOD_TO_CAPABILITY.get(toolOrRpcName)
|
|
?? toolOrRpcName;
|
|
}
|
|
|
|
export function patternMatches(pattern: string, value: string): boolean {
|
|
if (!pattern) return false;
|
|
if (pattern === '*') return true;
|
|
|
|
if (pattern.startsWith('host:')) {
|
|
return globOrRegexMatch(pattern.slice('host:'.length), value);
|
|
}
|
|
|
|
return globOrRegexMatch(pattern, value);
|
|
}
|
|
|
|
function globOrRegexMatch(pattern: string, value: string): boolean {
|
|
if (pattern.startsWith('/') && pattern.lastIndexOf('/') > 0) {
|
|
const lastSlash = pattern.lastIndexOf('/');
|
|
const body = pattern.slice(1, lastSlash);
|
|
const flags = pattern.slice(lastSlash + 1);
|
|
try {
|
|
return new RegExp(body, flags).test(value);
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
if (!pattern.includes('*') && !pattern.includes('?')) {
|
|
return value === pattern;
|
|
}
|
|
|
|
// OpenCode Wildcard.match semantics (trailing " *" allows optional args).
|
|
let escaped = pattern
|
|
.replace(/[.+^${}()|[\]\\]/g, '\\$&')
|
|
.replace(/\*/g, '.*')
|
|
.replace(/\?/g, '.');
|
|
if (escaped.endsWith(' .*')) {
|
|
escaped = `${escaped.slice(0, -3)}( .*)?`;
|
|
}
|
|
|
|
return new RegExp(`^${escaped}$`, 's').test(value);
|
|
}
|
|
|
|
function argsPatternMatches(
|
|
argsPattern: Record<string, string> | undefined,
|
|
args: Record<string, unknown> | undefined,
|
|
): boolean {
|
|
if (!argsPattern) return true;
|
|
if (!args) return false;
|
|
|
|
for (const [key, pattern] of Object.entries(argsPattern)) {
|
|
const argValue = args[key];
|
|
if (typeof argValue === 'undefined') return false;
|
|
if (!patternMatches(pattern, String(argValue))) return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
export function matchPermissionGrant(
|
|
rules: readonly PermissionGrantRule[],
|
|
ctx: PermissionGrantMatchContext,
|
|
): PermissionGrantRule | null {
|
|
if (rules.length === 0) return null;
|
|
|
|
const args = ctx.args ?? {};
|
|
const command = typeof args.command === 'string' ? args.command : '';
|
|
const commandGrantMatch = command ? matchCommandPatternGrants(rules, ctx, command, args) : null;
|
|
if (commandGrantMatch) return commandGrantMatch;
|
|
|
|
for (const rule of rules) {
|
|
if (rule.capabilityId !== ctx.capabilityId) continue;
|
|
if (rule.commandPattern) continue;
|
|
|
|
if (!argsPatternMatches(rule.argsPattern, args)) continue;
|
|
|
|
return rule;
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
function matchCommandPatternGrants(
|
|
rules: readonly PermissionGrantRule[],
|
|
ctx: PermissionGrantMatchContext,
|
|
command: string,
|
|
args: Record<string, unknown>,
|
|
): PermissionGrantRule | null {
|
|
const commandSegments = extractGrantableShellCommandSegments(command);
|
|
if (commandSegments.length === 0) return null;
|
|
|
|
const eligibleRules = rules.filter((rule) => (
|
|
rule.capabilityId === ctx.capabilityId
|
|
&& Boolean(rule.commandPattern)
|
|
&& argsPatternMatches(rule.argsPattern, args)
|
|
));
|
|
if (eligibleRules.length === 0) return null;
|
|
|
|
let firstMatch: PermissionGrantRule | null = null;
|
|
for (const segment of commandSegments) {
|
|
const matched = eligibleRules.find((rule) => patternMatches(rule.commandPattern!, segment));
|
|
if (!matched) return null;
|
|
firstMatch ??= matched;
|
|
}
|
|
|
|
return firstMatch;
|
|
}
|
|
|
|
export function sanitizePermissionGrants(raw: unknown): PermissionGrantRule[] {
|
|
if (!Array.isArray(raw)) return [];
|
|
|
|
const result: PermissionGrantRule[] = [];
|
|
for (const entry of raw) {
|
|
if (!entry || typeof entry !== 'object') continue;
|
|
const record = entry as Record<string, unknown>;
|
|
const capabilityId = typeof record.capabilityId === 'string' ? record.capabilityId.trim() : '';
|
|
if (!capabilityId) continue;
|
|
|
|
const rule: PermissionGrantRule = {
|
|
id: typeof record.id === 'string' && record.id.trim()
|
|
? record.id.trim().slice(0, 64)
|
|
: createPermissionGrantId(),
|
|
capabilityId,
|
|
sessionPattern: typeof record.sessionPattern === 'string' && record.sessionPattern.trim()
|
|
? record.sessionPattern.trim()
|
|
: '*',
|
|
createdAt: typeof record.createdAt === 'number' && Number.isFinite(record.createdAt)
|
|
? record.createdAt
|
|
: Date.now(),
|
|
};
|
|
|
|
if (typeof record.commandPattern === 'string' && record.commandPattern.trim()) {
|
|
rule.commandPattern = record.commandPattern.trim();
|
|
}
|
|
if (record.argsPattern && typeof record.argsPattern === 'object' && !Array.isArray(record.argsPattern)) {
|
|
const argsPattern: Record<string, string> = {};
|
|
for (const [key, value] of Object.entries(record.argsPattern as Record<string, unknown>)) {
|
|
if (typeof value === 'string' && value.trim()) {
|
|
argsPattern[key] = value.trim();
|
|
}
|
|
}
|
|
if (Object.keys(argsPattern).length > 0) {
|
|
rule.argsPattern = argsPattern;
|
|
}
|
|
}
|
|
if (typeof record.note === 'string' && record.note.trim()) {
|
|
rule.note = record.note.trim().slice(0, 240);
|
|
}
|
|
|
|
result.push(rule);
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
export function createPermissionGrantId(): string {
|
|
return `grant_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
|
}
|
|
|
|
const COMMAND_GRANT_CAPABILITIES = new Set([
|
|
'terminal.execute',
|
|
'terminal.start',
|
|
]);
|
|
|
|
const GRANTABLE_CAPABILITY_IDS: readonly string[] = Object.freeze(
|
|
[...new Set(
|
|
(cattyToolSpecs as CattyToolSpecRef[])
|
|
.filter((spec) => spec.policy?.write && !spec.policy?.bypassesApproval)
|
|
.map((spec) => spec.capabilityId),
|
|
)].sort(),
|
|
);
|
|
|
|
export function listGrantableCapabilityIds(): readonly string[] {
|
|
return GRANTABLE_CAPABILITY_IDS;
|
|
}
|
|
|
|
export function capabilitySupportsCommandPatternGrant(capabilityId: string): boolean {
|
|
return COMMAND_GRANT_CAPABILITIES.has(capabilityId);
|
|
}
|
|
|
|
function resolveCommandGrantPatterns(
|
|
capabilityId: string,
|
|
args: Record<string, unknown>,
|
|
): string[] | undefined {
|
|
if (!COMMAND_GRANT_CAPABILITIES.has(capabilityId)) return undefined;
|
|
const command = typeof args.command === 'string' ? args.command.trim() : '';
|
|
if (!command) return undefined;
|
|
return buildAlwaysAllowCommandPatterns(command);
|
|
}
|
|
|
|
export function buildGrantsFromApproval(
|
|
capabilityId: string,
|
|
args: Record<string, unknown>,
|
|
_chatSessionId?: string,
|
|
): PermissionGrantRule[] {
|
|
// OpenCode-style always-allow: global scope (not bound to a terminal session UUID).
|
|
const sessionPattern = '*';
|
|
const commandPatterns = resolveCommandGrantPatterns(capabilityId, args);
|
|
const createdAt = Date.now();
|
|
|
|
if (!commandPatterns) {
|
|
return [{
|
|
id: createPermissionGrantId(),
|
|
capabilityId,
|
|
sessionPattern,
|
|
createdAt,
|
|
}];
|
|
}
|
|
|
|
if (commandPatterns.length === 0) return [];
|
|
|
|
return commandPatterns.map((commandPattern) => ({
|
|
id: createPermissionGrantId(),
|
|
capabilityId,
|
|
sessionPattern,
|
|
commandPattern,
|
|
createdAt,
|
|
}));
|
|
}
|
|
|
|
export function buildGrantFromApproval(
|
|
capabilityId: string,
|
|
args: Record<string, unknown>,
|
|
chatSessionId?: string,
|
|
): PermissionGrantRule | null {
|
|
return buildGrantsFromApproval(capabilityId, args, chatSessionId)[0] ?? null;
|
|
}
|
|
|
|
let activeRules: PermissionGrantRule[] = [];
|
|
|
|
export function setActivePermissionGrants(rules: PermissionGrantRule[]): void {
|
|
activeRules = [...rules];
|
|
}
|
|
|
|
export function getActivePermissionGrants(): readonly PermissionGrantRule[] {
|
|
return activeRules;
|
|
}
|
|
|
|
export class PermissionGrantStore {
|
|
private rules: PermissionGrantRule[];
|
|
|
|
constructor(rules: PermissionGrantRule[] = []) {
|
|
this.rules = [...rules];
|
|
}
|
|
|
|
getRules(): readonly PermissionGrantRule[] {
|
|
return this.rules;
|
|
}
|
|
|
|
setRules(rules: PermissionGrantRule[]): void {
|
|
this.rules = [...rules];
|
|
}
|
|
|
|
addRule(rule: PermissionGrantRule): void {
|
|
this.rules = [...this.rules, rule];
|
|
}
|
|
|
|
updateRule(id: string, updates: Partial<Omit<PermissionGrantRule, 'id' | 'createdAt'>>): void {
|
|
this.rules = this.rules.map((rule) => (
|
|
rule.id === id ? { ...rule, ...updates } : rule
|
|
));
|
|
}
|
|
|
|
removeRule(id: string): void {
|
|
this.rules = this.rules.filter((rule) => rule.id !== id);
|
|
}
|
|
|
|
match(ctx: PermissionGrantMatchContext): PermissionGrantRule | null {
|
|
return matchPermissionGrant(this.rules, ctx);
|
|
}
|
|
}
|