Files
clawdbot/src/infra/exec-approvals.ts
Peter Steinberger e872f5335b fix: allow chained exec allowlists
Co-authored-by: Lucas Czekaj <1464539+czekaj@users.noreply.github.com>
2026-01-23 00:11:58 +00:00

1243 lines
35 KiB
TypeScript

import crypto from "node:crypto";
import fs from "node:fs";
import net from "node:net";
import os from "node:os";
import path from "node:path";
import { DEFAULT_AGENT_ID } from "../routing/session-key.js";
export type ExecHost = "sandbox" | "gateway" | "node";
export type ExecSecurity = "deny" | "allowlist" | "full";
export type ExecAsk = "off" | "on-miss" | "always";
export type ExecApprovalsDefaults = {
security?: ExecSecurity;
ask?: ExecAsk;
askFallback?: ExecSecurity;
autoAllowSkills?: boolean;
};
export type ExecAllowlistEntry = {
pattern: string;
lastUsedAt?: number;
lastUsedCommand?: string;
lastResolvedPath?: string;
};
export type ExecApprovalsAgent = ExecApprovalsDefaults & {
allowlist?: ExecAllowlistEntry[];
};
export type ExecApprovalsFile = {
version: 1;
socket?: {
path?: string;
token?: string;
};
defaults?: ExecApprovalsDefaults;
agents?: Record<string, ExecApprovalsAgent>;
};
export type ExecApprovalsSnapshot = {
path: string;
exists: boolean;
raw: string | null;
file: ExecApprovalsFile;
hash: string;
};
export type ExecApprovalsResolved = {
path: string;
socketPath: string;
token: string;
defaults: Required<ExecApprovalsDefaults>;
agent: Required<ExecApprovalsDefaults>;
allowlist: ExecAllowlistEntry[];
file: ExecApprovalsFile;
};
const DEFAULT_SECURITY: ExecSecurity = "deny";
const DEFAULT_ASK: ExecAsk = "on-miss";
const DEFAULT_ASK_FALLBACK: ExecSecurity = "deny";
const DEFAULT_AUTO_ALLOW_SKILLS = false;
const DEFAULT_SOCKET = "~/.clawdbot/exec-approvals.sock";
const DEFAULT_FILE = "~/.clawdbot/exec-approvals.json";
export const DEFAULT_SAFE_BINS = ["jq", "grep", "cut", "sort", "uniq", "head", "tail", "tr", "wc"];
function hashExecApprovalsRaw(raw: string | null): string {
return crypto
.createHash("sha256")
.update(raw ?? "")
.digest("hex");
}
function expandHome(value: string): string {
if (!value) return value;
if (value === "~") return os.homedir();
if (value.startsWith("~/")) return path.join(os.homedir(), value.slice(2));
return value;
}
export function resolveExecApprovalsPath(): string {
return expandHome(DEFAULT_FILE);
}
export function resolveExecApprovalsSocketPath(): string {
return expandHome(DEFAULT_SOCKET);
}
function normalizeAllowlistPattern(value: string | undefined): string | null {
const trimmed = value?.trim() ?? "";
return trimmed ? trimmed.toLowerCase() : null;
}
function mergeLegacyAgent(
current: ExecApprovalsAgent,
legacy: ExecApprovalsAgent,
): ExecApprovalsAgent {
const allowlist: ExecAllowlistEntry[] = [];
const seen = new Set<string>();
const pushEntry = (entry: ExecAllowlistEntry) => {
const key = normalizeAllowlistPattern(entry.pattern);
if (!key || seen.has(key)) return;
seen.add(key);
allowlist.push(entry);
};
for (const entry of current.allowlist ?? []) pushEntry(entry);
for (const entry of legacy.allowlist ?? []) pushEntry(entry);
return {
security: current.security ?? legacy.security,
ask: current.ask ?? legacy.ask,
askFallback: current.askFallback ?? legacy.askFallback,
autoAllowSkills: current.autoAllowSkills ?? legacy.autoAllowSkills,
allowlist: allowlist.length > 0 ? allowlist : undefined,
};
}
function ensureDir(filePath: string) {
const dir = path.dirname(filePath);
fs.mkdirSync(dir, { recursive: true });
}
export function normalizeExecApprovals(file: ExecApprovalsFile): ExecApprovalsFile {
const socketPath = file.socket?.path?.trim();
const token = file.socket?.token?.trim();
const agents = { ...file.agents };
const legacyDefault = agents.default;
if (legacyDefault) {
const main = agents[DEFAULT_AGENT_ID];
agents[DEFAULT_AGENT_ID] = main ? mergeLegacyAgent(main, legacyDefault) : legacyDefault;
delete agents.default;
}
const normalized: ExecApprovalsFile = {
version: 1,
socket: {
path: socketPath && socketPath.length > 0 ? socketPath : undefined,
token: token && token.length > 0 ? token : undefined,
},
defaults: {
security: file.defaults?.security,
ask: file.defaults?.ask,
askFallback: file.defaults?.askFallback,
autoAllowSkills: file.defaults?.autoAllowSkills,
},
agents,
};
return normalized;
}
function generateToken(): string {
return crypto.randomBytes(24).toString("base64url");
}
export function readExecApprovalsSnapshot(): ExecApprovalsSnapshot {
const filePath = resolveExecApprovalsPath();
if (!fs.existsSync(filePath)) {
const file = normalizeExecApprovals({ version: 1, agents: {} });
return {
path: filePath,
exists: false,
raw: null,
file,
hash: hashExecApprovalsRaw(null),
};
}
const raw = fs.readFileSync(filePath, "utf8");
let parsed: ExecApprovalsFile | null = null;
try {
parsed = JSON.parse(raw) as ExecApprovalsFile;
} catch {
parsed = null;
}
const file =
parsed?.version === 1
? normalizeExecApprovals(parsed)
: normalizeExecApprovals({ version: 1, agents: {} });
return {
path: filePath,
exists: true,
raw,
file,
hash: hashExecApprovalsRaw(raw),
};
}
export function loadExecApprovals(): ExecApprovalsFile {
const filePath = resolveExecApprovalsPath();
try {
if (!fs.existsSync(filePath)) {
return normalizeExecApprovals({ version: 1, agents: {} });
}
const raw = fs.readFileSync(filePath, "utf8");
const parsed = JSON.parse(raw) as ExecApprovalsFile;
if (parsed?.version !== 1) {
return normalizeExecApprovals({ version: 1, agents: {} });
}
return normalizeExecApprovals(parsed);
} catch {
return normalizeExecApprovals({ version: 1, agents: {} });
}
}
export function saveExecApprovals(file: ExecApprovalsFile) {
const filePath = resolveExecApprovalsPath();
ensureDir(filePath);
fs.writeFileSync(filePath, `${JSON.stringify(file, null, 2)}\n`, { mode: 0o600 });
try {
fs.chmodSync(filePath, 0o600);
} catch {
// best-effort on platforms without chmod
}
}
export function ensureExecApprovals(): ExecApprovalsFile {
const loaded = loadExecApprovals();
const next = normalizeExecApprovals(loaded);
const socketPath = next.socket?.path?.trim();
const token = next.socket?.token?.trim();
const updated: ExecApprovalsFile = {
...next,
socket: {
path: socketPath && socketPath.length > 0 ? socketPath : resolveExecApprovalsSocketPath(),
token: token && token.length > 0 ? token : generateToken(),
},
};
saveExecApprovals(updated);
return updated;
}
function normalizeSecurity(value: ExecSecurity | undefined, fallback: ExecSecurity): ExecSecurity {
if (value === "allowlist" || value === "full" || value === "deny") return value;
return fallback;
}
function normalizeAsk(value: ExecAsk | undefined, fallback: ExecAsk): ExecAsk {
if (value === "always" || value === "off" || value === "on-miss") return value;
return fallback;
}
export type ExecApprovalsDefaultOverrides = {
security?: ExecSecurity;
ask?: ExecAsk;
askFallback?: ExecSecurity;
autoAllowSkills?: boolean;
};
export function resolveExecApprovals(
agentId?: string,
overrides?: ExecApprovalsDefaultOverrides,
): ExecApprovalsResolved {
const file = ensureExecApprovals();
return resolveExecApprovalsFromFile({
file,
agentId,
overrides,
path: resolveExecApprovalsPath(),
socketPath: expandHome(file.socket?.path ?? resolveExecApprovalsSocketPath()),
token: file.socket?.token ?? "",
});
}
export function resolveExecApprovalsFromFile(params: {
file: ExecApprovalsFile;
agentId?: string;
overrides?: ExecApprovalsDefaultOverrides;
path?: string;
socketPath?: string;
token?: string;
}): ExecApprovalsResolved {
const file = normalizeExecApprovals(params.file);
const defaults = file.defaults ?? {};
const agentKey = params.agentId ?? DEFAULT_AGENT_ID;
const agent = file.agents?.[agentKey] ?? {};
const wildcard = file.agents?.["*"] ?? {};
const fallbackSecurity = params.overrides?.security ?? DEFAULT_SECURITY;
const fallbackAsk = params.overrides?.ask ?? DEFAULT_ASK;
const fallbackAskFallback = params.overrides?.askFallback ?? DEFAULT_ASK_FALLBACK;
const fallbackAutoAllowSkills = params.overrides?.autoAllowSkills ?? DEFAULT_AUTO_ALLOW_SKILLS;
const resolvedDefaults: Required<ExecApprovalsDefaults> = {
security: normalizeSecurity(defaults.security, fallbackSecurity),
ask: normalizeAsk(defaults.ask, fallbackAsk),
askFallback: normalizeSecurity(
defaults.askFallback ?? fallbackAskFallback,
fallbackAskFallback,
),
autoAllowSkills: Boolean(defaults.autoAllowSkills ?? fallbackAutoAllowSkills),
};
const resolvedAgent: Required<ExecApprovalsDefaults> = {
security: normalizeSecurity(
agent.security ?? wildcard.security ?? resolvedDefaults.security,
resolvedDefaults.security,
),
ask: normalizeAsk(agent.ask ?? wildcard.ask ?? resolvedDefaults.ask, resolvedDefaults.ask),
askFallback: normalizeSecurity(
agent.askFallback ?? wildcard.askFallback ?? resolvedDefaults.askFallback,
resolvedDefaults.askFallback,
),
autoAllowSkills: Boolean(
agent.autoAllowSkills ?? wildcard.autoAllowSkills ?? resolvedDefaults.autoAllowSkills,
),
};
const allowlist = [
...(Array.isArray(wildcard.allowlist) ? wildcard.allowlist : []),
...(Array.isArray(agent.allowlist) ? agent.allowlist : []),
];
return {
path: params.path ?? resolveExecApprovalsPath(),
socketPath: expandHome(
params.socketPath ?? file.socket?.path ?? resolveExecApprovalsSocketPath(),
),
token: params.token ?? file.socket?.token ?? "",
defaults: resolvedDefaults,
agent: resolvedAgent,
allowlist,
file,
};
}
type CommandResolution = {
rawExecutable: string;
resolvedPath?: string;
executableName: string;
};
function isExecutableFile(filePath: string): boolean {
try {
const stat = fs.statSync(filePath);
if (!stat.isFile()) return false;
if (process.platform !== "win32") {
fs.accessSync(filePath, fs.constants.X_OK);
}
return true;
} catch {
return false;
}
}
function parseFirstToken(command: string): string | null {
const trimmed = command.trim();
if (!trimmed) return null;
const first = trimmed[0];
if (first === '"' || first === "'") {
const end = trimmed.indexOf(first, 1);
if (end > 1) return trimmed.slice(1, end);
return trimmed.slice(1);
}
const match = /^[^\s]+/.exec(trimmed);
return match ? match[0] : null;
}
function resolveExecutablePath(rawExecutable: string, cwd?: string, env?: NodeJS.ProcessEnv) {
const expanded = rawExecutable.startsWith("~") ? expandHome(rawExecutable) : rawExecutable;
if (expanded.includes("/") || expanded.includes("\\")) {
if (path.isAbsolute(expanded)) {
return isExecutableFile(expanded) ? expanded : undefined;
}
const base = cwd && cwd.trim() ? cwd.trim() : process.cwd();
const candidate = path.resolve(base, expanded);
return isExecutableFile(candidate) ? candidate : undefined;
}
const envPath = env?.PATH ?? env?.Path ?? process.env.PATH ?? process.env.Path ?? "";
const entries = envPath.split(path.delimiter).filter(Boolean);
const hasExtension = process.platform === "win32" && path.extname(expanded).length > 0;
const extensions =
process.platform === "win32"
? hasExtension
? [""]
: (
env?.PATHEXT ??
env?.Pathext ??
process.env.PATHEXT ??
process.env.Pathext ??
".EXE;.CMD;.BAT;.COM"
)
.split(";")
.map((ext) => ext.toLowerCase())
: [""];
for (const entry of entries) {
for (const ext of extensions) {
const candidate = path.join(entry, expanded + ext);
if (isExecutableFile(candidate)) return candidate;
}
}
return undefined;
}
export function resolveCommandResolution(
command: string,
cwd?: string,
env?: NodeJS.ProcessEnv,
): CommandResolution | null {
const rawExecutable = parseFirstToken(command);
if (!rawExecutable) return null;
const resolvedPath = resolveExecutablePath(rawExecutable, cwd, env);
const executableName = resolvedPath ? path.basename(resolvedPath) : rawExecutable;
return { rawExecutable, resolvedPath, executableName };
}
export function resolveCommandResolutionFromArgv(
argv: string[],
cwd?: string,
env?: NodeJS.ProcessEnv,
): CommandResolution | null {
const rawExecutable = argv[0]?.trim();
if (!rawExecutable) return null;
const resolvedPath = resolveExecutablePath(rawExecutable, cwd, env);
const executableName = resolvedPath ? path.basename(resolvedPath) : rawExecutable;
return { rawExecutable, resolvedPath, executableName };
}
function normalizeMatchTarget(value: string): string {
return value.replace(/\\\\/g, "/").toLowerCase();
}
function tryRealpath(value: string): string | null {
try {
return fs.realpathSync(value);
} catch {
return null;
}
}
function globToRegExp(pattern: string): RegExp {
let regex = "^";
let i = 0;
while (i < pattern.length) {
const ch = pattern[i];
if (ch === "*") {
const next = pattern[i + 1];
if (next === "*") {
regex += ".*";
i += 2;
continue;
}
regex += "[^/]*";
i += 1;
continue;
}
if (ch === "?") {
regex += ".";
i += 1;
continue;
}
regex += ch.replace(/[.*+?^${}()|[\\]\\\\]/g, "\\$&");
i += 1;
}
regex += "$";
return new RegExp(regex, "i");
}
function matchesPattern(pattern: string, target: string): boolean {
const trimmed = pattern.trim();
if (!trimmed) return false;
const expanded = trimmed.startsWith("~") ? expandHome(trimmed) : trimmed;
const hasWildcard = /[*?]/.test(expanded);
let normalizedPattern = expanded;
let normalizedTarget = target;
if (process.platform === "win32" && !hasWildcard) {
normalizedPattern = tryRealpath(expanded) ?? expanded;
normalizedTarget = tryRealpath(target) ?? target;
}
normalizedPattern = normalizeMatchTarget(normalizedPattern);
normalizedTarget = normalizeMatchTarget(normalizedTarget);
const regex = globToRegExp(normalizedPattern);
return regex.test(normalizedTarget);
}
function resolveAllowlistCandidatePath(
resolution: CommandResolution | null,
cwd?: string,
): string | undefined {
if (!resolution) return undefined;
if (resolution.resolvedPath) return resolution.resolvedPath;
const raw = resolution.rawExecutable?.trim();
if (!raw) return undefined;
const expanded = raw.startsWith("~") ? expandHome(raw) : raw;
if (!expanded.includes("/") && !expanded.includes("\\")) return undefined;
if (path.isAbsolute(expanded)) return expanded;
const base = cwd && cwd.trim() ? cwd.trim() : process.cwd();
return path.resolve(base, expanded);
}
export function matchAllowlist(
entries: ExecAllowlistEntry[],
resolution: CommandResolution | null,
): ExecAllowlistEntry | null {
if (!entries.length || !resolution?.resolvedPath) return null;
const resolvedPath = resolution.resolvedPath;
for (const entry of entries) {
const pattern = entry.pattern?.trim();
if (!pattern) continue;
const hasPath = pattern.includes("/") || pattern.includes("\\") || pattern.includes("~");
if (!hasPath) continue;
if (matchesPattern(pattern, resolvedPath)) return entry;
}
return null;
}
export type ExecCommandSegment = {
raw: string;
argv: string[];
resolution: CommandResolution | null;
};
export type ExecCommandAnalysis = {
ok: boolean;
reason?: string;
segments: ExecCommandSegment[];
chains?: ExecCommandSegment[][]; // Segments grouped by chain operator (&&, ||, ;)
};
const DISALLOWED_PIPELINE_TOKENS = new Set([">", "<", "`", "\n", "\r", "(", ")"]);
type IteratorAction = "split" | "skip" | "include" | { reject: string };
/**
* Iterates through a command string while respecting shell quoting rules.
* The callback receives each character and the next character, and returns an action:
* - "split": push current buffer as a segment and start a new one
* - "skip": skip this character (and optionally the next via skip count)
* - "include": add this character to the buffer
* - { reject: reason }: abort with an error
*/
function iterateQuoteAware(
command: string,
onChar: (ch: string, next: string | undefined, index: number) => IteratorAction,
): { ok: true; parts: string[]; hasSplit: boolean } | { ok: false; reason: string } {
const parts: string[] = [];
let buf = "";
let inSingle = false;
let inDouble = false;
let escaped = false;
let hasSplit = false;
const pushPart = () => {
const trimmed = buf.trim();
if (trimmed) {
parts.push(trimmed);
}
buf = "";
};
for (let i = 0; i < command.length; i += 1) {
const ch = command[i];
const next = command[i + 1];
if (escaped) {
buf += ch;
escaped = false;
continue;
}
if (!inSingle && !inDouble && ch === "\\") {
escaped = true;
buf += ch;
continue;
}
if (inSingle) {
if (ch === "'") inSingle = false;
buf += ch;
continue;
}
if (inDouble) {
if (ch === '"') inDouble = false;
buf += ch;
continue;
}
if (ch === "'") {
inSingle = true;
buf += ch;
continue;
}
if (ch === '"') {
inDouble = true;
buf += ch;
continue;
}
const action = onChar(ch, next, i);
if (typeof action === "object" && "reject" in action) {
return { ok: false, reason: action.reject };
}
if (action === "split") {
pushPart();
hasSplit = true;
continue;
}
if (action === "skip") {
continue;
}
buf += ch;
}
if (escaped || inSingle || inDouble) {
return { ok: false, reason: "unterminated shell quote/escape" };
}
pushPart();
return { ok: true, parts, hasSplit };
}
function splitShellPipeline(command: string): { ok: boolean; reason?: string; segments: string[] } {
let emptySegment = false;
const result = iterateQuoteAware(command, (ch, next) => {
if (ch === "|" && next === "|") {
return { reject: "unsupported shell token: ||" };
}
if (ch === "|" && next === "&") {
return { reject: "unsupported shell token: |&" };
}
if (ch === "|") {
emptySegment = true;
return "split";
}
if (ch === "&" || ch === ";") {
return { reject: `unsupported shell token: ${ch}` };
}
if (DISALLOWED_PIPELINE_TOKENS.has(ch)) {
return { reject: `unsupported shell token: ${ch}` };
}
if (ch === "$" && next === "(") {
return { reject: "unsupported shell token: $()" };
}
emptySegment = false;
return "include";
});
if (!result.ok) {
return { ok: false, reason: result.reason, segments: [] };
}
if (emptySegment || result.parts.length === 0) {
return {
ok: false,
reason: result.parts.length === 0 ? "empty command" : "empty pipeline segment",
segments: [],
};
}
return { ok: true, segments: result.parts };
}
function tokenizeShellSegment(segment: string): string[] | null {
const tokens: string[] = [];
let buf = "";
let inSingle = false;
let inDouble = false;
let escaped = false;
const pushToken = () => {
if (buf.length > 0) {
tokens.push(buf);
buf = "";
}
};
for (let i = 0; i < segment.length; i += 1) {
const ch = segment[i];
if (escaped) {
buf += ch;
escaped = false;
continue;
}
if (!inSingle && !inDouble && ch === "\\") {
escaped = true;
continue;
}
if (inSingle) {
if (ch === "'") {
inSingle = false;
} else {
buf += ch;
}
continue;
}
if (inDouble) {
if (ch === '"') {
inDouble = false;
} else {
buf += ch;
}
continue;
}
if (ch === "'") {
inSingle = true;
continue;
}
if (ch === '"') {
inDouble = true;
continue;
}
if (/\s/.test(ch)) {
pushToken();
continue;
}
buf += ch;
}
if (escaped || inSingle || inDouble) {
return null;
}
pushToken();
return tokens;
}
function parseSegmentsFromParts(
parts: string[],
cwd?: string,
env?: NodeJS.ProcessEnv,
): ExecCommandSegment[] | null {
const segments: ExecCommandSegment[] = [];
for (const raw of parts) {
const argv = tokenizeShellSegment(raw);
if (!argv || argv.length === 0) {
return null;
}
segments.push({
raw,
argv,
resolution: resolveCommandResolutionFromArgv(argv, cwd, env),
});
}
return segments;
}
export function analyzeShellCommand(params: {
command: string;
cwd?: string;
env?: NodeJS.ProcessEnv;
}): ExecCommandAnalysis {
// First try splitting by chain operators (&&, ||, ;)
const chainParts = splitCommandChain(params.command);
if (chainParts) {
const chains: ExecCommandSegment[][] = [];
const allSegments: ExecCommandSegment[] = [];
for (const part of chainParts) {
const pipelineSplit = splitShellPipeline(part);
if (!pipelineSplit.ok) {
return { ok: false, reason: pipelineSplit.reason, segments: [] };
}
const segments = parseSegmentsFromParts(pipelineSplit.segments, params.cwd, params.env);
if (!segments) {
return { ok: false, reason: "unable to parse shell segment", segments: [] };
}
chains.push(segments);
allSegments.push(...segments);
}
return { ok: true, segments: allSegments, chains };
}
// No chain operators, parse as simple pipeline
const split = splitShellPipeline(params.command);
if (!split.ok) {
return { ok: false, reason: split.reason, segments: [] };
}
const segments = parseSegmentsFromParts(split.segments, params.cwd, params.env);
if (!segments) {
return { ok: false, reason: "unable to parse shell segment", segments: [] };
}
return { ok: true, segments };
}
export function analyzeArgvCommand(params: {
argv: string[];
cwd?: string;
env?: NodeJS.ProcessEnv;
}): ExecCommandAnalysis {
const argv = params.argv.filter((entry) => entry.trim().length > 0);
if (argv.length === 0) {
return { ok: false, reason: "empty argv", segments: [] };
}
return {
ok: true,
segments: [
{
raw: argv.join(" "),
argv,
resolution: resolveCommandResolutionFromArgv(argv, params.cwd, params.env),
},
],
};
}
function isPathLikeToken(value: string): boolean {
const trimmed = value.trim();
if (!trimmed) return false;
if (trimmed === "-") return false;
if (trimmed.startsWith("./") || trimmed.startsWith("../") || trimmed.startsWith("~")) return true;
if (trimmed.startsWith("/")) return true;
return /^[A-Za-z]:[\\/]/.test(trimmed);
}
function defaultFileExists(filePath: string): boolean {
try {
return fs.existsSync(filePath);
} catch {
return false;
}
}
export function normalizeSafeBins(entries?: string[]): Set<string> {
if (!Array.isArray(entries)) return new Set();
const normalized = entries
.map((entry) => entry.trim().toLowerCase())
.filter((entry) => entry.length > 0);
return new Set(normalized);
}
export function resolveSafeBins(entries?: string[] | null): Set<string> {
if (entries === undefined) return normalizeSafeBins(DEFAULT_SAFE_BINS);
return normalizeSafeBins(entries ?? []);
}
export function isSafeBinUsage(params: {
argv: string[];
resolution: CommandResolution | null;
safeBins: Set<string>;
cwd?: string;
fileExists?: (filePath: string) => boolean;
}): boolean {
if (params.safeBins.size === 0) return false;
const resolution = params.resolution;
const execName = resolution?.executableName?.toLowerCase();
if (!execName) return false;
const matchesSafeBin =
params.safeBins.has(execName) ||
(process.platform === "win32" && params.safeBins.has(path.parse(execName).name));
if (!matchesSafeBin) return false;
if (!resolution?.resolvedPath) return false;
const cwd = params.cwd ?? process.cwd();
const exists = params.fileExists ?? defaultFileExists;
const argv = params.argv.slice(1);
for (let i = 0; i < argv.length; i += 1) {
const token = argv[i];
if (!token) continue;
if (token === "-") continue;
if (token.startsWith("-")) {
const eqIndex = token.indexOf("=");
if (eqIndex > 0) {
const value = token.slice(eqIndex + 1);
if (value && (isPathLikeToken(value) || exists(path.resolve(cwd, value)))) {
return false;
}
}
continue;
}
if (isPathLikeToken(token)) return false;
if (exists(path.resolve(cwd, token))) return false;
}
return true;
}
export type ExecAllowlistEvaluation = {
allowlistSatisfied: boolean;
allowlistMatches: ExecAllowlistEntry[];
};
function evaluateSegments(
segments: ExecCommandSegment[],
params: {
allowlist: ExecAllowlistEntry[];
safeBins: Set<string>;
cwd?: string;
skillBins?: Set<string>;
autoAllowSkills?: boolean;
},
): { satisfied: boolean; matches: ExecAllowlistEntry[] } {
const matches: ExecAllowlistEntry[] = [];
const allowSkills = params.autoAllowSkills === true && (params.skillBins?.size ?? 0) > 0;
const satisfied = segments.every((segment) => {
const candidatePath = resolveAllowlistCandidatePath(segment.resolution, params.cwd);
const candidateResolution =
candidatePath && segment.resolution
? { ...segment.resolution, resolvedPath: candidatePath }
: segment.resolution;
const match = matchAllowlist(params.allowlist, candidateResolution);
if (match) matches.push(match);
const safe = isSafeBinUsage({
argv: segment.argv,
resolution: segment.resolution,
safeBins: params.safeBins,
cwd: params.cwd,
});
const skillAllow =
allowSkills && segment.resolution?.executableName
? params.skillBins?.has(segment.resolution.executableName)
: false;
return Boolean(match || safe || skillAllow);
});
return { satisfied, matches };
}
export function evaluateExecAllowlist(params: {
analysis: ExecCommandAnalysis;
allowlist: ExecAllowlistEntry[];
safeBins: Set<string>;
cwd?: string;
skillBins?: Set<string>;
autoAllowSkills?: boolean;
}): ExecAllowlistEvaluation {
const allowlistMatches: ExecAllowlistEntry[] = [];
if (!params.analysis.ok || params.analysis.segments.length === 0) {
return { allowlistSatisfied: false, allowlistMatches };
}
// If the analysis contains chains, evaluate each chain part separately
if (params.analysis.chains) {
for (const chainSegments of params.analysis.chains) {
const result = evaluateSegments(chainSegments, {
allowlist: params.allowlist,
safeBins: params.safeBins,
cwd: params.cwd,
skillBins: params.skillBins,
autoAllowSkills: params.autoAllowSkills,
});
if (!result.satisfied) {
return { allowlistSatisfied: false, allowlistMatches: [] };
}
allowlistMatches.push(...result.matches);
}
return { allowlistSatisfied: true, allowlistMatches };
}
// No chains, evaluate all segments together
const result = evaluateSegments(params.analysis.segments, {
allowlist: params.allowlist,
safeBins: params.safeBins,
cwd: params.cwd,
skillBins: params.skillBins,
autoAllowSkills: params.autoAllowSkills,
});
return { allowlistSatisfied: result.satisfied, allowlistMatches: result.matches };
}
/**
* Splits a command string by chain operators (&&, ||, ;) while respecting quotes.
* Returns null when no chain is present or when the chain is malformed.
*/
function splitCommandChain(command: string): string[] | null {
const parts: string[] = [];
let buf = "";
let inSingle = false;
let inDouble = false;
let escaped = false;
let foundChain = false;
let invalidChain = false;
const pushPart = () => {
const trimmed = buf.trim();
if (trimmed) {
parts.push(trimmed);
buf = "";
return true;
}
buf = "";
return false;
};
for (let i = 0; i < command.length; i += 1) {
const ch = command[i];
if (escaped) {
buf += ch;
escaped = false;
continue;
}
if (!inSingle && !inDouble && ch === "\\") {
escaped = true;
buf += ch;
continue;
}
if (inSingle) {
if (ch === "'") inSingle = false;
buf += ch;
continue;
}
if (inDouble) {
if (ch === '"') inDouble = false;
buf += ch;
continue;
}
if (ch === "'") {
inSingle = true;
buf += ch;
continue;
}
if (ch === '"') {
inDouble = true;
buf += ch;
continue;
}
if (ch === "&" && command[i + 1] === "&") {
if (!pushPart()) invalidChain = true;
i += 1;
foundChain = true;
continue;
}
if (ch === "|" && command[i + 1] === "|") {
if (!pushPart()) invalidChain = true;
i += 1;
foundChain = true;
continue;
}
if (ch === ";") {
if (!pushPart()) invalidChain = true;
foundChain = true;
continue;
}
buf += ch;
}
const pushedFinal = pushPart();
if (!foundChain) return null;
if (invalidChain || !pushedFinal) return null;
return parts.length > 0 ? parts : null;
}
export type ExecAllowlistAnalysis = {
analysisOk: boolean;
allowlistSatisfied: boolean;
allowlistMatches: ExecAllowlistEntry[];
segments: ExecCommandSegment[];
};
/**
* Evaluates allowlist for shell commands (including &&, ||, ;) and returns analysis metadata.
*/
export function evaluateShellAllowlist(params: {
command: string;
allowlist: ExecAllowlistEntry[];
safeBins: Set<string>;
cwd?: string;
env?: NodeJS.ProcessEnv;
skillBins?: Set<string>;
autoAllowSkills?: boolean;
}): ExecAllowlistAnalysis {
const chainParts = splitCommandChain(params.command);
if (!chainParts) {
const analysis = analyzeShellCommand({
command: params.command,
cwd: params.cwd,
env: params.env,
});
if (!analysis.ok) {
return {
analysisOk: false,
allowlistSatisfied: false,
allowlistMatches: [],
segments: [],
};
}
const evaluation = evaluateExecAllowlist({
analysis,
allowlist: params.allowlist,
safeBins: params.safeBins,
cwd: params.cwd,
skillBins: params.skillBins,
autoAllowSkills: params.autoAllowSkills,
});
return {
analysisOk: true,
allowlistSatisfied: evaluation.allowlistSatisfied,
allowlistMatches: evaluation.allowlistMatches,
segments: analysis.segments,
};
}
const allowlistMatches: ExecAllowlistEntry[] = [];
const segments: ExecCommandSegment[] = [];
for (const part of chainParts) {
const analysis = analyzeShellCommand({
command: part,
cwd: params.cwd,
env: params.env,
});
if (!analysis.ok) {
return {
analysisOk: false,
allowlistSatisfied: false,
allowlistMatches: [],
segments: [],
};
}
segments.push(...analysis.segments);
const evaluation = evaluateExecAllowlist({
analysis,
allowlist: params.allowlist,
safeBins: params.safeBins,
cwd: params.cwd,
skillBins: params.skillBins,
autoAllowSkills: params.autoAllowSkills,
});
allowlistMatches.push(...evaluation.allowlistMatches);
if (!evaluation.allowlistSatisfied) {
return {
analysisOk: true,
allowlistSatisfied: false,
allowlistMatches,
segments,
};
}
}
return {
analysisOk: true,
allowlistSatisfied: true,
allowlistMatches,
segments,
};
}
export function requiresExecApproval(params: {
ask: ExecAsk;
security: ExecSecurity;
analysisOk: boolean;
allowlistSatisfied: boolean;
}): boolean {
return (
params.ask === "always" ||
(params.ask === "on-miss" &&
params.security === "allowlist" &&
(!params.analysisOk || !params.allowlistSatisfied))
);
}
export function recordAllowlistUse(
approvals: ExecApprovalsFile,
agentId: string | undefined,
entry: ExecAllowlistEntry,
command: string,
resolvedPath?: string,
) {
const target = agentId ?? DEFAULT_AGENT_ID;
const agents = approvals.agents ?? {};
const existing = agents[target] ?? {};
const allowlist = Array.isArray(existing.allowlist) ? existing.allowlist : [];
const nextAllowlist = allowlist.map((item) =>
item.pattern === entry.pattern
? {
...item,
lastUsedAt: Date.now(),
lastUsedCommand: command,
lastResolvedPath: resolvedPath,
}
: item,
);
agents[target] = { ...existing, allowlist: nextAllowlist };
approvals.agents = agents;
saveExecApprovals(approvals);
}
export function addAllowlistEntry(
approvals: ExecApprovalsFile,
agentId: string | undefined,
pattern: string,
) {
const target = agentId ?? DEFAULT_AGENT_ID;
const agents = approvals.agents ?? {};
const existing = agents[target] ?? {};
const allowlist = Array.isArray(existing.allowlist) ? existing.allowlist : [];
const trimmed = pattern.trim();
if (!trimmed) return;
if (allowlist.some((entry) => entry.pattern === trimmed)) return;
allowlist.push({ pattern: trimmed, lastUsedAt: Date.now() });
agents[target] = { ...existing, allowlist };
approvals.agents = agents;
saveExecApprovals(approvals);
}
export function minSecurity(a: ExecSecurity, b: ExecSecurity): ExecSecurity {
const order: Record<ExecSecurity, number> = { deny: 0, allowlist: 1, full: 2 };
return order[a] <= order[b] ? a : b;
}
export function maxAsk(a: ExecAsk, b: ExecAsk): ExecAsk {
const order: Record<ExecAsk, number> = { off: 0, "on-miss": 1, always: 2 };
return order[a] >= order[b] ? a : b;
}
export type ExecApprovalDecision = "allow-once" | "allow-always" | "deny";
export async function requestExecApprovalViaSocket(params: {
socketPath: string;
token: string;
request: Record<string, unknown>;
timeoutMs?: number;
}): Promise<ExecApprovalDecision | null> {
const { socketPath, token, request } = params;
if (!socketPath || !token) return null;
const timeoutMs = params.timeoutMs ?? 15_000;
return await new Promise((resolve) => {
const client = new net.Socket();
let settled = false;
let buffer = "";
const finish = (value: ExecApprovalDecision | null) => {
if (settled) return;
settled = true;
try {
client.destroy();
} catch {
// ignore
}
resolve(value);
};
const timer = setTimeout(() => finish(null), timeoutMs);
const payload = JSON.stringify({
type: "request",
token,
id: crypto.randomUUID(),
request,
});
client.on("error", () => finish(null));
client.connect(socketPath, () => {
client.write(`${payload}\n`);
});
client.on("data", (data) => {
buffer += data.toString("utf8");
let idx = buffer.indexOf("\n");
while (idx !== -1) {
const line = buffer.slice(0, idx).trim();
buffer = buffer.slice(idx + 1);
idx = buffer.indexOf("\n");
if (!line) continue;
try {
const msg = JSON.parse(line) as { type?: string; decision?: ExecApprovalDecision };
if (msg?.type === "decision" && msg.decision) {
clearTimeout(timer);
finish(msg.decision);
return;
}
} catch {
// ignore
}
}
});
});
}