Files
NetMesh/electron/shared/permissionGrants.cjs

610 lines
15 KiB
JavaScript
Raw Normal View History

"use strict";
/**
* Permission grant pattern matching shared between main (MCP) and renderer.
*/
function patternMatches(pattern, value) {
if (typeof pattern !== "string" || pattern.length === 0) return false;
if (pattern === "*") return true;
if (typeof value !== "string") return false;
if (pattern.startsWith("host:")) {
const hostPattern = pattern.slice("host:".length);
return globOrRegexMatch(hostPattern, value);
}
return globOrRegexMatch(pattern, value);
}
function globOrRegexMatch(pattern, value) {
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, args) {
if (!argsPattern || typeof argsPattern !== "object") return true;
if (!args || typeof args !== "object") return false;
for (const [key, pattern] of Object.entries(argsPattern)) {
const argValue = args[key];
if (typeof argValue === "undefined") return false;
if (!patternMatches(String(pattern), String(argValue))) return false;
}
return true;
}
const CWD_COMMANDS = new Set([
"cd",
"chdir",
"popd",
"pushd",
"push-location",
"set-location",
]);
function unquoteShellToken(token) {
if (token.length >= 2) {
const first = token[0];
const last = token[token.length - 1];
if ((first === "\"" || first === "'") && first === last) {
return token.slice(1, -1);
}
}
return token;
}
function tokenizeShellCommand(command) {
const matches = command.match(/(?:[^\s"']+|"[^"]*"|'[^']*')+/g) || [];
return matches.map(unquoteShellToken);
}
function lastNonWhitespaceChar(value) {
return value.match(/\S(?=\s*$)/)?.[0];
}
function readArithmeticExpansionEnd(segment, startIndex) {
if (segment[startIndex] !== "$" || segment[startIndex + 1] !== "(" || segment[startIndex + 2] !== "(") {
return null;
}
let depth = 1;
let quote = null;
for (let index = startIndex + 3; index < segment.length; index += 1) {
const char = segment[index];
const next = segment[index + 1];
if (quote) {
if (char === "\\" && quote !== "'" && next) {
index += 1;
continue;
}
if (char === quote) quote = null;
continue;
}
if (char === "\\" && next) {
index += 1;
continue;
}
if (char === "\"" || char === "'" || char === "`") {
quote = char;
continue;
}
if (char === "(") {
depth += 1;
continue;
}
if (char === ")") {
if (depth === 1 && next === ")") return index + 2;
if (depth > 1) depth -= 1;
}
}
return segment.length;
}
function hasExecutableShellExpansion(segment) {
let quote = null;
for (let index = 0; index < segment.length; index += 1) {
const char = segment[index];
const next = segment[index + 1];
if (quote) {
if (char === "\\" && quote === "\"" && next) {
index += 1;
continue;
}
if (char === quote) {
quote = null;
continue;
}
if (quote === "\"" && char === "$" && next === "(") return true;
if (quote === "\"" && char === "`") return true;
continue;
}
if (char === "\\" && next) {
index += 1;
continue;
}
if (char === "'") {
quote = char;
continue;
}
if (char === "\"") {
quote = char;
continue;
}
if (char === "`") return true;
if (char === "$" && next === "(") return true;
if ((char === "<" || char === ">") && next === "(") return true;
}
return false;
}
function readEscapeDigits(value, startIndex, maxLength, pattern) {
let endIndex = startIndex;
while (
endIndex < value.length
&& endIndex < startIndex + maxLength
&& pattern.test(value[endIndex])
) {
endIndex += 1;
}
if (endIndex === startIndex) return null;
return { digits: value.slice(startIndex, endIndex), endIndex: endIndex - 1 };
}
function codePointToString(codePoint) {
try {
return String.fromCodePoint(codePoint);
} catch {
return "";
}
}
function readAnsiCEscape(value, backslashIndex) {
const escapeIndex = backslashIndex + 1;
const char = value[escapeIndex];
if (!char) return { text: "\\", endIndex: backslashIndex };
const simpleEscapes = {
a: "\x07",
b: "\b",
e: "\x1B",
E: "\x1B",
f: "\f",
n: "\n",
r: "\r",
t: "\t",
v: "\v",
"\\": "\\",
"'": "'",
"\"": "\"",
"?": "?",
};
const simple = simpleEscapes[char];
if (simple !== undefined) return { text: simple, endIndex: escapeIndex };
if (char === "x") {
const digits = readEscapeDigits(value, escapeIndex + 1, 2, /[0-9a-fA-F]/);
if (!digits) return { text: "\\x", endIndex: escapeIndex };
return {
text: codePointToString(Number.parseInt(digits.digits, 16)),
endIndex: digits.endIndex,
};
}
if (char === "u" || char === "U") {
const digits = readEscapeDigits(value, escapeIndex + 1, char === "u" ? 4 : 8, /[0-9a-fA-F]/);
if (!digits) return { text: `\\${char}`, endIndex: escapeIndex };
return {
text: codePointToString(Number.parseInt(digits.digits, 16)),
endIndex: digits.endIndex,
};
}
if (/[0-7]/.test(char)) {
const digits = readEscapeDigits(value, escapeIndex, 3, /[0-7]/);
return {
text: codePointToString(Number.parseInt(digits.digits, 8)),
endIndex: digits.endIndex,
};
}
return { text: `\\${char}`, endIndex: escapeIndex };
}
function readHereDocDelimiterWord(segment, startIndex) {
let index = startIndex;
while (index < segment.length && /\s/.test(segment[index])) index += 1;
let text = "";
let quote = null;
let ansiQuote = false;
for (; index < segment.length; index += 1) {
const char = segment[index];
const next = segment[index + 1];
if (quote) {
if (ansiQuote && char === "\\") {
const escape = readAnsiCEscape(segment, index);
text += escape.text;
index = escape.endIndex;
continue;
}
if (char === "\\" && quote !== "'" && next) {
text += next;
index += 1;
continue;
}
if (char === quote) {
quote = null;
ansiQuote = false;
continue;
}
text += char;
continue;
}
if (/\s/.test(char) || char === ";" || char === "|" || char === "&") break;
if (char === "\\" && next) {
text += next;
index += 1;
continue;
}
if (char === "$" && (next === "'" || next === "\"")) {
quote = next;
ansiQuote = next === "'";
index += 1;
continue;
}
if (char === "\"" || char === "'" || char === "`") {
quote = char;
ansiQuote = false;
continue;
}
text += char;
}
return text ? { text, endIndex: index } : null;
}
function extractHereDocTerminators(segment) {
const terminators = [];
let quote = null;
for (let index = 0; index < segment.length; index += 1) {
const char = segment[index];
const next = segment[index + 1];
if (quote) {
if (char === "\\" && quote !== "'" && next) {
index += 1;
continue;
}
if (char === quote) quote = null;
continue;
}
const arithmeticEnd = readArithmeticExpansionEnd(segment, index);
if (arithmeticEnd !== null) {
index = arithmeticEnd - 1;
continue;
}
if (char === "\\" && next) {
index += 1;
continue;
}
if (char === "\"" || char === "'" || char === "`") {
quote = char;
continue;
}
if (char !== "<" || next !== "<") continue;
if (segment[index + 2] === "<") {
index += 2;
continue;
}
const stripLeadingTabs = segment[index + 2] === "-";
const delimiterStart = index + (stripLeadingTabs ? 3 : 2);
const delimiter = readHereDocDelimiterWord(segment, delimiterStart);
if (!delimiter) continue;
terminators.push({ text: delimiter.text, stripLeadingTabs });
index = delimiter.endIndex - 1;
}
return terminators;
}
function skipHereDocBodies(command, startIndex, terminators) {
let cursor = startIndex;
for (const terminator of terminators) {
while (cursor < command.length) {
const lineEnd = command.indexOf("\n", cursor);
const end = lineEnd === -1 ? command.length : lineEnd;
const rawLine = command.slice(cursor, end);
const line = terminator.stripLeadingTabs ? rawLine.replace(/^\t+/, "") : rawLine;
cursor = lineEnd === -1 ? command.length : lineEnd + 1;
if (line === terminator.text) break;
}
}
return cursor;
}
function splitShellCommandSegments(command) {
const segments = [];
let current = "";
let quote = null;
let inComment = false;
let lineHereDocTerminators = [];
const flush = () => {
const segment = current.trim();
if (segment) segments.push(segment);
current = "";
};
const flushCommandSegment = () => {
lineHereDocTerminators.push(...extractHereDocTerminators(current));
flush();
};
const finishLine = (bodyStartIndex) => {
flushCommandSegment();
const hereDocTerminators = lineHereDocTerminators;
lineHereDocTerminators = [];
if (hereDocTerminators.length === 0) return bodyStartIndex;
return skipHereDocBodies(command, bodyStartIndex, hereDocTerminators);
};
for (let index = 0; index < command.length; index += 1) {
const char = command[index];
const next = command[index + 1];
if (inComment) {
if (char === "\n") {
inComment = false;
index = finishLine(index + 1) - 1;
}
continue;
}
if (quote) {
current += char;
if (char === "\\" && quote !== "'" && next) {
current += next;
index += 1;
continue;
}
if (char === quote) quote = null;
continue;
}
if (char === "\\" && next === "\n") {
index += 1;
continue;
}
if (char === "\\" && next) {
current += char + next;
index += 1;
continue;
}
if (char === "\"" || char === "'" || char === "`") {
quote = char;
current += char;
continue;
}
const arithmeticEnd = readArithmeticExpansionEnd(command, index);
if (arithmeticEnd !== null) {
current += command.slice(index, arithmeticEnd);
index = arithmeticEnd - 1;
continue;
}
if (char === "#") {
const previous = current[current.length - 1];
if (!previous || /\s/.test(previous)) {
inComment = true;
continue;
}
}
if (char === "\n") {
index = finishLine(index + 1) - 1;
continue;
}
if (char === ";") {
flushCommandSegment();
continue;
}
if (char === "&" && next === "&") {
flushCommandSegment();
index += 1;
continue;
}
if (char === "|" && next === "|") {
flushCommandSegment();
index += 1;
continue;
}
if (char === "&") {
const previous = lastNonWhitespaceChar(current);
if (next === ">" || previous === ">" || previous === "<") {
current += char;
continue;
}
flushCommandSegment();
continue;
}
if (char === "|") {
flushCommandSegment();
if (next === "&") index += 1;
continue;
}
current += char;
}
flush();
return segments;
}
function extractGrantableShellCommandSegments(command) {
return splitShellCommandSegments(command).filter((segment) => {
const tokens = tokenizeShellCommand(segment);
const cmd = tokens[0] && tokens[0].toLowerCase();
return tokens.length > 0 && !(cmd && CWD_COMMANDS.has(cmd) && !hasExecutableShellExpansion(segment));
});
}
function matchCommandPatternGrants(rules, ctx, command, args) {
const commandSegments = extractGrantableShellCommandSegments(command);
if (commandSegments.length === 0) return null;
const eligibleRules = rules.filter((rule) => (
rule
&& rule.capabilityId === ctx?.capabilityId
&& rule.commandPattern
&& argsPatternMatches(rule.argsPattern, args)
));
if (eligibleRules.length === 0) return null;
let firstMatch = null;
for (const segment of commandSegments) {
const matched = eligibleRules.find((rule) => patternMatches(rule.commandPattern, segment));
if (!matched) return null;
if (!firstMatch) firstMatch = matched;
}
return firstMatch;
}
function matchPermissionGrant(rules, ctx) {
if (!Array.isArray(rules) || rules.length === 0) return null;
const args = ctx?.args && typeof ctx.args === "object" ? 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 || typeof rule.capabilityId !== "string") continue;
if (rule.capabilityId !== ctx?.capabilityId) continue;
if (rule.commandPattern) continue;
if (!argsPatternMatches(rule.argsPattern, args)) continue;
return rule;
}
return null;
}
function sanitizePermissionGrants(raw) {
if (!Array.isArray(raw)) return [];
const result = [];
for (const entry of raw) {
if (!entry || typeof entry !== "object") continue;
const capabilityId = typeof entry.capabilityId === "string" ? entry.capabilityId.trim() : "";
if (!capabilityId) continue;
const rule = {
id: typeof entry.id === "string" && entry.id.trim()
? entry.id.trim().slice(0, 64)
: `grant_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
capabilityId,
sessionPattern: typeof entry.sessionPattern === "string" && entry.sessionPattern.trim()
? entry.sessionPattern.trim()
: "*",
createdAt: typeof entry.createdAt === "number" && Number.isFinite(entry.createdAt)
? entry.createdAt
: Date.now(),
};
if (typeof entry.commandPattern === "string" && entry.commandPattern.trim()) {
rule.commandPattern = entry.commandPattern.trim();
}
if (entry.argsPattern && typeof entry.argsPattern === "object" && !Array.isArray(entry.argsPattern)) {
const argsPattern = {};
for (const [key, value] of Object.entries(entry.argsPattern)) {
if (typeof value === "string" && value.trim()) {
argsPattern[key] = value.trim();
}
}
if (Object.keys(argsPattern).length > 0) {
rule.argsPattern = argsPattern;
}
}
if (typeof entry.note === "string" && entry.note.trim()) {
rule.note = entry.note.trim().slice(0, 240);
}
result.push(rule);
}
return result;
}
module.exports = {
patternMatches,
matchPermissionGrant,
sanitizePermissionGrants,
};