Files
openclaw/src/infra/exec-approvals-policy.test.ts
2026-04-04 07:12:26 +01:00

595 lines
16 KiB
TypeScript

import { describe, expect, it } from "vitest";
import type { OpenClawConfig } from "../config/config.js";
import { DEFAULT_AGENT_ID } from "../routing/session-key.js";
import {
collectExecPolicyScopeSnapshots,
resolveExecPolicyScopeSummary,
} from "./exec-approvals-effective.js";
import {
makeMockCommandResolution,
makeMockExecutableResolution,
} from "./exec-approvals-test-helpers.js";
import {
evaluateExecAllowlist,
hasDurableExecApproval,
maxAsk,
minSecurity,
type ExecApprovalsFile,
normalizeExecAsk,
normalizeExecHost,
normalizeExecTarget,
normalizeExecSecurity,
requiresExecApproval,
} from "./exec-approvals.js";
describe("exec approvals policy helpers", () => {
it.each([
{ raw: " gateway ", expected: "gateway" },
{ raw: "NODE", expected: "node" },
{ raw: "", expected: null },
{ raw: "ssh", expected: null },
])("normalizes exec host value %j", ({ raw, expected }) => {
expect(normalizeExecHost(raw)).toBe(expected);
});
it.each([
{ raw: " auto ", expected: "auto" },
{ raw: " gateway ", expected: "gateway" },
{ raw: "NODE", expected: "node" },
{ raw: "", expected: null },
{ raw: "ssh", expected: null },
])("normalizes exec target value %j", ({ raw, expected }) => {
expect(normalizeExecTarget(raw)).toBe(expected);
});
it.each([
{ raw: " allowlist ", expected: "allowlist" },
{ raw: "FULL", expected: "full" },
{ raw: "unknown", expected: null },
])("normalizes exec security value %j", ({ raw, expected }) => {
expect(normalizeExecSecurity(raw)).toBe(expected);
});
it.each([
{ raw: " on-miss ", expected: "on-miss" },
{ raw: "ALWAYS", expected: "always" },
{ raw: "maybe", expected: null },
])("normalizes exec ask value %j", ({ raw, expected }) => {
expect(normalizeExecAsk(raw)).toBe(expected);
});
it.each([
{ left: "deny" as const, right: "full" as const, expected: "deny" as const },
{
left: "allowlist" as const,
right: "full" as const,
expected: "allowlist" as const,
},
{
left: "full" as const,
right: "allowlist" as const,
expected: "allowlist" as const,
},
])("minSecurity picks the more restrictive value for %j", ({ left, right, expected }) => {
expect(minSecurity(left, right)).toBe(expected);
});
it.each([
{ left: "off" as const, right: "always" as const, expected: "always" as const },
{ left: "on-miss" as const, right: "off" as const, expected: "on-miss" as const },
{ left: "always" as const, right: "on-miss" as const, expected: "always" as const },
])("maxAsk picks the more aggressive ask mode for %j", ({ left, right, expected }) => {
expect(maxAsk(left, right)).toBe(expected);
});
it.each([
{
ask: "always" as const,
security: "allowlist" as const,
analysisOk: true,
allowlistSatisfied: true,
expected: true,
},
{
ask: "always" as const,
security: "full" as const,
analysisOk: true,
allowlistSatisfied: false,
durableApprovalSatisfied: true,
expected: true,
},
{
ask: "off" as const,
security: "allowlist" as const,
analysisOk: true,
allowlistSatisfied: false,
expected: false,
},
{
ask: "on-miss" as const,
security: "allowlist" as const,
analysisOk: true,
allowlistSatisfied: true,
expected: false,
},
{
ask: "on-miss" as const,
security: "allowlist" as const,
analysisOk: false,
allowlistSatisfied: false,
expected: true,
},
{
ask: "on-miss" as const,
security: "full" as const,
analysisOk: false,
allowlistSatisfied: false,
expected: false,
},
])("requiresExecApproval respects ask mode and allowlist satisfaction for %j", (testCase) => {
expect(requiresExecApproval(testCase)).toBe(testCase.expected);
});
it("treats exact-command allow-always approvals as durable trust", () => {
expect(
hasDurableExecApproval({
analysisOk: false,
segmentAllowlistEntries: [],
allowlist: [
{
pattern: "=command:613b5a60181648fd",
source: "allow-always",
},
],
commandText: 'powershell -NoProfile -Command "Write-Output hi"',
}),
).toBe(true);
});
it("treats fully allow-always-matched segments as durable trust", () => {
expect(
hasDurableExecApproval({
analysisOk: true,
segmentAllowlistEntries: [
{ pattern: "/usr/bin/echo", source: "allow-always" },
{ pattern: "/usr/bin/printf", source: "allow-always" },
],
allowlist: [],
}),
).toBe(true);
});
it("marks policy-blocked segments as non-durable allowlist entries", () => {
const executable = makeMockExecutableResolution({
rawExecutable: "/usr/bin/echo",
resolvedPath: "/usr/bin/echo",
executableName: "echo",
});
const result = evaluateExecAllowlist({
analysis: {
ok: true,
segments: [
{
raw: "/usr/bin/echo ok",
argv: ["/usr/bin/echo", "ok"],
resolution: makeMockCommandResolution({
execution: executable,
}),
},
{
raw: "/bin/sh -lc whoami",
argv: ["/bin/sh", "-lc", "whoami"],
resolution: makeMockCommandResolution({
execution: makeMockExecutableResolution({
rawExecutable: "/bin/sh",
resolvedPath: "/bin/sh",
executableName: "sh",
}),
policyBlocked: true,
}),
},
],
},
allowlist: [{ pattern: "/usr/bin/echo", source: "allow-always" }],
safeBins: new Set(),
cwd: "/tmp",
platform: process.platform,
});
expect(result.allowlistSatisfied).toBe(false);
expect(result.segmentAllowlistEntries).toEqual([
expect.objectContaining({ pattern: "/usr/bin/echo" }),
null,
]);
expect(
hasDurableExecApproval({
analysisOk: true,
segmentAllowlistEntries: result.segmentAllowlistEntries,
allowlist: [{ pattern: "/usr/bin/echo", source: "allow-always" }],
}),
).toBe(false);
});
it("explains stricter host security and ask precedence", () => {
const summary = resolveExecPolicyScopeSummary({
approvals: {
version: 1,
defaults: {
security: "allowlist",
ask: "always",
askFallback: "deny",
},
},
scopeExecConfig: {
security: "full",
ask: "off",
},
configPath: "tools.exec",
scopeLabel: "tools.exec",
});
expect(summary.security).toMatchObject({
requested: "full",
host: "allowlist",
effective: "allowlist",
hostSource: "~/.openclaw/exec-approvals.json defaults.security",
note: "stricter host security wins",
});
expect(summary.ask).toMatchObject({
requested: "off",
host: "always",
effective: "always",
hostSource: "~/.openclaw/exec-approvals.json defaults.ask",
note: "more aggressive ask wins",
});
expect(summary.askFallback).toEqual({
effective: "deny",
source: "~/.openclaw/exec-approvals.json defaults.askFallback",
});
});
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: {
version: 1,
defaults: {
ask: "off",
},
},
scopeExecConfig: {
ask: "always",
},
configPath: "tools.exec",
scopeLabel: "tools.exec",
});
expect(summary.ask).toMatchObject({
requested: "always",
host: "off",
effective: "off",
note: "host ask=off suppresses prompts",
});
});
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: {
version: 1,
defaults: {
security: "full",
ask: "off",
askFallback: "full",
},
agents: {
"*": {
security: "allowlist",
ask: "always",
askFallback: "deny",
},
},
},
scopeExecConfig: {
security: "full",
ask: "off",
},
configPath: "agents.list.runner.tools.exec",
scopeLabel: "agent:runner",
agentId: "runner",
});
expect(summary.security).toMatchObject({
host: "allowlist",
hostSource: "~/.openclaw/exec-approvals.json agents.*.security",
});
expect(summary.ask).toMatchObject({
host: "always",
hostSource: "~/.openclaw/exec-approvals.json agents.*.ask",
});
expect(summary.askFallback).toEqual({
effective: "deny",
source: "~/.openclaw/exec-approvals.json agents.*.askFallback",
});
});
it("inherits requested agent policy from global tools.exec config", () => {
const summary = resolveExecPolicyScopeSummary({
approvals: {
version: 1,
agents: {
runner: {
security: "allowlist",
ask: "always",
},
},
},
globalExecConfig: {
security: "full",
ask: "off",
},
configPath: "agents.list.runner.tools.exec",
scopeLabel: "agent:runner",
agentId: "runner",
});
expect(summary.security).toMatchObject({
requested: "full",
requestedSource: "tools.exec.security",
host: "allowlist",
effective: "allowlist",
});
expect(summary.ask).toMatchObject({
requested: "off",
requestedSource: "tools.exec.ask",
host: "always",
effective: "always",
});
});
it("reports askFallback from the OpenClaw default when approvals omit it", () => {
const summary = resolveExecPolicyScopeSummary({
approvals: {
version: 1,
agents: {},
},
configPath: "tools.exec",
scopeLabel: "tools.exec",
});
expect(summary.askFallback).toEqual({
effective: "full",
source: "OpenClaw default (full)",
});
});
it("collects global, configured-agent, and approvals-only agent scopes", () => {
const snapshots = collectExecPolicyScopeSnapshots({
cfg: {
tools: {
exec: {
security: "full",
ask: "off",
},
},
agents: {
list: [{ id: "runner" }],
},
} satisfies OpenClawConfig,
approvals: {
version: 1,
agents: {
runner: {
security: "allowlist",
},
batch: {
ask: "always",
},
},
},
});
expect(snapshots.map((snapshot) => snapshot.scopeLabel)).toEqual([
"tools.exec",
"agent:batch",
"agent:runner",
]);
expect(snapshots[1]?.ask).toMatchObject({
requested: "off",
requestedSource: "tools.exec.ask",
host: "always",
effective: "always",
});
expect(snapshots[2]?.security).toMatchObject({
requested: "full",
requestedSource: "tools.exec.security",
host: "allowlist",
effective: "allowlist",
});
});
it("avoids a duplicate default-agent scope when main only appears in approvals", () => {
const snapshots = collectExecPolicyScopeSnapshots({
cfg: {
tools: {
exec: {
security: "full",
ask: "off",
},
},
} satisfies OpenClawConfig,
approvals: {
version: 1,
agents: {
[DEFAULT_AGENT_ID]: {
security: "allowlist",
ask: "always",
},
},
},
});
expect(snapshots.map((snapshot) => snapshot.scopeLabel)).toEqual(["tools.exec"]);
expect(snapshots[0]?.security).toMatchObject({
host: "allowlist",
hostSource: "~/.openclaw/exec-approvals.json agents.main.security",
});
expect(snapshots[0]?.ask).toMatchObject({
host: "always",
hostSource: "~/.openclaw/exec-approvals.json agents.main.ask",
});
});
it("keeps the default agent scope when main has an explicit exec override", () => {
const snapshots = collectExecPolicyScopeSnapshots({
cfg: {
tools: {
exec: {
security: "full",
ask: "off",
},
},
agents: {
list: [
{
id: DEFAULT_AGENT_ID,
tools: {
exec: {
ask: "always",
},
},
},
],
},
} satisfies OpenClawConfig,
approvals: {
version: 1,
},
});
expect(snapshots.map((snapshot) => snapshot.scopeLabel)).toEqual(["tools.exec", "agent:main"]);
expect(snapshots[1]?.ask).toMatchObject({
requested: "always",
requestedSource: "agents.list.main.tools.exec.ask",
});
});
});