Exec approvals: fix policy source attribution (#59367)

Merged via squash.

Prepared head SHA: 974945a9f0
Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com>
Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com>
Reviewed-by: @gumadeiras
This commit is contained in:
Gustavo Madeira Santana
2026-04-02 01:28:14 -04:00
committed by GitHub
parent ad6e42906f
commit f69570f820
10 changed files with 427 additions and 78 deletions

View File

@@ -18,6 +18,7 @@ Docs: https://docs.openclaw.ai
- WhatsApp/media: add HTML, XML, and CSS to the MIME map and fall back gracefully for unknown media types instead of dropping the attachment. (#51562) Thanks @bobbyt74.
- WhatsApp/presence: send `unavailable` presence on connect in self-chat mode so personal-phone users stop losing all push notifications while the gateway is running. (#59410) Thanks @mcaxtr.
- Providers/OpenAI-compatible routing: centralize native-vs-proxy request policy so hidden attribution and related OpenAI-family defaults only apply on verified native endpoints across stream, websocket, and shared audio HTTP paths. (#59433) Thanks @vincentkoc.
- Exec approvals/doctor: report host policy sources from the real approvals file path and ignore malformed host override values when attributing effective policy conflicts. (#59367) Thanks @gumadeiras.
## 2026.4.1-beta.1

View File

@@ -46,6 +46,11 @@ function createExecApprovals(): ExecApprovalsResolved {
askFallback: "full",
autoAllowSkills: false,
},
agentSources: {
security: "defaults.security",
ask: "defaults.ask",
askFallback: "defaults.askFallback",
},
allowlist: [],
file: {
version: 1,

View File

@@ -52,6 +52,11 @@ vi.mock("../infra/exec-approvals.js", async (importOriginal) => {
askFallback: "deny",
autoAllowSkills: false,
},
agentSources: {
security: "defaults.security",
ask: "defaults.ask",
askFallback: "defaults.askFallback",
},
allowlist: [],
file: {
version: 1,

View File

@@ -236,13 +236,13 @@ describe("exec approvals CLI", () => {
expect.objectContaining({
scopeLabel: "agent:runner",
security: expect.objectContaining({
hostSource: "~/.openclaw/exec-approvals.json agents.*.security",
hostSource: "/tmp/local-exec-approvals.json agents.*.security",
}),
ask: expect.objectContaining({
hostSource: "~/.openclaw/exec-approvals.json agents.*.ask",
hostSource: "/tmp/local-exec-approvals.json agents.*.ask",
}),
askFallback: expect.objectContaining({
source: "~/.openclaw/exec-approvals.json agents.*.askFallback",
source: "/tmp/local-exec-approvals.json agents.*.askFallback",
}),
}),
]),
@@ -304,7 +304,7 @@ describe("exec approvals CLI", () => {
}),
askFallback: expect.objectContaining({
effective: "deny",
source: "~/.openclaw/exec-approvals.json defaults.askFallback",
source: "/tmp/node-exec-approvals.json defaults.askFallback",
}),
}),
],

View File

@@ -184,6 +184,7 @@ function buildEffectivePolicyReport(params: {
cfg: OpenClawConfig | null;
source: ApprovalsTargetSource;
approvals: ExecApprovalsFile;
hostPath: string;
}): EffectivePolicyReport {
if (params.source === "node") {
if (!params.cfg) {
@@ -196,6 +197,7 @@ function buildEffectivePolicyReport(params: {
scopes: collectExecPolicyScopeSnapshots({
cfg: params.cfg,
approvals: params.approvals,
hostPath: params.hostPath,
}),
note: "Effective exec policy is the node host approvals file intersected with gateway tools.exec policy.",
};
@@ -210,6 +212,7 @@ function buildEffectivePolicyReport(params: {
scopes: collectExecPolicyScopeSnapshots({
cfg: params.cfg,
approvals: params.approvals,
hostPath: params.hostPath,
}),
note: "Effective exec policy is the host approvals file intersected with requested tools.exec policy.",
};
@@ -474,6 +477,7 @@ export function registerExecApprovalsCli(program: Command) {
cfg,
source,
approvals: snapshot.file,
hostPath: snapshot.path,
});
if (opts.json) {
defaultRuntime.writeJson({ ...snapshot, effectivePolicy }, 0);

View File

@@ -343,6 +343,39 @@ describe("noteSecurityWarnings gateway exposure", () => {
expect(message).toContain('agents.runner.ask="always"');
});
it("ignores malformed host policy fields when attributing doctor conflicts", async () => {
await withExecApprovalsFile(
{
version: 1,
defaults: {
ask: "always",
},
agents: {
runner: {
ask: "foo",
},
},
},
async () => {
await noteSecurityWarnings({
tools: {
exec: {
ask: "off",
},
},
agents: {
list: [{ id: "runner" }],
},
} as OpenClawConfig);
},
);
const message = lastMessage();
expect(message).toContain("agents.list.runner.tools.exec is broader than the host exec policy");
expect(message).toContain('defaults.ask="always"');
expect(message).not.toContain('agents.runner.ask="foo"');
});
it('does not warn about durable allow-always trust when ask="always" is enforced', async () => {
await withExecApprovalsFile(
{

View File

@@ -165,6 +165,92 @@ describe("exec approvals default agent migration", () => {
});
});
describe("exec approvals invalid explicit policy fallback", () => {
it("treats invalid explicit agent fields as masked and falls back to defaults instead of wildcard", () => {
const resolved = resolveExecApprovalsFromFile({
file: {
version: 1,
defaults: {
security: "deny",
ask: "on-miss",
askFallback: "deny",
},
agents: {
"*": {
security: "full",
ask: "always",
askFallback: "full",
},
runner: {
security: "foo" as unknown as ExecApprovalsAgent["security"],
ask: "Always" as unknown as ExecApprovalsAgent["ask"],
askFallback: "bar" as unknown as ExecApprovalsAgent["askFallback"],
},
},
},
agentId: "runner",
overrides: {
security: "full",
ask: "off",
askFallback: "full",
},
});
expect(resolved.agent).toMatchObject({
security: "deny",
ask: "on-miss",
askFallback: "deny",
});
expect(resolved.agentSources).toEqual({
security: "defaults.security",
ask: "defaults.ask",
askFallback: "defaults.askFallback",
});
});
it("treats null explicit agent fields as unset and still considers wildcard", () => {
const resolved = resolveExecApprovalsFromFile({
file: {
version: 1,
defaults: {
security: "full",
ask: "off",
askFallback: "full",
},
agents: {
"*": {
security: "deny",
ask: "always",
askFallback: "deny",
},
runner: {
security: null as unknown as ExecApprovalsAgent["security"],
ask: null as unknown as ExecApprovalsAgent["ask"],
askFallback: null as unknown as ExecApprovalsAgent["askFallback"],
},
},
},
agentId: "runner",
overrides: {
security: "full",
ask: "off",
askFallback: "full",
},
});
expect(resolved.agent).toMatchObject({
security: "deny",
ask: "always",
askFallback: "deny",
});
expect(resolved.agentSources).toEqual({
security: "agents.*.security",
ask: "agents.*.ask",
askFallback: "agents.*.askFallback",
});
});
});
describe("normalizeExecApprovals handles string allowlist entries (#9790)", () => {
function normalizeMainAllowlist(file: ExecApprovalsFile): ExecAllowlistEntry[] | undefined {
const normalized = normalizeExecApprovals(file);

View File

@@ -62,24 +62,6 @@ function formatRequestedSource(params: {
type ExecPolicyField = "security" | "ask" | "askFallback";
function readExecPolicyField(params: {
field: ExecPolicyField;
entry?: {
security?: ExecSecurity;
ask?: ExecAsk;
askFallback?: ExecSecurity;
};
}): ExecSecurity | ExecAsk | undefined {
switch (params.field) {
case "security":
return params.entry?.security;
case "ask":
return params.entry?.ask;
case "askFallback":
return params.entry?.askFallback;
}
}
function resolveRequestedField<TValue extends ExecSecurity | ExecAsk>(params: {
field: ExecPolicyRequestedField;
scopeExecConfig?: ExecPolicyConfig;
@@ -89,7 +71,7 @@ function resolveRequestedField<TValue extends ExecSecurity | ExecAsk>(params: {
if (scopeValue !== undefined) {
return {
value: scopeValue as TValue,
sourcePath: params.field && "scope",
sourcePath: "scope",
};
}
const globalValue = params.globalExecConfig?.[params.field];
@@ -106,28 +88,13 @@ function resolveRequestedField<TValue extends ExecSecurity | ExecAsk>(params: {
};
}
function resolveHostFieldSource(params: {
function formatHostFieldSource(params: {
hostPath: string;
agentId?: string;
field: ExecPolicyField;
approvals: ExecApprovalsFile;
sourceSuffix: string | null;
}): string {
const agentKey = params.agentId ?? DEFAULT_AGENT_ID;
const explicitAgentEntry = params.approvals.agents?.[agentKey];
if (readExecPolicyField({ field: params.field, entry: explicitAgentEntry }) !== undefined) {
return `${params.hostPath} agents.${agentKey}.${params.field}`;
}
const wildcardEntry = params.approvals.agents?.["*"];
if (readExecPolicyField({ field: params.field, entry: wildcardEntry }) !== undefined) {
return `${params.hostPath} agents.*.${params.field}`;
}
if (
readExecPolicyField({
field: params.field,
entry: params.approvals.defaults,
}) !== undefined
) {
return `${params.hostPath} defaults.${params.field}`;
if (params.sourceSuffix) {
return `${params.hostPath} ${params.sourceSuffix}`;
}
if (params.field === "askFallback") {
return `OpenClaw default (${DEFAULT_EXEC_APPROVAL_ASK_FALLBACK})`;
@@ -149,24 +116,17 @@ function resolveAskNote(params: {
return "more aggressive ask wins";
}
function formatHostSource(params: {
hostPath: string;
agentId?: string;
field: ExecPolicyField;
approvals: ExecApprovalsFile;
}): string {
return resolveHostFieldSource(params);
}
export function collectExecPolicyScopeSnapshots(params: {
cfg: OpenClawConfig;
approvals: ExecApprovalsFile;
hostPath?: string;
}): ExecPolicyScopeSnapshot[] {
const snapshots = [
resolveExecPolicyScopeSnapshot({
approvals: params.approvals,
scopeExecConfig: params.cfg.tools?.exec,
configPath: "tools.exec",
hostPath: params.hostPath,
scopeLabel: "tools.exec",
}),
];
@@ -188,6 +148,7 @@ export function collectExecPolicyScopeSnapshots(params: {
scopeExecConfig: agentConfig?.tools?.exec,
globalExecConfig,
configPath: `agents.list.${agentId}.tools.exec`,
hostPath: params.hostPath,
scopeLabel: `agent:${agentId}`,
agentId,
}),
@@ -256,11 +217,10 @@ export function resolveExecPolicyScopeSnapshot(params: {
defaultValue: DEFAULT_REQUESTED_SECURITY,
}),
host: resolved.agent.security,
hostSource: formatHostSource({
hostSource: formatHostFieldSource({
hostPath,
agentId: params.agentId,
field: "security",
approvals: params.approvals,
sourceSuffix: resolved.agentSources.security,
}),
effective: effectiveSecurity,
note:
@@ -277,11 +237,10 @@ export function resolveExecPolicyScopeSnapshot(params: {
defaultValue: DEFAULT_REQUESTED_ASK,
}),
host: resolved.agent.ask,
hostSource: formatHostSource({
hostSource: formatHostFieldSource({
hostPath,
agentId: params.agentId,
field: "ask",
approvals: params.approvals,
sourceSuffix: resolved.agentSources.ask,
}),
effective: effectiveAsk,
note: resolveAskNote({
@@ -292,11 +251,10 @@ export function resolveExecPolicyScopeSnapshot(params: {
},
askFallback: {
effective: resolved.agent.askFallback,
source: formatHostSource({
source: formatHostFieldSource({
hostPath,
agentId: params.agentId,
field: "askFallback",
approvals: params.approvals,
sourceSuffix: resolved.agentSources.askFallback,
}),
},
allowedDecisions: resolveExecApprovalAllowedDecisions({ ask: effectiveAsk }),

View File

@@ -14,6 +14,7 @@ import {
hasDurableExecApproval,
maxAsk,
minSecurity,
type ExecApprovalsFile,
normalizeExecAsk,
normalizeExecHost,
normalizeExecTarget,
@@ -234,6 +235,33 @@ describe("exec approvals policy helpers", () => {
});
});
it("uses the actual approvals path when reporting host sources", () => {
const summary = resolveExecPolicyScopeSummary({
approvals: {
version: 1,
defaults: {
security: "allowlist",
ask: "always",
askFallback: "deny",
},
},
scopeExecConfig: {
security: "full",
ask: "off",
},
configPath: "tools.exec",
scopeLabel: "tools.exec",
hostPath: "/tmp/node-exec-approvals.json",
});
expect(summary.security.hostSource).toBe("/tmp/node-exec-approvals.json defaults.security");
expect(summary.ask.hostSource).toBe("/tmp/node-exec-approvals.json defaults.ask");
expect(summary.askFallback).toEqual({
effective: "deny",
source: "/tmp/node-exec-approvals.json defaults.askFallback",
});
});
it("explains host ask=off suppression separately from stricter ask", () => {
const summary = resolveExecPolicyScopeSummary({
approvals: {
@@ -257,6 +285,99 @@ describe("exec approvals policy helpers", () => {
});
});
it("skips malformed host fields when attributing their source", () => {
const approvals = {
version: 1,
defaults: {
ask: "always",
},
agents: {
runner: {
ask: "foo",
},
},
} as unknown as ExecApprovalsFile;
const summary = resolveExecPolicyScopeSummary({
approvals,
globalExecConfig: {
ask: "off",
},
configPath: "agents.list.runner.tools.exec",
scopeLabel: "agent:runner",
agentId: "runner",
});
expect(summary.ask).toMatchObject({
requested: "off",
host: "always",
hostSource: "~/.openclaw/exec-approvals.json defaults.ask",
effective: "always",
note: "more aggressive ask wins",
});
});
it("ignores malformed non-string host fields when attributing their source", () => {
const approvals = {
version: 1,
defaults: {
ask: "always",
},
agents: {
runner: {
ask: true,
},
},
} as unknown as ExecApprovalsFile;
const summary = resolveExecPolicyScopeSummary({
approvals,
globalExecConfig: {
ask: "off",
},
configPath: "agents.list.runner.tools.exec",
scopeLabel: "agent:runner",
agentId: "runner",
});
expect(summary.ask).toMatchObject({
requested: "off",
host: "always",
hostSource: "~/.openclaw/exec-approvals.json defaults.ask",
effective: "always",
note: "more aggressive ask wins",
});
});
it("does not credit mixed-case host fields that resolution ignores", () => {
const approvals = {
version: 1,
defaults: {
ask: "always",
},
agents: {
runner: {
ask: "Always",
},
},
} as unknown as ExecApprovalsFile;
const summary = resolveExecPolicyScopeSummary({
approvals,
globalExecConfig: {
ask: "off",
},
configPath: "agents.list.runner.tools.exec",
scopeLabel: "agent:runner",
agentId: "runner",
});
expect(summary.ask).toMatchObject({
requested: "off",
host: "always",
hostSource: "~/.openclaw/exec-approvals.json defaults.ask",
effective: "always",
note: "more aggressive ask wins",
});
});
it("attributes host policy to wildcard agent entries before defaults", () => {
const summary = resolveExecPolicyScopeSummary({
approvals: {

View File

@@ -151,6 +151,11 @@ export type ExecApprovalsResolved = {
token: string;
defaults: Required<ExecApprovalsDefaults>;
agent: Required<ExecApprovalsDefaults>;
agentSources: {
security: string | null;
ask: string | null;
askFallback: string | null;
};
allowlist: ExecAllowlistEntry[];
file: ExecApprovalsFile;
};
@@ -419,18 +424,127 @@ export function ensureExecApprovals(): ExecApprovalsFile {
return updated;
}
function normalizeSecurity(value: ExecSecurity | undefined, fallback: ExecSecurity): ExecSecurity {
if (value === "allowlist" || value === "full" || value === "deny") {
return value;
}
return fallback;
function isExecSecurity(value: unknown): value is ExecSecurity {
return value === "allowlist" || value === "full" || value === "deny";
}
function normalizeAsk(value: ExecAsk | undefined, fallback: ExecAsk): ExecAsk {
if (value === "always" || value === "off" || value === "on-miss") {
return value;
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 fallback;
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;
wildcard: ExecApprovalsAgent;
agentKey: string;
fallback: ExecSecurity;
}): ResolvedExecPolicyField<ExecSecurity> {
const fallbackField = resolveDefaultSecurityField({
field: params.field,
defaults: params.defaults,
fallback: params.fallback,
});
const agentValue = params.agent[params.field];
if (agentValue != null) {
if (isExecSecurity(agentValue)) {
return {
value: agentValue,
source: `agents.${params.agentKey}.${params.field}`,
};
}
return fallbackField;
}
const wildcardValue = params.wildcard[params.field];
if (wildcardValue != null) {
if (isExecSecurity(wildcardValue)) {
return {
value: wildcardValue,
source: `agents.*.${params.field}`,
};
}
return fallbackField;
}
return fallbackField;
}
function resolveAgentAskField(params: {
defaults: ExecApprovalsDefaults;
agent: ExecApprovalsAgent;
wildcard: ExecApprovalsAgent;
agentKey: string;
fallback: ExecAsk;
}): ResolvedExecPolicyField<ExecAsk> {
const fallbackField = resolveDefaultAskField({
defaults: params.defaults,
fallback: params.fallback,
});
if (params.agent.ask != null) {
if (isExecAsk(params.agent.ask)) {
return {
value: params.agent.ask,
source: `agents.${params.agentKey}.ask`,
};
}
return fallbackField;
}
if (params.wildcard.ask != null) {
if (isExecAsk(params.wildcard.ask)) {
return {
value: params.wildcard.ask,
source: "agents.*.ask",
};
}
return fallbackField;
}
return fallbackField;
}
export type ExecApprovalsDefaultOverrides = {
@@ -481,16 +595,33 @@ export function resolveExecApprovalsFromFile(params: {
),
autoAllowSkills: Boolean(defaults.autoAllowSkills ?? fallbackAutoAllowSkills),
};
const resolvedAgentSecurity = resolveAgentSecurityField({
field: "security",
defaults,
agent,
wildcard,
agentKey,
fallback: resolvedDefaults.security,
});
const resolvedAgentAsk = resolveAgentAskField({
defaults,
agent,
wildcard,
agentKey,
fallback: resolvedDefaults.ask,
});
const resolvedAgentAskFallback = resolveAgentSecurityField({
field: "askFallback",
defaults,
agent,
wildcard,
agentKey,
fallback: resolvedDefaults.askFallback,
});
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,
),
security: resolvedAgentSecurity.value,
ask: resolvedAgentAsk.value,
askFallback: resolvedAgentAskFallback.value,
autoAllowSkills: Boolean(
agent.autoAllowSkills ?? wildcard.autoAllowSkills ?? resolvedDefaults.autoAllowSkills,
),
@@ -507,6 +638,11 @@ export function resolveExecApprovalsFromFile(params: {
token: params.token ?? file.socket?.token ?? "",
defaults: resolvedDefaults,
agent: resolvedAgent,
agentSources: {
security: resolvedAgentSecurity.source,
ask: resolvedAgentAsk.source,
askFallback: resolvedAgentAskFallback.source,
},
allowlist,
file,
};