mirror of https://github.com/openclaw/openclaw.git
989 lines
29 KiB
TypeScript
989 lines
29 KiB
TypeScript
import crypto from "node:crypto";
|
|
import fs from "node:fs";
|
|
import path from "node:path";
|
|
import { DEFAULT_AGENT_ID } from "../routing/session-key.js";
|
|
import type { ExecCommandSegment } from "./exec-approvals-analysis.js";
|
|
import { resolveAllowAlwaysPatternEntries } from "./exec-approvals-allowlist.js";
|
|
import { expandHomePrefix } from "./home-dir.js";
|
|
import { requestJsonlSocket } from "./jsonl-socket.js";
|
|
export * from "./exec-approvals-analysis.js";
|
|
export * from "./exec-approvals-allowlist.js";
|
|
|
|
export type ExecHost = "sandbox" | "gateway" | "node";
|
|
export type ExecTarget = "auto" | ExecHost;
|
|
export type ExecSecurity = "deny" | "allowlist" | "full";
|
|
export type ExecAsk = "off" | "on-miss" | "always";
|
|
|
|
export function normalizeExecHost(value?: string | null): ExecHost | null {
|
|
const normalized = value?.trim().toLowerCase();
|
|
if (normalized === "sandbox" || normalized === "gateway" || normalized === "node") {
|
|
return normalized;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
export function normalizeExecTarget(value?: string | null): ExecTarget | null {
|
|
const normalized = value?.trim().toLowerCase();
|
|
if (normalized === "auto") {
|
|
return normalized;
|
|
}
|
|
return normalizeExecHost(normalized);
|
|
}
|
|
|
|
/** Coerce a raw JSON field to string, returning undefined for non-string types. */
|
|
function toStringOrUndefined(value: unknown): string | undefined {
|
|
return typeof value === "string" ? value : undefined;
|
|
}
|
|
|
|
export function normalizeExecSecurity(value?: string | null): ExecSecurity | null {
|
|
const normalized = value?.trim().toLowerCase();
|
|
if (normalized === "deny" || normalized === "allowlist" || normalized === "full") {
|
|
return normalized;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
export function normalizeExecAsk(value?: string | null): ExecAsk | null {
|
|
const normalized = value?.trim().toLowerCase();
|
|
if (normalized === "off" || normalized === "on-miss" || normalized === "always") {
|
|
return normalized;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
export type SystemRunApprovalBinding = {
|
|
argv: string[];
|
|
cwd: string | null;
|
|
agentId: string | null;
|
|
sessionKey: string | null;
|
|
envHash: string | null;
|
|
};
|
|
|
|
export type SystemRunApprovalFileOperand = {
|
|
argvIndex: number;
|
|
path: string;
|
|
sha256: string;
|
|
};
|
|
|
|
export type SystemRunApprovalPlan = {
|
|
argv: string[];
|
|
cwd: string | null;
|
|
commandText: string;
|
|
commandPreview?: string | null;
|
|
agentId: string | null;
|
|
sessionKey: string | null;
|
|
mutableFileOperand?: SystemRunApprovalFileOperand | null;
|
|
};
|
|
|
|
export type ExecApprovalRequestPayload = {
|
|
command: string;
|
|
commandPreview?: string | null;
|
|
commandArgv?: string[];
|
|
// Optional UI-safe env key preview for approval prompts.
|
|
envKeys?: string[];
|
|
systemRunBinding?: SystemRunApprovalBinding | null;
|
|
systemRunPlan?: SystemRunApprovalPlan | null;
|
|
cwd?: string | null;
|
|
nodeId?: string | null;
|
|
host?: string | null;
|
|
security?: string | null;
|
|
ask?: string | null;
|
|
allowedDecisions?: readonly ExecApprovalDecision[];
|
|
agentId?: string | null;
|
|
resolvedPath?: string | null;
|
|
sessionKey?: string | null;
|
|
turnSourceChannel?: string | null;
|
|
turnSourceTo?: string | null;
|
|
turnSourceAccountId?: string | null;
|
|
turnSourceThreadId?: string | number | null;
|
|
};
|
|
|
|
export type ExecApprovalRequest = {
|
|
id: string;
|
|
request: ExecApprovalRequestPayload;
|
|
createdAtMs: number;
|
|
expiresAtMs: number;
|
|
};
|
|
|
|
export type ExecApprovalResolved = {
|
|
id: string;
|
|
decision: ExecApprovalDecision;
|
|
resolvedBy?: string | null;
|
|
ts: number;
|
|
request?: ExecApprovalRequest["request"];
|
|
};
|
|
|
|
export type ExecApprovalsDefaults = {
|
|
security?: ExecSecurity;
|
|
ask?: ExecAsk;
|
|
askFallback?: ExecSecurity;
|
|
autoAllowSkills?: boolean;
|
|
};
|
|
|
|
export type ExecAllowlistEntry = {
|
|
id?: string;
|
|
pattern: string;
|
|
source?: "allow-always";
|
|
commandText?: string;
|
|
argPattern?: 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>;
|
|
agentSources: {
|
|
security: string | null;
|
|
ask: string | null;
|
|
askFallback: string | null;
|
|
};
|
|
allowlist: ExecAllowlistEntry[];
|
|
file: ExecApprovalsFile;
|
|
};
|
|
|
|
// Keep CLI + gateway defaults in sync.
|
|
export const DEFAULT_EXEC_APPROVAL_TIMEOUT_MS = 1_800_000;
|
|
|
|
const DEFAULT_SECURITY: ExecSecurity = "full";
|
|
const DEFAULT_ASK: ExecAsk = "off";
|
|
export const DEFAULT_EXEC_APPROVAL_ASK_FALLBACK: ExecSecurity = "full";
|
|
const DEFAULT_AUTO_ALLOW_SKILLS = false;
|
|
const DEFAULT_SOCKET = "~/.openclaw/exec-approvals.sock";
|
|
const DEFAULT_FILE = "~/.openclaw/exec-approvals.json";
|
|
|
|
function hashExecApprovalsRaw(raw: string | null): string {
|
|
return crypto
|
|
.createHash("sha256")
|
|
.update(raw ?? "")
|
|
.digest("hex");
|
|
}
|
|
|
|
export function resolveExecApprovalsPath(): string {
|
|
return expandHomePrefix(DEFAULT_FILE);
|
|
}
|
|
|
|
export function resolveExecApprovalsSocketPath(): string {
|
|
return expandHomePrefix(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 patternKey = normalizeAllowlistPattern(entry.pattern);
|
|
if (!patternKey) {
|
|
return;
|
|
}
|
|
const key = `${patternKey}\x00${entry.argPattern?.trim() ?? ""}`;
|
|
if (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 });
|
|
}
|
|
|
|
// Coerce legacy/corrupted allowlists into `ExecAllowlistEntry[]` before we spread
|
|
// entries to add ids (spreading strings creates {"0":"l","1":"s",...}).
|
|
function coerceAllowlistEntries(allowlist: unknown): ExecAllowlistEntry[] | undefined {
|
|
if (!Array.isArray(allowlist) || allowlist.length === 0) {
|
|
return Array.isArray(allowlist) ? (allowlist as ExecAllowlistEntry[]) : undefined;
|
|
}
|
|
let changed = false;
|
|
const result: ExecAllowlistEntry[] = [];
|
|
for (const item of allowlist) {
|
|
if (typeof item === "string") {
|
|
const trimmed = item.trim();
|
|
if (trimmed) {
|
|
result.push({ pattern: trimmed });
|
|
changed = true;
|
|
} else {
|
|
changed = true; // dropped empty string
|
|
}
|
|
} else if (item && typeof item === "object" && !Array.isArray(item)) {
|
|
const pattern = (item as { pattern?: unknown }).pattern;
|
|
if (typeof pattern === "string" && pattern.trim().length > 0) {
|
|
result.push(item as ExecAllowlistEntry);
|
|
} else {
|
|
changed = true; // dropped invalid entry
|
|
}
|
|
} else {
|
|
changed = true; // dropped invalid entry
|
|
}
|
|
}
|
|
return changed ? (result.length > 0 ? result : undefined) : (allowlist as ExecAllowlistEntry[]);
|
|
}
|
|
|
|
function ensureAllowlistIds(
|
|
allowlist: ExecAllowlistEntry[] | undefined,
|
|
): ExecAllowlistEntry[] | undefined {
|
|
if (!Array.isArray(allowlist) || allowlist.length === 0) {
|
|
return allowlist;
|
|
}
|
|
let changed = false;
|
|
const next = allowlist.map((entry) => {
|
|
if (entry.id) {
|
|
return entry;
|
|
}
|
|
changed = true;
|
|
return { ...entry, id: crypto.randomUUID() };
|
|
});
|
|
return changed ? next : allowlist;
|
|
}
|
|
|
|
function stripAllowlistCommandText(
|
|
allowlist: ExecAllowlistEntry[] | undefined,
|
|
): ExecAllowlistEntry[] | undefined {
|
|
if (!Array.isArray(allowlist) || allowlist.length === 0) {
|
|
return allowlist;
|
|
}
|
|
let changed = false;
|
|
const next = allowlist.map((entry) => {
|
|
if (typeof entry.commandText !== "string") {
|
|
return entry;
|
|
}
|
|
changed = true;
|
|
const { commandText: _commandText, ...rest } = entry;
|
|
return rest;
|
|
});
|
|
return changed ? next : allowlist;
|
|
}
|
|
|
|
function sanitizeExecApprovalPolicy(
|
|
policy: ExecApprovalsDefaults | ExecApprovalsAgent | undefined,
|
|
): ExecApprovalsDefaults {
|
|
const security = toStringOrUndefined(policy?.security)?.trim();
|
|
const ask = toStringOrUndefined(policy?.ask)?.trim();
|
|
const askFallback = toStringOrUndefined(policy?.askFallback)?.trim();
|
|
return {
|
|
security:
|
|
security === "deny" || security === "allowlist" || security === "full" ? security : undefined,
|
|
ask: ask === "off" || ask === "on-miss" || ask === "always" ? ask : undefined,
|
|
askFallback:
|
|
askFallback === "deny" || askFallback === "allowlist" || askFallback === "full"
|
|
? askFallback
|
|
: undefined,
|
|
autoAllowSkills: policy?.autoAllowSkills,
|
|
};
|
|
}
|
|
|
|
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;
|
|
}
|
|
for (const [key, agent] of Object.entries(agents)) {
|
|
const coerced = coerceAllowlistEntries(agent.allowlist);
|
|
const withIds = ensureAllowlistIds(coerced);
|
|
const allowlist = stripAllowlistCommandText(withIds);
|
|
const sanitizedPolicy = sanitizeExecApprovalPolicy(agent);
|
|
const agentChanged =
|
|
allowlist !== agent.allowlist ||
|
|
sanitizedPolicy.security !== agent.security ||
|
|
sanitizedPolicy.ask !== agent.ask ||
|
|
sanitizedPolicy.askFallback !== agent.askFallback;
|
|
if (agentChanged) {
|
|
agents[key] = {
|
|
...agent,
|
|
allowlist,
|
|
security: sanitizedPolicy.security,
|
|
ask: sanitizedPolicy.ask,
|
|
askFallback: sanitizedPolicy.askFallback,
|
|
};
|
|
}
|
|
}
|
|
const sanitizedDefaults = sanitizeExecApprovalPolicy(file.defaults);
|
|
const normalized: ExecApprovalsFile = {
|
|
version: 1,
|
|
socket: {
|
|
path: socketPath && socketPath.length > 0 ? socketPath : undefined,
|
|
token: token && token.length > 0 ? token : undefined,
|
|
},
|
|
defaults: {
|
|
...sanitizedDefaults,
|
|
},
|
|
agents,
|
|
};
|
|
return normalized;
|
|
}
|
|
|
|
export function mergeExecApprovalsSocketDefaults(params: {
|
|
normalized: ExecApprovalsFile;
|
|
current?: ExecApprovalsFile;
|
|
}): ExecApprovalsFile {
|
|
const currentSocketPath = params.current?.socket?.path?.trim();
|
|
const currentToken = params.current?.socket?.token?.trim();
|
|
const socketPath =
|
|
params.normalized.socket?.path?.trim() ?? currentSocketPath ?? resolveExecApprovalsSocketPath();
|
|
const token = params.normalized.socket?.token?.trim() ?? currentToken ?? "";
|
|
return {
|
|
...params.normalized,
|
|
socket: {
|
|
path: socketPath,
|
|
token,
|
|
},
|
|
};
|
|
}
|
|
|
|
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 isExecSecurity(value: unknown): value is ExecSecurity {
|
|
return value === "allowlist" || value === "full" || value === "deny";
|
|
}
|
|
|
|
function isExecAsk(value: unknown): value is ExecAsk {
|
|
return value === "always" || value === "off" || value === "on-miss";
|
|
}
|
|
|
|
function normalizeSecurity(value: unknown, fallback: ExecSecurity): ExecSecurity {
|
|
return isExecSecurity(value) ? value : fallback;
|
|
}
|
|
|
|
function normalizeAsk(value: unknown, fallback: ExecAsk): ExecAsk {
|
|
return isExecAsk(value) ? value : fallback;
|
|
}
|
|
|
|
type ResolvedExecPolicyField<TValue extends ExecSecurity | ExecAsk> = {
|
|
value: TValue;
|
|
source: string | null;
|
|
};
|
|
|
|
function resolveDefaultSecurityField(params: {
|
|
field: "security" | "askFallback";
|
|
defaults: ExecApprovalsDefaults;
|
|
fallback: ExecSecurity;
|
|
}): ResolvedExecPolicyField<ExecSecurity> {
|
|
const defaultValue = params.defaults[params.field];
|
|
if (isExecSecurity(defaultValue)) {
|
|
return {
|
|
value: defaultValue,
|
|
source: `defaults.${params.field}`,
|
|
};
|
|
}
|
|
return {
|
|
value: params.fallback,
|
|
source: null,
|
|
};
|
|
}
|
|
|
|
function resolveDefaultAskField(params: {
|
|
defaults: ExecApprovalsDefaults;
|
|
fallback: ExecAsk;
|
|
}): ResolvedExecPolicyField<ExecAsk> {
|
|
if (isExecAsk(params.defaults.ask)) {
|
|
return {
|
|
value: params.defaults.ask,
|
|
source: "defaults.ask",
|
|
};
|
|
}
|
|
return {
|
|
value: params.fallback,
|
|
source: null,
|
|
};
|
|
}
|
|
|
|
function resolveAgentSecurityField(params: {
|
|
field: "security" | "askFallback";
|
|
defaults: ExecApprovalsDefaults;
|
|
agent: ExecApprovalsAgent;
|
|
rawAgent: ExecApprovalsAgent;
|
|
wildcard: ExecApprovalsAgent;
|
|
rawWildcard: ExecApprovalsAgent;
|
|
agentKey: string;
|
|
fallback: ExecSecurity;
|
|
}): ResolvedExecPolicyField<ExecSecurity> {
|
|
const fallbackField = resolveDefaultSecurityField({
|
|
field: params.field,
|
|
defaults: params.defaults,
|
|
fallback: params.fallback,
|
|
});
|
|
const rawAgentValue = params.rawAgent[params.field];
|
|
if (rawAgentValue != null) {
|
|
if (isExecSecurity(params.agent[params.field])) {
|
|
return {
|
|
value: params.agent[params.field] as ExecSecurity,
|
|
source: `agents.${params.agentKey}.${params.field}`,
|
|
};
|
|
}
|
|
return fallbackField;
|
|
}
|
|
const rawWildcardValue = params.rawWildcard[params.field];
|
|
if (rawWildcardValue != null) {
|
|
if (isExecSecurity(params.wildcard[params.field])) {
|
|
return {
|
|
value: params.wildcard[params.field] as ExecSecurity,
|
|
source: `agents.*.${params.field}`,
|
|
};
|
|
}
|
|
return fallbackField;
|
|
}
|
|
return fallbackField;
|
|
}
|
|
|
|
function resolveAgentAskField(params: {
|
|
defaults: ExecApprovalsDefaults;
|
|
agent: ExecApprovalsAgent;
|
|
rawAgent: ExecApprovalsAgent;
|
|
wildcard: ExecApprovalsAgent;
|
|
rawWildcard: ExecApprovalsAgent;
|
|
agentKey: string;
|
|
fallback: ExecAsk;
|
|
}): ResolvedExecPolicyField<ExecAsk> {
|
|
const fallbackField = resolveDefaultAskField({
|
|
defaults: params.defaults,
|
|
fallback: params.fallback,
|
|
});
|
|
if (params.rawAgent.ask != null) {
|
|
if (isExecAsk(params.agent.ask)) {
|
|
return {
|
|
value: params.agent.ask,
|
|
source: `agents.${params.agentKey}.ask`,
|
|
};
|
|
}
|
|
return fallbackField;
|
|
}
|
|
if (params.rawWildcard.ask != null) {
|
|
if (isExecAsk(params.wildcard.ask)) {
|
|
return {
|
|
value: params.wildcard.ask,
|
|
source: "agents.*.ask",
|
|
};
|
|
}
|
|
return fallbackField;
|
|
}
|
|
return fallbackField;
|
|
}
|
|
|
|
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: expandHomePrefix(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 rawFile = params.file;
|
|
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 rawAgent = rawFile.agents?.[agentKey] ?? {};
|
|
const rawWildcard = rawFile.agents?.["*"] ?? {};
|
|
const fallbackSecurity = params.overrides?.security ?? DEFAULT_SECURITY;
|
|
const fallbackAsk = params.overrides?.ask ?? DEFAULT_ASK;
|
|
const fallbackAskFallback = params.overrides?.askFallback ?? DEFAULT_EXEC_APPROVAL_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 resolvedAgentSecurity = resolveAgentSecurityField({
|
|
field: "security",
|
|
defaults,
|
|
agent,
|
|
rawAgent,
|
|
wildcard,
|
|
rawWildcard,
|
|
agentKey,
|
|
fallback: resolvedDefaults.security,
|
|
});
|
|
const resolvedAgentAsk = resolveAgentAskField({
|
|
defaults,
|
|
agent,
|
|
rawAgent,
|
|
wildcard,
|
|
rawWildcard,
|
|
agentKey,
|
|
fallback: resolvedDefaults.ask,
|
|
});
|
|
const resolvedAgentAskFallback = resolveAgentSecurityField({
|
|
field: "askFallback",
|
|
defaults,
|
|
agent,
|
|
rawAgent,
|
|
wildcard,
|
|
rawWildcard,
|
|
agentKey,
|
|
fallback: resolvedDefaults.askFallback,
|
|
});
|
|
const resolvedAgent: Required<ExecApprovalsDefaults> = {
|
|
security: resolvedAgentSecurity.value,
|
|
ask: resolvedAgentAsk.value,
|
|
askFallback: resolvedAgentAskFallback.value,
|
|
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: expandHomePrefix(
|
|
params.socketPath ?? file.socket?.path ?? resolveExecApprovalsSocketPath(),
|
|
),
|
|
token: params.token ?? file.socket?.token ?? "",
|
|
defaults: resolvedDefaults,
|
|
agent: resolvedAgent,
|
|
agentSources: {
|
|
security: resolvedAgentSecurity.source,
|
|
ask: resolvedAgentAsk.source,
|
|
askFallback: resolvedAgentAskFallback.source,
|
|
},
|
|
allowlist,
|
|
file,
|
|
};
|
|
}
|
|
|
|
export function requiresExecApproval(params: {
|
|
ask: ExecAsk;
|
|
security: ExecSecurity;
|
|
analysisOk: boolean;
|
|
allowlistSatisfied: boolean;
|
|
durableApprovalSatisfied?: boolean;
|
|
}): boolean {
|
|
if (params.ask === "always") {
|
|
return true;
|
|
}
|
|
if (params.durableApprovalSatisfied === true) {
|
|
return false;
|
|
}
|
|
return (
|
|
params.ask === "on-miss" &&
|
|
params.security === "allowlist" &&
|
|
(!params.analysisOk || !params.allowlistSatisfied)
|
|
);
|
|
}
|
|
|
|
export function hasDurableExecApproval(params: {
|
|
analysisOk: boolean;
|
|
segmentAllowlistEntries: Array<ExecAllowlistEntry | null>;
|
|
allowlist?: readonly ExecAllowlistEntry[];
|
|
commandText?: string | null;
|
|
}): boolean {
|
|
const normalizedCommand = params.commandText?.trim();
|
|
const commandPattern = normalizedCommand
|
|
? buildDurableCommandApprovalPattern(normalizedCommand)
|
|
: null;
|
|
const exactCommandMatch = normalizedCommand
|
|
? (params.allowlist ?? []).some(
|
|
(entry) =>
|
|
entry.source === "allow-always" &&
|
|
(entry.pattern === commandPattern ||
|
|
(typeof entry.commandText === "string" &&
|
|
entry.commandText.trim() === normalizedCommand)),
|
|
)
|
|
: false;
|
|
const allowlistMatch =
|
|
params.analysisOk &&
|
|
params.segmentAllowlistEntries.length > 0 &&
|
|
params.segmentAllowlistEntries.every((entry) => entry?.source === "allow-always");
|
|
return exactCommandMatch || allowlistMatch;
|
|
}
|
|
|
|
function buildDurableCommandApprovalPattern(commandText: string): string {
|
|
const digest = crypto.createHash("sha256").update(commandText).digest("hex").slice(0, 16);
|
|
return `=command:${digest}`;
|
|
}
|
|
|
|
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.argPattern ?? undefined) === (entry.argPattern ?? undefined)
|
|
? {
|
|
...item,
|
|
id: item.id ?? crypto.randomUUID(),
|
|
lastUsedAt: Date.now(),
|
|
lastUsedCommand: command,
|
|
lastResolvedPath: resolvedPath,
|
|
}
|
|
: item,
|
|
);
|
|
agents[target] = { ...existing, allowlist: nextAllowlist };
|
|
approvals.agents = agents;
|
|
saveExecApprovals(approvals);
|
|
}
|
|
|
|
function buildAllowlistEntryMatchKey(entry: Pick<ExecAllowlistEntry, "pattern" | "argPattern">): string {
|
|
return `${entry.pattern}\x00${entry.argPattern?.trim() ?? ""}`;
|
|
}
|
|
|
|
export function recordAllowlistMatchesUse(params: {
|
|
approvals: ExecApprovalsFile;
|
|
agentId: string | undefined;
|
|
matches: readonly ExecAllowlistEntry[];
|
|
command: string;
|
|
resolvedPath?: string;
|
|
}): void {
|
|
if (params.matches.length === 0) {
|
|
return;
|
|
}
|
|
const seen = new Set<string>();
|
|
for (const match of params.matches) {
|
|
if (!match.pattern) {
|
|
continue;
|
|
}
|
|
const key = buildAllowlistEntryMatchKey(match);
|
|
if (seen.has(key)) {
|
|
continue;
|
|
}
|
|
seen.add(key);
|
|
recordAllowlistUse(
|
|
params.approvals,
|
|
params.agentId,
|
|
match,
|
|
params.command,
|
|
params.resolvedPath,
|
|
);
|
|
}
|
|
}
|
|
|
|
export function addAllowlistEntry(
|
|
approvals: ExecApprovalsFile,
|
|
agentId: string | undefined,
|
|
pattern: string,
|
|
options?: {
|
|
argPattern?: string;
|
|
source?: ExecAllowlistEntry["source"];
|
|
},
|
|
) {
|
|
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;
|
|
}
|
|
const trimmedArgPattern = options?.argPattern?.trim() || undefined;
|
|
const existingEntry = allowlist.find(
|
|
(entry) =>
|
|
entry.pattern === trimmed && (entry.argPattern ?? undefined) === trimmedArgPattern,
|
|
);
|
|
if (existingEntry && (!options?.source || existingEntry.source === options.source)) {
|
|
return;
|
|
}
|
|
const now = Date.now();
|
|
const nextAllowlist = existingEntry
|
|
? allowlist.map((entry) =>
|
|
entry.pattern === trimmed
|
|
? {
|
|
...entry,
|
|
argPattern: trimmedArgPattern,
|
|
source: options?.source ?? entry.source,
|
|
lastUsedAt: now,
|
|
}
|
|
: entry,
|
|
)
|
|
: [
|
|
...allowlist,
|
|
{
|
|
id: crypto.randomUUID(),
|
|
pattern: trimmed,
|
|
argPattern: trimmedArgPattern,
|
|
source: options?.source,
|
|
lastUsedAt: now,
|
|
},
|
|
];
|
|
agents[target] = { ...existing, allowlist: nextAllowlist };
|
|
approvals.agents = agents;
|
|
saveExecApprovals(approvals);
|
|
}
|
|
|
|
export function addDurableCommandApproval(
|
|
approvals: ExecApprovalsFile,
|
|
agentId: string | undefined,
|
|
commandText: string,
|
|
) {
|
|
const normalized = commandText.trim();
|
|
if (!normalized) {
|
|
return;
|
|
}
|
|
addAllowlistEntry(approvals, agentId, buildDurableCommandApprovalPattern(normalized), {
|
|
source: "allow-always",
|
|
});
|
|
}
|
|
|
|
export function persistAllowAlwaysPatterns(params: {
|
|
approvals: ExecApprovalsFile;
|
|
agentId: string | undefined;
|
|
segments: ExecCommandSegment[];
|
|
cwd?: string;
|
|
env?: NodeJS.ProcessEnv;
|
|
platform?: string | null;
|
|
strictInlineEval?: boolean;
|
|
}): ReturnType<typeof resolveAllowAlwaysPatternEntries> {
|
|
const patterns = resolveAllowAlwaysPatternEntries({
|
|
segments: params.segments,
|
|
cwd: params.cwd,
|
|
env: params.env,
|
|
platform: params.platform,
|
|
strictInlineEval: params.strictInlineEval,
|
|
});
|
|
for (const pattern of patterns) {
|
|
if (!pattern.pattern) {
|
|
continue;
|
|
}
|
|
addAllowlistEntry(params.approvals, params.agentId, pattern.pattern, {
|
|
argPattern: pattern.argPattern,
|
|
source: "allow-always",
|
|
});
|
|
}
|
|
return patterns;
|
|
}
|
|
|
|
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 const DEFAULT_EXEC_APPROVAL_DECISIONS = [
|
|
"allow-once",
|
|
"allow-always",
|
|
"deny",
|
|
] as const satisfies readonly ExecApprovalDecision[];
|
|
|
|
export function resolveExecApprovalAllowedDecisions(params?: {
|
|
ask?: string | null;
|
|
}): readonly ExecApprovalDecision[] {
|
|
const ask = normalizeExecAsk(params?.ask);
|
|
if (ask === "always") {
|
|
return ["allow-once", "deny"];
|
|
}
|
|
return DEFAULT_EXEC_APPROVAL_DECISIONS;
|
|
}
|
|
|
|
export function resolveExecApprovalRequestAllowedDecisions(params?: {
|
|
ask?: string | null;
|
|
allowedDecisions?: readonly ExecApprovalDecision[] | readonly string[] | null;
|
|
}): readonly ExecApprovalDecision[] {
|
|
const explicit = Array.isArray(params?.allowedDecisions)
|
|
? params.allowedDecisions.filter(
|
|
(decision): decision is ExecApprovalDecision =>
|
|
decision === "allow-once" || decision === "allow-always" || decision === "deny",
|
|
)
|
|
: [];
|
|
return explicit.length > 0 ? explicit : resolveExecApprovalAllowedDecisions({ ask: params?.ask });
|
|
}
|
|
|
|
export function isExecApprovalDecisionAllowed(params: {
|
|
decision: ExecApprovalDecision;
|
|
ask?: string | null;
|
|
}): boolean {
|
|
return resolveExecApprovalAllowedDecisions({ ask: params.ask }).includes(params.decision);
|
|
}
|
|
|
|
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;
|
|
const payload = JSON.stringify({
|
|
type: "request",
|
|
token,
|
|
id: crypto.randomUUID(),
|
|
request,
|
|
});
|
|
|
|
return await requestJsonlSocket({
|
|
socketPath,
|
|
requestLine: payload,
|
|
timeoutMs,
|
|
accept: (value) => {
|
|
const msg = value as { type?: string; decision?: ExecApprovalDecision };
|
|
if (msg?.type === "decision" && msg.decision) {
|
|
return msg.decision;
|
|
}
|
|
return undefined;
|
|
},
|
|
});
|
|
}
|