mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-20 13:41:30 +00:00
595 lines
16 KiB
TypeScript
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",
|
|
});
|
|
});
|
|
});
|