mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-07 22:09:57 +00:00
chore: merge origin/main into main
This commit is contained in:
@@ -363,6 +363,16 @@ describe("legacy config detection", () => {
|
||||
expectedValue: "work",
|
||||
});
|
||||
});
|
||||
it("accepts bindings[].comment on load", () => {
|
||||
expectValidConfigValue({
|
||||
config: {
|
||||
bindings: [{ agentId: "main", comment: "primary route", match: { channel: "telegram" } }],
|
||||
},
|
||||
readValue: (config) =>
|
||||
(config as { bindings?: Array<{ comment?: string }> }).bindings?.[0]?.comment,
|
||||
expectedValue: "primary route",
|
||||
});
|
||||
});
|
||||
it("rejects session.sendPolicy.rules[].match.provider on load", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
const configPath = path.join(home, ".openclaw", "openclaw.json");
|
||||
|
||||
@@ -77,4 +77,60 @@ describe("config io paths", () => {
|
||||
expect(io.loadConfig().gateway?.port).toBe(20003);
|
||||
});
|
||||
});
|
||||
|
||||
it("normalizes safeBinProfiles at config load time", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
const configDir = path.join(home, ".openclaw");
|
||||
await fs.mkdir(configDir, { recursive: true });
|
||||
const configPath = path.join(configDir, "openclaw.json");
|
||||
await fs.writeFile(
|
||||
configPath,
|
||||
JSON.stringify(
|
||||
{
|
||||
tools: {
|
||||
exec: {
|
||||
safeBinProfiles: {
|
||||
" MyFilter ": {
|
||||
allowedValueFlags: ["--limit", " --limit ", ""],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
agents: {
|
||||
list: [
|
||||
{
|
||||
id: "ops",
|
||||
tools: {
|
||||
exec: {
|
||||
safeBinProfiles: {
|
||||
" Custom ": {
|
||||
deniedFlags: ["-f", " -f ", ""],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
"utf-8",
|
||||
);
|
||||
const io = createIoForHome(home);
|
||||
expect(io.configPath).toBe(configPath);
|
||||
const cfg = io.loadConfig();
|
||||
expect(cfg.tools?.exec?.safeBinProfiles).toEqual({
|
||||
myfilter: {
|
||||
allowedValueFlags: ["--limit"],
|
||||
},
|
||||
});
|
||||
expect(cfg.agents?.list?.[0]?.tools?.exec?.safeBinProfiles).toEqual({
|
||||
custom: {
|
||||
deniedFlags: ["-f"],
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,6 +6,7 @@ import { isDeepStrictEqual } from "node:util";
|
||||
import JSON5 from "json5";
|
||||
import { ensureOwnerDisplaySecret } from "../agents/owner-display.js";
|
||||
import { loadDotEnv } from "../infra/dotenv.js";
|
||||
import { normalizeSafeBinProfileFixtures } from "../infra/exec-safe-bin-policy.js";
|
||||
import { resolveRequiredHomeDir } from "../infra/home-dir.js";
|
||||
import {
|
||||
loadShellEnvFallback,
|
||||
@@ -555,6 +556,33 @@ function maybeLoadDotEnvForConfig(env: NodeJS.ProcessEnv): void {
|
||||
loadDotEnv({ quiet: true });
|
||||
}
|
||||
|
||||
function normalizeExecSafeBinProfilesInConfig(cfg: OpenClawConfig): void {
|
||||
const normalizeExec = (exec: unknown) => {
|
||||
if (!exec || typeof exec !== "object" || Array.isArray(exec)) {
|
||||
return;
|
||||
}
|
||||
const typedExec = exec as { safeBinProfiles?: Record<string, unknown> };
|
||||
const normalized = normalizeSafeBinProfileFixtures(
|
||||
typedExec.safeBinProfiles as Record<
|
||||
string,
|
||||
{
|
||||
minPositional?: number;
|
||||
maxPositional?: number;
|
||||
allowedValueFlags?: readonly string[];
|
||||
deniedFlags?: readonly string[];
|
||||
}
|
||||
>,
|
||||
);
|
||||
typedExec.safeBinProfiles = Object.keys(normalized).length > 0 ? normalized : undefined;
|
||||
};
|
||||
|
||||
normalizeExec(cfg.tools?.exec);
|
||||
const agents = Array.isArray(cfg.agents?.list) ? cfg.agents.list : [];
|
||||
for (const agent of agents) {
|
||||
normalizeExec(agent?.tools?.exec);
|
||||
}
|
||||
}
|
||||
|
||||
export function parseConfigJson5(
|
||||
raw: string,
|
||||
json5: { parse: (value: string) => unknown } = JSON5,
|
||||
@@ -675,6 +703,7 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) {
|
||||
),
|
||||
);
|
||||
normalizeConfigPaths(cfg);
|
||||
normalizeExecSafeBinProfilesInConfig(cfg);
|
||||
|
||||
const duplicates = findDuplicateAgentDirs(cfg, {
|
||||
env: deps.env,
|
||||
@@ -875,6 +904,16 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) {
|
||||
}
|
||||
|
||||
warnIfConfigFromFuture(validated.config, deps.logger);
|
||||
const snapshotConfig = normalizeConfigPaths(
|
||||
applyTalkApiKey(
|
||||
applyModelDefaults(
|
||||
applyAgentDefaults(
|
||||
applySessionDefaults(applyLoggingDefaults(applyMessageDefaults(validated.config))),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
normalizeExecSafeBinProfilesInConfig(snapshotConfig);
|
||||
return {
|
||||
snapshot: {
|
||||
path: configPath,
|
||||
@@ -885,17 +924,7 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) {
|
||||
// for config set/unset operations (issue #6070)
|
||||
resolved: coerceConfig(resolvedConfigRaw),
|
||||
valid: true,
|
||||
config: normalizeConfigPaths(
|
||||
applyTalkApiKey(
|
||||
applyModelDefaults(
|
||||
applyAgentDefaults(
|
||||
applySessionDefaults(
|
||||
applyLoggingDefaults(applyMessageDefaults(validated.config)),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
config: snapshotConfig,
|
||||
hash,
|
||||
issues: [],
|
||||
warnings: validated.warnings,
|
||||
|
||||
101
src/config/runtime-group-policy.test.ts
Normal file
101
src/config/runtime-group-policy.test.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
GROUP_POLICY_BLOCKED_LABEL,
|
||||
resetMissingProviderGroupPolicyFallbackWarningsForTesting,
|
||||
resolveAllowlistProviderRuntimeGroupPolicy,
|
||||
resolveDefaultGroupPolicy,
|
||||
resolveOpenProviderRuntimeGroupPolicy,
|
||||
resolveRuntimeGroupPolicy,
|
||||
warnMissingProviderGroupPolicyFallbackOnce,
|
||||
} from "./runtime-group-policy.js";
|
||||
|
||||
beforeEach(() => {
|
||||
resetMissingProviderGroupPolicyFallbackWarningsForTesting();
|
||||
});
|
||||
|
||||
describe("resolveRuntimeGroupPolicy", () => {
|
||||
it.each([
|
||||
{
|
||||
title: "fails closed when provider config is missing and no defaults are set",
|
||||
params: { providerConfigPresent: false },
|
||||
expectedPolicy: "allowlist",
|
||||
expectedFallbackApplied: true,
|
||||
},
|
||||
{
|
||||
title: "keeps configured fallback when provider config is present",
|
||||
params: { providerConfigPresent: true, configuredFallbackPolicy: "open" as const },
|
||||
expectedPolicy: "open",
|
||||
expectedFallbackApplied: false,
|
||||
},
|
||||
{
|
||||
title: "ignores global defaults when provider config is missing",
|
||||
params: {
|
||||
providerConfigPresent: false,
|
||||
defaultGroupPolicy: "disabled" as const,
|
||||
configuredFallbackPolicy: "open" as const,
|
||||
missingProviderFallbackPolicy: "allowlist" as const,
|
||||
},
|
||||
expectedPolicy: "allowlist",
|
||||
expectedFallbackApplied: true,
|
||||
},
|
||||
])("$title", ({ params, expectedPolicy, expectedFallbackApplied }) => {
|
||||
const resolved = resolveRuntimeGroupPolicy(params);
|
||||
expect(resolved.groupPolicy).toBe(expectedPolicy);
|
||||
expect(resolved.providerMissingFallbackApplied).toBe(expectedFallbackApplied);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveOpenProviderRuntimeGroupPolicy", () => {
|
||||
it("uses open fallback when provider config exists", () => {
|
||||
const resolved = resolveOpenProviderRuntimeGroupPolicy({
|
||||
providerConfigPresent: true,
|
||||
});
|
||||
expect(resolved.groupPolicy).toBe("open");
|
||||
expect(resolved.providerMissingFallbackApplied).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveAllowlistProviderRuntimeGroupPolicy", () => {
|
||||
it("uses allowlist fallback when provider config exists", () => {
|
||||
const resolved = resolveAllowlistProviderRuntimeGroupPolicy({
|
||||
providerConfigPresent: true,
|
||||
});
|
||||
expect(resolved.groupPolicy).toBe("allowlist");
|
||||
expect(resolved.providerMissingFallbackApplied).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveDefaultGroupPolicy", () => {
|
||||
it("returns channels.defaults.groupPolicy when present", () => {
|
||||
const resolved = resolveDefaultGroupPolicy({
|
||||
channels: { defaults: { groupPolicy: "disabled" } },
|
||||
});
|
||||
expect(resolved).toBe("disabled");
|
||||
});
|
||||
});
|
||||
|
||||
describe("warnMissingProviderGroupPolicyFallbackOnce", () => {
|
||||
it("logs only once per provider/account key", () => {
|
||||
const lines: string[] = [];
|
||||
const first = warnMissingProviderGroupPolicyFallbackOnce({
|
||||
providerMissingFallbackApplied: true,
|
||||
providerKey: "runtime-policy-test",
|
||||
accountId: "account-a",
|
||||
blockedLabel: GROUP_POLICY_BLOCKED_LABEL.room,
|
||||
log: (message) => lines.push(message),
|
||||
});
|
||||
const second = warnMissingProviderGroupPolicyFallbackOnce({
|
||||
providerMissingFallbackApplied: true,
|
||||
providerKey: "runtime-policy-test",
|
||||
accountId: "account-a",
|
||||
blockedLabel: GROUP_POLICY_BLOCKED_LABEL.room,
|
||||
log: (message) => lines.push(message),
|
||||
});
|
||||
|
||||
expect(first).toBe(true);
|
||||
expect(second).toBe(false);
|
||||
expect(lines).toHaveLength(1);
|
||||
expect(lines[0]).toContain("channels.runtime-policy-test is missing");
|
||||
expect(lines[0]).toContain("room messages blocked");
|
||||
});
|
||||
});
|
||||
118
src/config/runtime-group-policy.ts
Normal file
118
src/config/runtime-group-policy.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
import type { GroupPolicy } from "./types.base.js";
|
||||
|
||||
export type RuntimeGroupPolicyResolution = {
|
||||
groupPolicy: GroupPolicy;
|
||||
providerMissingFallbackApplied: boolean;
|
||||
};
|
||||
|
||||
export type RuntimeGroupPolicyParams = {
|
||||
providerConfigPresent: boolean;
|
||||
groupPolicy?: GroupPolicy;
|
||||
defaultGroupPolicy?: GroupPolicy;
|
||||
configuredFallbackPolicy?: GroupPolicy;
|
||||
missingProviderFallbackPolicy?: GroupPolicy;
|
||||
};
|
||||
|
||||
export function resolveRuntimeGroupPolicy(
|
||||
params: RuntimeGroupPolicyParams,
|
||||
): RuntimeGroupPolicyResolution {
|
||||
const configuredFallbackPolicy = params.configuredFallbackPolicy ?? "open";
|
||||
const missingProviderFallbackPolicy = params.missingProviderFallbackPolicy ?? "allowlist";
|
||||
const groupPolicy = params.providerConfigPresent
|
||||
? (params.groupPolicy ?? params.defaultGroupPolicy ?? configuredFallbackPolicy)
|
||||
: (params.groupPolicy ?? missingProviderFallbackPolicy);
|
||||
const providerMissingFallbackApplied =
|
||||
!params.providerConfigPresent && params.groupPolicy === undefined;
|
||||
return { groupPolicy, providerMissingFallbackApplied };
|
||||
}
|
||||
|
||||
export type ResolveProviderRuntimeGroupPolicyParams = {
|
||||
providerConfigPresent: boolean;
|
||||
groupPolicy?: GroupPolicy;
|
||||
defaultGroupPolicy?: GroupPolicy;
|
||||
};
|
||||
|
||||
export type GroupPolicyDefaultsConfig = {
|
||||
channels?: {
|
||||
defaults?: {
|
||||
groupPolicy?: GroupPolicy;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
export function resolveDefaultGroupPolicy(cfg: GroupPolicyDefaultsConfig): GroupPolicy | undefined {
|
||||
return cfg.channels?.defaults?.groupPolicy;
|
||||
}
|
||||
|
||||
export const GROUP_POLICY_BLOCKED_LABEL = {
|
||||
group: "group messages",
|
||||
guild: "guild messages",
|
||||
room: "room messages",
|
||||
channel: "channel messages",
|
||||
space: "space messages",
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Standard provider runtime policy:
|
||||
* - configured provider fallback: open
|
||||
* - missing provider fallback: allowlist (fail-closed)
|
||||
*/
|
||||
export function resolveOpenProviderRuntimeGroupPolicy(
|
||||
params: ResolveProviderRuntimeGroupPolicyParams,
|
||||
): RuntimeGroupPolicyResolution {
|
||||
return resolveRuntimeGroupPolicy({
|
||||
providerConfigPresent: params.providerConfigPresent,
|
||||
groupPolicy: params.groupPolicy,
|
||||
defaultGroupPolicy: params.defaultGroupPolicy,
|
||||
configuredFallbackPolicy: "open",
|
||||
missingProviderFallbackPolicy: "allowlist",
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Strict provider runtime policy:
|
||||
* - configured provider fallback: allowlist
|
||||
* - missing provider fallback: allowlist (fail-closed)
|
||||
*/
|
||||
export function resolveAllowlistProviderRuntimeGroupPolicy(
|
||||
params: ResolveProviderRuntimeGroupPolicyParams,
|
||||
): RuntimeGroupPolicyResolution {
|
||||
return resolveRuntimeGroupPolicy({
|
||||
providerConfigPresent: params.providerConfigPresent,
|
||||
groupPolicy: params.groupPolicy,
|
||||
defaultGroupPolicy: params.defaultGroupPolicy,
|
||||
configuredFallbackPolicy: "allowlist",
|
||||
missingProviderFallbackPolicy: "allowlist",
|
||||
});
|
||||
}
|
||||
|
||||
const warnedMissingProviderGroupPolicy = new Set<string>();
|
||||
|
||||
export function warnMissingProviderGroupPolicyFallbackOnce(params: {
|
||||
providerMissingFallbackApplied: boolean;
|
||||
providerKey: string;
|
||||
accountId?: string;
|
||||
blockedLabel?: string;
|
||||
log: (message: string) => void;
|
||||
}): boolean {
|
||||
if (!params.providerMissingFallbackApplied) {
|
||||
return false;
|
||||
}
|
||||
const key = `${params.providerKey}:${params.accountId ?? "*"}`;
|
||||
if (warnedMissingProviderGroupPolicy.has(key)) {
|
||||
return false;
|
||||
}
|
||||
warnedMissingProviderGroupPolicy.add(key);
|
||||
const blockedLabel = params.blockedLabel?.trim() || "group messages";
|
||||
params.log(
|
||||
`${params.providerKey}: channels.${params.providerKey} is missing; defaulting groupPolicy to "allowlist" (${blockedLabel} blocked until explicitly configured).`,
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Test helper. Keeps warning-cache state deterministic across test files.
|
||||
*/
|
||||
export function resetMissingProviderGroupPolicyFallbackWarningsForTesting(): void {
|
||||
warnedMissingProviderGroupPolicy.clear();
|
||||
}
|
||||
@@ -88,12 +88,14 @@ export const FIELD_HELP: Record<string, string> = {
|
||||
"Enable known poll tool no-progress loop detection (default: true).",
|
||||
"tools.loopDetection.detectors.pingPong": "Enable ping-pong loop detection (default: true).",
|
||||
"tools.exec.notifyOnExit":
|
||||
"When true (default), backgrounded exec sessions enqueue a system event and request a heartbeat on exit.",
|
||||
"When true (default), backgrounded exec sessions on exit and node exec lifecycle events enqueue a system event and request a heartbeat.",
|
||||
"tools.exec.notifyOnExitEmptySuccess":
|
||||
"When true, successful backgrounded exec exits with empty output still enqueue a completion system event (default: false).",
|
||||
"tools.exec.pathPrepend": "Directories to prepend to PATH for exec runs (gateway/sandbox).",
|
||||
"tools.exec.safeBins":
|
||||
"Allow stdin-only safe binaries to run without explicit allowlist entries.",
|
||||
"tools.exec.safeBinProfiles":
|
||||
"Optional per-binary safe-bin profiles (positional limits + allowed/denied flags).",
|
||||
"tools.fs.workspaceOnly":
|
||||
"Restrict filesystem tools (read/write/edit/apply_patch) to the workspace directory (default: false).",
|
||||
"tools.sessions.visibility":
|
||||
|
||||
@@ -92,6 +92,7 @@ export const FIELD_LABELS: Record<string, string> = {
|
||||
"tools.exec.node": "Exec Node Binding",
|
||||
"tools.exec.pathPrepend": "Exec PATH Prepend",
|
||||
"tools.exec.safeBins": "Exec Safe Bins",
|
||||
"tools.exec.safeBinProfiles": "Exec Safe Bin Profiles",
|
||||
"tools.message.allowCrossContextSend": "Allow Cross-Context Messaging",
|
||||
"tools.message.crossContext.allowWithinProvider": "Allow Cross-Context (Same Provider)",
|
||||
"tools.message.crossContext.allowAcrossProviders": "Allow Cross-Context (Across Providers)",
|
||||
|
||||
@@ -72,6 +72,7 @@ export type AgentsConfig = {
|
||||
|
||||
export type AgentBinding = {
|
||||
agentId: string;
|
||||
comment?: string;
|
||||
match: {
|
||||
channel: string;
|
||||
accountId?: string;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { ChatType } from "../channels/chat-type.js";
|
||||
import type { SafeBinProfileFixture } from "../infra/exec-safe-bin-policy.js";
|
||||
import type { AgentElevatedAllowFromConfig, SessionSendPolicyAction } from "./types.base.js";
|
||||
|
||||
export type MediaUnderstandingScopeMatch = {
|
||||
@@ -190,6 +191,8 @@ export type ExecToolConfig = {
|
||||
pathPrepend?: string[];
|
||||
/** Safe stdin-only binaries that can run without allowlist entries. */
|
||||
safeBins?: string[];
|
||||
/** Optional custom safe-bin profiles for entries in tools.exec.safeBins. */
|
||||
safeBinProfiles?: Record<string, SafeBinProfileFixture>;
|
||||
/** Default time (ms) before an exec command auto-backgrounds. */
|
||||
backgroundMs?: number;
|
||||
/** Default timeout (seconds) before auto-killing exec commands. */
|
||||
|
||||
@@ -337,6 +337,15 @@ const ToolExecApplyPatchSchema = z
|
||||
.strict()
|
||||
.optional();
|
||||
|
||||
const ToolExecSafeBinProfileSchema = z
|
||||
.object({
|
||||
minPositional: z.number().int().nonnegative().optional(),
|
||||
maxPositional: z.number().int().nonnegative().optional(),
|
||||
allowedValueFlags: z.array(z.string()).optional(),
|
||||
deniedFlags: z.array(z.string()).optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
const ToolExecBaseShape = {
|
||||
host: z.enum(["sandbox", "gateway", "node"]).optional(),
|
||||
security: z.enum(["deny", "allowlist", "full"]).optional(),
|
||||
@@ -344,6 +353,7 @@ const ToolExecBaseShape = {
|
||||
node: z.string().optional(),
|
||||
pathPrepend: z.array(z.string()).optional(),
|
||||
safeBins: z.array(z.string()).optional(),
|
||||
safeBinProfiles: z.record(z.string(), ToolExecSafeBinProfileSchema).optional(),
|
||||
backgroundMs: z.number().int().positive().optional(),
|
||||
timeoutSec: z.number().int().positive().optional(),
|
||||
cleanupMs: z.number().int().positive().optional(),
|
||||
|
||||
@@ -16,6 +16,7 @@ export const BindingsSchema = z
|
||||
z
|
||||
.object({
|
||||
agentId: z.string(),
|
||||
comment: z.string().optional(),
|
||||
match: z
|
||||
.object({
|
||||
channel: z.string(),
|
||||
|
||||
Reference in New Issue
Block a user