refactor(policy): split doctor evaluator modules (#106313)

* refactor(policy): split doctor evaluator modules

* refactor(policy): split runtime shape owners

* refactor(policy): split doctor policy domains
This commit is contained in:
Peter Steinberger
2026-07-13 09:35:11 -07:00
committed by GitHub
parent c87941e523
commit d8fb5b404e
35 changed files with 5970 additions and 5709 deletions
@@ -0,0 +1,129 @@
import type { HealthFinding } from "openclaw/plugin-sdk/health";
import { isRecord } from "openclaw/plugin-sdk/string-coerce-runtime";
import { CHECK_IDS } from "./check-ids.js";
import { SUPPORTED_AUTH_PROFILE_METADATA } from "./policy-constants.js";
import { isChannelDenyRule } from "./policy-runtime.js";
import { unsupportedPolicyKey } from "./shape-helpers.js";
import { ocPathSegment } from "./utils.js";
export function authProfileMetadataRequirementFindings(
policy: unknown,
policyPath: string,
policyDocName: string,
): readonly HealthFinding[] {
if (
!isRecord(policy) ||
!isRecord(policy.auth) ||
!isRecord(policy.auth.profiles) ||
policy.auth.profiles.requireMetadata === undefined
) {
return [];
}
if (!Array.isArray(policy.auth.profiles.requireMetadata)) {
return [
{
checkId: CHECK_IDS.policyInvalidFile,
severity: "error",
message: `${policyPath} auth.profiles.requireMetadata must be an array of metadata keys.`,
source: "policy",
path: policyPath,
target: `oc://${policyDocName}/auth/profiles/requireMetadata`,
fixHint: `Use supported metadata keys: ${SUPPORTED_AUTH_PROFILE_METADATA.join(", ")}.`,
},
];
}
const invalidIndex = policy.auth.profiles.requireMetadata.findIndex(
(entry) =>
typeof entry !== "string" ||
!SUPPORTED_AUTH_PROFILE_METADATA.includes(
entry.trim().toLowerCase() as (typeof SUPPORTED_AUTH_PROFILE_METADATA)[number],
),
);
if (invalidIndex < 0) {
return [];
}
return [
{
checkId: CHECK_IDS.policyInvalidFile,
severity: "error",
message: `${policyPath} auth.profiles.requireMetadata[${invalidIndex}] must be a supported metadata key.`,
source: "policy",
path: policyPath,
target: `oc://${policyDocName}/auth/profiles/requireMetadata/#${invalidIndex}`,
fixHint: `Use supported metadata keys: ${SUPPORTED_AUTH_PROFILE_METADATA.join(", ")}.`,
},
];
}
export function invalidChannelDenyRuleFindings(
policy: unknown,
policyPath: string,
policyDocName: string,
): readonly HealthFinding[] {
if (!isRecord(policy) || !isRecord(policy.channels) || policy.channels.denyRules === undefined) {
return [];
}
if (!Array.isArray(policy.channels.denyRules)) {
return [
{
checkId: CHECK_IDS.policyInvalidFile,
severity: "error",
message: `${policyPath} channels.denyRules must be an array.`,
source: "policy",
path: policyPath,
target: `oc://${policyDocName}/channels/denyRules`,
fixHint: `Fix ${policyPath} so channel deny rules are an array.`,
},
];
}
for (const [index, rule] of policy.channels.denyRules.entries()) {
if (!isRecord(rule)) {
continue;
}
const unsupportedRuleKey = unsupportedPolicyKey(rule, ["id", "reason", "when"]);
if (unsupportedRuleKey !== undefined) {
return [
{
checkId: CHECK_IDS.policyInvalidFile,
severity: "error",
message: `${policyPath} channels.denyRules[${index}].${unsupportedRuleKey} is not supported in channel deny rules.`,
source: "policy",
path: policyPath,
target: `oc://${policyDocName}/channels/denyRules/#${index}/${ocPathSegment(unsupportedRuleKey)}`,
fixHint: `Remove channels.denyRules[${index}].${unsupportedRuleKey} or use id, when.provider, and reason.`,
},
];
}
if (isRecord(rule.when)) {
const unsupportedWhenKey = unsupportedPolicyKey(rule.when, ["provider"]);
if (unsupportedWhenKey !== undefined) {
return [
{
checkId: CHECK_IDS.policyInvalidFile,
severity: "error",
message: `${policyPath} channels.denyRules[${index}].when.${unsupportedWhenKey} is not supported in channel deny rules.`,
source: "policy",
path: policyPath,
target: `oc://${policyDocName}/channels/denyRules/#${index}/when/${ocPathSegment(unsupportedWhenKey)}`,
fixHint: `Remove channels.denyRules[${index}].when.${unsupportedWhenKey} or use when.provider.`,
},
];
}
}
}
const invalid = policy.channels.denyRules.findIndex((rule) => !isChannelDenyRule(rule));
if (invalid < 0) {
return [];
}
return [
{
checkId: CHECK_IDS.policyInvalidFile,
severity: "error",
message: `${policyPath} channels.denyRules[${invalid}] must define when.provider as a string.`,
source: "policy",
path: policyPath,
target: `oc://${policyDocName}/channels/denyRules/#${invalid}`,
fixHint: `Fix ${policyPath} so each channel deny rule has a provider match.`,
},
];
}
@@ -0,0 +1,405 @@
import type { HealthFinding } from "openclaw/plugin-sdk/health";
import { isRecord } from "openclaw/plugin-sdk/string-coerce-runtime";
import {
agentWorkspacePolicyShapeFinding,
toolPosturePolicyShapeFinding,
} from "./agent-tool-shapes.js";
import { execApprovalAllowlistExpectedShapeFinding } from "./exec-approval-rules.js";
import {
SUPPORTED_DM_POLICIES,
SUPPORTED_DM_SCOPES,
SUPPORTED_EXEC_APPROVAL_SECURITY,
} from "./policy-constants.js";
import {
policyShapeFinding,
policyStringArrayPropertyShapeFinding,
unsupportedPolicyKey,
} from "./shape-helpers.js";
import { ocPathSegment } from "./utils.js";
export function ingressPolicyShapeFinding(
value: unknown,
params: {
readonly policyDocName: string;
readonly policyPath: string;
readonly targetPrefix?: string;
readonly propertyPrefix?: string;
readonly allowSession?: boolean;
},
): HealthFinding | undefined {
const targetPrefix = params.targetPrefix ?? "ingress";
const propertyPrefix = params.propertyPrefix ?? "ingress";
const allowSession = params.allowSession ?? true;
if (value === undefined) {
return undefined;
}
if (!isRecord(value)) {
return policyShapeFinding(
params.policyPath,
`oc://${params.policyDocName}/${targetPrefix}`,
`${params.policyPath} ${propertyPrefix} must be an object.`,
`Fix ${params.policyPath} so ${propertyPrefix} is an object.`,
);
}
if (!allowSession && value.session !== undefined) {
return policyShapeFinding(
params.policyPath,
`oc://${params.policyDocName}/${targetPrefix}/session`,
`${params.policyPath} ${propertyPrefix}.session is not supported by the channelIds selector.`,
`Move session ingress rules to top-level ingress; scoped ingress currently supports ingress.channels.*.`,
);
}
const unsupportedIngressKey = unsupportedPolicyKey(value, ["channels", "session"]);
if (unsupportedIngressKey !== undefined) {
return policyShapeFinding(
params.policyPath,
`oc://${params.policyDocName}/${targetPrefix}/${ocPathSegment(unsupportedIngressKey)}`,
`${params.policyPath} ${propertyPrefix}.${unsupportedIngressKey} is not supported in ingress policy.`,
`Remove ${propertyPrefix}.${unsupportedIngressKey} or use ingress.session or ingress.channels.`,
);
}
for (const section of ["session", "channels"] as const) {
if (value[section] !== undefined && !isRecord(value[section])) {
return policyShapeFinding(
params.policyPath,
`oc://${params.policyDocName}/${targetPrefix}/${section}`,
`${params.policyPath} ${propertyPrefix}.${section} must be an object.`,
`Fix ${params.policyPath} so ${propertyPrefix}.${section} is an object.`,
);
}
}
const session = isRecord(value.session) ? value.session : {};
const unsupportedSessionKey = unsupportedPolicyKey(session, ["requireDmScope"]);
if (unsupportedSessionKey !== undefined) {
return policyShapeFinding(
params.policyPath,
`oc://${params.policyDocName}/${targetPrefix}/session/${ocPathSegment(unsupportedSessionKey)}`,
`${params.policyPath} ${propertyPrefix}.session.${unsupportedSessionKey} is not supported in ingress policy.`,
`Remove ${propertyPrefix}.session.${unsupportedSessionKey} or use ${propertyPrefix}.session.requireDmScope.`,
);
}
if (
session.requireDmScope !== undefined &&
!SUPPORTED_DM_SCOPES.includes(session.requireDmScope as (typeof SUPPORTED_DM_SCOPES)[number])
) {
return policyShapeFinding(
params.policyPath,
`oc://${params.policyDocName}/${targetPrefix}/session/requireDmScope`,
`${params.policyPath} ${propertyPrefix}.session.requireDmScope must be a supported DM scope.`,
`Use supported DM scopes: ${SUPPORTED_DM_SCOPES.join(", ")}.`,
);
}
const channels = isRecord(value.channels) ? value.channels : {};
const unsupportedChannelsKey = unsupportedPolicyKey(channels, [
"allowDmPolicies",
"denyOpenGroups",
"requireMentionInGroups",
]);
if (unsupportedChannelsKey !== undefined) {
return policyShapeFinding(
params.policyPath,
`oc://${params.policyDocName}/${targetPrefix}/channels/${ocPathSegment(unsupportedChannelsKey)}`,
`${params.policyPath} ${propertyPrefix}.channels.${unsupportedChannelsKey} is not supported in ingress policy.`,
`Remove ${propertyPrefix}.channels.${unsupportedChannelsKey} or use a supported ingress channel policy rule.`,
);
}
const allowDmPoliciesFinding = policyStringArrayPropertyShapeFinding(channels.allowDmPolicies, {
allowed: SUPPORTED_DM_POLICIES,
policyDocName: params.policyDocName,
policyPath: params.policyPath,
property: `${propertyPrefix}.channels.allowDmPolicies`,
target: `${targetPrefix}/channels/allowDmPolicies`,
valueName: "DM policy",
});
if (allowDmPoliciesFinding !== undefined) {
return allowDmPoliciesFinding;
}
for (const key of ["denyOpenGroups", "requireMentionInGroups"] as const) {
if (channels[key] !== undefined && typeof channels[key] !== "boolean") {
return policyShapeFinding(
params.policyPath,
`oc://${params.policyDocName}/${targetPrefix}/channels/${key}`,
`${params.policyPath} ${propertyPrefix}.channels.${key} must be a boolean.`,
`Set ${propertyPrefix}.channels.${key} to true or false.`,
);
}
}
return undefined;
}
export function execApprovalsPolicyShapeFinding(
value: unknown,
params: {
readonly policyDocName: string;
readonly policyPath: string;
readonly targetPrefix?: string;
readonly propertyPrefix?: string;
readonly allowDefaults?: boolean;
},
): HealthFinding | undefined {
const targetPrefix = params.targetPrefix ?? "execApprovals";
const propertyPrefix = params.propertyPrefix ?? "execApprovals";
const allowDefaults = params.allowDefaults ?? true;
if (value === undefined) {
return undefined;
}
if (!isRecord(value)) {
return policyShapeFinding(
params.policyPath,
`oc://${params.policyDocName}/${targetPrefix}`,
`${params.policyPath} ${propertyPrefix} must be an object.`,
`Fix ${params.policyPath} so ${propertyPrefix} is an object.`,
);
}
const unsupportedTopLevel = unsupportedPolicyKey(
value,
allowDefaults ? ["agents", "defaults", "requireFile"] : ["agents"],
);
if (unsupportedTopLevel !== undefined) {
return policyShapeFinding(
params.policyPath,
`oc://${params.policyDocName}/${targetPrefix}/${ocPathSegment(unsupportedTopLevel)}`,
`${params.policyPath} ${propertyPrefix}.${unsupportedTopLevel} is not supported in exec approvals policy.`,
`Remove ${propertyPrefix}.${unsupportedTopLevel} or use a supported execApprovals rule.`,
);
}
if (value.requireFile !== undefined && typeof value.requireFile !== "boolean") {
return policyShapeFinding(
params.policyPath,
`oc://${params.policyDocName}/${targetPrefix}/requireFile`,
`${params.policyPath} ${propertyPrefix}.requireFile must be a boolean.`,
`Set execApprovals.requireFile to true or false.`,
);
}
for (const section of (allowDefaults ? ["defaults", "agents"] : ["agents"]) as readonly (
| "agents"
| "defaults"
)[]) {
if (value[section] !== undefined && !isRecord(value[section])) {
return policyShapeFinding(
params.policyPath,
`oc://${params.policyDocName}/${targetPrefix}/${section}`,
`${params.policyPath} ${propertyPrefix}.${section} must be an object.`,
`Fix ${params.policyPath} so ${propertyPrefix}.${section} is an object.`,
);
}
}
const defaults = allowDefaults && isRecord(value.defaults) ? value.defaults : {};
const unsupportedDefaultsKey = unsupportedPolicyKey(defaults, ["allowSecurity"]);
if (unsupportedDefaultsKey !== undefined) {
return policyShapeFinding(
params.policyPath,
`oc://${params.policyDocName}/${targetPrefix}/defaults/${ocPathSegment(unsupportedDefaultsKey)}`,
`${params.policyPath} ${propertyPrefix}.defaults.${unsupportedDefaultsKey} is not supported in exec approvals policy.`,
`Use execApprovals.defaults.allowSecurity or remove the unsupported rule.`,
);
}
const defaultsSecurityFinding = policyStringArrayPropertyShapeFinding(defaults.allowSecurity, {
allowed: SUPPORTED_EXEC_APPROVAL_SECURITY,
policyDocName: params.policyDocName,
policyPath: params.policyPath,
property: `${propertyPrefix}.defaults.allowSecurity`,
target: `${targetPrefix}/defaults/allowSecurity`,
valueName: "exec approval security mode",
});
if (defaultsSecurityFinding !== undefined) {
return defaultsSecurityFinding;
}
const agents = isRecord(value.agents) ? value.agents : {};
const unsupportedAgentsKey = unsupportedPolicyKey(agents, [
"allowAutoAllowSkills",
"allowSecurity",
"allowlist",
]);
if (unsupportedAgentsKey !== undefined) {
return policyShapeFinding(
params.policyPath,
`oc://${params.policyDocName}/${targetPrefix}/agents/${ocPathSegment(unsupportedAgentsKey)}`,
`${params.policyPath} ${propertyPrefix}.agents.${unsupportedAgentsKey} is not supported in exec approvals policy.`,
`Use execApprovals.agents.allowSecurity, execApprovals.agents.allowAutoAllowSkills, or execApprovals.agents.allowlist.expected.`,
);
}
const agentSecurityFinding = policyStringArrayPropertyShapeFinding(agents.allowSecurity, {
allowed: SUPPORTED_EXEC_APPROVAL_SECURITY,
policyDocName: params.policyDocName,
policyPath: params.policyPath,
property: `${propertyPrefix}.agents.allowSecurity`,
target: `${targetPrefix}/agents/allowSecurity`,
valueName: "exec approval security mode",
});
if (agentSecurityFinding !== undefined) {
return agentSecurityFinding;
}
if (
agents.allowAutoAllowSkills !== undefined &&
typeof agents.allowAutoAllowSkills !== "boolean"
) {
return policyShapeFinding(
params.policyPath,
`oc://${params.policyDocName}/${targetPrefix}/agents/allowAutoAllowSkills`,
`${params.policyPath} ${propertyPrefix}.agents.allowAutoAllowSkills must be a boolean.`,
`Set execApprovals.agents.allowAutoAllowSkills to true or false.`,
);
}
if (agents.allowlist !== undefined && !isRecord(agents.allowlist)) {
return policyShapeFinding(
params.policyPath,
`oc://${params.policyDocName}/${targetPrefix}/agents/allowlist`,
`${params.policyPath} ${propertyPrefix}.agents.allowlist must be an object.`,
`Fix ${params.policyPath} so ${propertyPrefix}.agents.allowlist is an object.`,
);
}
const allowlist = isRecord(agents.allowlist) ? agents.allowlist : {};
const unsupportedAllowlistKey = unsupportedPolicyKey(allowlist, ["expected"]);
if (unsupportedAllowlistKey !== undefined) {
return policyShapeFinding(
params.policyPath,
`oc://${params.policyDocName}/${targetPrefix}/agents/allowlist/${ocPathSegment(unsupportedAllowlistKey)}`,
`${params.policyPath} ${propertyPrefix}.agents.allowlist.${unsupportedAllowlistKey} is not supported in exec approvals policy.`,
`Use execApprovals.agents.allowlist.expected or remove the unsupported rule.`,
);
}
return execApprovalAllowlistExpectedShapeFinding(allowlist.expected, {
policyDocName: params.policyDocName,
policyPath: params.policyPath,
property: `${propertyPrefix}.agents.allowlist.expected`,
target: `${targetPrefix}/agents/allowlist/expected`,
});
}
export function agentsPolicyShapeFinding(
value: unknown,
params: {
readonly policyDocName: string;
readonly policyPath: string;
},
): HealthFinding | undefined {
if (value === undefined) {
return undefined;
}
if (!isRecord(value)) {
return policyShapeFinding(
params.policyPath,
`oc://${params.policyDocName}/agents`,
`${params.policyPath} agents must be an object.`,
`Fix ${params.policyPath} so agents is an object.`,
);
}
const unsupportedAgentsKey = unsupportedPolicyKey(value, ["workspace"]);
if (unsupportedAgentsKey !== undefined) {
return policyShapeFinding(
params.policyPath,
`oc://${params.policyDocName}/agents/${ocPathSegment(unsupportedAgentsKey)}`,
`${params.policyPath} agents.${unsupportedAgentsKey} is not supported in agents policy.`,
`Remove agents.${unsupportedAgentsKey} or use agents.workspace.`,
);
}
const workspaceFinding = agentWorkspacePolicyShapeFinding(value.workspace, {
policyDocName: params.policyDocName,
policyPath: params.policyPath,
targetPrefix: "agents/workspace",
propertyPrefix: "agents.workspace",
});
if (workspaceFinding !== undefined) {
return workspaceFinding;
}
return undefined;
}
export function scopedDataHandlingPolicyShapeFinding(
dataHandling: Record<string, unknown>,
params: {
readonly policyPath: string;
readonly policyDocName: string;
readonly targetPrefix: string;
readonly scopeName: string;
},
): HealthFinding | undefined {
const unsupportedKey = Object.keys(dataHandling).find((key) => key !== "memory");
if (unsupportedKey !== undefined) {
return policyShapeFinding(
params.policyPath,
`oc://${params.policyDocName}/${params.targetPrefix}/dataHandling/${ocPathSegment(unsupportedKey)}`,
`${params.policyPath} scopes.${params.scopeName}.dataHandling.${unsupportedKey} is not a supported scoped policy section.`,
`Move global data-handling rules to top-level dataHandling, or use dataHandling.memory with agentIds.`,
);
}
if (dataHandling.memory !== undefined && !isRecord(dataHandling.memory)) {
return policyShapeFinding(
params.policyPath,
`oc://${params.policyDocName}/${params.targetPrefix}/dataHandling/memory`,
`${params.policyPath} scopes.${params.scopeName}.dataHandling.memory must be an object.`,
`Fix ${params.policyPath} so the scoped dataHandling.memory policy section is an object.`,
);
}
if (!isRecord(dataHandling.memory)) {
return undefined;
}
const unsupportedMemoryKey = Object.keys(dataHandling.memory).find(
(key) => key !== "denySessionTranscriptIndexing",
);
if (unsupportedMemoryKey !== undefined) {
return policyShapeFinding(
params.policyPath,
`oc://${params.policyDocName}/${params.targetPrefix}/dataHandling/memory/${ocPathSegment(unsupportedMemoryKey)}`,
`${params.policyPath} scopes.${params.scopeName}.dataHandling.memory.${unsupportedMemoryKey} is not a supported scoped policy rule.`,
`Use dataHandling.memory.denySessionTranscriptIndexing or remove the unsupported rule.`,
);
}
if (
dataHandling.memory.denySessionTranscriptIndexing !== undefined &&
typeof dataHandling.memory.denySessionTranscriptIndexing !== "boolean"
) {
return policyShapeFinding(
params.policyPath,
`oc://${params.policyDocName}/${params.targetPrefix}/dataHandling/memory/denySessionTranscriptIndexing`,
`${params.policyPath} scopes.${params.scopeName}.dataHandling.memory.denySessionTranscriptIndexing must be a boolean.`,
`Set dataHandling.memory.denySessionTranscriptIndexing to true or false.`,
);
}
return undefined;
}
export function scopedToolsPolicyShapeFinding(
value: Record<string, unknown>,
params: {
readonly policyDocName: string;
readonly policyPath: string;
readonly targetPrefix: string;
readonly propertyPrefix: string;
},
): HealthFinding | undefined {
const allowedTopLevel = new Set(["profiles", "fs", "exec", "elevated", "alsoAllow", "denyTools"]);
const unsupportedTopLevel = Object.keys(value).find((key) => !allowedTopLevel.has(key));
if (unsupportedTopLevel !== undefined) {
return policyShapeFinding(
params.policyPath,
`oc://${params.policyDocName}/${params.targetPrefix}/${ocPathSegment(unsupportedTopLevel)}`,
`${params.policyPath} ${params.propertyPrefix}.${unsupportedTopLevel} is not supported in agent-scoped tools policy.`,
`Move ${params.propertyPrefix}.${unsupportedTopLevel} to top-level tools or use a supported scoped tools posture rule.`,
);
}
for (const [section, allowedKeys] of [
["profiles", ["allow"]],
["fs", ["requireWorkspaceOnly"]],
["exec", ["allowSecurity", "requireAsk", "allowHosts"]],
["elevated", ["allow"]],
["alsoAllow", ["expected"]],
] as const) {
const sectionValue = value[section];
if (!isRecord(sectionValue)) {
continue;
}
const allowed = new Set<string>(allowedKeys);
const unsupportedKey = Object.keys(sectionValue).find((key) => !allowed.has(key));
if (unsupportedKey !== undefined) {
return policyShapeFinding(
params.policyPath,
`oc://${params.policyDocName}/${params.targetPrefix}/${section}/${ocPathSegment(unsupportedKey)}`,
`${params.policyPath} ${params.propertyPrefix}.${section}.${unsupportedKey} is not supported in agent-scoped tools policy.`,
`Move ${params.propertyPrefix}.${section}.${unsupportedKey} to top-level tools or use a supported scoped tools posture rule.`,
);
}
}
return toolPosturePolicyShapeFinding(value, params);
}
@@ -0,0 +1,259 @@
import type { HealthFinding } from "openclaw/plugin-sdk/health";
import { isRecord } from "openclaw/plugin-sdk/string-coerce-runtime";
import {
SUPPORTED_AGENT_WORKSPACE_DENY_TOOLS,
SUPPORTED_TOOL_EXEC_ASK,
SUPPORTED_TOOL_EXEC_HOST,
SUPPORTED_TOOL_EXEC_SECURITY,
SUPPORTED_TOOL_PROFILES,
} from "./policy-constants.js";
import {
policyShapeFinding,
policyStringArrayPropertyShapeFinding,
unsupportedPolicyKey,
} from "./shape-helpers.js";
import { ocPathSegment } from "./utils.js";
export function agentWorkspacePolicyShapeFinding(
value: unknown,
params: {
readonly policyDocName: string;
readonly policyPath: string;
readonly targetPrefix: string;
readonly propertyPrefix: string;
},
): HealthFinding | undefined {
if (value === undefined) {
return undefined;
}
if (!isRecord(value)) {
return policyShapeFinding(
params.policyPath,
`oc://${params.policyDocName}/${params.targetPrefix}`,
`${params.policyPath} ${params.propertyPrefix} must be an object.`,
`Fix ${params.policyPath} so ${params.propertyPrefix} is an object.`,
);
}
const unsupportedWorkspaceKey = unsupportedPolicyKey(value, ["allowedAccess", "denyTools"]);
if (unsupportedWorkspaceKey !== undefined) {
return policyShapeFinding(
params.policyPath,
`oc://${params.policyDocName}/${params.targetPrefix}/${ocPathSegment(unsupportedWorkspaceKey)}`,
`${params.policyPath} ${params.propertyPrefix}.${unsupportedWorkspaceKey} is not supported in agent workspace policy.`,
`Remove ${params.propertyPrefix}.${unsupportedWorkspaceKey} or use a supported agent workspace policy rule.`,
);
}
const allowedAccess = value.allowedAccess;
if (allowedAccess !== undefined && !Array.isArray(allowedAccess)) {
return policyShapeFinding(
params.policyPath,
`oc://${params.policyDocName}/${params.targetPrefix}/allowedAccess`,
`${params.policyPath} ${params.propertyPrefix}.allowedAccess must be an array.`,
'Use workspace access values such as ["none", "ro"].',
);
}
if (Array.isArray(allowedAccess)) {
const invalidIndex = allowedAccess.findIndex(
(entry) => entry !== "none" && entry !== "ro" && entry !== "rw",
);
if (invalidIndex >= 0) {
return policyShapeFinding(
params.policyPath,
`oc://${params.policyDocName}/${params.targetPrefix}/allowedAccess/#${invalidIndex}`,
`${params.policyPath} ${params.propertyPrefix}.allowedAccess[${invalidIndex}] must be none, ro, or rw.`,
'Use workspace access values such as ["none", "ro"].',
);
}
}
const denyTools = value.denyTools;
if (denyTools !== undefined && !Array.isArray(denyTools)) {
return policyShapeFinding(
params.policyPath,
`oc://${params.policyDocName}/${params.targetPrefix}/denyTools`,
`${params.policyPath} ${params.propertyPrefix}.denyTools must be an array.`,
'Use tool ids such as ["exec", "process", "write", "edit", "apply_patch"].',
);
}
if (Array.isArray(denyTools)) {
const invalidIndex = denyTools.findIndex(
(entry) =>
typeof entry !== "string" ||
!SUPPORTED_AGENT_WORKSPACE_DENY_TOOLS.includes(
entry.trim() as (typeof SUPPORTED_AGENT_WORKSPACE_DENY_TOOLS)[number],
),
);
if (invalidIndex >= 0) {
return policyShapeFinding(
params.policyPath,
`oc://${params.policyDocName}/${params.targetPrefix}/denyTools/#${invalidIndex}`,
`${params.policyPath} ${params.propertyPrefix}.denyTools[${invalidIndex}] must be a supported agent workspace tool id.`,
`Use supported tool ids: ${SUPPORTED_AGENT_WORKSPACE_DENY_TOOLS.join(", ")}.`,
);
}
}
return undefined;
}
export function toolPosturePolicyShapeFinding(
tools: Record<string, unknown>,
params: {
readonly policyDocName: string;
readonly policyPath: string;
readonly targetPrefix?: string;
readonly propertyPrefix?: string;
},
): HealthFinding | undefined {
const targetPrefix = params.targetPrefix ?? "tools";
const propertyPrefix = params.propertyPrefix ?? "tools";
const allowedTopLevel = [
"alsoAllow",
"denyTools",
"elevated",
"exec",
"fs",
"profiles",
"requireMetadata",
];
const unsupportedTopLevel = unsupportedPolicyKey(tools, allowedTopLevel);
if (unsupportedTopLevel !== undefined) {
return policyShapeFinding(
params.policyPath,
`oc://${params.policyDocName}/${targetPrefix}/${ocPathSegment(unsupportedTopLevel)}`,
`${params.policyPath} ${propertyPrefix}.${unsupportedTopLevel} is not supported in tools policy.`,
`Remove ${propertyPrefix}.${unsupportedTopLevel} or use a supported tools policy rule.`,
);
}
for (const section of ["profiles", "fs", "exec", "elevated", "alsoAllow"] as const) {
if (tools[section] !== undefined && !isRecord(tools[section])) {
return policyShapeFinding(
params.policyPath,
`oc://${params.policyDocName}/${targetPrefix}/${section}`,
`${params.policyPath} ${propertyPrefix}.${section} must be an object.`,
`Fix ${params.policyPath} so ${propertyPrefix}.${section} is an object.`,
);
}
}
const profiles = isRecord(tools.profiles) ? tools.profiles : {};
const unsupportedProfileKey = unsupportedPolicyKey(profiles, ["allow"]);
if (unsupportedProfileKey !== undefined) {
return policyShapeFinding(
params.policyPath,
`oc://${params.policyDocName}/${targetPrefix}/profiles/${ocPathSegment(unsupportedProfileKey)}`,
`${params.policyPath} ${propertyPrefix}.profiles.${unsupportedProfileKey} is not supported in tools policy.`,
`Remove ${propertyPrefix}.profiles.${unsupportedProfileKey} or use ${propertyPrefix}.profiles.allow.`,
);
}
const profileAllowFinding = policyStringArrayPropertyShapeFinding(profiles.allow, {
allowed: SUPPORTED_TOOL_PROFILES,
policyDocName: params.policyDocName,
policyPath: params.policyPath,
property: `${propertyPrefix}.profiles.allow`,
target: `${targetPrefix}/profiles/allow`,
valueName: "tool profile id",
});
if (profileAllowFinding !== undefined) {
return profileAllowFinding;
}
const fs = isRecord(tools.fs) ? tools.fs : {};
const unsupportedFsKey = unsupportedPolicyKey(fs, ["requireWorkspaceOnly"]);
if (unsupportedFsKey !== undefined) {
return policyShapeFinding(
params.policyPath,
`oc://${params.policyDocName}/${targetPrefix}/fs/${ocPathSegment(unsupportedFsKey)}`,
`${params.policyPath} ${propertyPrefix}.fs.${unsupportedFsKey} is not supported in tools policy.`,
`Remove ${propertyPrefix}.fs.${unsupportedFsKey} or use ${propertyPrefix}.fs.requireWorkspaceOnly.`,
);
}
if (fs.requireWorkspaceOnly !== undefined && typeof fs.requireWorkspaceOnly !== "boolean") {
return policyShapeFinding(
params.policyPath,
`oc://${params.policyDocName}/${targetPrefix}/fs/requireWorkspaceOnly`,
`${params.policyPath} ${propertyPrefix}.fs.requireWorkspaceOnly must be a boolean.`,
`Set ${propertyPrefix}.fs.requireWorkspaceOnly to true or false.`,
);
}
const exec = isRecord(tools.exec) ? tools.exec : {};
const unsupportedExecKey = unsupportedPolicyKey(exec, [
"allowHosts",
"allowSecurity",
"requireAsk",
]);
if (unsupportedExecKey !== undefined) {
return policyShapeFinding(
params.policyPath,
`oc://${params.policyDocName}/${targetPrefix}/exec/${ocPathSegment(unsupportedExecKey)}`,
`${params.policyPath} ${propertyPrefix}.exec.${unsupportedExecKey} is not supported in tools policy.`,
`Remove ${propertyPrefix}.exec.${unsupportedExecKey} or use a supported tools exec policy rule.`,
);
}
const execLists = [
["allowSecurity", SUPPORTED_TOOL_EXEC_SECURITY, "exec security mode"],
["requireAsk", SUPPORTED_TOOL_EXEC_ASK, "exec ask mode"],
["allowHosts", SUPPORTED_TOOL_EXEC_HOST, "exec host"],
] as const;
for (const [key, supported, valueName] of execLists) {
const finding = policyStringArrayPropertyShapeFinding(exec[key], {
allowed: supported,
policyDocName: params.policyDocName,
policyPath: params.policyPath,
property: `${propertyPrefix}.exec.${key}`,
target: `${targetPrefix}/exec/${key}`,
valueName,
});
if (finding !== undefined) {
return finding;
}
}
const elevated = isRecord(tools.elevated) ? tools.elevated : {};
const unsupportedElevatedKey = unsupportedPolicyKey(elevated, ["allow"]);
if (unsupportedElevatedKey !== undefined) {
return policyShapeFinding(
params.policyPath,
`oc://${params.policyDocName}/${targetPrefix}/elevated/${ocPathSegment(unsupportedElevatedKey)}`,
`${params.policyPath} ${propertyPrefix}.elevated.${unsupportedElevatedKey} is not supported in tools policy.`,
`Remove ${propertyPrefix}.elevated.${unsupportedElevatedKey} or use ${propertyPrefix}.elevated.allow.`,
);
}
if (elevated.allow !== undefined && typeof elevated.allow !== "boolean") {
return policyShapeFinding(
params.policyPath,
`oc://${params.policyDocName}/${targetPrefix}/elevated/allow`,
`${params.policyPath} ${propertyPrefix}.elevated.allow must be a boolean.`,
`Set ${propertyPrefix}.elevated.allow to true or false.`,
);
}
const alsoAllow = isRecord(tools.alsoAllow) ? tools.alsoAllow : {};
const unsupportedAlsoAllowKey = unsupportedPolicyKey(alsoAllow, ["expected"]);
if (unsupportedAlsoAllowKey !== undefined) {
return policyShapeFinding(
params.policyPath,
`oc://${params.policyDocName}/${targetPrefix}/alsoAllow/${ocPathSegment(unsupportedAlsoAllowKey)}`,
`${params.policyPath} ${propertyPrefix}.alsoAllow.${unsupportedAlsoAllowKey} is not supported in tools policy.`,
`Remove ${propertyPrefix}.alsoAllow.${unsupportedAlsoAllowKey} or use ${propertyPrefix}.alsoAllow.expected.`,
);
}
const alsoAllowExpectedFinding = policyStringArrayPropertyShapeFinding(alsoAllow.expected, {
policyDocName: params.policyDocName,
policyPath: params.policyPath,
property: `${propertyPrefix}.alsoAllow.expected`,
target: `${targetPrefix}/alsoAllow/expected`,
valueName: "tool id",
});
if (alsoAllowExpectedFinding !== undefined) {
return alsoAllowExpectedFinding;
}
const denyToolsFinding = policyStringArrayPropertyShapeFinding(tools.denyTools, {
policyDocName: params.policyDocName,
policyPath: params.policyPath,
property: `${propertyPrefix}.denyTools`,
target: `${targetPrefix}/denyTools`,
valueName: "tool id or group",
});
return denyToolsFinding;
}
@@ -0,0 +1,160 @@
import type { HealthFinding } from "openclaw/plugin-sdk/health";
import { isRecord } from "openclaw/plugin-sdk/string-coerce-runtime";
import type { PolicyAgentWorkspaceEvidence, PolicyEvidence } from "../policy-state.js";
import { agentsPolicyShapeFinding } from "./access-shapes.js";
import { CHECK_IDS } from "./check-ids.js";
import { agentScopedPolicyTargets, scopedWorkspaceAgentMatches } from "./policy-scope.js";
import { hasValidScopedPolicy } from "./scoped-policy-shape.js";
import { ocPathSegment, readStringList } from "./utils.js";
export function agentWorkspaceFindings(
policy: unknown,
policyPath: string,
policyDocName: string,
evidence: PolicyEvidence,
): readonly HealthFinding[] {
if (
agentsPolicyShapeFinding(isRecord(policy) ? policy.agents : undefined, {
policyDocName,
policyPath,
}) !== undefined
) {
return [];
}
return [
...agentWorkspaceAccessFindings(
policy,
["agents", "workspace", "allowedAccess"],
policyDocName,
"agents/workspace/allowedAccess",
evidence,
() => true,
),
...agentWorkspaceToolDenyFindings(
policy,
["agents", "workspace", "denyTools"],
policyDocName,
"agents/workspace/denyTools",
evidence,
() => true,
),
...agentScopedWorkspaceFindings(policy, policyPath, policyDocName, evidence),
];
}
function agentWorkspaceAccessFindings(
policy: unknown,
policyPath: readonly string[],
policyDocName: string,
requirementPath: string,
evidence: PolicyEvidence,
evidenceFilter: (entry: PolicyAgentWorkspaceEvidence) => boolean,
): readonly HealthFinding[] {
const allowed = new Set(readStringList(policy, policyPath));
if (allowed.size === 0) {
return [];
}
return (evidence.agentWorkspace ?? [])
.filter(evidenceFilter)
.filter(
(entry) =>
entry.kind === "workspaceAccess" &&
entry.value !== undefined &&
(entry.sandboxEnabled !== true || !allowed.has(entry.value)),
)
.map((entry): HealthFinding => {
const label = entry.agentId === undefined ? "agents.defaults" : `agent '${entry.agentId}'`;
const sandboxDisabled = entry.sandboxEnabled !== true;
const observed = sandboxDisabled
? `sandbox mode '${entry.sandboxMode ?? "off"}'`
: `sandbox workspaceAccess '${entry.value ?? ""}'`;
const ocPath = sandboxDisabled ? (entry.sandboxModeSource ?? entry.source) : entry.source;
return {
checkId: CHECK_IDS.policyAgentsWorkspaceAccessDenied,
severity: "error",
message: `${label} ${observed} is not allowed by policy.`,
source: "policy",
path: "openclaw config",
ocPath,
target: ocPath,
requirement: `oc://${policyDocName}/${requirementPath}`,
fixHint: "Enable sandbox mode with workspaceAccess none/ro or update policy after review.",
};
});
}
function agentWorkspaceToolDenyFindings(
policy: unknown,
policyPath: readonly string[],
policyDocName: string,
requirementPath: string,
evidence: PolicyEvidence,
evidenceFilter: (entry: PolicyAgentWorkspaceEvidence) => boolean,
): readonly HealthFinding[] {
const requiredDeniedTools = new Set(readStringList(policy, policyPath));
if (requiredDeniedTools.size === 0) {
return [];
}
return (evidence.agentWorkspace ?? [])
.filter(evidenceFilter)
.filter(
(entry) =>
entry.kind === "toolDeny" &&
entry.tool !== undefined &&
requiredDeniedTools.has(entry.tool) &&
entry.denied !== true,
)
.map((entry): HealthFinding => {
const label = entry.agentId === undefined ? "agents.defaults" : `agent '${entry.agentId}'`;
return {
checkId: CHECK_IDS.policyAgentsToolNotDenied,
severity: "error",
message: `${label} does not deny required tool '${entry.tool ?? ""}'.`,
source: "policy",
path: "openclaw config",
ocPath: entry.source,
target: entry.source,
requirement: `oc://${policyDocName}/${requirementPath}`,
fixHint:
"Add the tool to tools.deny or agents.list[].tools.deny, or update policy after review.",
};
});
}
function agentScopedWorkspaceFindings(
policy: unknown,
policyPath: string,
policyDocName: string,
evidence: PolicyEvidence,
): readonly HealthFinding[] {
if (!hasValidScopedPolicy(policy, policyPath, policyDocName)) {
return [];
}
const findings: HealthFinding[] = [];
for (const target of agentScopedPolicyTargets(policy)) {
const scopedAgents = isRecord(target.overlay.agents) ? target.overlay.agents : {};
const workspace = isRecord(scopedAgents.workspace) ? scopedAgents.workspace : {};
const requirementBase = `scopes/${ocPathSegment(target.scopeName)}/agents/workspace`;
const evidenceFilter = (entry: PolicyAgentWorkspaceEvidence) =>
scopedWorkspaceAgentMatches(entry, target.agentId, evidence.agentWorkspace ?? []);
findings.push(
...agentWorkspaceAccessFindings(
{ workspace },
["workspace", "allowedAccess"],
policyDocName,
`${requirementBase}/allowedAccess`,
evidence,
evidenceFilter,
),
...agentWorkspaceToolDenyFindings(
{ workspace },
["workspace", "denyTools"],
policyDocName,
`${requirementBase}/denyTools`,
evidence,
evidenceFilter,
),
);
}
return findings;
}
@@ -5,8 +5,8 @@ import type {
HealthRepairResult,
OpenClawConfig,
} from "openclaw/plugin-sdk/health";
import { CHECK_IDS, type POLICY_CHECK_IDS } from "./check-ids.js";
import { POLICY_FIX_METADATA_BY_CHECK_ID } from "./fix-metadata.js";
import { CHECK_IDS, type POLICY_CHECK_IDS } from "./metadata.js";
type PolicyCheckId = (typeof POLICY_CHECK_IDS)[number];
type ConfigRecord = Record<string, unknown>;
+136
View File
@@ -0,0 +1,136 @@
// Policy doctor check IDs.
export const CHECK_IDS = {
policyAttestationMismatch: "policy/attestation-hash-mismatch",
policyDeniedChannelProvider: "policy/channels-denied-provider",
policyHashMismatch: "policy/policy-hash-mismatch",
policyInvalidFile: "policy/policy-jsonc-invalid",
policyMissingFile: "policy/policy-jsonc-missing",
policyDeniedMcpServer: "policy/mcp-denied-server",
policyUnapprovedMcpServer: "policy/mcp-unapproved-server",
policyDeniedModelProvider: "policy/models-denied-provider",
policyUnapprovedModelProvider: "policy/models-unapproved-provider",
policyPrivateNetworkAccess: "policy/network-private-access-enabled",
policyIngressDmPolicyUnapproved: "policy/ingress-dm-policy-unapproved",
policyIngressDmScopeUnapproved: "policy/ingress-dm-scope-unapproved",
policyIngressOpenGroupsDenied: "policy/ingress-open-groups-denied",
policyIngressGroupMentionRequired: "policy/ingress-group-mention-required",
policyGatewayNonLoopbackBind: "policy/gateway-non-loopback-bind",
policyGatewayAuthDisabled: "policy/gateway-auth-disabled",
policyGatewayRateLimitMissing: "policy/gateway-rate-limit-missing",
policyGatewayControlUiInsecure: "policy/gateway-control-ui-insecure",
policyGatewayTailscaleFunnel: "policy/gateway-tailscale-funnel",
policyGatewayRemoteEnabled: "policy/gateway-remote-enabled",
policyGatewayHttpEndpointEnabled: "policy/gateway-http-endpoint-enabled",
policyGatewayHttpUrlFetchUnrestricted: "policy/gateway-http-url-fetch-unrestricted",
policyGatewayNodeCommandDenied: "policy/gateway-node-command-denied",
policyAgentsWorkspaceAccessDenied: "policy/agents-workspace-access-denied",
policyAgentsToolNotDenied: "policy/agents-tool-not-denied",
policyToolsElevatedEnabled: "policy/tools-elevated-enabled",
policyToolsAlsoAllowMissing: "policy/tools-also-allow-missing",
policyToolsAlsoAllowUnexpected: "policy/tools-also-allow-unexpected",
policyToolsExecAskUnapproved: "policy/tools-exec-ask-unapproved",
policyToolsExecHostUnapproved: "policy/tools-exec-host-unapproved",
policyToolsExecSecurityUnapproved: "policy/tools-exec-security-unapproved",
policyToolsFsWorkspaceOnlyRequired: "policy/tools-fs-workspace-only-required",
policyToolsProfileUnapproved: "policy/tools-profile-unapproved",
policyToolsRequiredDenyMissing: "policy/tools-required-deny-missing",
policySandboxModeUnapproved: "policy/sandbox-mode-unapproved",
policySandboxBackendUnapproved: "policy/sandbox-backend-unapproved",
policySandboxContainerPostureUnobservable: "policy/sandbox-container-posture-unobservable",
policySandboxContainerHostNetworkDenied: "policy/sandbox-container-host-network-denied",
policySandboxContainerNamespaceJoinDenied: "policy/sandbox-container-namespace-join-denied",
policySandboxContainerMountModeRequired: "policy/sandbox-container-mount-mode-required",
policySandboxContainerRuntimeSocketMount: "policy/sandbox-container-runtime-socket-mount",
policySandboxContainerUnconfinedProfile: "policy/sandbox-container-unconfined-profile",
policySandboxBrowserCdpSourceRangeMissing: "policy/sandbox-browser-cdp-source-range-missing",
policyDataHandlingRedactionDisabled: "policy/data-handling-redaction-disabled",
policyDataHandlingTelemetryContentCapture: "policy/data-handling-telemetry-content-capture",
policyDataHandlingSessionRetentionNotEnforced:
"policy/data-handling-session-retention-not-enforced",
policyDataHandlingSessionTranscriptMemory:
"policy/data-handling-session-transcript-memory-enabled",
policySecretsUnmanagedProvider: "policy/secrets-unmanaged-provider",
policySecretsDeniedProviderSource: "policy/secrets-denied-provider-source",
policySecretsInsecureProvider: "policy/secrets-insecure-provider",
policyAuthProfileInvalidMetadata: "policy/auth-profile-invalid-metadata",
policyAuthProfileUnapprovedMode: "policy/auth-profile-unapproved-mode",
policyExecApprovalsMissing: "policy/exec-approvals-missing",
policyExecApprovalsInvalid: "policy/exec-approvals-invalid",
policyExecApprovalsDefaultSecurityUnapproved: "policy/exec-approvals-default-security-unapproved",
policyExecApprovalsAgentSecurityUnapproved: "policy/exec-approvals-agent-security-unapproved",
policyExecApprovalsAutoAllowSkillsEnabled: "policy/exec-approvals-auto-allow-skills-enabled",
policyExecApprovalsAllowlistMissing: "policy/exec-approvals-allowlist-missing",
policyExecApprovalsAllowlistUnexpected: "policy/exec-approvals-allowlist-unexpected",
policyMissingToolOwner: "policy/tools-missing-owner",
policyMissingToolRisk: "policy/tools-missing-risk-level",
policyMissingToolSensitivity: "policy/tools-missing-sensitivity-token",
policyUnknownToolRisk: "policy/tools-unknown-risk-level",
policyUnknownToolSensitivity: "policy/tools-unknown-sensitivity-token",
} as const;
export const POLICY_CHECK_IDS = [
CHECK_IDS.policyMissingFile,
CHECK_IDS.policyInvalidFile,
CHECK_IDS.policyHashMismatch,
CHECK_IDS.policyAttestationMismatch,
CHECK_IDS.policyDeniedChannelProvider,
CHECK_IDS.policyDeniedMcpServer,
CHECK_IDS.policyUnapprovedMcpServer,
CHECK_IDS.policyDeniedModelProvider,
CHECK_IDS.policyUnapprovedModelProvider,
CHECK_IDS.policyPrivateNetworkAccess,
CHECK_IDS.policyIngressDmPolicyUnapproved,
CHECK_IDS.policyIngressDmScopeUnapproved,
CHECK_IDS.policyIngressOpenGroupsDenied,
CHECK_IDS.policyIngressGroupMentionRequired,
CHECK_IDS.policyGatewayNonLoopbackBind,
CHECK_IDS.policyGatewayAuthDisabled,
CHECK_IDS.policyGatewayRateLimitMissing,
CHECK_IDS.policyGatewayControlUiInsecure,
CHECK_IDS.policyGatewayTailscaleFunnel,
CHECK_IDS.policyGatewayRemoteEnabled,
CHECK_IDS.policyGatewayHttpEndpointEnabled,
CHECK_IDS.policyGatewayHttpUrlFetchUnrestricted,
CHECK_IDS.policyGatewayNodeCommandDenied,
CHECK_IDS.policyAgentsWorkspaceAccessDenied,
CHECK_IDS.policyAgentsToolNotDenied,
CHECK_IDS.policyToolsProfileUnapproved,
CHECK_IDS.policyToolsFsWorkspaceOnlyRequired,
CHECK_IDS.policyToolsExecSecurityUnapproved,
CHECK_IDS.policyToolsExecAskUnapproved,
CHECK_IDS.policyToolsExecHostUnapproved,
CHECK_IDS.policyToolsElevatedEnabled,
CHECK_IDS.policyToolsAlsoAllowMissing,
CHECK_IDS.policyToolsAlsoAllowUnexpected,
CHECK_IDS.policyToolsRequiredDenyMissing,
CHECK_IDS.policySandboxModeUnapproved,
CHECK_IDS.policySandboxBackendUnapproved,
CHECK_IDS.policySandboxContainerPostureUnobservable,
CHECK_IDS.policySandboxContainerHostNetworkDenied,
CHECK_IDS.policySandboxContainerNamespaceJoinDenied,
CHECK_IDS.policySandboxContainerMountModeRequired,
CHECK_IDS.policySandboxContainerRuntimeSocketMount,
CHECK_IDS.policySandboxContainerUnconfinedProfile,
CHECK_IDS.policySandboxBrowserCdpSourceRangeMissing,
CHECK_IDS.policyDataHandlingRedactionDisabled,
CHECK_IDS.policyDataHandlingTelemetryContentCapture,
CHECK_IDS.policyDataHandlingSessionRetentionNotEnforced,
CHECK_IDS.policyDataHandlingSessionTranscriptMemory,
CHECK_IDS.policySecretsUnmanagedProvider,
CHECK_IDS.policySecretsDeniedProviderSource,
CHECK_IDS.policySecretsInsecureProvider,
CHECK_IDS.policyAuthProfileInvalidMetadata,
CHECK_IDS.policyAuthProfileUnapprovedMode,
CHECK_IDS.policyExecApprovalsMissing,
CHECK_IDS.policyExecApprovalsInvalid,
CHECK_IDS.policyExecApprovalsDefaultSecurityUnapproved,
CHECK_IDS.policyExecApprovalsAgentSecurityUnapproved,
CHECK_IDS.policyExecApprovalsAutoAllowSkillsEnabled,
CHECK_IDS.policyExecApprovalsAllowlistMissing,
CHECK_IDS.policyExecApprovalsAllowlistUnexpected,
CHECK_IDS.policyMissingToolRisk,
CHECK_IDS.policyUnknownToolRisk,
CHECK_IDS.policyMissingToolSensitivity,
CHECK_IDS.policyMissingToolOwner,
CHECK_IDS.policyUnknownToolSensitivity,
] as const;
@@ -0,0 +1,327 @@
import type { HealthFinding } from "openclaw/plugin-sdk/health";
import { isRecord } from "openclaw/plugin-sdk/string-coerce-runtime";
import type { PolicyDataHandlingEvidence, PolicyEvidence } from "../policy-state.js";
import { CHECK_IDS } from "./check-ids.js";
import {
authProfileAllowModesShapeFindings,
dataHandlingEntries,
dataHandlingFinding,
dataHandlingLabel,
dataHandlingPolicyShapeFindings,
secretPolicyShapeFindings,
} from "./data-auth-shapes.js";
import { authProfileHasMetadata, requiredAuthProfileMetadata } from "./policy-runtime.js";
import {
agentScopedPolicyTargets,
dataHandlingPolicyHasRules,
scopedAgentIdMatches,
} from "./policy-scope.js";
import { ocPathSegment, readPolicyBoolean, readStringList } from "./utils.js";
export function secretAuthProvenanceFindings(
policy: unknown,
policyPath: string,
policyDocName: string,
evidence: PolicyEvidence,
): readonly HealthFinding[] {
const secretShapeFindings = secretPolicyShapeFindings(policy, policyPath, policyDocName);
const authShapeFindings = authProfileAllowModesShapeFindings(policy, policyPath, policyDocName);
return [
...(secretShapeFindings.length > 0
? secretShapeFindings
: [
...secretManagedProviderFindings(policy, policyDocName, evidence),
...secretDeniedSourceFindings(policy, policyDocName, evidence),
...secretInsecureProviderFindings(policy, policyDocName, evidence),
]),
...(authShapeFindings.length > 0
? authShapeFindings
: [
...authProfileMetadataFindings(policy, policyDocName, evidence),
...authProfileModeFindings(policy, policyDocName, evidence),
]),
];
}
export function dataHandlingFindings(
policy: unknown,
policyPath: string,
policyDocName: string,
evidence: PolicyEvidence,
): readonly HealthFinding[] {
const shapeFindings = dataHandlingPolicyShapeFindings(policy, policyPath, policyDocName);
if (shapeFindings.length > 0) {
return shapeFindings;
}
const findings: HealthFinding[] = [];
findings.push(
...dataHandlingFindingsForRule(policy, policyDocName, "dataHandling", evidence, () => true),
);
for (const target of agentScopedPolicyTargets(policy)) {
if (!dataHandlingPolicyHasRules(target.overlay.dataHandling)) {
continue;
}
findings.push(
...dataHandlingFindingsForRule(
target.overlay,
policyDocName,
`scopes/${ocPathSegment(target.scopeName)}/dataHandling`,
evidence,
(entry) =>
entry.kind !== "memorySessionTranscriptIndexing" ||
scopedDataHandlingAgentMatches(entry, target.agentId, evidence.dataHandling ?? []),
),
);
}
return findings;
}
function scopedDataHandlingAgentMatches(
entry: PolicyDataHandlingEvidence,
policyAgentId: string,
entries: readonly PolicyDataHandlingEvidence[],
): boolean {
if (entry.id === "memory-qmd-session-transcripts") {
return true;
}
if (scopedAgentIdMatches(entry.agentId, policyAgentId)) {
return true;
}
return (
entry.id === "agents-defaults-memory-session-transcripts" &&
!entries.some(
(candidate) =>
candidate.scope === "agent" &&
candidate.kind === entry.kind &&
scopedAgentIdMatches(candidate.agentId, policyAgentId),
)
);
}
function dataHandlingFindingsForRule(
policy: unknown,
policyDocName: string,
requirementBase: string,
evidence: PolicyEvidence,
evidenceFilter: (entry: PolicyDataHandlingEvidence) => boolean,
): readonly HealthFinding[] {
const dataHandling = isRecord(policy) ? policy.dataHandling : undefined;
if (!isRecord(dataHandling)) {
return [];
}
const findings: HealthFinding[] = [];
if (readPolicyBoolean(dataHandling, ["sensitiveLogging", "requireRedaction"]) === true) {
findings.push(
...dataHandlingEntries(evidence, "sensitiveLoggingRedaction")
.filter(evidenceFilter)
.filter((entry) => entry.value !== true)
.map((entry) =>
dataHandlingFinding(entry, {
checkId: CHECK_IDS.policyDataHandlingRedactionDisabled,
message: "Sensitive logging redaction is disabled.",
requirement: `oc://${policyDocName}/${requirementBase}/sensitiveLogging/requireRedaction`,
fixHint: "Set logging.redactSensitive to tools or update policy after review.",
}),
),
);
}
if (readPolicyBoolean(dataHandling, ["telemetry", "denyContentCapture"]) === true) {
findings.push(
...dataHandlingEntries(evidence, "telemetryContentCapture")
.filter(evidenceFilter)
.filter((entry) => entry.value === true)
.map((entry) =>
dataHandlingFinding(entry, {
checkId: CHECK_IDS.policyDataHandlingTelemetryContentCapture,
message: "Telemetry content capture is enabled.",
requirement: `oc://${policyDocName}/${requirementBase}/telemetry/denyContentCapture`,
fixHint: "Disable diagnostics.otel.captureContent or update policy after review.",
}),
),
);
}
if (readPolicyBoolean(dataHandling, ["retention", "requireSessionMaintenance"]) === true) {
findings.push(
...dataHandlingEntries(evidence, "sessionRetentionMode")
.filter(evidenceFilter)
.filter((entry) => entry.value !== "enforce")
.map((entry) =>
dataHandlingFinding(entry, {
checkId: CHECK_IDS.policyDataHandlingSessionRetentionNotEnforced,
message: `Session retention maintenance mode is '${entry.value ?? "unknown"}'.`,
requirement: `oc://${policyDocName}/${requirementBase}/retention/requireSessionMaintenance`,
fixHint: "Set session.maintenance.mode to enforce or update policy after review.",
}),
),
);
}
if (readPolicyBoolean(dataHandling, ["memory", "denySessionTranscriptIndexing"]) === true) {
findings.push(
...dataHandlingEntries(evidence, "memorySessionTranscriptIndexing")
.filter(evidenceFilter)
.filter((entry) => entry.value === true)
.map((entry) =>
dataHandlingFinding(entry, {
checkId: CHECK_IDS.policyDataHandlingSessionTranscriptMemory,
message: `${dataHandlingLabel(entry)} enables session transcript memory indexing.`,
requirement: `oc://${policyDocName}/${requirementBase}/memory/denySessionTranscriptIndexing`,
fixHint:
"Disable session transcript memory indexing for the matching config surface or update policy after review.",
}),
),
);
}
return findings;
}
function secretManagedProviderFindings(
policy: unknown,
policyDocName: string,
evidence: PolicyEvidence,
): readonly HealthFinding[] {
if (readPolicyBoolean(policy, ["secrets", "requireManagedProviders"]) !== true) {
return [];
}
const secrets = evidence.secrets ?? [];
const providerKeys = new Set(
secrets
.filter((secret) => secret.kind === "provider" && secret.providerSource !== undefined)
.map((secret) => `${secret.providerSource}:${secret.id}`),
);
return secrets
.filter(
(secret) =>
secret.kind === "input" &&
secret.provenance === "secretRef" &&
(secret.refProvider === undefined ||
secret.refSource === undefined ||
!providerKeys.has(`${secret.refSource}:${secret.refProvider}`)),
)
.map((secret): HealthFinding => {
return {
checkId: CHECK_IDS.policySecretsUnmanagedProvider,
severity: "error",
message: `SecretRef uses unmanaged provider '${secret.refProvider ?? "default"}'.`,
source: "policy",
path: "openclaw config",
ocPath: secret.source,
target: secret.source,
requirement: `oc://${policyDocName}/secrets/requireManagedProviders`,
fixHint:
"Declare the referenced provider under secrets.providers or update policy after review.",
};
});
}
function secretDeniedSourceFindings(
policy: unknown,
policyDocName: string,
evidence: PolicyEvidence,
): readonly HealthFinding[] {
const deniedSources = new Set(readStringList(policy, ["secrets", "denySources"]));
if (deniedSources.size === 0) {
return [];
}
return (evidence.secrets ?? [])
.filter((secret) => {
const source = secret.kind === "provider" ? secret.providerSource : secret.refSource;
return source !== undefined && deniedSources.has(source);
})
.map((secret): HealthFinding => {
const source = secret.kind === "provider" ? secret.providerSource : secret.refSource;
return {
checkId: CHECK_IDS.policySecretsDeniedProviderSource,
severity: "error",
message: `Secret ${secret.kind} '${secret.id}' uses denied source '${source}'.`,
source: "policy",
path: "openclaw config",
ocPath: secret.source,
target: secret.source,
requirement: `oc://${policyDocName}/secrets/denySources`,
fixHint: "Move this secret to an approved source or update policy after review.",
};
});
}
function secretInsecureProviderFindings(
policy: unknown,
policyDocName: string,
evidence: PolicyEvidence,
): readonly HealthFinding[] {
if (readPolicyBoolean(policy, ["secrets", "allowInsecureProviders"]) !== false) {
return [];
}
return (evidence.secrets ?? [])
.filter((secret) => secret.kind === "provider" && (secret.insecure?.length ?? 0) > 0)
.map((secret): HealthFinding => {
return {
checkId: CHECK_IDS.policySecretsInsecureProvider,
severity: "error",
message: `Secret provider '${secret.id}' enables insecure posture: ${(secret.insecure ?? []).join(", ")}.`,
source: "policy",
path: "openclaw config",
ocPath: secret.source,
target: secret.source,
requirement: `oc://${policyDocName}/secrets/allowInsecureProviders`,
fixHint: "Remove insecure provider overrides or update policy after review.",
};
});
}
function authProfileMetadataFindings(
policy: unknown,
policyDocName: string,
evidence: PolicyEvidence,
): readonly HealthFinding[] {
const requiredMetadata = requiredAuthProfileMetadata(policy);
if (requiredMetadata.size === 0) {
return [];
}
return (evidence.authProfiles ?? []).flatMap((profile): HealthFinding[] => {
const missing = [...requiredMetadata].filter(
(metadata) => !authProfileHasMetadata(profile, metadata),
);
if (missing.length === 0) {
return [];
}
return [
{
checkId: CHECK_IDS.policyAuthProfileInvalidMetadata,
severity: "error",
message: `Auth profile '${profile.id}' is missing required metadata: ${missing.join(", ")}.`,
source: "policy",
path: "openclaw config",
ocPath: profile.source,
target: profile.source,
requirement: `oc://${policyDocName}/auth/profiles/requireMetadata`,
fixHint: "Set auth.profiles.<id>.provider and a supported auth profile mode.",
},
];
});
}
function authProfileModeFindings(
policy: unknown,
policyDocName: string,
evidence: PolicyEvidence,
): readonly HealthFinding[] {
const allowedModes = new Set(readStringList(policy, ["auth", "profiles", "allowModes"]));
if (allowedModes.size === 0) {
return [];
}
return (evidence.authProfiles ?? [])
.filter((profile) => profile.mode !== undefined && !allowedModes.has(profile.mode))
.map((profile): HealthFinding => {
return {
checkId: CHECK_IDS.policyAuthProfileUnapprovedMode,
severity: "error",
message: `Auth profile '${profile.id}' uses mode '${profile.mode}' outside the policy allowlist.`,
source: "policy",
path: "openclaw config",
ocPath: profile.source,
target: profile.source,
requirement: `oc://${policyDocName}/auth/profiles/allowModes`,
fixHint: "Change the auth profile mode or update policy after review.",
};
});
}
@@ -0,0 +1,296 @@
import type { HealthFinding } from "openclaw/plugin-sdk/health";
import { isRecord } from "openclaw/plugin-sdk/string-coerce-runtime";
import type { PolicyDataHandlingEvidence, PolicyEvidence } from "../policy-state.js";
import { POLICY_CHECK_IDS } from "./check-ids.js";
import { SUPPORTED_AUTH_PROFILE_MODES } from "./policy-constants.js";
import { getPolicyPath } from "./policy-scope.js";
import { policyShapeFinding, unsupportedPolicyKey } from "./shape-helpers.js";
import { ocPathSegment } from "./utils.js";
export function dataHandlingPolicyShapeFindings(
policy: unknown,
policyPath: string,
policyDocName: string,
): readonly HealthFinding[] {
if (!isRecord(policy)) {
return [];
}
if (!isRecord(policy.dataHandling)) {
return [];
}
return [
policySectionUnsupportedKeyFinding(policy.dataHandling, {
policyPath,
policyDocName,
propertyPath: "dataHandling",
targetPath: "dataHandling",
sectionName: "data-handling",
allowedKeys: ["memory", "retention", "sensitiveLogging", "telemetry"],
}),
dataHandlingSectionShapeFinding(policy.dataHandling, {
policyPath,
policyDocName,
propertyPath: "dataHandling.sensitiveLogging",
targetPath: "dataHandling/sensitiveLogging",
section: "sensitiveLogging",
}),
dataHandlingSectionShapeFinding(policy.dataHandling, {
policyPath,
policyDocName,
propertyPath: "dataHandling.telemetry",
targetPath: "dataHandling/telemetry",
section: "telemetry",
}),
dataHandlingSectionShapeFinding(policy.dataHandling, {
policyPath,
policyDocName,
propertyPath: "dataHandling.retention",
targetPath: "dataHandling/retention",
section: "retention",
}),
dataHandlingSectionShapeFinding(policy.dataHandling, {
policyPath,
policyDocName,
propertyPath: "dataHandling.memory",
targetPath: "dataHandling/memory",
section: "memory",
}),
dataHandlingBooleanShapeFinding(policy.dataHandling, {
policyPath,
policyDocName,
propertyPath: "dataHandling.sensitiveLogging.requireRedaction",
targetPath: "dataHandling/sensitiveLogging/requireRedaction",
path: ["sensitiveLogging", "requireRedaction"],
}),
dataHandlingBooleanShapeFinding(policy.dataHandling, {
policyPath,
policyDocName,
propertyPath: "dataHandling.telemetry.denyContentCapture",
targetPath: "dataHandling/telemetry/denyContentCapture",
path: ["telemetry", "denyContentCapture"],
}),
dataHandlingBooleanShapeFinding(policy.dataHandling, {
policyPath,
policyDocName,
propertyPath: "dataHandling.retention.requireSessionMaintenance",
targetPath: "dataHandling/retention/requireSessionMaintenance",
path: ["retention", "requireSessionMaintenance"],
}),
dataHandlingBooleanShapeFinding(policy.dataHandling, {
policyPath,
policyDocName,
propertyPath: "dataHandling.memory.denySessionTranscriptIndexing",
targetPath: "dataHandling/memory/denySessionTranscriptIndexing",
path: ["memory", "denySessionTranscriptIndexing"],
}),
].filter((finding): finding is HealthFinding => finding !== undefined);
}
function policySectionUnsupportedKeyFinding(
value: Record<string, unknown>,
params: {
readonly policyPath: string;
readonly policyDocName: string;
readonly propertyPath: string;
readonly targetPath: string;
readonly sectionName: string;
readonly allowedKeys: readonly string[];
},
): HealthFinding | undefined {
const unsupportedKey = unsupportedPolicyKey(value, params.allowedKeys);
if (unsupportedKey === undefined) {
return undefined;
}
return policyShapeFinding(
params.policyPath,
`oc://${params.policyDocName}/${params.targetPath}/${ocPathSegment(unsupportedKey)}`,
`${params.policyPath} ${params.propertyPath}.${unsupportedKey} is not supported in ${params.sectionName} policy.`,
`Remove ${params.propertyPath}.${unsupportedKey} or use a supported ${params.sectionName} policy rule.`,
);
}
function dataHandlingSectionShapeFinding(
dataHandling: Record<string, unknown>,
params: {
readonly policyPath: string;
readonly policyDocName: string;
readonly propertyPath: string;
readonly targetPath: string;
readonly section: string;
},
): HealthFinding | undefined {
const value = dataHandling[params.section];
if (value === undefined || isRecord(value)) {
return undefined;
}
return policyShapeFinding(
params.policyPath,
`oc://${params.policyDocName}/${params.targetPath}`,
`${params.policyPath} ${params.propertyPath} must be an object.`,
`Fix ${params.propertyPath} so it contains boolean policy rules.`,
);
}
function dataHandlingBooleanShapeFinding(
dataHandling: unknown,
params: {
readonly policyPath: string;
readonly policyDocName: string;
readonly propertyPath: string;
readonly targetPath: string;
readonly path: readonly string[];
},
): HealthFinding | undefined {
const value = getPolicyPath(dataHandling, params.path);
if (isRecord(dataHandling) && typeof params.path[0] === "string") {
const section = dataHandling[params.path[0]];
if (isRecord(section) && typeof params.path[1] === "string") {
const sectionPath = params.path.slice(0, -1).join(".");
const unsupportedKey = unsupportedPolicyKey(section, [params.path[1]]);
if (unsupportedKey !== undefined) {
return policyShapeFinding(
params.policyPath,
`oc://${params.policyDocName}/${params.targetPath
.split("/")
.slice(0, -1)
.join("/")}/${ocPathSegment(unsupportedKey)}`,
`${params.policyPath} dataHandling.${sectionPath}.${unsupportedKey} is not supported in data-handling policy.`,
`Remove dataHandling.${sectionPath}.${unsupportedKey} or use ${params.propertyPath}.`,
);
}
}
}
if (value === undefined || typeof value === "boolean") {
return undefined;
}
return policyShapeFinding(
params.policyPath,
`oc://${params.policyDocName}/${params.targetPath}`,
`${params.policyPath} ${params.propertyPath} must be a boolean.`,
`Set ${params.propertyPath} to true or false.`,
);
}
export function dataHandlingEntries(
evidence: PolicyEvidence,
kind: PolicyDataHandlingEvidence["kind"],
): readonly PolicyDataHandlingEvidence[] {
return (evidence.dataHandling ?? []).filter((entry) => entry.kind === kind);
}
export function dataHandlingFinding(
entry: PolicyDataHandlingEvidence,
params: {
readonly checkId: (typeof POLICY_CHECK_IDS)[number];
readonly message: string;
readonly requirement: string;
readonly fixHint: string;
},
): HealthFinding {
return {
checkId: params.checkId,
severity: "error",
message: params.message,
source: "policy",
path: "openclaw config",
ocPath: entry.source,
target: entry.source,
requirement: params.requirement,
fixHint: params.fixHint,
};
}
export function dataHandlingLabel(entry: PolicyDataHandlingEvidence): string {
return entry.agentId === undefined ? "Global data handling config" : `agent '${entry.agentId}'`;
}
export function secretPolicyShapeFindings(
policy: unknown,
policyPath: string,
policyDocName: string,
): readonly HealthFinding[] {
if (!isRecord(policy) || !isRecord(policy.secrets)) {
return [];
}
const findings: HealthFinding[] = [];
for (const key of ["requireManagedProviders", "allowInsecureProviders"] as const) {
if (policy.secrets[key] !== undefined && typeof policy.secrets[key] !== "boolean") {
findings.push(
policyShapeFinding(
policyPath,
`oc://${policyDocName}/secrets/${key}`,
`${policyPath} secrets.${key} must be a boolean.`,
`Set secrets.${key} to true or false.`,
),
);
}
}
if (policy.secrets.denySources !== undefined && !Array.isArray(policy.secrets.denySources)) {
findings.push(
policyShapeFinding(
policyPath,
`oc://${policyDocName}/secrets/denySources`,
`${policyPath} secrets.denySources must be an array of source names.`,
'Use an array such as ["exec"] or remove secrets.denySources.',
),
);
} else if (Array.isArray(policy.secrets.denySources)) {
const invalidIndex = policy.secrets.denySources.findIndex(
(entry) => typeof entry !== "string" || entry.trim() === "",
);
if (invalidIndex >= 0) {
findings.push(
policyShapeFinding(
policyPath,
`oc://${policyDocName}/secrets/denySources/#${invalidIndex}`,
`${policyPath} secrets.denySources[${invalidIndex}] must be a non-empty source name.`,
"Use non-empty source names such as env, file, exec, or openclaw.",
),
);
}
}
return findings;
}
export function authProfileAllowModesShapeFindings(
policy: unknown,
policyPath: string,
policyDocName: string,
): readonly HealthFinding[] {
if (
!isRecord(policy) ||
!isRecord(policy.auth) ||
!isRecord(policy.auth.profiles) ||
policy.auth.profiles.allowModes === undefined
) {
return [];
}
if (!Array.isArray(policy.auth.profiles.allowModes)) {
return [
policyShapeFinding(
policyPath,
`oc://${policyDocName}/auth/profiles/allowModes`,
`${policyPath} auth.profiles.allowModes must be an array of auth modes.`,
`Use supported auth modes: ${SUPPORTED_AUTH_PROFILE_MODES.join(", ")}.`,
),
];
}
const invalidIndex = policy.auth.profiles.allowModes.findIndex(
(entry) =>
typeof entry !== "string" ||
!SUPPORTED_AUTH_PROFILE_MODES.includes(
entry.trim().toLowerCase() as (typeof SUPPORTED_AUTH_PROFILE_MODES)[number],
),
);
if (invalidIndex < 0) {
return [];
}
return [
policyShapeFinding(
policyPath,
`oc://${policyDocName}/auth/profiles/allowModes/#${invalidIndex}`,
`${policyPath} auth.profiles.allowModes[${invalidIndex}] must be a supported auth mode.`,
`Use supported auth modes: ${SUPPORTED_AUTH_PROFILE_MODES.join(", ")}.`,
),
];
}
+426
View File
@@ -0,0 +1,426 @@
import type { HealthCheckContext, HealthFinding } from "openclaw/plugin-sdk/health";
import { isRecord } from "openclaw/plugin-sdk/string-coerce-runtime";
import {
collectPolicyEvidence,
createPolicyAttestation,
policyDocumentHash,
type PolicyEvidence,
} from "../policy-state.js";
import {
authProfileMetadataRequirementFindings,
invalidChannelDenyRuleFindings,
} from "./access-findings.js";
import { agentWorkspaceFindings } from "./agent-workspace-findings.js";
import { CHECK_IDS, POLICY_CHECK_IDS } from "./check-ids.js";
import { dataHandlingFindings, secretAuthProvenanceFindings } from "./data-auth-findings.js";
import { execApprovalsFindings } from "./exec-approval-findings.js";
import { ingressFindings } from "./ingress-findings.js";
import { SUPPORTED_TOOL_METADATA } from "./policy-constants.js";
import {
execApprovalsDisplayName,
parsePolicyFile,
policyChecksEnabled,
policyDisplayName,
policySettings,
readChannelDenyRules,
readExecApprovalsFile,
readPolicyFile,
readWorkspaceFile,
requiredToolMetadata,
type PolicySettings,
} from "./policy-runtime.js";
import {
policyHasAgentWorkspaceRules,
policyHasAuthProfileRules,
policyHasDataHandlingRules,
policyHasExecApprovalsRules,
policyHasGatewayRules,
policyHasIngressRules,
policyHasSandboxPostureRules,
policyHasSecretRules,
policyHasToolPostureRules,
} from "./policy-scope.js";
import { policyContainerShapeFindings } from "./policy-shape.js";
import { sandboxPostureFindings } from "./sandbox-findings.js";
import { gatewayExposureFindings } from "./scopes/gateway.js";
import {
mcpServerFindings,
modelProviderFindings,
networkFindings,
} from "./scopes/model-network.js";
import {
toolOwnerFindings,
toolPostureFindings,
toolRiskFindings,
toolSensitivityFindings,
toolUnknownRiskFindings,
} from "./tool-findings.js";
import type { PolicyEvaluation } from "./types.js";
const policyEvaluationCache = new WeakMap<HealthCheckContext, Promise<PolicyEvaluation>>();
export function evaluatePolicy(ctx: HealthCheckContext): Promise<PolicyEvaluation> {
const cached = policyEvaluationCache.get(ctx);
if (cached !== undefined) {
return cached;
}
const next = evaluatePolicyUncached(ctx);
policyEvaluationCache.set(ctx, next);
return next;
}
async function evaluatePolicyUncached(ctx: HealthCheckContext): Promise<PolicyEvaluation> {
const settings = policySettings(ctx);
const policyPath = policyDisplayName(ctx);
let evidence: PolicyEvidence = collectPolicyEvidence(ctx.cfg as Record<string, unknown>, {
includeIngress: false,
includeGatewayExposure: false,
includeAgentWorkspace: false,
includeToolPosture: false,
includeSandboxPosture: false,
includeSecrets: false,
includeAuthProfiles: false,
includeExecApprovals: false,
});
const findings: HealthFinding[] = [];
if (!policyChecksEnabled(ctx, settings)) {
return {
policyPath,
evidence,
expectedAttestationHash: settings.expectedAttestationHash,
findings,
attestedFindings: findings,
};
}
const policyFile = await readPolicyFile(ctx);
if (policyFile === null) {
findings.push({
checkId: CHECK_IDS.policyMissingFile,
severity: "warning",
message: `${policyPath} is missing for the enabled Policy plugin.`,
source: "policy",
path: policyPath,
fixHint: `Restore ${policyPath} or add the policy artifact for this workspace.`,
});
return {
policyPath,
evidence,
expectedAttestationHash: settings.expectedAttestationHash,
findings,
attestedFindings: findings,
};
}
const parsedPolicy = parsePolicyFile(policyFile.raw);
if (!parsedPolicy.ok) {
findings.push(policyParseFinding(policyFile.displayName, policyFile.ocDocName, parsedPolicy));
return {
policyPath,
evidence,
expectedAttestationHash: settings.expectedAttestationHash,
findings,
attestedFindings: findings,
};
}
const policy = parsedPolicy.value;
const policyHash = policyDocumentHash(policy);
const expectedHash = settings.expectedHash;
if (
typeof expectedHash === "string" &&
expectedHash.trim() !== "" &&
policyHash !== expectedHash.trim()
) {
findings.push({
checkId: CHECK_IDS.policyHashMismatch,
severity: "error",
message: `${policyFile.displayName} does not match the configured policy hash.`,
source: "policy",
path: policyFile.displayName,
target: `oc://${policyFile.ocDocName}`,
requirement: "oc://openclaw.config/plugins/entries/policy/config/expectedHash",
fixHint: `Restore the approved policy artifact or update plugins.entries.policy.config.expectedHash after review.`,
});
return {
policyPath,
policy: { value: policy, hash: policyHash },
evidence,
expectedAttestationHash: settings.expectedAttestationHash,
findings,
attestedFindings: findings,
};
}
const metadataRequirementFindings = toolMetadataRequirementFindings(
policy,
policyFile.displayName,
policyFile.ocDocName,
);
const authMetadataRequirementFindings = authProfileMetadataRequirementFindings(
policy,
policyFile.displayName,
policyFile.ocDocName,
);
const requiredMetadata =
metadataRequirementFindings.length === 0 ? requiredToolMetadata(policy) : new Set<string>();
const includeSecrets = policyHasSecretRules(policy);
const includeAuthProfiles = policyHasAuthProfileRules(policy);
const includeIngress = policyHasIngressRules(policy);
const includeGatewayExposure = policyHasGatewayRules(policy);
const includeAgentWorkspace = policyHasAgentWorkspaceRules(policy);
const includeDataHandling = policyHasDataHandlingRules(policy);
const includeSandboxPosture = policyHasSandboxPostureRules(policy);
const includeExecApprovals = policyHasExecApprovalsRules(policy);
const execApprovalsFile = includeExecApprovals ? await readExecApprovalsFile(ctx) : undefined;
if (requiredMetadata.size > 0) {
const toolsFile = await readWorkspaceFile(ctx, "TOOLS.md");
evidence = await collectPolicyEvidence(ctx.cfg as Record<string, unknown>, {
toolsRaw: toolsFile?.raw ?? "",
includeIngress,
includeGatewayExposure,
includeAgentWorkspace,
includeDataHandling,
includeToolPosture: policyHasToolPostureRules(policy),
includeSandboxPosture,
includeSecrets,
includeAuthProfiles,
includeExecApprovals,
execApprovalsRaw: includeExecApprovals ? (execApprovalsFile?.raw ?? null) : undefined,
});
} else {
evidence = collectPolicyEvidence(ctx.cfg as Record<string, unknown>, {
includeIngress,
includeGatewayExposure,
includeAgentWorkspace,
includeDataHandling,
includeToolPosture: policyHasToolPostureRules(policy),
includeSandboxPosture,
includeSecrets,
includeAuthProfiles,
includeExecApprovals,
execApprovalsRaw: includeExecApprovals ? (execApprovalsFile?.raw ?? null) : undefined,
});
}
const policyFindings: HealthFinding[] = [
...policyContainerShapeFindings(policy, policyFile.displayName, policyFile.ocDocName),
...channelFindings(policy, policyFile.displayName, policyFile.ocDocName, evidence),
...mcpServerFindings(policy, policyFile.ocDocName, evidence),
...modelProviderFindings(policy, policyFile.ocDocName, evidence),
...networkFindings(policy, policyFile.ocDocName, evidence),
...ingressFindings(policy, policyFile.displayName, policyFile.ocDocName, evidence),
...gatewayExposureFindings(policy, policyFile.ocDocName, evidence),
...agentWorkspaceFindings(policy, policyFile.displayName, policyFile.ocDocName, evidence),
...toolPostureFindings(policy, policyFile.displayName, policyFile.ocDocName, evidence),
...sandboxPostureFindings(policy, policyFile.displayName, policyFile.ocDocName, evidence),
...dataHandlingFindings(policy, policyFile.displayName, policyFile.ocDocName, evidence),
...secretAuthProvenanceFindings(policy, policyFile.displayName, policyFile.ocDocName, evidence),
...execApprovalsFindings(
policy,
policyFile.displayName,
policyFile.ocDocName,
evidence,
execApprovalsFile,
execApprovalsDisplayName(),
),
...authMetadataRequirementFindings,
...metadataRequirementFindings,
];
if (requiredMetadata.has("risk")) {
policyFindings.push(...toolRiskFindings(policyFile.ocDocName, evidence));
policyFindings.push(...toolUnknownRiskFindings(policyFile.ocDocName, evidence));
}
if (requiredMetadata.has("sensitivity")) {
policyFindings.push(...toolSensitivityFindings(policyFile.ocDocName, evidence));
}
if (requiredMetadata.has("owner")) {
policyFindings.push(...toolOwnerFindings(policyFile.ocDocName, evidence));
}
const attestationFindings = policyAttestationFindings(
policyFile.displayName,
policyHash,
evidence,
policyFindings,
settings,
);
if (hasPolicyValidationFinding(policyFindings)) {
findings.push(...policyFindings);
} else if (attestationFindings.length > 0) {
findings.push(...attestationFindings);
} else {
findings.push(...policyFindings);
}
return {
policyPath,
policy: { value: policy, hash: policyHash },
evidence,
expectedAttestationHash: settings.expectedAttestationHash,
findings,
attestedFindings: policyFindings,
};
}
function policyParseFinding(
policyPath: string,
policyDocName: string,
parseError: { readonly message: string },
): HealthFinding {
return {
checkId: CHECK_IDS.policyInvalidFile,
severity: "error",
message: `${policyPath} could not be parsed: ${parseError.message}`,
source: "policy",
path: policyPath,
target: `oc://${policyDocName}`,
fixHint: `Fix ${policyPath} so policy conformance checks can run.`,
};
}
export function findingsForCheck(
evaluation: PolicyEvaluation,
checkId: (typeof POLICY_CHECK_IDS)[number],
): readonly HealthFinding[] {
return evaluation.findings.filter((finding) => finding.checkId === checkId);
}
function hasPolicyValidationFinding(findings: readonly HealthFinding[]): boolean {
return findings.some((finding) => finding.checkId === CHECK_IDS.policyInvalidFile);
}
function channelFindings(
policy: unknown,
policyPath: string,
policyDocName: string,
evidence: PolicyEvidence,
): readonly HealthFinding[] {
const invalidRules = invalidChannelDenyRuleFindings(policy, policyPath, policyDocName);
if (invalidRules.length > 0) {
return invalidRules;
}
const denyRules = readChannelDenyRules(policy, policyDocName);
if (denyRules.length === 0) {
return [];
}
return evidence.channels.flatMap((channel): HealthFinding[] => {
if (channel.enabled === false) {
return [];
}
const rule = denyRules.find((candidate) => candidate.when?.provider === channel.provider);
if (rule === undefined) {
return [];
}
return [
{
checkId: CHECK_IDS.policyDeniedChannelProvider,
severity: "error",
message: `Channel '${channel.id}' uses denied provider '${channel.provider}'.`,
source: "policy",
path: "openclaw config",
ocPath: channel.source,
target: channel.source,
requirement: rule.requirement,
fixHint:
rule.reason ??
"Disable this channel, remove it from config, or update the policy deny rule.",
},
];
});
}
function policyAttestationFindings(
policyPath: string,
policyHash: string,
evidence: PolicyEvidence,
findings: readonly HealthFinding[],
settings: PolicySettings,
): readonly HealthFinding[] {
const expected = settings.expectedAttestationHash?.trim();
if (!expected) {
return [];
}
const current = createPolicyAttestation({
ok: findings.length === 0,
checkedAt: new Date(0).toISOString(),
policyPath,
policyHash,
evidence,
findings: findings.map(toAttestedFinding),
});
if (current.attestationHash === expected) {
return [];
}
return [
{
checkId: CHECK_IDS.policyAttestationMismatch,
severity: "error",
message: "The current policy check no longer matches the accepted policy attestation.",
source: "policy",
path: "policy attestation",
target: "oc://policy/attestation/current",
requirement: "oc://openclaw.config/plugins/entries/policy/config/expectedAttestationHash",
fixHint: `Run policy check, review attestation ${current.attestationHash}, then update plugins.entries.policy.config.expectedAttestationHash and the supervisor/gateway accepted attestation.`,
},
];
}
function toAttestedFinding(finding: HealthFinding): Record<string, unknown> {
return {
checkId: finding.checkId,
severity: finding.severity,
message: finding.message,
...(finding.source !== undefined ? { source: finding.source } : {}),
...(finding.path !== undefined ? { path: finding.path } : {}),
...(finding.line !== undefined ? { line: finding.line } : {}),
...(finding.column !== undefined ? { column: finding.column } : {}),
...(finding.ocPath !== undefined ? { ocPath: finding.ocPath } : {}),
...(finding.target !== undefined ? { target: finding.target } : {}),
...(finding.requirement !== undefined ? { requirement: finding.requirement } : {}),
...(finding.fixHint !== undefined ? { fixHint: finding.fixHint } : {}),
};
}
function toolMetadataRequirementFindings(
policy: unknown,
policyPath: string,
policyDocName: string,
): readonly HealthFinding[] {
if (!isRecord(policy) || !isRecord(policy.tools) || policy.tools.requireMetadata === undefined) {
return [];
}
if (!Array.isArray(policy.tools.requireMetadata)) {
return [
{
checkId: CHECK_IDS.policyInvalidFile,
severity: "error",
message: `${policyPath} tools.requireMetadata must be an array of metadata keys.`,
source: "policy",
path: policyPath,
target: `oc://${policyDocName}/tools/requireMetadata`,
fixHint: `Use supported metadata keys: ${SUPPORTED_TOOL_METADATA.join(", ")}.`,
},
];
}
const invalidIndex = policy.tools.requireMetadata.findIndex(
(entry) =>
typeof entry !== "string" ||
!SUPPORTED_TOOL_METADATA.includes(
entry.trim().toLowerCase() as (typeof SUPPORTED_TOOL_METADATA)[number],
),
);
if (invalidIndex < 0) {
return [];
}
return [
{
checkId: CHECK_IDS.policyInvalidFile,
severity: "error",
message: `${policyPath} tools.requireMetadata[${invalidIndex}] must be a supported metadata key.`,
source: "policy",
path: policyPath,
target: `oc://${policyDocName}/tools/requireMetadata/#${invalidIndex}`,
fixHint: `Use supported metadata keys: ${SUPPORTED_TOOL_METADATA.join(", ")}.`,
},
];
}
@@ -0,0 +1,384 @@
import type { HealthFinding } from "openclaw/plugin-sdk/health";
import { normalizeAgentId } from "openclaw/plugin-sdk/routing";
import { isRecord } from "openclaw/plugin-sdk/string-coerce-runtime";
import type { PolicyEvidence, PolicyExecApprovalEvidence } from "../policy-state.js";
import { execApprovalsPolicyShapeFinding } from "./access-shapes.js";
import { CHECK_IDS, POLICY_CHECK_IDS } from "./check-ids.js";
import {
effectiveExecApprovalAgentAutoAllowSkillsEntry,
effectiveExecApprovalAgentSecurityEntry,
execApprovalAllowlistMissingTarget,
execApprovalAllowlistRequirementKey,
formatExecApprovalAllowlistEntry,
formatExecApprovalAllowlistRequirement,
readExecApprovalAllowlistRequirements,
syntheticExecApprovalAgentEntry,
} from "./exec-approval-rules.js";
import { parseExecApprovalsFile } from "./policy-runtime.js";
import { agentScopedPolicyTargets } from "./policy-scope.js";
import { hasValidScopedPolicy } from "./scoped-policy-shape.js";
import { ocPathSegment, readPolicyBoolean, readStringList } from "./utils.js";
export function execApprovalsFindings(
policy: unknown,
policyPath: string,
policyDocName: string,
evidence: PolicyEvidence,
file:
| { readonly raw: string; readonly displayName: string; readonly ocDocName: string }
| null
| undefined,
displayName: string,
): readonly HealthFinding[] {
if (!isRecord(policy)) {
return [];
}
const findings: HealthFinding[] = [];
const entries = evidence.execApprovals ?? [];
const defaults = entries.find((entry) => entry.kind === "defaults");
const defaultSecurity = defaults?.security ?? "full";
if (isRecord(policy.execApprovals)) {
const shapeFinding = execApprovalsPolicyShapeFinding(policy.execApprovals, {
policyDocName,
policyPath,
});
if (shapeFinding !== undefined) {
return [shapeFinding];
}
const fileFindings = execApprovalsFileFindings(policy.execApprovals, {
policyDocName,
file,
displayName,
requirementBase: "execApprovals",
});
findings.push(...fileFindings);
if (fileFindings.length > 0) {
return findings;
}
findings.push(
...execApprovalsRuleFindings(policy.execApprovals, {
entries,
defaultSecurity,
defaults,
displayName,
fileDisplayName: file?.displayName,
policyDocName,
requirementBase: "execApprovals",
}),
);
}
if (!hasValidScopedPolicy(policy, policyPath, policyDocName)) {
return findings;
}
const scopedFileFindingScopes = new Set<string>();
for (const target of agentScopedPolicyTargets(policy)) {
if (!isRecord(target.overlay.execApprovals)) {
continue;
}
const requirementBase = `scopes/${ocPathSegment(target.scopeName)}/execApprovals`;
const shapeFinding = execApprovalsPolicyShapeFinding(target.overlay.execApprovals, {
policyDocName,
policyPath,
targetPrefix: requirementBase,
propertyPrefix: `scopes.${target.scopeName}.execApprovals`,
allowDefaults: false,
});
if (shapeFinding !== undefined) {
findings.push(shapeFinding);
continue;
}
const fileFindings = execApprovalsFileFindings(target.overlay.execApprovals, {
policyDocName,
file,
displayName,
requirementBase,
});
if (fileFindings.length > 0) {
if (!scopedFileFindingScopes.has(target.scopeName)) {
findings.push(...fileFindings);
scopedFileFindingScopes.add(target.scopeName);
}
continue;
}
findings.push(
...execApprovalsRuleFindings(target.overlay.execApprovals, {
entries,
defaultSecurity,
defaults,
displayName,
fileDisplayName: file?.displayName,
policyDocName,
requirementBase,
targetAgentId: target.agentId,
}),
);
}
return findings;
}
function execApprovalsFileFindings(
execApprovalsPolicy: Record<string, unknown>,
params: {
readonly policyDocName: string;
readonly file:
| { readonly raw: string; readonly displayName: string; readonly ocDocName: string }
| null
| undefined;
readonly displayName: string;
readonly requirementBase: string;
},
): readonly HealthFinding[] {
const requireFile = readPolicyBoolean(execApprovalsPolicy, ["requireFile"]) === true;
const needsArtifactEvidence =
requireFile || execApprovalsPolicyNeedsArtifactEvidence(execApprovalsPolicy);
if (needsArtifactEvidence && params.file === null) {
return [
{
checkId: CHECK_IDS.policyExecApprovalsMissing,
severity: "error",
message: "exec-approvals.json evidence is required by policy but was not found.",
source: "policy",
path: params.displayName,
target: "oc://exec-approvals.json",
requirement: `oc://${params.policyDocName}/${
requireFile ? `${params.requirementBase}/requireFile` : params.requirementBase
}`,
fixHint: "Restore the approved exec approvals artifact or update policy after review.",
},
];
}
if (params.file === null || params.file === undefined) {
return [];
}
const parsed = parseExecApprovalsFile(params.file.raw);
if (parsed.ok || !needsArtifactEvidence) {
return [];
}
return [
{
checkId: CHECK_IDS.policyExecApprovalsInvalid,
severity: "error",
message: `${params.file.displayName} could not be parsed: ${parsed.message}`,
source: "policy",
path: params.file.displayName,
target: `oc://${params.file.ocDocName}`,
requirement: `oc://${params.policyDocName}/${params.requirementBase}`,
fixHint: "Fix exec-approvals.json so it is valid JSON.",
},
];
}
function execApprovalsPolicyNeedsArtifactEvidence(
execApprovalsPolicy: Record<string, unknown>,
): boolean {
return isRecord(execApprovalsPolicy.defaults) || isRecord(execApprovalsPolicy.agents);
}
function execApprovalsRuleFindings(
execApprovalsPolicy: Record<string, unknown>,
params: {
readonly entries: readonly PolicyExecApprovalEvidence[];
readonly defaultSecurity: string;
readonly defaults?: PolicyExecApprovalEvidence;
readonly displayName: string;
readonly fileDisplayName?: string;
readonly policyDocName: string;
readonly requirementBase: string;
readonly targetAgentId?: string;
},
): readonly HealthFinding[] {
const findings: HealthFinding[] = [];
const allowedDefaults = new Set(
readStringList(execApprovalsPolicy, ["defaults", "allowSecurity"]),
);
if (
params.targetAgentId === undefined &&
allowedDefaults.size > 0 &&
!allowedDefaults.has(params.defaultSecurity.toLowerCase())
) {
findings.push(
execApprovalFinding(params.defaults, {
checkId: CHECK_IDS.policyExecApprovalsDefaultSecurityUnapproved,
message: `exec approvals defaults use unapproved security mode '${params.defaultSecurity}'.`,
requirement: `oc://${params.policyDocName}/${params.requirementBase}/defaults/allowSecurity`,
fixHint: "Set defaults.security to an approved mode or update policy after review.",
}),
);
}
const allowedAgents = new Set(readStringList(execApprovalsPolicy, ["agents", "allowSecurity"]));
if (allowedAgents.size > 0) {
const agentEntries =
params.targetAgentId === undefined
? globalExecApprovalAgentSecurityEntries(params.entries, params.defaults)
: [
effectiveExecApprovalAgentSecurityEntry(params.entries, params.targetAgentId) ??
params.defaults ??
syntheticExecApprovalAgentEntry(params.targetAgentId),
];
for (const entry of agentEntries) {
const security = entry.security ?? params.defaultSecurity;
if (allowedAgents.has(security.toLowerCase())) {
continue;
}
findings.push(
execApprovalFinding(entry, {
checkId: CHECK_IDS.policyExecApprovalsAgentSecurityUnapproved,
message: `exec approvals agent '${entry.agentId ?? params.targetAgentId ?? "inherited defaults"}' uses unapproved security mode '${security}'.`,
requirement: `oc://${params.policyDocName}/${params.requirementBase}/agents/allowSecurity`,
fixHint:
"Set the agent approval security mode to an approved value or update policy after review.",
}),
);
}
}
const allowAutoAllowSkills = readPolicyBoolean(execApprovalsPolicy, [
"agents",
"allowAutoAllowSkills",
]);
if (allowAutoAllowSkills === false) {
const autoAllowEntries =
params.targetAgentId === undefined
? globalExecApprovalAgentAutoAllowSkillsEntries(params.entries, params.defaults)
: [
effectiveExecApprovalAgentAutoAllowSkillsEntry(params.entries, params.targetAgentId) ??
params.defaults ??
syntheticExecApprovalAgentEntry(params.targetAgentId),
];
for (const entry of autoAllowEntries) {
if (entry.autoAllowSkills !== true) {
continue;
}
findings.push(
execApprovalFinding(entry, {
checkId: CHECK_IDS.policyExecApprovalsAutoAllowSkillsEnabled,
message: `exec approvals agent '${entry.agentId ?? params.targetAgentId ?? "inherited defaults"}' enables autoAllowSkills outside policy.`,
requirement: `oc://${params.policyDocName}/${params.requirementBase}/agents/allowAutoAllowSkills`,
fixHint:
"Set autoAllowSkills to false or update policy after reviewing implicit skill CLI trust.",
}),
);
}
}
const expected = readExecApprovalAllowlistRequirements(execApprovalsPolicy, [
"agents",
"allowlist",
"expected",
]);
if (expected !== undefined) {
const expectedSet = new Set(expected.map((entry) => entry.key));
const actualEntries = execApprovalAllowlistEntries(params.entries, params.targetAgentId).filter(
(entry) => entry.pattern !== undefined,
);
const actual = actualEntries
.map((entry) =>
execApprovalAllowlistRequirementKey(entry.pattern as string, entry.argPattern),
)
.toSorted();
const actualSet = new Set(actual);
for (const entry of expected.toSorted((a, b) => a.key.localeCompare(b.key))) {
if (!actualSet.has(entry.key)) {
const requirement = `oc://${params.policyDocName}/${params.requirementBase}/agents/allowlist/expected`;
const target = execApprovalAllowlistMissingTarget(params.targetAgentId);
findings.push({
checkId: CHECK_IDS.policyExecApprovalsAllowlistMissing,
severity: "error",
message: `exec approvals allowlist is missing expected pattern '${formatExecApprovalAllowlistRequirement(entry)}'.`,
source: "policy",
path: params.fileDisplayName ?? params.displayName,
target,
requirement,
fixHint: "Add the expected approval pattern or update policy after review.",
});
}
}
for (const key of actualSet) {
if (expectedSet.has(key)) {
continue;
}
const entry = actualEntries.find(
(candidate) =>
candidate.pattern !== undefined &&
execApprovalAllowlistRequirementKey(candidate.pattern, candidate.argPattern) === key,
);
findings.push(
execApprovalFinding(entry, {
checkId: CHECK_IDS.policyExecApprovalsAllowlistUnexpected,
message: `exec approvals allowlist has unexpected pattern '${formatExecApprovalAllowlistEntry(entry)}'.`,
requirement: `oc://${params.policyDocName}/${params.requirementBase}/agents/allowlist/expected`,
fixHint: "Remove the unexpected approval pattern or update policy after review.",
}),
);
}
}
return findings;
}
function globalExecApprovalAgentSecurityEntries(
entries: readonly PolicyExecApprovalEvidence[],
defaults: PolicyExecApprovalEvidence | undefined,
): readonly PolicyExecApprovalEvidence[] {
const agentEntries = entries.filter((candidate) => candidate.kind === "agent");
const wildcard = agentEntries.find((entry) => entry.agentId === "*");
const securityEntries = agentEntries.filter(
(entry) =>
entry.agentId === "*" || entry.security !== undefined || entry.securityConfigured === true,
);
return wildcard === undefined
? [...securityEntries, defaults ?? syntheticExecApprovalAgentEntry("*")]
: securityEntries;
}
function globalExecApprovalAgentAutoAllowSkillsEntries(
entries: readonly PolicyExecApprovalEvidence[],
defaults: PolicyExecApprovalEvidence | undefined,
): readonly PolicyExecApprovalEvidence[] {
const agentEntries = entries.filter((candidate) => candidate.kind === "agent");
const wildcard = agentEntries.find((entry) => entry.agentId === "*");
const explicitEntries = agentEntries.filter((entry) => entry.autoAllowSkills !== undefined);
return wildcard?.autoAllowSkills === undefined
? [...explicitEntries, defaults ?? syntheticExecApprovalAgentEntry("*")]
: explicitEntries;
}
function execApprovalAllowlistEntries(
entries: readonly PolicyExecApprovalEvidence[],
agentId: string | undefined,
): readonly PolicyExecApprovalEvidence[] {
if (agentId === undefined) {
return entries.filter((entry) => entry.kind === "allowlist");
}
return entries.filter(
(entry) =>
entry.kind === "allowlist" &&
entry.agentId !== undefined &&
(normalizeAgentId(entry.agentId) === normalizeAgentId(agentId) || entry.agentId === "*"),
);
}
function execApprovalFinding(
entry: PolicyExecApprovalEvidence | undefined,
params: {
readonly checkId: (typeof POLICY_CHECK_IDS)[number];
readonly message: string;
readonly requirement: string;
readonly fixHint: string;
},
): HealthFinding {
const target = entry?.source ?? "oc://exec-approvals.json";
return {
checkId: params.checkId,
severity: "error",
message: params.message,
source: "policy",
path: "exec-approvals.json",
ocPath: target,
target,
requirement: params.requirement,
fixHint: params.fixHint,
};
}
@@ -0,0 +1,176 @@
import type { HealthFinding } from "openclaw/plugin-sdk/health";
import { normalizeAgentId } from "openclaw/plugin-sdk/routing";
import { isRecord } from "openclaw/plugin-sdk/string-coerce-runtime";
import type { PolicyExecApprovalEvidence } from "../policy-state.js";
import { policyShapeFinding, unsupportedPolicyKey } from "./shape-helpers.js";
import { ocPathSegment } from "./utils.js";
export function execApprovalAllowlistExpectedShapeFinding(
value: unknown,
params: {
readonly property: string;
readonly policyDocName: string;
readonly policyPath: string;
readonly target: string;
},
): HealthFinding | undefined {
if (value === undefined) {
return undefined;
}
if (!Array.isArray(value)) {
return policyShapeFinding(
params.policyPath,
`oc://${params.policyDocName}/${params.target}`,
`${params.policyPath} ${params.property} must be an array.`,
`Fix ${params.policyPath} so ${params.property} is an array of exec approval allowlist entries.`,
);
}
const invalidIndex = value.findIndex(
(entry) => execApprovalAllowlistRequirement(entry) === undefined,
);
if (invalidIndex < 0) {
return undefined;
}
return policyShapeFinding(
params.policyPath,
`oc://${params.policyDocName}/${params.target}/#${invalidIndex}`,
`${params.policyPath} ${params.property}[${invalidIndex}] must be a non-empty string or an object with pattern and optional argPattern strings.`,
`Use entries such as "deploy" or { "pattern": "deploy", "argPattern": "^--prod$" }.`,
);
}
type ExecApprovalAllowlistRequirement = {
readonly key: string;
readonly pattern: string;
readonly argPattern?: string;
};
export function readExecApprovalAllowlistRequirements(
policy: unknown,
path: readonly string[],
): readonly ExecApprovalAllowlistRequirement[] | undefined {
let current: unknown = policy;
for (const part of path) {
if (!isRecord(current)) {
return undefined;
}
current = current[part];
}
if (!Array.isArray(current)) {
return undefined;
}
const entries = current.map(execApprovalAllowlistRequirement);
return entries.every((entry): entry is ExecApprovalAllowlistRequirement => entry !== undefined)
? entries
: undefined;
}
function execApprovalAllowlistRequirement(
value: unknown,
): ExecApprovalAllowlistRequirement | undefined {
if (typeof value === "string") {
const pattern = value.trim();
return pattern === "" ? undefined : execApprovalAllowlistRequirementFromParts(pattern);
}
if (!isRecord(value)) {
return undefined;
}
if (unsupportedPolicyKey(value, ["argPattern", "pattern"]) !== undefined) {
return undefined;
}
const pattern = typeof value.pattern === "string" ? value.pattern.trim() : "";
if (pattern === "") {
return undefined;
}
const argPattern = typeof value.argPattern === "string" ? value.argPattern.trim() : undefined;
if (value.argPattern !== undefined && argPattern === undefined) {
return undefined;
}
return execApprovalAllowlistRequirementFromParts(
pattern,
argPattern === "" ? undefined : argPattern,
);
}
function execApprovalAllowlistRequirementFromParts(
pattern: string,
argPattern?: string,
): ExecApprovalAllowlistRequirement {
return {
key: execApprovalAllowlistRequirementKey(pattern, argPattern),
pattern,
...(argPattern === undefined ? {} : { argPattern }),
};
}
export function execApprovalAllowlistRequirementKey(
pattern: string,
argPattern: string | undefined,
): string {
return `${pattern}\0${argPattern ?? ""}`;
}
export function execApprovalAllowlistMissingTarget(agentId: string | undefined): string {
return agentId === undefined
? "oc://exec-approvals.json"
: `oc://exec-approvals.json/agents/${ocPathSegment(agentId)}/allowlist`;
}
export function formatExecApprovalAllowlistRequirement(
entry: ExecApprovalAllowlistRequirement,
): string {
return formatExecApprovalAllowlistParts(entry.pattern, entry.argPattern);
}
export function formatExecApprovalAllowlistEntry(
entry: PolicyExecApprovalEvidence | undefined,
): string {
return formatExecApprovalAllowlistParts(entry?.pattern ?? "", entry?.argPattern);
}
function formatExecApprovalAllowlistParts(pattern: string, argPattern: string | undefined): string {
return argPattern === undefined ? pattern : `${pattern} argPattern=${argPattern}`;
}
export function effectiveExecApprovalAgentSecurityEntry(
entries: readonly PolicyExecApprovalEvidence[],
agentId: string,
): PolicyExecApprovalEvidence | undefined {
const exact = entries.find(
(entry) =>
entry.kind === "agent" &&
entry.agentId !== undefined &&
normalizeAgentId(entry.agentId) === normalizeAgentId(agentId),
);
const wildcard = entries.find((entry) => entry.kind === "agent" && entry.agentId === "*");
if (exact?.security !== undefined || exact?.securityConfigured === true) {
return exact;
}
return wildcard?.security === undefined ? (exact ?? wildcard) : wildcard;
}
export function effectiveExecApprovalAgentAutoAllowSkillsEntry(
entries: readonly PolicyExecApprovalEvidence[],
agentId: string,
): PolicyExecApprovalEvidence | undefined {
const exact = entries.find(
(entry) =>
entry.kind === "agent" &&
entry.agentId !== undefined &&
normalizeAgentId(entry.agentId) === normalizeAgentId(agentId),
);
if (exact?.autoAllowSkills !== undefined) {
return exact;
}
const wildcard = entries.find((entry) => entry.kind === "agent" && entry.agentId === "*");
return wildcard?.autoAllowSkills === undefined ? undefined : wildcard;
}
export function syntheticExecApprovalAgentEntry(agentId: string): PolicyExecApprovalEvidence {
return {
id: `agent:${agentId}:runtime-defaults`,
kind: "agent",
source: "oc://exec-approvals.json",
agentId,
};
}
+1 -1
View File
@@ -1,5 +1,5 @@
// Policy doctor fix metadata classifies findings before patch builders exist.
import { CHECK_IDS, POLICY_CHECK_IDS } from "./metadata.js";
import { CHECK_IDS, POLICY_CHECK_IDS } from "./check-ids.js";
type PolicyFixClass = "automatic" | "reviewRequired" | "manual" | "validateOnly" | "unsupported";
@@ -0,0 +1,265 @@
import type { HealthFinding } from "openclaw/plugin-sdk/health";
import { isRecord } from "openclaw/plugin-sdk/string-coerce-runtime";
import type { PolicyEvidence, PolicyIngressEvidence } from "../policy-state.js";
import { ingressPolicyShapeFinding } from "./access-shapes.js";
import { CHECK_IDS, POLICY_CHECK_IDS } from "./check-ids.js";
import { normalizePolicyChannelId } from "./policy-runtime.js";
import { channelScopedPolicyTargets } from "./policy-scope.js";
import { hasValidScopedPolicy } from "./scoped-policy-shape.js";
import { ocPathSegment, readPolicyBoolean, readString, readStringList } from "./utils.js";
export function ingressFindings(
policy: unknown,
policyPath: string,
policyDocName: string,
evidence: PolicyEvidence,
): readonly HealthFinding[] {
if (!isRecord(policy)) {
return [];
}
const findings: HealthFinding[] = [];
const ingressPolicy = policy.ingress;
if (
ingressPolicyShapeFinding(ingressPolicy, { policyDocName, policyPath }) === undefined &&
isRecord(ingressPolicy)
) {
findings.push(
...ingressFindingsForRule(ingressPolicy, policyDocName, "ingress", evidence, () => true),
);
}
if (hasValidScopedPolicy(policy, policyPath, policyDocName)) {
for (const target of channelScopedPolicyTargets(policy)) {
if (
ingressPolicyShapeFinding(target.overlay.ingress, {
policyDocName,
policyPath,
targetPrefix: `scopes/${ocPathSegment(target.scopeName)}/ingress`,
propertyPrefix: `scopes.${target.scopeName}.ingress`,
allowSession: false,
}) !== undefined ||
!isRecord(target.overlay.ingress)
) {
continue;
}
findings.push(
...ingressFindingsForRule(
target.overlay.ingress,
policyDocName,
`scopes/${ocPathSegment(target.scopeName)}/ingress`,
evidence,
(entry) => scopedIngressChannelMatches(entry, target.channelId),
),
);
}
}
return findings;
}
function ingressFindingsForRule(
ingressPolicy: Record<string, unknown> | undefined,
policyDocName: string,
requirementBase: string,
evidence: PolicyEvidence,
evidenceFilter: (entry: PolicyIngressEvidence) => boolean,
): readonly HealthFinding[] {
if (!isRecord(ingressPolicy)) {
return [];
}
return [
...ingressDmScopeFindings(
ingressPolicy,
policyDocName,
requirementBase,
evidence,
evidenceFilter,
),
...ingressDmPolicyFindings(
ingressPolicy,
policyDocName,
requirementBase,
evidence,
evidenceFilter,
),
...ingressOpenGroupFindings(
ingressPolicy,
policyDocName,
requirementBase,
evidence,
evidenceFilter,
),
...ingressRequireMentionFindings(
ingressPolicy,
policyDocName,
requirementBase,
evidence,
evidenceFilter,
),
];
}
function ingressDmScopeFindings(
ingressPolicy: Record<string, unknown>,
policyDocName: string,
requirementBase: string,
evidence: PolicyEvidence,
evidenceFilter: (entry: PolicyIngressEvidence) => boolean,
): readonly HealthFinding[] {
const required = readString(ingressPolicy, ["session", "requireDmScope"]);
if (required === undefined) {
return [];
}
return ingressEntries(evidence, "sessionDmScope")
.filter(evidenceFilter)
.filter((entry) => entry.value !== required)
.map((entry) =>
ingressFinding(entry, {
checkId: CHECK_IDS.policyIngressDmScopeUnapproved,
message: `session.dmScope '${entry.value ?? ""}' does not match policy.`,
requirement: `oc://${policyDocName}/${requirementBase}/session/requireDmScope`,
fixHint:
"Set session.dmScope to the required isolation scope or update policy after review.",
}),
);
}
function ingressDmPolicyFindings(
ingressPolicy: Record<string, unknown>,
policyDocName: string,
requirementBase: string,
evidence: PolicyEvidence,
evidenceFilter: (entry: PolicyIngressEvidence) => boolean,
): readonly HealthFinding[] {
const allowed = new Set(readStringList(ingressPolicy, ["channels", "allowDmPolicies"]));
if (allowed.size === 0) {
return [];
}
return ingressEntries(evidence, "channelDmPolicy")
.filter(evidenceFilter)
.filter((entry) => typeof entry.value === "string" && !allowed.has(entry.value.toLowerCase()))
.map((entry) =>
ingressFinding(entry, {
checkId: CHECK_IDS.policyIngressDmPolicyUnapproved,
message: `${ingressLabel(entry)} uses unapproved DM policy '${entry.value ?? ""}'.`,
requirement: `oc://${policyDocName}/${requirementBase}/channels/allowDmPolicies`,
fixHint: "Set the channel DM policy to an allowed value or update policy after review.",
}),
);
}
function ingressOpenGroupFindings(
ingressPolicy: Record<string, unknown>,
policyDocName: string,
requirementBase: string,
evidence: PolicyEvidence,
evidenceFilter: (entry: PolicyIngressEvidence) => boolean,
): readonly HealthFinding[] {
if (readPolicyBoolean(ingressPolicy, ["channels", "denyOpenGroups"]) !== true) {
return [];
}
return ingressEntries(evidence, "channelGroupPolicy")
.filter(evidenceFilter)
.filter((entry) => entry.value !== "allowlist" && entry.value !== "disabled")
.map((entry) =>
ingressFinding(entry, {
checkId: CHECK_IDS.policyIngressOpenGroupsDenied,
message: `${ingressLabel(entry)} allows open group ingress.`,
requirement: `oc://${policyDocName}/${requirementBase}/channels/denyOpenGroups`,
fixHint: "Set groupPolicy to allowlist or disabled, or update policy after review.",
}),
);
}
function ingressRequireMentionFindings(
ingressPolicy: Record<string, unknown>,
policyDocName: string,
requirementBase: string,
evidence: PolicyEvidence,
evidenceFilter: (entry: PolicyIngressEvidence) => boolean,
): readonly HealthFinding[] {
if (readPolicyBoolean(ingressPolicy, ["channels", "requireMentionInGroups"]) !== true) {
return [];
}
const groupPolicies = ingressEntries(evidence, "channelGroupPolicy").filter(evidenceFilter);
return ingressEntries(evidence, "channelRequireMention")
.filter(evidenceFilter)
.filter((entry) => !isGroupIngressDisabled(entry, groupPolicies))
.filter((entry) => entry.value !== true)
.map((entry) =>
ingressFinding(entry, {
checkId: CHECK_IDS.policyIngressGroupMentionRequired,
message: `${ingressLabel(entry)} does not require group mentions.`,
requirement: `oc://${policyDocName}/${requirementBase}/channels/requireMentionInGroups`,
fixHint:
"Set requireMention=true for the channel/group entry or update policy after review.",
}),
);
}
function isGroupIngressDisabled(
entry: PolicyIngressEvidence,
groupPolicies: readonly PolicyIngressEvidence[],
): boolean {
const entryParent = ocPathParent(entry.source);
const channelDefaultsParent = "oc://openclaw.config/channels/defaults";
const matches = groupPolicies
.filter((candidate) => {
const candidateParent = ocPathParent(candidate.source);
return (
candidate.channel === entry.channel &&
(candidate.accountId ?? "") === (entry.accountId ?? "") &&
(candidateParent === channelDefaultsParent ||
entryParent === candidateParent ||
entryParent.startsWith(`${candidateParent}/`))
);
})
.toSorted(
(left, right) => ocPathParent(right.source).length - ocPathParent(left.source).length,
);
return matches[0]?.value === "disabled";
}
function ocPathParent(source: string): string {
return source.slice(0, Math.max(0, source.lastIndexOf("/")));
}
function ingressEntries(
evidence: PolicyEvidence,
kind: PolicyIngressEvidence["kind"],
): readonly PolicyIngressEvidence[] {
return (evidence.ingress ?? []).filter((entry) => entry.kind === kind);
}
function scopedIngressChannelMatches(
entry: PolicyIngressEvidence,
policyChannelId: string,
): boolean {
return normalizePolicyChannelId(entry.channel ?? "") === policyChannelId;
}
function ingressFinding(
entry: PolicyIngressEvidence,
params: {
readonly checkId: (typeof POLICY_CHECK_IDS)[number];
readonly message: string;
readonly requirement: string;
readonly fixHint: string;
},
): HealthFinding {
return {
checkId: params.checkId,
severity: "error",
message: params.message,
source: "policy",
path: "openclaw config",
ocPath: entry.source,
target: entry.source,
requirement: params.requirement,
fixHint: params.fixHint,
};
}
function ingressLabel(entry: PolicyIngressEvidence): string {
const account = entry.accountId === undefined ? "" : ` account '${entry.accountId}'`;
const group = entry.groupId === undefined ? "" : ` group '${entry.groupId}'`;
return `channel '${entry.channel ?? "unknown"}'${account}${group}`;
}
@@ -1,12 +1,8 @@
// Policy doctor metadata tests cover rule metadata.
import { describe, expect, it } from "vitest";
import { CHECK_IDS, POLICY_CHECK_IDS } from "./check-ids.js";
import { POLICY_FIX_METADATA_BY_CHECK_ID } from "./fix-metadata.js";
import {
CHECK_IDS,
POLICY_CHECK_IDS,
POLICY_RULE_METADATA,
type PolicyRuleMetadata,
} from "./metadata.js";
import { POLICY_RULE_METADATA, type PolicyRuleMetadata } from "./metadata.js";
const POLICY_FIX_METADATA = [...POLICY_FIX_METADATA_BY_CHECK_ID.values()];
type PolicyFixMetadata = (typeof POLICY_FIX_METADATA)[number];
+2 -136
View File
@@ -1,139 +1,5 @@
// Policy doctor check IDs and rule metadata.
export const CHECK_IDS = {
policyAttestationMismatch: "policy/attestation-hash-mismatch",
policyDeniedChannelProvider: "policy/channels-denied-provider",
policyHashMismatch: "policy/policy-hash-mismatch",
policyInvalidFile: "policy/policy-jsonc-invalid",
policyMissingFile: "policy/policy-jsonc-missing",
policyDeniedMcpServer: "policy/mcp-denied-server",
policyUnapprovedMcpServer: "policy/mcp-unapproved-server",
policyDeniedModelProvider: "policy/models-denied-provider",
policyUnapprovedModelProvider: "policy/models-unapproved-provider",
policyPrivateNetworkAccess: "policy/network-private-access-enabled",
policyIngressDmPolicyUnapproved: "policy/ingress-dm-policy-unapproved",
policyIngressDmScopeUnapproved: "policy/ingress-dm-scope-unapproved",
policyIngressOpenGroupsDenied: "policy/ingress-open-groups-denied",
policyIngressGroupMentionRequired: "policy/ingress-group-mention-required",
policyGatewayNonLoopbackBind: "policy/gateway-non-loopback-bind",
policyGatewayAuthDisabled: "policy/gateway-auth-disabled",
policyGatewayRateLimitMissing: "policy/gateway-rate-limit-missing",
policyGatewayControlUiInsecure: "policy/gateway-control-ui-insecure",
policyGatewayTailscaleFunnel: "policy/gateway-tailscale-funnel",
policyGatewayRemoteEnabled: "policy/gateway-remote-enabled",
policyGatewayHttpEndpointEnabled: "policy/gateway-http-endpoint-enabled",
policyGatewayHttpUrlFetchUnrestricted: "policy/gateway-http-url-fetch-unrestricted",
policyGatewayNodeCommandDenied: "policy/gateway-node-command-denied",
policyAgentsWorkspaceAccessDenied: "policy/agents-workspace-access-denied",
policyAgentsToolNotDenied: "policy/agents-tool-not-denied",
policyToolsElevatedEnabled: "policy/tools-elevated-enabled",
policyToolsAlsoAllowMissing: "policy/tools-also-allow-missing",
policyToolsAlsoAllowUnexpected: "policy/tools-also-allow-unexpected",
policyToolsExecAskUnapproved: "policy/tools-exec-ask-unapproved",
policyToolsExecHostUnapproved: "policy/tools-exec-host-unapproved",
policyToolsExecSecurityUnapproved: "policy/tools-exec-security-unapproved",
policyToolsFsWorkspaceOnlyRequired: "policy/tools-fs-workspace-only-required",
policyToolsProfileUnapproved: "policy/tools-profile-unapproved",
policyToolsRequiredDenyMissing: "policy/tools-required-deny-missing",
policySandboxModeUnapproved: "policy/sandbox-mode-unapproved",
policySandboxBackendUnapproved: "policy/sandbox-backend-unapproved",
policySandboxContainerPostureUnobservable: "policy/sandbox-container-posture-unobservable",
policySandboxContainerHostNetworkDenied: "policy/sandbox-container-host-network-denied",
policySandboxContainerNamespaceJoinDenied: "policy/sandbox-container-namespace-join-denied",
policySandboxContainerMountModeRequired: "policy/sandbox-container-mount-mode-required",
policySandboxContainerRuntimeSocketMount: "policy/sandbox-container-runtime-socket-mount",
policySandboxContainerUnconfinedProfile: "policy/sandbox-container-unconfined-profile",
policySandboxBrowserCdpSourceRangeMissing: "policy/sandbox-browser-cdp-source-range-missing",
policyDataHandlingRedactionDisabled: "policy/data-handling-redaction-disabled",
policyDataHandlingTelemetryContentCapture: "policy/data-handling-telemetry-content-capture",
policyDataHandlingSessionRetentionNotEnforced:
"policy/data-handling-session-retention-not-enforced",
policyDataHandlingSessionTranscriptMemory:
"policy/data-handling-session-transcript-memory-enabled",
policySecretsUnmanagedProvider: "policy/secrets-unmanaged-provider",
policySecretsDeniedProviderSource: "policy/secrets-denied-provider-source",
policySecretsInsecureProvider: "policy/secrets-insecure-provider",
policyAuthProfileInvalidMetadata: "policy/auth-profile-invalid-metadata",
policyAuthProfileUnapprovedMode: "policy/auth-profile-unapproved-mode",
policyExecApprovalsMissing: "policy/exec-approvals-missing",
policyExecApprovalsInvalid: "policy/exec-approvals-invalid",
policyExecApprovalsDefaultSecurityUnapproved: "policy/exec-approvals-default-security-unapproved",
policyExecApprovalsAgentSecurityUnapproved: "policy/exec-approvals-agent-security-unapproved",
policyExecApprovalsAutoAllowSkillsEnabled: "policy/exec-approvals-auto-allow-skills-enabled",
policyExecApprovalsAllowlistMissing: "policy/exec-approvals-allowlist-missing",
policyExecApprovalsAllowlistUnexpected: "policy/exec-approvals-allowlist-unexpected",
policyMissingToolOwner: "policy/tools-missing-owner",
policyMissingToolRisk: "policy/tools-missing-risk-level",
policyMissingToolSensitivity: "policy/tools-missing-sensitivity-token",
policyUnknownToolRisk: "policy/tools-unknown-risk-level",
policyUnknownToolSensitivity: "policy/tools-unknown-sensitivity-token",
} as const;
export const POLICY_CHECK_IDS = [
CHECK_IDS.policyMissingFile,
CHECK_IDS.policyInvalidFile,
CHECK_IDS.policyHashMismatch,
CHECK_IDS.policyAttestationMismatch,
CHECK_IDS.policyDeniedChannelProvider,
CHECK_IDS.policyDeniedMcpServer,
CHECK_IDS.policyUnapprovedMcpServer,
CHECK_IDS.policyDeniedModelProvider,
CHECK_IDS.policyUnapprovedModelProvider,
CHECK_IDS.policyPrivateNetworkAccess,
CHECK_IDS.policyIngressDmPolicyUnapproved,
CHECK_IDS.policyIngressDmScopeUnapproved,
CHECK_IDS.policyIngressOpenGroupsDenied,
CHECK_IDS.policyIngressGroupMentionRequired,
CHECK_IDS.policyGatewayNonLoopbackBind,
CHECK_IDS.policyGatewayAuthDisabled,
CHECK_IDS.policyGatewayRateLimitMissing,
CHECK_IDS.policyGatewayControlUiInsecure,
CHECK_IDS.policyGatewayTailscaleFunnel,
CHECK_IDS.policyGatewayRemoteEnabled,
CHECK_IDS.policyGatewayHttpEndpointEnabled,
CHECK_IDS.policyGatewayHttpUrlFetchUnrestricted,
CHECK_IDS.policyGatewayNodeCommandDenied,
CHECK_IDS.policyAgentsWorkspaceAccessDenied,
CHECK_IDS.policyAgentsToolNotDenied,
CHECK_IDS.policyToolsProfileUnapproved,
CHECK_IDS.policyToolsFsWorkspaceOnlyRequired,
CHECK_IDS.policyToolsExecSecurityUnapproved,
CHECK_IDS.policyToolsExecAskUnapproved,
CHECK_IDS.policyToolsExecHostUnapproved,
CHECK_IDS.policyToolsElevatedEnabled,
CHECK_IDS.policyToolsAlsoAllowMissing,
CHECK_IDS.policyToolsAlsoAllowUnexpected,
CHECK_IDS.policyToolsRequiredDenyMissing,
CHECK_IDS.policySandboxModeUnapproved,
CHECK_IDS.policySandboxBackendUnapproved,
CHECK_IDS.policySandboxContainerPostureUnobservable,
CHECK_IDS.policySandboxContainerHostNetworkDenied,
CHECK_IDS.policySandboxContainerNamespaceJoinDenied,
CHECK_IDS.policySandboxContainerMountModeRequired,
CHECK_IDS.policySandboxContainerRuntimeSocketMount,
CHECK_IDS.policySandboxContainerUnconfinedProfile,
CHECK_IDS.policySandboxBrowserCdpSourceRangeMissing,
CHECK_IDS.policyDataHandlingRedactionDisabled,
CHECK_IDS.policyDataHandlingTelemetryContentCapture,
CHECK_IDS.policyDataHandlingSessionRetentionNotEnforced,
CHECK_IDS.policyDataHandlingSessionTranscriptMemory,
CHECK_IDS.policySecretsUnmanagedProvider,
CHECK_IDS.policySecretsDeniedProviderSource,
CHECK_IDS.policySecretsInsecureProvider,
CHECK_IDS.policyAuthProfileInvalidMetadata,
CHECK_IDS.policyAuthProfileUnapprovedMode,
CHECK_IDS.policyExecApprovalsMissing,
CHECK_IDS.policyExecApprovalsInvalid,
CHECK_IDS.policyExecApprovalsDefaultSecurityUnapproved,
CHECK_IDS.policyExecApprovalsAgentSecurityUnapproved,
CHECK_IDS.policyExecApprovalsAutoAllowSkillsEnabled,
CHECK_IDS.policyExecApprovalsAllowlistMissing,
CHECK_IDS.policyExecApprovalsAllowlistUnexpected,
CHECK_IDS.policyMissingToolRisk,
CHECK_IDS.policyUnknownToolRisk,
CHECK_IDS.policyMissingToolSensitivity,
CHECK_IDS.policyMissingToolOwner,
CHECK_IDS.policyUnknownToolSensitivity,
] as const;
// Policy doctor rule metadata.
import { CHECK_IDS, POLICY_CHECK_IDS } from "./check-ids.js";
type PolicyStrictnessKind =
| "allowlist-subset"
@@ -0,0 +1,75 @@
import { POLICY_RULE_METADATA, type PolicyRuleMetadata } from "./metadata.js";
export const POLICY_RULES: readonly PolicyRuleMetadata[] = POLICY_RULE_METADATA;
export const KNOWN_RISK_LEVELS = ["low", "medium", "high", "critical"] as const;
export const KNOWN_SENSITIVITY_LEVELS = [
"public",
"internal",
"confidential",
"restricted",
] as const;
export const SUPPORTED_TOOL_METADATA = ["risk", "sensitivity", "owner"] as const;
export const SUPPORTED_AUTH_PROFILE_METADATA = ["provider", "mode"] as const;
export const SUPPORTED_AUTH_PROFILE_MODES = ["api_key", "aws-sdk", "oauth", "token"] as const;
export const SUPPORTED_POLICY_SECTIONS = [
"auth",
"agents",
"channels",
"dataHandling",
"execApprovals",
"gateway",
"ingress",
"mcp",
"models",
"network",
"sandbox",
"scopes",
"secrets",
"tools",
] as const;
export const SUPPORTED_GATEWAY_POLICY_SECTIONS = [
"auth",
"controlUi",
"exposure",
"http",
"nodes",
"remote",
] as const;
export const SUPPORTED_GATEWAY_HTTP_ENDPOINTS = ["chatCompletions", "responses"] as const;
export const SUPPORTED_DM_POLICIES = ["pairing", "allowlist", "open", "disabled"] as const;
export const SUPPORTED_DM_SCOPES = [
"main",
"per-peer",
"per-channel-peer",
"per-account-channel-peer",
] as const;
export const SUPPORTED_AGENT_WORKSPACE_DENY_TOOLS = [
"exec",
"process",
"write",
"edit",
"apply_patch",
] as const;
export const SUPPORTED_TOOL_PROFILES = ["minimal", "coding", "messaging", "full"] as const;
export const SUPPORTED_TOOL_EXEC_SECURITY = ["deny", "allowlist", "full"] as const;
export const SUPPORTED_TOOL_EXEC_ASK = ["off", "on-miss", "always"] as const;
export const SUPPORTED_TOOL_EXEC_HOST = ["auto", "sandbox", "gateway", "node"] as const;
export const SUPPORTED_EXEC_APPROVAL_SECURITY = ["deny", "allowlist", "full"] as const;
export const SUPPORTED_SANDBOX_MODES = ["off", "non-main", "all"] as const;
@@ -0,0 +1,425 @@
import os from "node:os";
import { basename, isAbsolute, resolve } from "node:path";
import JSON5 from "json5";
import type { HealthCheckContext, HealthFinding } from "openclaw/plugin-sdk/health";
import { createLazyRuntimeModule } from "openclaw/plugin-sdk/lazy-runtime";
import { isRecord } from "openclaw/plugin-sdk/string-coerce-runtime";
import type { PolicyAuthProfileEvidence } from "../policy-state.js";
import { POLICY_TOOL_GROUPS } from "../tool-policy-conformance.js";
import { CHECK_IDS } from "./check-ids.js";
import {
SUPPORTED_AUTH_PROFILE_METADATA,
SUPPORTED_AUTH_PROFILE_MODES,
} from "./policy-constants.js";
import { readPolicyStringArray } from "./utils.js";
const loadFsPromisesModule = createLazyRuntimeModule(() => import("node:fs/promises"));
export async function readPolicyFile(
ctx: HealthCheckContext,
): Promise<{ raw: string; path: string; displayName: string; ocDocName: string } | null> {
const displayName = policyDisplayName(ctx);
const path = resolveWorkspacePath(ctx, policyPathSetting(ctx));
try {
const fs = await loadFsPromisesModule();
return {
raw: await fs.readFile(path, "utf-8"),
path,
displayName,
ocDocName: basename(displayName),
};
} catch (err) {
if (isNotFound(err)) {
return null;
}
throw err;
}
}
export async function readExecApprovalsFile(
ctx: HealthCheckContext,
): Promise<{ raw: string; path: string; displayName: string; ocDocName: string } | null> {
const artifact = execApprovalsArtifactLocation(ctx);
try {
const fs = await loadFsPromisesModule();
return {
raw: await fs.readFile(artifact.path, "utf-8"),
path: artifact.path,
displayName: artifact.displayName,
ocDocName: "exec-approvals.json",
};
} catch (err) {
if (isNotFound(err)) {
return null;
}
throw err;
}
}
export async function readWorkspaceFile(
ctx: HealthCheckContext,
fileName: string,
): Promise<{ raw: string; path: string } | null> {
const path = resolveWorkspacePath(ctx, fileName);
try {
const fs = await loadFsPromisesModule();
return { raw: await fs.readFile(path, "utf-8"), path };
} catch (err) {
if (isNotFound(err)) {
return null;
}
throw err;
}
}
function resolvePolicyArtifactPath(ctx: HealthCheckContext, fileName: string): string {
if (fileName.startsWith("~/") || fileName.startsWith("~\\")) {
const home = resolvePolicyArtifactHomeDir();
if (home !== undefined) {
return resolve(home, fileName.slice(2));
}
}
return resolveWorkspacePath(ctx, fileName);
}
function resolvePolicyArtifactHomeDir(): string | undefined {
const explicitHome = normalizedEnvValue(process.env.OPENCLAW_HOME);
if (explicitHome !== undefined) {
if (explicitHome === "~" || explicitHome.startsWith("~/") || explicitHome.startsWith("~\\")) {
return resolvePolicyHomeRelativePath(explicitHome);
}
return resolve(explicitHome);
}
return resolveOsPolicyHomeDir();
}
function resolvePolicyHomeRelativePath(value: string): string {
const fallbackHome = resolveOsPolicyHomeDir();
return fallbackHome === undefined
? resolve(value)
: resolve(value.replace(/^~(?=$|[\\/])/, fallbackHome));
}
function resolveOsPolicyHomeDir(): string | undefined {
return (
normalizedEnvValue(process.env.HOME) ??
normalizedEnvValue(process.env.USERPROFILE) ??
safeOsHomeDir()
);
}
function safeOsHomeDir(): string | undefined {
try {
return normalizedEnvValue(os.homedir());
} catch {
return undefined;
}
}
function normalizedEnvValue(value: string | undefined): string | undefined {
const trimmed = value?.trim();
return trimmed === undefined || trimmed === "" || trimmed === "undefined" || trimmed === "null"
? undefined
: trimmed;
}
function resolveWorkspacePath(ctx: HealthCheckContext, fileName: string): string {
if (isAbsolute(fileName)) {
return fileName;
}
return resolve(ctx.cwd ?? process.cwd(), fileName);
}
function isNotFound(err: unknown): boolean {
return typeof err === "object" && err !== null && "code" in err && err.code === "ENOENT";
}
export function parseExecApprovalsFile(
raw: string,
):
| { readonly ok: true; readonly value: unknown }
| { readonly ok: false; readonly message: string } {
try {
const value = JSON.parse(raw);
if (!isRecord(value) || value.version !== 1) {
return { ok: false, message: "unsupported exec approvals version" };
}
return { ok: true, value };
} catch (err) {
return {
ok: false,
message: err instanceof Error ? err.message : String(err),
};
}
}
export function parsePolicyFile(
raw: string,
):
| { readonly ok: true; readonly value: unknown }
| { readonly ok: false; readonly message: string } {
try {
return { ok: true, value: JSON5.parse(raw) };
} catch (err) {
return {
ok: false,
message: err instanceof Error ? err.message : String(err),
};
}
}
export function workspaceRepairsEnabled(ctx: HealthCheckContext): boolean {
return policySettings(ctx).workspaceRepairs === true;
}
export function workspaceRepairsDisabledResult(fileName: string): {
readonly status: "skipped";
readonly reason: string;
readonly changes: readonly string[];
readonly warnings: readonly string[];
} {
const reason = "workspace repairs are disabled";
return {
status: "skipped",
reason,
changes: [],
warnings: [
`Skipped ${fileName} repair. Enable plugins.entries.policy.config.workspaceRepairs to let doctor --fix edit workspace files.`,
],
};
}
export function readChannelDenyRules(
policy: unknown,
policyDocName: string,
): readonly {
readonly id?: string;
readonly when?: { readonly provider?: string };
readonly reason?: string;
readonly requirement: string;
}[] {
if (
!isRecord(policy) ||
!isRecord(policy.channels) ||
!Array.isArray(policy.channels.denyRules)
) {
return [];
}
return policy.channels.denyRules
.map((rule, index) => ({ rule, index }))
.filter(
(
entry,
): entry is {
readonly index: number;
readonly rule: {
readonly id?: string;
readonly when?: { readonly provider?: string };
readonly reason?: string;
};
} => isChannelDenyRule(entry.rule),
)
.map(({ rule, index }) => {
const next: {
id?: string;
when?: { readonly provider?: string };
reason?: string;
requirement: string;
} = {
when: rule.when,
requirement: `oc://${policyDocName}/channels/denyRules/#${index}`,
};
if (rule.id !== undefined) {
next.id = rule.id;
}
if (rule.reason !== undefined) {
next.reason = rule.reason;
}
return next;
});
}
export function isChannelDenyRule(value: unknown): value is {
readonly id?: string;
readonly when?: { readonly provider?: string };
readonly reason?: string;
} {
return (
isRecord(value) &&
(value.id === undefined || typeof value.id === "string") &&
(value.reason === undefined || typeof value.reason === "string") &&
isRecord(value.when) &&
typeof value.when.provider === "string"
);
}
export function channelIdsFromFindings(findings: readonly HealthFinding[]): readonly string[] {
return [
...new Set(
findings
.filter((finding) => finding.checkId === CHECK_IDS.policyDeniedChannelProvider)
.map((finding) => finding.ocPath?.match(/^oc:\/\/openclaw\.config\/channels\/(.+)$/)?.[1])
.filter((id): id is string => id !== undefined && id !== ""),
),
];
}
export function disableChannels(
cfg: HealthCheckContext["cfg"],
channelIds: readonly string[],
): { readonly config: HealthCheckContext["cfg"]; readonly changed: readonly string[] } {
if (!isRecord(cfg.channels)) {
return { config: cfg, changed: [] };
}
const channels: Record<string, unknown> = { ...cfg.channels };
const changed: string[] = [];
for (const id of channelIds) {
const current = channels[id];
if (!isRecord(current) || current.enabled === false) {
continue;
}
channels[id] = { ...current, enabled: false };
changed.push(id);
}
if (changed.length === 0) {
return { config: cfg, changed };
}
return { config: { ...cfg, channels }, changed };
}
export type PolicySettings = {
readonly enabled?: boolean;
readonly workspaceRepairs?: boolean;
readonly expectedHash?: string;
readonly expectedAttestationHash?: string;
readonly path?: string;
};
export function policySettings(ctx: HealthCheckContext): PolicySettings {
const pluginConfig = ctx.cfg.plugins?.entries?.["policy"]?.config;
if (!isRecord(pluginConfig)) {
return {};
}
return pluginConfig;
}
export function policyChecksEnabled(ctx: HealthCheckContext, settings: PolicySettings): boolean {
const entry = ctx.cfg.plugins?.entries?.["policy"];
if (!isRecord(entry) || entry.enabled === false) {
return false;
}
return settings.enabled !== false;
}
export function requiredToolMetadata(policy: unknown): ReadonlySet<string> {
return new Set(readPolicyStringArray(policy, ["tools", "requireMetadata"]) ?? []);
}
export function requiredAuthProfileMetadata(
policy: unknown,
): ReadonlySet<(typeof SUPPORTED_AUTH_PROFILE_METADATA)[number]> {
const entries = readPolicyStringArray(policy, ["auth", "profiles", "requireMetadata"]) ?? [];
return new Set(
entries.filter((entry): entry is (typeof SUPPORTED_AUTH_PROFILE_METADATA)[number] =>
SUPPORTED_AUTH_PROFILE_METADATA.includes(
entry as (typeof SUPPORTED_AUTH_PROFILE_METADATA)[number],
),
),
);
}
export function authProfileHasMetadata(
profile: PolicyAuthProfileEvidence,
metadata: (typeof SUPPORTED_AUTH_PROFILE_METADATA)[number],
): boolean {
if (metadata === "provider") {
return profile.provider !== undefined && profile.provider.trim() !== "";
}
return SUPPORTED_AUTH_PROFILE_MODES.includes(
profile.mode as (typeof SUPPORTED_AUTH_PROFILE_MODES)[number],
);
}
function policyToolGlobMatches(tool: string, pattern: string): boolean {
const escaped = pattern.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
return new RegExp(`^${escaped.replaceAll("\\*", ".*")}$`).test(tool);
}
export function toolListCoversTool(list: readonly string[], tool: string): boolean {
for (const entry of list) {
const normalized = normalizePolicyToolName(entry);
if (normalized === "*" || normalized === tool) {
return true;
}
if (POLICY_TOOL_GROUPS[normalized]?.includes(tool)) {
return true;
}
if (normalized.includes("*") && policyToolGlobMatches(tool, normalized)) {
return true;
}
}
return false;
}
export function expandPolicyToolRequirement(value: string): readonly string[] {
const normalized = normalizePolicyToolName(value);
return POLICY_TOOL_GROUPS[normalized] ?? [normalized];
}
function normalizePolicyToolName(value: string): string {
const normalized = value.trim().toLowerCase();
if (normalized === "bash") {
return "exec";
}
if (normalized === "apply-patch") {
return "apply_patch";
}
return normalized;
}
export function normalizePolicyChannelId(value: string): string {
return value.trim().toLowerCase();
}
function canonicalExecApprovalsPath(): string {
return "~/.openclaw/exec-approvals.json";
}
function execApprovalsArtifactLocation(ctx: HealthCheckContext): {
readonly path: string;
readonly displayName: string;
} {
const stateDir = normalizedEnvValue(process.env.OPENCLAW_STATE_DIR);
if (stateDir !== undefined) {
const path = resolve(resolvePolicyStateDir(stateDir), "exec-approvals.json");
return { path, displayName: path };
}
return {
path: resolvePolicyArtifactPath(ctx, canonicalExecApprovalsPath()),
displayName: canonicalExecApprovalsPath(),
};
}
export function execApprovalsDisplayName(): string {
const stateDir = normalizedEnvValue(process.env.OPENCLAW_STATE_DIR);
if (stateDir === undefined) {
return canonicalExecApprovalsPath();
}
return resolve(resolvePolicyStateDir(stateDir), "exec-approvals.json");
}
function resolvePolicyStateDir(stateDir: string): string {
return stateDir.startsWith("~") ? resolvePolicyHomeRelativePath(stateDir) : resolve(stateDir);
}
function policyPathSetting(ctx: HealthCheckContext): string {
const configured = policySettings(ctx).path;
return typeof configured === "string" && configured.trim() !== ""
? configured.trim()
: "policy.jsonc";
}
export function policyDisplayName(ctx: HealthCheckContext): string {
const configured = policyPathSetting(ctx);
return isAbsolute(configured) ? basename(configured) : configured;
}
@@ -0,0 +1,462 @@
import type { HealthFinding } from "openclaw/plugin-sdk/health";
import { normalizeAgentId } from "openclaw/plugin-sdk/routing";
import { isRecord } from "openclaw/plugin-sdk/string-coerce-runtime";
import type { PolicyAgentWorkspaceEvidence, PolicyToolPostureEvidence } from "../policy-state.js";
import {
SANDBOX_CONTAINER_POLICY_RULES,
type PolicyRuleMetadata,
type PolicyScopeSelectorKind,
} from "./metadata.js";
import { POLICY_RULES } from "./policy-constants.js";
import { normalizePolicyChannelId } from "./policy-runtime.js";
import { policyShapeFinding } from "./shape-helpers.js";
import { isPolicyValueAtLeastAsStrict } from "./strictness.js";
import { ocPathSegment } from "./utils.js";
export function scopedWorkspaceAgentMatches(
entry: PolicyAgentWorkspaceEvidence,
policyAgentId: string,
entries: readonly PolicyAgentWorkspaceEvidence[],
): boolean {
if (scopedAgentIdMatches(entry.agentId, policyAgentId)) {
return true;
}
return entry.scope === "defaults" && !hasScopedAgentEvidence(entries, entry.kind, policyAgentId);
}
export function scopedToolAgentMatches(
entry: PolicyToolPostureEvidence,
policyAgentId: string,
entries: readonly PolicyToolPostureEvidence[],
): boolean {
if (scopedAgentIdMatches(entry.agentId, policyAgentId)) {
return true;
}
return entry.scope === "global" && !hasScopedToolEvidence(entries, entry.kind, policyAgentId);
}
function hasScopedAgentEvidence(
entries: readonly PolicyAgentWorkspaceEvidence[],
kind: PolicyAgentWorkspaceEvidence["kind"],
policyAgentId: string,
): boolean {
return entries.some(
(candidate) =>
candidate.scope === "agent" &&
candidate.kind === kind &&
scopedAgentIdMatches(candidate.agentId, policyAgentId),
);
}
function hasScopedToolEvidence(
entries: readonly PolicyToolPostureEvidence[],
kind: PolicyToolPostureEvidence["kind"],
policyAgentId: string,
): boolean {
return entries.some(
(candidate) =>
candidate.scope === "agent" &&
candidate.kind === kind &&
scopedAgentIdMatches(candidate.agentId, policyAgentId),
);
}
export function scopedAgentIdMatches(
evidenceAgentId: string | undefined,
policyAgentId: string,
): boolean {
return (
evidenceAgentId !== undefined &&
normalizeAgentId(evidenceAgentId) === normalizeAgentId(policyAgentId)
);
}
export function policyHasExecApprovalsRules(policy: unknown): boolean {
if (!isRecord(policy)) {
return false;
}
if (execApprovalsPolicyHasRules(policy.execApprovals)) {
return true;
}
return agentScopedPolicyOverlays(policy).some(([, overlay]) =>
execApprovalsPolicyHasRules(overlay.execApprovals),
);
}
function execApprovalsPolicyHasRules(value: unknown): boolean {
return (
isRecord(value) &&
(value.requireFile !== undefined || isRecord(value.defaults) || isRecord(value.agents))
);
}
export function policyHasSecretRules(policy: unknown): boolean {
if (!isRecord(policy) || !isRecord(policy.secrets)) {
return false;
}
return (
policy.secrets.requireManagedProviders !== undefined ||
policy.secrets.denySources !== undefined ||
policy.secrets.allowInsecureProviders !== undefined
);
}
export function policyHasAuthProfileRules(policy: unknown): boolean {
return (
isRecord(policy) &&
isRecord(policy.auth) &&
isRecord(policy.auth.profiles) &&
(policy.auth.profiles.requireMetadata !== undefined ||
policy.auth.profiles.allowModes !== undefined)
);
}
export function policyHasIngressRules(policy: unknown): boolean {
if (!isRecord(policy)) {
return false;
}
if (ingressPolicyHasRules(policy.ingress)) {
return true;
}
return agentScopedPolicyOverlays(policy).some(([, overlay]) =>
ingressPolicyHasRules(overlay.ingress),
);
}
function ingressPolicyHasRules(value: unknown): boolean {
if (!isRecord(value)) {
return false;
}
const ingress = value;
return (
(isRecord(ingress.session) && ingress.session.requireDmScope !== undefined) ||
(isRecord(ingress.channels) &&
(ingress.channels.allowDmPolicies !== undefined ||
ingress.channels.denyOpenGroups !== undefined ||
ingress.channels.requireMentionInGroups !== undefined))
);
}
export function policyHasGatewayRules(policy: unknown): boolean {
if (!isRecord(policy) || !isRecord(policy.gateway)) {
return false;
}
const gateway = policy.gateway;
return (
(isRecord(gateway.exposure) &&
(gateway.exposure.allowNonLoopbackBind !== undefined ||
gateway.exposure.allowTailscaleFunnel !== undefined)) ||
(isRecord(gateway.auth) &&
(gateway.auth.requireAuth !== undefined ||
gateway.auth.requireExplicitRateLimit !== undefined)) ||
(isRecord(gateway.controlUi) && gateway.controlUi.allowInsecure !== undefined) ||
(isRecord(gateway.remote) && gateway.remote.allow !== undefined) ||
(isRecord(gateway.http) &&
(gateway.http.denyEndpoints !== undefined ||
gateway.http.requireUrlAllowlists !== undefined)) ||
(isRecord(gateway.nodes) && gateway.nodes.denyCommands !== undefined)
);
}
export function policyHasAgentWorkspaceRules(policy: unknown): boolean {
if (!isRecord(policy)) {
return false;
}
if (isRecord(policy.agents) && workspacePolicyHasRules(policy.agents.workspace)) {
return true;
}
return agentScopedPolicyOverlays(policy).some(([, overlay]) => {
const scopedAgents = isRecord(overlay.agents) ? overlay.agents : {};
return workspacePolicyHasRules(scopedAgents.workspace);
});
}
export function policyHasSandboxPostureRules(policy: unknown): boolean {
if (!isRecord(policy)) {
return false;
}
if (sandboxPosturePolicyHasRules(policy.sandbox)) {
return true;
}
return agentScopedPolicyOverlays(policy).some(([, overlay]) =>
sandboxPosturePolicyHasRules(overlay.sandbox),
);
}
function sandboxPosturePolicyHasRules(value: unknown): boolean {
if (!isRecord(value)) {
return false;
}
const sandbox = value;
const containers = isRecord(sandbox.containers) ? sandbox.containers : undefined;
const browser = isRecord(sandbox.browser) ? sandbox.browser : undefined;
return (
sandbox.requireMode !== undefined ||
sandbox.allowBackends !== undefined ||
(containers !== undefined &&
SANDBOX_CONTAINER_POLICY_RULES.some((rule) => containers[rule.key] !== undefined)) ||
browser?.requireCdpSourceRange !== undefined
);
}
export function policyHasDataHandlingRules(policy: unknown): boolean {
if (!isRecord(policy)) {
return false;
}
if (dataHandlingPolicyHasRules(policy.dataHandling)) {
return true;
}
return agentScopedPolicyOverlays(policy).some(([, overlay]) =>
dataHandlingPolicyHasRules(overlay.dataHandling),
);
}
export function dataHandlingPolicyHasRules(value: unknown): boolean {
if (!isRecord(value)) {
return false;
}
const dataHandling = value;
return (
(isRecord(dataHandling.sensitiveLogging) &&
dataHandling.sensitiveLogging.requireRedaction !== undefined) ||
(isRecord(dataHandling.telemetry) && dataHandling.telemetry.denyContentCapture !== undefined) ||
(isRecord(dataHandling.retention) &&
dataHandling.retention.requireSessionMaintenance !== undefined) ||
(isRecord(dataHandling.memory) &&
dataHandling.memory.denySessionTranscriptIndexing !== undefined)
);
}
export function policyHasToolPostureRules(policy: unknown): boolean {
if (!isRecord(policy)) {
return false;
}
if (toolPosturePolicyHasRules(policy.tools)) {
return true;
}
return agentScopedPolicyOverlays(policy).some(([, overlay]) =>
toolPosturePolicyHasRules(overlay.tools),
);
}
function workspacePolicyHasRules(value: unknown): boolean {
return isRecord(value) && (value.allowedAccess !== undefined || value.denyTools !== undefined);
}
function toolPosturePolicyHasRules(value: unknown): boolean {
if (!isRecord(value)) {
return false;
}
const tools = value;
return (
(isRecord(tools.profiles) && tools.profiles.allow !== undefined) ||
(isRecord(tools.fs) && tools.fs.requireWorkspaceOnly !== undefined) ||
(isRecord(tools.exec) &&
(tools.exec.allowSecurity !== undefined ||
tools.exec.requireAsk !== undefined ||
tools.exec.allowHosts !== undefined)) ||
(isRecord(tools.elevated) && tools.elevated.allow !== undefined) ||
(isRecord(tools.alsoAllow) && tools.alsoAllow.expected !== undefined) ||
tools.denyTools !== undefined
);
}
type AgentScopedPolicyTarget = {
readonly scopeName: string;
readonly agentId: string;
readonly overlay: Record<string, unknown>;
};
type ChannelScopedPolicyTarget = {
readonly scopeName: string;
readonly channelId: string;
readonly overlay: Record<string, unknown>;
};
function agentScopedPolicyOverlays(
policy: unknown,
): readonly (readonly [string, Record<string, unknown>])[] {
if (!isRecord(policy) || !isRecord(policy.scopes)) {
return [];
}
return Object.entries(policy.scopes).filter((entry): entry is [string, Record<string, unknown>] =>
isRecord(entry[1]),
);
}
export function agentScopedPolicyTargets(policy: unknown): readonly AgentScopedPolicyTarget[] {
const targets: AgentScopedPolicyTarget[] = [];
for (const [scopeName, overlay] of agentScopedPolicyOverlays(policy)) {
if (!Array.isArray(overlay.agentIds)) {
continue;
}
for (const rawAgentId of overlay.agentIds) {
if (typeof rawAgentId !== "string" || rawAgentId.trim() === "") {
continue;
}
targets.push({ scopeName, agentId: normalizeAgentId(rawAgentId), overlay });
}
}
return targets;
}
export function channelScopedPolicyTargets(policy: unknown): readonly ChannelScopedPolicyTarget[] {
const targets: ChannelScopedPolicyTarget[] = [];
for (const [scopeName, overlay] of agentScopedPolicyOverlays(policy)) {
if (!Array.isArray(overlay.channelIds)) {
continue;
}
for (const rawChannelId of overlay.channelIds) {
if (typeof rawChannelId !== "string" || rawChannelId.trim() === "") {
continue;
}
targets.push({ scopeName, channelId: normalizePolicyChannelId(rawChannelId), overlay });
}
}
return targets;
}
type ScopedPolicyField = {
readonly fieldPath: string;
readonly propertyPath: string;
readonly targetPath: string;
readonly metadata: PolicyRuleMetadata;
readonly value: unknown;
};
export function duplicateScopedPolicyFieldFinding(
scopes: Record<string, unknown>,
params: {
readonly policyDocName: string;
readonly policyPath: string;
readonly policy: Record<string, unknown>;
},
): HealthFinding | undefined {
return (
duplicateScopedFieldFinding(scopes, {
...params,
selector: "agentIds",
selectorLabel: "agent",
normalize: normalizeAgentId,
}) ??
duplicateScopedFieldFinding(scopes, {
...params,
selector: "channelIds",
selectorLabel: "channel",
normalize: normalizePolicyChannelId,
})
);
}
function duplicateScopedFieldFinding(
scopes: Record<string, unknown>,
params: {
readonly policyDocName: string;
readonly policyPath: string;
readonly policy: Record<string, unknown>;
readonly selector: PolicyScopeSelectorKind;
readonly selectorLabel: string;
readonly normalize: (value: string) => string;
},
): HealthFinding | undefined {
const seen = new Map<
string,
{
readonly scopeName: string;
readonly propertyPath: string;
readonly field: ScopedPolicyField;
}
>();
for (const [scopeName, overlay] of Object.entries(scopes)) {
if (!isRecord(overlay)) {
continue;
}
const selectorValues = overlay[params.selector];
if (!Array.isArray(selectorValues)) {
continue;
}
const fields = scopedPolicyFields(scopeName, overlay, params.selector);
for (const rawSelectorValue of selectorValues) {
if (typeof rawSelectorValue !== "string" || rawSelectorValue.trim() === "") {
continue;
}
const selectorValue = params.normalize(rawSelectorValue);
for (const field of fields) {
const topLevelValue = getPolicyPath(params.policy, field.metadata.policyPath);
if (
topLevelValue !== undefined &&
!isPolicyValueAtLeastAsStrict(field.metadata, field.value, topLevelValue)
) {
return policyShapeFinding(
params.policyPath,
`oc://${params.policyDocName}/${field.targetPath}`,
`${params.policyPath} scopes.${scopeName}.${field.propertyPath} is weaker than the top-level ${field.propertyPath} policy.`,
`Use an equally or more restrictive scoped value, or remove the scoped override.`,
);
}
const key = `${selectorValue}\0${field.fieldPath}`;
const previous = seen.get(key);
if (previous !== undefined) {
if (isPolicyValueAtLeastAsStrict(field.metadata, field.value, previous.field.value)) {
seen.set(key, {
scopeName,
propertyPath: `scopes.${scopeName}.${field.propertyPath}`,
field,
});
continue;
}
return policyShapeFinding(
params.policyPath,
`oc://${params.policyDocName}/${field.targetPath}`,
`${params.policyPath} scopes.${scopeName}.${field.propertyPath} is not an equally or more restrictive override of ${previous.propertyPath} for ${params.selectorLabel} '${selectorValue}'.`,
`Use one effective scoped value per ${params.selectorLabel}, or make later scoped values stricter according to policy metadata.`,
);
}
seen.set(key, {
scopeName,
propertyPath: `scopes.${scopeName}.${field.propertyPath}`,
field,
});
}
}
}
return undefined;
}
function scopedPolicyFields(
scopeName: string,
overlay: Record<string, unknown>,
selector: PolicyScopeSelectorKind,
): readonly ScopedPolicyField[] {
const prefix = `scopes/${ocPathSegment(scopeName)}`;
return POLICY_RULES.filter((rule) => rule.scopeSelectors?.includes(selector) === true)
.map((rule) => ({ rule, value: scopedPolicyValue(overlay, rule.policyPath) }))
.filter((entry) => entry.value !== undefined)
.map(({ rule, value }) => ({
fieldPath: rule.policyPath.join("."),
propertyPath: rule.policyPath.join("."),
targetPath: `${prefix}/${rule.policyPath.map(ocPathSegment).join("/")}`,
metadata: rule,
value,
}));
}
function scopedPolicyValue(overlay: Record<string, unknown>, path: readonly string[]): unknown {
const [root, ...remainingPath] = path;
if (!root) {
return undefined;
}
const scopedRoot = root === "agents" ? overlay.agents : overlay[root];
return getPolicyPath(scopedRoot, remainingPath);
}
export function getPolicyPath(value: unknown, path: readonly string[]): unknown {
let current = value;
for (const part of path) {
if (!isRecord(current)) {
return undefined;
}
current = current[part];
}
return current;
}
@@ -0,0 +1,354 @@
import type { HealthFinding } from "openclaw/plugin-sdk/health";
import { isRecord } from "openclaw/plugin-sdk/string-coerce-runtime";
import {
agentsPolicyShapeFinding,
execApprovalsPolicyShapeFinding,
ingressPolicyShapeFinding,
} from "./access-shapes.js";
import { toolPosturePolicyShapeFinding } from "./agent-tool-shapes.js";
import { SUPPORTED_POLICY_SECTIONS } from "./policy-constants.js";
import { gatewayPolicyShapeFinding, sandboxPolicyShapeFinding } from "./sandbox-gateway-shapes.js";
import { scopedPolicyShapeFinding } from "./scoped-policy-shape.js";
import {
policyShapeFinding,
policyStringArrayShapeFinding,
unsupportedPolicyKey,
} from "./shape-helpers.js";
import { ocPathSegment } from "./utils.js";
export function policyContainerShapeFindings(
policy: unknown,
policyPath: string,
policyDocName: string,
): readonly HealthFinding[] {
if (!isRecord(policy)) {
return [
policyShapeFinding(
policyPath,
`oc://${policyDocName}`,
`${policyPath} must contain a policy object.`,
`Fix ${policyPath} so the top-level policy is an object.`,
),
];
}
const unsupportedTopLevel = unsupportedPolicyKey(policy, SUPPORTED_POLICY_SECTIONS);
if (unsupportedTopLevel !== undefined) {
return [
policyShapeFinding(
policyPath,
`oc://${policyDocName}/${ocPathSegment(unsupportedTopLevel)}`,
`${policyPath} ${unsupportedTopLevel} is not a supported policy section.`,
`Remove ${unsupportedTopLevel} or use a supported policy section.`,
),
];
}
if (policy.tools !== undefined && !isRecord(policy.tools)) {
return [
policyShapeFinding(
policyPath,
`oc://${policyDocName}/tools`,
`${policyPath} tools must be an object.`,
`Fix ${policyPath} so tools is an object.`,
),
];
}
if (isRecord(policy.tools)) {
const postureFinding = toolPosturePolicyShapeFinding(policy.tools, {
policyDocName,
policyPath,
});
if (postureFinding !== undefined) {
return [postureFinding];
}
}
if (policy.channels !== undefined && !isRecord(policy.channels)) {
return [
policyShapeFinding(
policyPath,
`oc://${policyDocName}/channels`,
`${policyPath} channels must be an object.`,
`Fix ${policyPath} so channels is an object.`,
),
];
}
if (isRecord(policy.channels)) {
const unsupportedChannelKey = unsupportedPolicyKey(policy.channels, ["denyRules"]);
if (unsupportedChannelKey !== undefined) {
return [
policyShapeFinding(
policyPath,
`oc://${policyDocName}/channels/${ocPathSegment(unsupportedChannelKey)}`,
`${policyPath} channels.${unsupportedChannelKey} is not supported in channel policy.`,
`Remove channels.${unsupportedChannelKey} or use channels.denyRules.`,
),
];
}
}
if (policy.mcp !== undefined && !isRecord(policy.mcp)) {
return [
policyShapeFinding(
policyPath,
`oc://${policyDocName}/mcp`,
`${policyPath} mcp must be an object.`,
`Fix ${policyPath} so mcp is an object.`,
),
];
}
if (isRecord(policy.mcp)) {
const unsupportedMcpKey = unsupportedPolicyKey(policy.mcp, ["servers"]);
if (unsupportedMcpKey !== undefined) {
return [
policyShapeFinding(
policyPath,
`oc://${policyDocName}/mcp/${ocPathSegment(unsupportedMcpKey)}`,
`${policyPath} mcp.${unsupportedMcpKey} is not supported in MCP policy.`,
`Remove mcp.${unsupportedMcpKey} or use mcp.servers.`,
),
];
}
}
if (policy.dataHandling !== undefined && !isRecord(policy.dataHandling)) {
return [
policyShapeFinding(
policyPath,
`oc://${policyDocName}/dataHandling`,
`${policyPath} dataHandling must be an object.`,
`Fix ${policyPath} so dataHandling is an object.`,
),
];
}
if (isRecord(policy.mcp)) {
const finding = policyStringArrayShapeFinding(policy.mcp.servers, {
property: "mcp.servers",
policyDocName,
policyPath,
target: "mcp/servers",
valueName: "MCP server id",
});
if (finding !== undefined) {
return [finding];
}
}
if (policy.models !== undefined && !isRecord(policy.models)) {
return [
policyShapeFinding(
policyPath,
`oc://${policyDocName}/models`,
`${policyPath} models must be an object.`,
`Fix ${policyPath} so models is an object.`,
),
];
}
if (isRecord(policy.models)) {
const unsupportedModelsKey = unsupportedPolicyKey(policy.models, ["providers"]);
if (unsupportedModelsKey !== undefined) {
return [
policyShapeFinding(
policyPath,
`oc://${policyDocName}/models/${ocPathSegment(unsupportedModelsKey)}`,
`${policyPath} models.${unsupportedModelsKey} is not supported in model policy.`,
`Remove models.${unsupportedModelsKey} or use models.providers.`,
),
];
}
}
if (isRecord(policy.models)) {
const finding = policyStringArrayShapeFinding(policy.models.providers, {
property: "models.providers",
policyDocName,
policyPath,
target: "models/providers",
valueName: "model provider id",
});
if (finding !== undefined) {
return [finding];
}
}
if (policy.network !== undefined && !isRecord(policy.network)) {
return [
policyShapeFinding(
policyPath,
`oc://${policyDocName}/network`,
`${policyPath} network must be an object.`,
`Fix ${policyPath} so network is an object.`,
),
];
}
if (isRecord(policy.network)) {
const unsupportedNetworkKey = unsupportedPolicyKey(policy.network, ["privateNetwork"]);
if (unsupportedNetworkKey !== undefined) {
return [
policyShapeFinding(
policyPath,
`oc://${policyDocName}/network/${ocPathSegment(unsupportedNetworkKey)}`,
`${policyPath} network.${unsupportedNetworkKey} is not supported in network policy.`,
`Remove network.${unsupportedNetworkKey} or use network.privateNetwork.`,
),
];
}
if (policy.network.privateNetwork !== undefined && !isRecord(policy.network.privateNetwork)) {
return [
policyShapeFinding(
policyPath,
`oc://${policyDocName}/network/privateNetwork`,
`${policyPath} network.privateNetwork must be an object.`,
`Fix ${policyPath} so network.privateNetwork is an object.`,
),
];
}
if (isRecord(policy.network.privateNetwork)) {
const unsupportedPrivateNetworkKey = unsupportedPolicyKey(policy.network.privateNetwork, [
"allow",
]);
if (unsupportedPrivateNetworkKey !== undefined) {
return [
policyShapeFinding(
policyPath,
`oc://${policyDocName}/network/privateNetwork/${ocPathSegment(unsupportedPrivateNetworkKey)}`,
`${policyPath} network.privateNetwork.${unsupportedPrivateNetworkKey} is not supported in network policy.`,
`Remove network.privateNetwork.${unsupportedPrivateNetworkKey} or use network.privateNetwork.allow.`,
),
];
}
}
if (
isRecord(policy.network.privateNetwork) &&
policy.network.privateNetwork.allow !== undefined &&
typeof policy.network.privateNetwork.allow !== "boolean"
) {
return [
policyShapeFinding(
policyPath,
`oc://${policyDocName}/network/privateNetwork/allow`,
`${policyPath} network.privateNetwork.allow must be a boolean.`,
`Fix ${policyPath} so network.privateNetwork.allow is true or false.`,
),
];
}
}
if (policy.secrets !== undefined && !isRecord(policy.secrets)) {
return [
policyShapeFinding(
policyPath,
`oc://${policyDocName}/secrets`,
`${policyPath} secrets must be an object.`,
`Fix ${policyPath} so secrets is an object.`,
),
];
}
if (isRecord(policy.secrets)) {
const unsupportedSecretsKey = unsupportedPolicyKey(policy.secrets, [
"allowInsecureProviders",
"denySources",
"requireManagedProviders",
]);
if (unsupportedSecretsKey !== undefined) {
return [
policyShapeFinding(
policyPath,
`oc://${policyDocName}/secrets/${ocPathSegment(unsupportedSecretsKey)}`,
`${policyPath} secrets.${unsupportedSecretsKey} is not supported in secrets policy.`,
`Remove secrets.${unsupportedSecretsKey} or use a supported secrets policy rule.`,
),
];
}
}
if (policy.auth !== undefined && !isRecord(policy.auth)) {
return [
policyShapeFinding(
policyPath,
`oc://${policyDocName}/auth`,
`${policyPath} auth must be an object.`,
`Fix ${policyPath} so auth is an object.`,
),
];
}
if (isRecord(policy.auth)) {
const unsupportedAuthKey = unsupportedPolicyKey(policy.auth, ["profiles"]);
if (unsupportedAuthKey !== undefined) {
return [
policyShapeFinding(
policyPath,
`oc://${policyDocName}/auth/${ocPathSegment(unsupportedAuthKey)}`,
`${policyPath} auth.${unsupportedAuthKey} is not supported in auth policy.`,
`Remove auth.${unsupportedAuthKey} or use auth.profiles.`,
),
];
}
}
if (
isRecord(policy.auth) &&
policy.auth.profiles !== undefined &&
!isRecord(policy.auth.profiles)
) {
return [
policyShapeFinding(
policyPath,
`oc://${policyDocName}/auth/profiles`,
`${policyPath} auth.profiles must be an object.`,
`Fix ${policyPath} so auth.profiles is an object.`,
),
];
}
if (isRecord(policy.auth) && isRecord(policy.auth.profiles)) {
const unsupportedProfilesKey = unsupportedPolicyKey(policy.auth.profiles, [
"allowModes",
"requireMetadata",
]);
if (unsupportedProfilesKey !== undefined) {
return [
policyShapeFinding(
policyPath,
`oc://${policyDocName}/auth/profiles/${ocPathSegment(unsupportedProfilesKey)}`,
`${policyPath} auth.profiles.${unsupportedProfilesKey} is not supported in auth profile policy.`,
`Remove auth.profiles.${unsupportedProfilesKey} or use a supported auth profile policy rule.`,
),
];
}
}
const execApprovalsFinding = execApprovalsPolicyShapeFinding(policy.execApprovals, {
policyDocName,
policyPath,
});
if (execApprovalsFinding !== undefined) {
return [execApprovalsFinding];
}
const sandboxFinding = sandboxPolicyShapeFinding(policy.sandbox, {
policyDocName,
policyPath,
});
if (sandboxFinding !== undefined) {
return [sandboxFinding];
}
const ingressFindingValue = ingressPolicyShapeFinding(policy.ingress, {
policyDocName,
policyPath,
});
if (ingressFindingValue !== undefined) {
return [ingressFindingValue];
}
const gatewayFinding = gatewayPolicyShapeFinding(policy.gateway, {
policyDocName,
policyPath,
});
if (gatewayFinding !== undefined) {
return [gatewayFinding];
}
const agentsFinding = agentsPolicyShapeFinding(policy.agents, {
policyDocName,
policyPath,
});
if (agentsFinding !== undefined) {
return [agentsFinding];
}
const scopesFinding = scopedPolicyShapeFinding(policy.scopes, {
policyDocName,
policyPath,
policy,
});
if (scopesFinding !== undefined) {
return [scopesFinding];
}
return [];
}
File diff suppressed because it is too large Load Diff
@@ -5,8 +5,8 @@ import type {
HealthRepairEffect,
HealthRepairResult,
} from "openclaw/plugin-sdk/health";
import { CHECK_IDS, type POLICY_CHECK_IDS } from "./check-ids.js";
import { POLICY_FIX_METADATA_BY_CHECK_ID } from "./fix-metadata.js";
import { CHECK_IDS, type POLICY_CHECK_IDS } from "./metadata.js";
type PolicyCheckId = (typeof POLICY_CHECK_IDS)[number];
@@ -0,0 +1,498 @@
import type { HealthFinding } from "openclaw/plugin-sdk/health";
import { isRecord } from "openclaw/plugin-sdk/string-coerce-runtime";
import type { PolicyEvidence, PolicySandboxPostureEvidence } from "../policy-state.js";
import { CHECK_IDS, POLICY_CHECK_IDS } from "./check-ids.js";
import { SANDBOX_CONTAINER_POLICY_RULES } from "./metadata.js";
import { agentScopedPolicyTargets, scopedAgentIdMatches } from "./policy-scope.js";
import { sandboxPolicyShapeFinding } from "./sandbox-gateway-shapes.js";
import { hasValidScopedPolicy } from "./scoped-policy-shape.js";
import { ocPathSegment, readPolicyBoolean, readStringList } from "./utils.js";
export function sandboxPostureFindings(
policy: unknown,
policyPath: string,
policyDocName: string,
evidence: PolicyEvidence,
): readonly HealthFinding[] {
if (!isRecord(policy)) {
return [];
}
const findings: HealthFinding[] = [];
const sandboxPolicy = policy.sandbox;
if (
isRecord(sandboxPolicy) &&
sandboxPolicyShapeFinding(sandboxPolicy, { policyDocName, policyPath }) === undefined
) {
findings.push(
...sandboxPostureFindingsForRule(
sandboxPolicy,
policyDocName,
"sandbox",
evidence,
() => true,
),
);
}
if (!hasValidScopedPolicy(policy, policyPath, policyDocName)) {
return findings;
}
for (const target of agentScopedPolicyTargets(policy)) {
const scopedSandboxPolicy = target.overlay.sandbox;
if (
sandboxPolicyShapeFinding(scopedSandboxPolicy, {
policyDocName,
policyPath,
targetPrefix: `scopes/${ocPathSegment(target.scopeName)}/sandbox`,
propertyPrefix: `scopes.${target.scopeName}.sandbox`,
}) !== undefined ||
!isRecord(scopedSandboxPolicy)
) {
continue;
}
findings.push(
...sandboxPostureFindingsForRule(
scopedSandboxPolicy,
policyDocName,
`scopes/${ocPathSegment(target.scopeName)}/sandbox`,
evidence,
(entry) => scopedSandboxAgentMatches(entry, target.agentId, evidence.sandboxPosture ?? []),
),
);
}
return findings;
}
function sandboxPostureFindingsForRule(
sandboxPolicy: Record<string, unknown> | undefined,
policyDocName: string,
requirementBase: string,
evidence: PolicyEvidence,
evidenceFilter: (entry: PolicySandboxPostureEvidence) => boolean,
): readonly HealthFinding[] {
if (!isRecord(sandboxPolicy)) {
return [];
}
return [
...sandboxModeFindings(sandboxPolicy, policyDocName, requirementBase, evidence, evidenceFilter),
...sandboxBackendFindings(
sandboxPolicy,
policyDocName,
requirementBase,
evidence,
evidenceFilter,
),
...sandboxContainerPostureUnobservableFindings(
sandboxPolicy,
policyDocName,
requirementBase,
evidence,
evidenceFilter,
),
...sandboxContainerHostNetworkFindings(
sandboxPolicy,
policyDocName,
requirementBase,
evidence,
evidenceFilter,
),
...sandboxContainerNamespaceJoinFindings(
sandboxPolicy,
policyDocName,
requirementBase,
evidence,
evidenceFilter,
),
...sandboxContainerMountModeFindings(
sandboxPolicy,
policyDocName,
requirementBase,
evidence,
evidenceFilter,
),
...sandboxContainerRuntimeSocketMountFindings(
sandboxPolicy,
policyDocName,
requirementBase,
evidence,
evidenceFilter,
),
...sandboxContainerUnconfinedProfileFindings(
sandboxPolicy,
policyDocName,
requirementBase,
evidence,
evidenceFilter,
),
...sandboxBrowserCdpSourceRangeFindings(
sandboxPolicy,
policyDocName,
requirementBase,
evidence,
evidenceFilter,
),
];
}
function scopedSandboxAgentMatches(
entry: PolicySandboxPostureEvidence,
policyAgentId: string,
entries: readonly PolicySandboxPostureEvidence[],
): boolean {
if (scopedAgentIdMatches(entry.agentId, policyAgentId)) {
return true;
}
return (
entry.scope === "defaults" &&
!scopedSandboxDefaultDisabledForAgent(entry, policyAgentId, entries) &&
!entries.some(
(candidate) =>
candidate.scope === "agent" &&
sandboxPostureEntriesDescribeSameField(candidate, entry) &&
scopedAgentIdMatches(candidate.agentId, policyAgentId),
)
);
}
function scopedSandboxDefaultDisabledForAgent(
entry: PolicySandboxPostureEvidence,
policyAgentId: string,
entries: readonly PolicySandboxPostureEvidence[],
): boolean {
if (sandboxEntryRequiresContainerBackend(entry)) {
const backend = entries.find(
(candidate) =>
candidate.scope === "agent" &&
candidate.kind === "backend" &&
scopedAgentIdMatches(candidate.agentId, policyAgentId),
);
if (typeof backend?.value === "string" && backend.value.toLowerCase() !== "docker") {
return true;
}
}
if (sandboxEntryRequiresBrowser(entry)) {
const browser = entries.find(
(candidate) =>
candidate.scope === "agent" &&
candidate.kind === "browserCdpSourceRange" &&
scopedAgentIdMatches(candidate.agentId, policyAgentId),
);
if (browser?.value === false) {
return true;
}
}
return false;
}
function sandboxEntryRequiresContainerBackend(entry: PolicySandboxPostureEvidence): boolean {
return (
(entry.kind === "containerNetwork" && entry.networkSurface === "docker") ||
entry.kind === "containerSecurityProfile" ||
(entry.kind === "containerMount" && entry.bindSurface === "docker")
);
}
function sandboxEntryRequiresBrowser(entry: PolicySandboxPostureEvidence): boolean {
return (
entry.kind === "browserCdpSourceRange" ||
(entry.kind === "containerNetwork" && entry.networkSurface === "browser") ||
(entry.kind === "containerMount" && entry.bindSurface === "browser")
);
}
function sandboxPostureEntriesDescribeSameField(
candidate: PolicySandboxPostureEvidence,
baseline: PolicySandboxPostureEvidence,
): boolean {
return (
candidate.kind === baseline.kind &&
candidate.bindSurface === baseline.bindSurface &&
candidate.networkSurface === baseline.networkSurface &&
candidate.profile === baseline.profile
);
}
function sandboxModeFindings(
sandboxPolicy: Record<string, unknown>,
policyDocName: string,
requirementBase: string,
evidence: PolicyEvidence,
evidenceFilter: (entry: PolicySandboxPostureEvidence) => boolean,
): readonly HealthFinding[] {
const allowed = new Set(readStringList(sandboxPolicy, ["requireMode"]));
if (allowed.size === 0) {
return [];
}
return sandboxPostureEntries(evidence, "mode")
.filter(evidenceFilter)
.filter((entry) => typeof entry.value === "string" && !allowed.has(entry.value.toLowerCase()))
.map((entry) =>
sandboxPostureFinding(entry, {
checkId: CHECK_IDS.policySandboxModeUnapproved,
message: `${sandboxPostureLabel(entry)} uses unapproved sandbox mode '${entry.value ?? ""}'.`,
requirement: `oc://${policyDocName}/${requirementBase}/requireMode`,
fixHint:
"Set agents.defaults.sandbox.mode or agents.list[].sandbox.mode to an approved value.",
}),
);
}
function sandboxBackendFindings(
sandboxPolicy: Record<string, unknown>,
policyDocName: string,
requirementBase: string,
evidence: PolicyEvidence,
evidenceFilter: (entry: PolicySandboxPostureEvidence) => boolean,
): readonly HealthFinding[] {
const allowed = new Set(readStringList(sandboxPolicy, ["allowBackends"]));
if (allowed.size === 0) {
return [];
}
return sandboxPostureEntries(evidence, "backend")
.filter(evidenceFilter)
.filter((entry) => typeof entry.value === "string" && !allowed.has(entry.value.toLowerCase()))
.map((entry) =>
sandboxPostureFinding(entry, {
checkId: CHECK_IDS.policySandboxBackendUnapproved,
message: `${sandboxPostureLabel(entry)} uses unapproved sandbox backend '${entry.value ?? ""}'.`,
requirement: `oc://${policyDocName}/${requirementBase}/allowBackends`,
fixHint: "Use an approved sandbox backend or update policy after review.",
}),
);
}
function sandboxContainerPostureUnobservableFindings(
sandboxPolicy: Record<string, unknown>,
policyDocName: string,
requirementBase: string,
evidence: PolicyEvidence,
evidenceFilter: (entry: PolicySandboxPostureEvidence) => boolean,
): readonly HealthFinding[] {
const enabledRules = SANDBOX_CONTAINER_POLICY_RULES.filter(
(rule) => readPolicyBoolean(sandboxPolicy, ["containers", rule.key]) === true,
);
if (enabledRules.length === 0) {
return [];
}
return sandboxPostureEntries(evidence, "backend")
.filter(evidenceFilter)
.filter((entry) => typeof entry.value === "string" && entry.value.toLowerCase() !== "docker")
.flatMap((entry) =>
enabledRules.map((rule) =>
sandboxPostureFinding(entry, {
checkId: CHECK_IDS.policySandboxContainerPostureUnobservable,
message: `${sandboxPostureLabel(entry)} uses sandbox backend '${entry.value ?? ""}', which cannot observe ${rule.label}.`,
requirement: `oc://${policyDocName}/${requirementBase}/containers/${rule.key}`,
fixHint:
"Use an observable container backend for this sandbox or remove the container posture rule.",
}),
),
);
}
function sandboxContainerHostNetworkFindings(
sandboxPolicy: Record<string, unknown>,
policyDocName: string,
requirementBase: string,
evidence: PolicyEvidence,
evidenceFilter: (entry: PolicySandboxPostureEvidence) => boolean,
): readonly HealthFinding[] {
if (readPolicyBoolean(sandboxPolicy, ["containers", "denyHostNetwork"]) !== true) {
return [];
}
return sandboxPostureEntries(evidence, "containerNetwork")
.filter(evidenceFilter)
.filter((entry) => typeof entry.value === "string" && entry.value.toLowerCase() === "host")
.map((entry) =>
sandboxPostureFinding(entry, {
checkId: CHECK_IDS.policySandboxContainerHostNetworkDenied,
message: `${sandboxPostureLabel(entry)} uses host container network mode.`,
requirement: `oc://${policyDocName}/${requirementBase}/containers/denyHostNetwork`,
fixHint: "Change the container network mode or update policy after review.",
}),
);
}
function sandboxContainerNamespaceJoinFindings(
sandboxPolicy: Record<string, unknown>,
policyDocName: string,
requirementBase: string,
evidence: PolicyEvidence,
evidenceFilter: (entry: PolicySandboxPostureEvidence) => boolean,
): readonly HealthFinding[] {
if (readPolicyBoolean(sandboxPolicy, ["containers", "denyContainerNamespaceJoin"]) !== true) {
return [];
}
const containerNamespacePrefix = "container:";
return sandboxPostureEntries(evidence, "containerNetwork")
.filter(evidenceFilter)
.filter(
(entry) =>
typeof entry.value === "string" &&
entry.value.toLowerCase().startsWith(containerNamespacePrefix),
)
.map((entry) =>
sandboxPostureFinding(entry, {
checkId: CHECK_IDS.policySandboxContainerNamespaceJoinDenied,
message: `${sandboxPostureLabel(entry)} joins another container network namespace '${entry.value ?? ""}'.`,
requirement: `oc://${policyDocName}/${requirementBase}/containers/denyContainerNamespaceJoin`,
fixHint: "Change the container network mode or update policy after review.",
}),
);
}
function sandboxContainerMountModeFindings(
sandboxPolicy: Record<string, unknown>,
policyDocName: string,
requirementBase: string,
evidence: PolicyEvidence,
evidenceFilter: (entry: PolicySandboxPostureEvidence) => boolean,
): readonly HealthFinding[] {
if (readPolicyBoolean(sandboxPolicy, ["containers", "requireReadOnlyMounts"]) !== true) {
return [];
}
return sandboxPostureEntries(evidence, "containerMount")
.filter(evidenceFilter)
.filter((entry) => entry.bindMode !== "ro")
.map((entry) =>
sandboxPostureFinding(entry, {
checkId: CHECK_IDS.policySandboxContainerMountModeRequired,
message: `${sandboxPostureLabel(entry)} has container mount '${entry.bind ?? ""}' with mode '${entry.bindMode ?? "unknown"}'.`,
requirement: `oc://${policyDocName}/${requirementBase}/containers/requireReadOnlyMounts`,
fixHint: "Set the mount mode to read-only or update policy after review.",
}),
);
}
function sandboxContainerRuntimeSocketMountFindings(
sandboxPolicy: Record<string, unknown>,
policyDocName: string,
requirementBase: string,
evidence: PolicyEvidence,
evidenceFilter: (entry: PolicySandboxPostureEvidence) => boolean,
): readonly HealthFinding[] {
if (
readPolicyBoolean(sandboxPolicy, ["containers", "denyContainerRuntimeSocketMounts"]) !== true
) {
return [];
}
return sandboxPostureEntries(evidence, "containerMount")
.filter(evidenceFilter)
.filter((entry) => bindHostLooksLikeContainerRuntimeSocket(entry.bindHost))
.map((entry) =>
sandboxPostureFinding(entry, {
checkId: CHECK_IDS.policySandboxContainerRuntimeSocketMount,
message: `${sandboxPostureLabel(entry)} binds host container runtime socket '${entry.bindHost ?? ""}'.`,
requirement: `oc://${policyDocName}/${requirementBase}/containers/denyContainerRuntimeSocketMounts`,
fixHint: "Remove the container runtime socket bind or update policy after review.",
}),
);
}
function sandboxContainerUnconfinedProfileFindings(
sandboxPolicy: Record<string, unknown>,
policyDocName: string,
requirementBase: string,
evidence: PolicyEvidence,
evidenceFilter: (entry: PolicySandboxPostureEvidence) => boolean,
): readonly HealthFinding[] {
if (readPolicyBoolean(sandboxPolicy, ["containers", "denyUnconfinedProfiles"]) !== true) {
return [];
}
return sandboxPostureEntries(evidence, "containerSecurityProfile")
.filter(evidenceFilter)
.filter(
(entry) => typeof entry.value === "string" && entry.value.toLowerCase() === "unconfined",
)
.map((entry) =>
sandboxPostureFinding(entry, {
checkId: CHECK_IDS.policySandboxContainerUnconfinedProfile,
message: `${sandboxPostureLabel(entry)} sets container ${entry.profile ?? "security"} profile to unconfined.`,
requirement: `oc://${policyDocName}/${requirementBase}/containers/denyUnconfinedProfiles`,
fixHint: "Remove the unconfined container profile or update policy after review.",
}),
);
}
function sandboxBrowserCdpSourceRangeFindings(
sandboxPolicy: Record<string, unknown>,
policyDocName: string,
requirementBase: string,
evidence: PolicyEvidence,
evidenceFilter: (entry: PolicySandboxPostureEvidence) => boolean,
): readonly HealthFinding[] {
if (readPolicyBoolean(sandboxPolicy, ["browser", "requireCdpSourceRange"]) !== true) {
return [];
}
return sandboxPostureEntries(evidence, "browserCdpSourceRange")
.filter(evidenceFilter)
.filter((entry) => entry.value === undefined)
.map((entry) =>
sandboxPostureFinding(entry, {
checkId: CHECK_IDS.policySandboxBrowserCdpSourceRangeMissing,
message: `${sandboxPostureLabel(entry)} enables sandbox browser without cdpSourceRange.`,
requirement: `oc://${policyDocName}/${requirementBase}/browser/requireCdpSourceRange`,
fixHint: "Set agents.*.sandbox.browser.cdpSourceRange or update policy after review.",
}),
);
}
function sandboxPostureEntries(
evidence: PolicyEvidence,
kind: PolicySandboxPostureEvidence["kind"],
): readonly PolicySandboxPostureEvidence[] {
return (evidence.sandboxPosture ?? []).filter((entry) => entry.kind === kind);
}
function sandboxPostureFinding(
entry: PolicySandboxPostureEvidence,
params: {
readonly checkId: (typeof POLICY_CHECK_IDS)[number];
readonly message: string;
readonly requirement: string;
readonly fixHint: string;
},
): HealthFinding {
return {
checkId: params.checkId,
severity: "error",
message: params.message,
source: "policy",
path: "openclaw config",
ocPath: entry.source,
target: entry.source,
requirement: params.requirement,
fixHint: params.fixHint,
};
}
function sandboxPostureLabel(entry: PolicySandboxPostureEvidence): string {
return entry.agentId === undefined ? "default sandbox config" : `agent '${entry.agentId}'`;
}
const CONTAINER_RUNTIME_SOCKET_BASENAMES = new Set([
"containerd.sock",
"docker.sock",
"podman.sock",
]);
const CONTAINER_RUNTIME_SOCKET_PATHS = new Set([
"/run/containerd/containerd.sock",
"/run/docker.sock",
"/run/podman/podman.sock",
"/var/run/docker.sock",
"/var/run/podman/podman.sock",
]);
function bindHostLooksLikeContainerRuntimeSocket(value: string | undefined): boolean {
if (value === undefined) {
return false;
}
const normalized = value.replaceAll("\\", "/").toLowerCase();
const basenameLocal = normalized.split("/").at(-1) ?? "";
return (
CONTAINER_RUNTIME_SOCKET_PATHS.has(normalized) ||
CONTAINER_RUNTIME_SOCKET_BASENAMES.has(basenameLocal)
);
}
@@ -0,0 +1,278 @@
import type { HealthFinding } from "openclaw/plugin-sdk/health";
import { isRecord } from "openclaw/plugin-sdk/string-coerce-runtime";
import { SANDBOX_CONTAINER_POLICY_RULES } from "./metadata.js";
import {
SUPPORTED_GATEWAY_HTTP_ENDPOINTS,
SUPPORTED_GATEWAY_POLICY_SECTIONS,
SUPPORTED_SANDBOX_MODES,
} from "./policy-constants.js";
import {
policyShapeFinding,
policyStringArrayPropertyShapeFinding,
unsupportedPolicyKey,
} from "./shape-helpers.js";
import { ocPathSegment } from "./utils.js";
export function sandboxPolicyShapeFinding(
value: unknown,
params: {
readonly policyDocName: string;
readonly policyPath: string;
readonly targetPrefix?: string;
readonly propertyPrefix?: string;
},
): HealthFinding | undefined {
const targetPrefix = params.targetPrefix ?? "sandbox";
const propertyPrefix = params.propertyPrefix ?? "sandbox";
if (value === undefined) {
return undefined;
}
if (!isRecord(value)) {
return policyShapeFinding(
params.policyPath,
`oc://${params.policyDocName}/${targetPrefix}`,
`${params.policyPath} ${propertyPrefix} must be an object.`,
`Fix ${params.policyPath} so ${propertyPrefix} is an object.`,
);
}
const unsupportedTopLevel = unsupportedPolicyKey(value, [
"requireMode",
"allowBackends",
"containers",
"browser",
]);
if (unsupportedTopLevel !== undefined) {
return policyShapeFinding(
params.policyPath,
`oc://${params.policyDocName}/${targetPrefix}/${ocPathSegment(unsupportedTopLevel)}`,
`${params.policyPath} ${propertyPrefix}.${unsupportedTopLevel} is not supported in sandbox policy.`,
`Remove ${propertyPrefix}.${unsupportedTopLevel} or use a supported sandbox posture rule.`,
);
}
const modeFinding = policyStringArrayPropertyShapeFinding(value.requireMode, {
allowed: SUPPORTED_SANDBOX_MODES,
policyDocName: params.policyDocName,
policyPath: params.policyPath,
property: `${propertyPrefix}.requireMode`,
target: `${targetPrefix}/requireMode`,
valueName: "sandbox mode",
});
if (modeFinding !== undefined) {
return modeFinding;
}
const backendFinding = policyStringArrayPropertyShapeFinding(value.allowBackends, {
policyDocName: params.policyDocName,
policyPath: params.policyPath,
property: `${propertyPrefix}.allowBackends`,
target: `${targetPrefix}/allowBackends`,
valueName: "sandbox backend id",
});
if (backendFinding !== undefined) {
return backendFinding;
}
for (const section of ["containers", "browser"] as const) {
if (value[section] !== undefined && !isRecord(value[section])) {
return policyShapeFinding(
params.policyPath,
`oc://${params.policyDocName}/${targetPrefix}/${section}`,
`${params.policyPath} ${propertyPrefix}.${section} must be an object.`,
`Fix ${params.policyPath} so ${propertyPrefix}.${section} is an object.`,
);
}
}
const containers = isRecord(value.containers) ? value.containers : {};
const unsupportedContainerKey = unsupportedPolicyKey(
containers,
SANDBOX_CONTAINER_POLICY_RULES.map((rule) => rule.key),
);
if (unsupportedContainerKey !== undefined) {
return policyShapeFinding(
params.policyPath,
`oc://${params.policyDocName}/${targetPrefix}/containers/${ocPathSegment(unsupportedContainerKey)}`,
`${params.policyPath} ${propertyPrefix}.containers.${unsupportedContainerKey} is not supported in sandbox policy.`,
`Remove ${propertyPrefix}.containers.${unsupportedContainerKey} or use a supported sandbox container posture rule.`,
);
}
for (const { key } of SANDBOX_CONTAINER_POLICY_RULES) {
if (containers[key] !== undefined && typeof containers[key] !== "boolean") {
return policyShapeFinding(
params.policyPath,
`oc://${params.policyDocName}/${targetPrefix}/containers/${key}`,
`${params.policyPath} ${propertyPrefix}.containers.${key} must be a boolean.`,
`Set ${propertyPrefix}.containers.${key} to true or false.`,
);
}
}
const browser = isRecord(value.browser) ? value.browser : {};
const unsupportedBrowserKey = unsupportedPolicyKey(browser, ["requireCdpSourceRange"]);
if (unsupportedBrowserKey !== undefined) {
return policyShapeFinding(
params.policyPath,
`oc://${params.policyDocName}/${targetPrefix}/browser/${ocPathSegment(unsupportedBrowserKey)}`,
`${params.policyPath} ${propertyPrefix}.browser.${unsupportedBrowserKey} is not supported in sandbox policy.`,
`Remove ${propertyPrefix}.browser.${unsupportedBrowserKey} or use a supported sandbox browser posture rule.`,
);
}
if (
browser.requireCdpSourceRange !== undefined &&
typeof browser.requireCdpSourceRange !== "boolean"
) {
return policyShapeFinding(
params.policyPath,
`oc://${params.policyDocName}/${targetPrefix}/browser/requireCdpSourceRange`,
`${params.policyPath} ${propertyPrefix}.browser.requireCdpSourceRange must be a boolean.`,
`Set ${propertyPrefix}.browser.requireCdpSourceRange to true or false.`,
);
}
return undefined;
}
export function gatewayPolicyShapeFinding(
value: unknown,
params: {
readonly policyDocName: string;
readonly policyPath: string;
},
): HealthFinding | undefined {
if (value === undefined) {
return undefined;
}
if (!isRecord(value)) {
return policyShapeFinding(
params.policyPath,
`oc://${params.policyDocName}/gateway`,
`${params.policyPath} gateway must be an object.`,
`Fix ${params.policyPath} so gateway is an object.`,
);
}
for (const section of ["exposure", "auth", "controlUi", "remote", "http", "nodes"] as const) {
if (value[section] !== undefined && !isRecord(value[section])) {
return policyShapeFinding(
params.policyPath,
`oc://${params.policyDocName}/gateway/${section}`,
`${params.policyPath} gateway.${section} must be an object.`,
`Fix ${params.policyPath} so gateway.${section} is an object.`,
);
}
}
const unsupportedGatewayKey = unsupportedPolicyKey(value, SUPPORTED_GATEWAY_POLICY_SECTIONS);
if (unsupportedGatewayKey !== undefined) {
return policyShapeFinding(
params.policyPath,
`oc://${params.policyDocName}/gateway/${ocPathSegment(unsupportedGatewayKey)}`,
`${params.policyPath} gateway.${unsupportedGatewayKey} is not supported in Gateway policy.`,
`Remove gateway.${unsupportedGatewayKey} or use a supported Gateway policy section.`,
);
}
const exposure = isRecord(value.exposure) ? value.exposure : {};
const auth = isRecord(value.auth) ? value.auth : {};
const controlUi = isRecord(value.controlUi) ? value.controlUi : {};
const remote = isRecord(value.remote) ? value.remote : {};
const http = isRecord(value.http) ? value.http : {};
const nodes = isRecord(value.nodes) ? value.nodes : {};
for (const [section, sectionValue, allowedKeys] of [
["exposure", exposure, ["allowNonLoopbackBind", "allowTailscaleFunnel"]],
["auth", auth, ["requireAuth", "requireExplicitRateLimit"]],
["controlUi", controlUi, ["allowInsecure"]],
["remote", remote, ["allow"]],
["http", http, ["denyEndpoints", "requireUrlAllowlists"]],
["nodes", nodes, ["denyCommands"]],
] as const) {
const unsupportedKey = unsupportedPolicyKey(sectionValue, allowedKeys);
if (unsupportedKey !== undefined) {
return policyShapeFinding(
params.policyPath,
`oc://${params.policyDocName}/gateway/${section}/${ocPathSegment(unsupportedKey)}`,
`${params.policyPath} gateway.${section}.${unsupportedKey} is not supported in Gateway policy.`,
`Remove gateway.${section}.${unsupportedKey} or use a supported Gateway policy rule.`,
);
}
}
const booleanRules = [
[
"gateway/exposure/allowNonLoopbackBind",
"gateway.exposure.allowNonLoopbackBind",
exposure.allowNonLoopbackBind,
],
[
"gateway/exposure/allowTailscaleFunnel",
"gateway.exposure.allowTailscaleFunnel",
exposure.allowTailscaleFunnel,
],
["gateway/auth/requireAuth", "gateway.auth.requireAuth", auth.requireAuth],
[
"gateway/auth/requireExplicitRateLimit",
"gateway.auth.requireExplicitRateLimit",
auth.requireExplicitRateLimit,
],
["gateway/controlUi/allowInsecure", "gateway.controlUi.allowInsecure", controlUi.allowInsecure],
["gateway/remote/allow", "gateway.remote.allow", remote.allow],
[
"gateway/http/requireUrlAllowlists",
"gateway.http.requireUrlAllowlists",
http.requireUrlAllowlists,
],
] as const;
for (const [target, property, ruleValue] of booleanRules) {
if (ruleValue !== undefined && typeof ruleValue !== "boolean") {
return policyShapeFinding(
params.policyPath,
`oc://${params.policyDocName}/${target}`,
`${params.policyPath} ${property} must be a boolean.`,
`Fix ${params.policyPath} so ${property} is true or false.`,
);
}
}
const denyEndpoints = http.denyEndpoints;
if (denyEndpoints !== undefined && !Array.isArray(denyEndpoints)) {
return policyShapeFinding(
params.policyPath,
`oc://${params.policyDocName}/gateway/http/denyEndpoints`,
`${params.policyPath} gateway.http.denyEndpoints must be an array.`,
'Use an array of endpoint ids such as ["responses"] or remove gateway.http.denyEndpoints.',
);
}
if (Array.isArray(denyEndpoints)) {
const invalidIndex = denyEndpoints.findIndex(
(entry) =>
typeof entry !== "string" ||
!SUPPORTED_GATEWAY_HTTP_ENDPOINTS.includes(
entry.trim() as (typeof SUPPORTED_GATEWAY_HTTP_ENDPOINTS)[number],
),
);
if (invalidIndex >= 0) {
return policyShapeFinding(
params.policyPath,
`oc://${params.policyDocName}/gateway/http/denyEndpoints/#${invalidIndex}`,
`${params.policyPath} gateway.http.denyEndpoints[${invalidIndex}] must be a supported endpoint id.`,
`Use supported endpoint ids: ${SUPPORTED_GATEWAY_HTTP_ENDPOINTS.join(", ")}.`,
);
}
}
const denyCommands = nodes.denyCommands;
if (denyCommands !== undefined && !Array.isArray(denyCommands)) {
return policyShapeFinding(
params.policyPath,
`oc://${params.policyDocName}/gateway/nodes/denyCommands`,
`${params.policyPath} gateway.nodes.denyCommands must be an array.`,
'Use an array of node command ids such as ["system.run"] or remove gateway.nodes.denyCommands.',
);
}
if (Array.isArray(denyCommands)) {
const invalidIndex = denyCommands.findIndex(
(entry) => typeof entry !== "string" || entry.trim() === "",
);
if (invalidIndex >= 0) {
return policyShapeFinding(
params.policyPath,
`oc://${params.policyDocName}/gateway/nodes/denyCommands/#${invalidIndex}`,
`${params.policyPath} gateway.nodes.denyCommands[${invalidIndex}] must be a non-empty node command id.`,
"Use non-empty node command ids.",
);
}
}
return undefined;
}
@@ -0,0 +1,286 @@
import type { HealthFinding } from "openclaw/plugin-sdk/health";
import { normalizeAgentId } from "openclaw/plugin-sdk/routing";
import { isRecord } from "openclaw/plugin-sdk/string-coerce-runtime";
import {
execApprovalsPolicyShapeFinding,
ingressPolicyShapeFinding,
scopedDataHandlingPolicyShapeFinding,
scopedToolsPolicyShapeFinding,
} from "./access-shapes.js";
import { agentWorkspacePolicyShapeFinding } from "./agent-tool-shapes.js";
import { normalizePolicyChannelId } from "./policy-runtime.js";
import { duplicateScopedPolicyFieldFinding } from "./policy-scope.js";
import { sandboxPolicyShapeFinding } from "./sandbox-gateway-shapes.js";
import { policyShapeFinding, policyStringArrayPropertyShapeFinding } from "./shape-helpers.js";
import { ocPathSegment } from "./utils.js";
export function scopedPolicyShapeFinding(
value: unknown,
params: {
readonly policyDocName: string;
readonly policyPath: string;
readonly policy: Record<string, unknown>;
},
): HealthFinding | undefined {
if (value === undefined) {
return undefined;
}
if (!isRecord(value)) {
return policyShapeFinding(
params.policyPath,
`oc://${params.policyDocName}/scopes`,
`${params.policyPath} scopes must be an object.`,
`Fix ${params.policyPath} so scopes maps scope names to policy overlays with selectors such as agentIds.`,
);
}
for (const [scopeName, overlay] of Object.entries(value)) {
const targetPrefix = `scopes/${ocPathSegment(scopeName)}`;
if (!isRecord(overlay)) {
return policyShapeFinding(
params.policyPath,
`oc://${params.policyDocName}/${targetPrefix}`,
`${params.policyPath} scopes.${scopeName} must be an object.`,
`Fix ${params.policyPath} so the named policy scope is an object.`,
);
}
const hasAgentIds = overlay.agentIds !== undefined;
const hasChannelIds = overlay.channelIds !== undefined;
if (!hasAgentIds && !hasChannelIds) {
return policyShapeFinding(
params.policyPath,
`oc://${params.policyDocName}/${targetPrefix}`,
`${params.policyPath} scopes.${scopeName} must define at least one selector.`,
`List agentIds for agent-scoped policy or channelIds for channel-scoped ingress policy.`,
);
}
const agentIdsFinding = scopedSelectorShapeFinding(overlay.agentIds, {
policyDocName: params.policyDocName,
policyPath: params.policyPath,
property: `scopes.${scopeName}.agentIds`,
target: `${targetPrefix}/agentIds`,
valueName: "agent id",
normalize: normalizeAgentId,
});
if (agentIdsFinding !== undefined) {
return agentIdsFinding;
}
const channelIdsFinding = scopedSelectorShapeFinding(overlay.channelIds, {
policyDocName: params.policyDocName,
policyPath: params.policyPath,
property: `scopes.${scopeName}.channelIds`,
target: `${targetPrefix}/channelIds`,
valueName: "channel id",
normalize: normalizePolicyChannelId,
});
if (channelIdsFinding !== undefined) {
return channelIdsFinding;
}
if (overlay.ingress !== undefined && !hasChannelIds) {
return policyShapeFinding(
params.policyPath,
`oc://${params.policyDocName}/${targetPrefix}/ingress`,
`${params.policyPath} scopes.${scopeName}.ingress requires the channelIds selector.`,
`Move global ingress rules to top-level ingress, or list channelIds for channel-scoped ingress policy.`,
);
}
if (
(overlay.agents !== undefined ||
overlay.dataHandling !== undefined ||
overlay.execApprovals !== undefined ||
overlay.tools !== undefined ||
overlay.sandbox !== undefined) &&
!hasAgentIds
) {
return policyShapeFinding(
params.policyPath,
`oc://${params.policyDocName}/${targetPrefix}`,
`${params.policyPath} scopes.${scopeName} uses agent-scoped sections without agentIds.`,
`List agentIds for agents.workspace, dataHandling.memory, tools, or sandbox policy sections.`,
);
}
const unsupportedKey = Object.keys(overlay).find(
(key) =>
key !== "agentIds" &&
key !== "channelIds" &&
key !== "agents" &&
key !== "dataHandling" &&
key !== "execApprovals" &&
key !== "tools" &&
key !== "sandbox" &&
key !== "ingress",
);
if (unsupportedKey !== undefined) {
return policyShapeFinding(
params.policyPath,
`oc://${params.policyDocName}/${targetPrefix}/${ocPathSegment(unsupportedKey)}`,
`${params.policyPath} scopes.${scopeName}.${unsupportedKey} is not a supported scoped policy section.`,
`Use agentIds with agents.workspace, dataHandling.memory, execApprovals, tools, or sandbox, and channelIds with ingress.channels.`,
);
}
if (overlay.dataHandling !== undefined && !isRecord(overlay.dataHandling)) {
return policyShapeFinding(
params.policyPath,
`oc://${params.policyDocName}/${targetPrefix}/dataHandling`,
`${params.policyPath} scopes.${scopeName}.dataHandling must be an object.`,
`Fix ${params.policyPath} so the scoped dataHandling policy section is an object.`,
);
}
if (isRecord(overlay.dataHandling)) {
const scopedDataHandlingFinding = scopedDataHandlingPolicyShapeFinding(overlay.dataHandling, {
policyPath: params.policyPath,
policyDocName: params.policyDocName,
targetPrefix,
scopeName,
});
if (scopedDataHandlingFinding !== undefined) {
return scopedDataHandlingFinding;
}
}
if (overlay.agents !== undefined && !isRecord(overlay.agents)) {
return policyShapeFinding(
params.policyPath,
`oc://${params.policyDocName}/${targetPrefix}/agents`,
`${params.policyPath} scopes.${scopeName}.agents must be an object.`,
`Fix ${params.policyPath} so the scoped agents policy section is an object.`,
);
}
const scopedAgents = isRecord(overlay.agents) ? overlay.agents : {};
const unsupportedAgentKey = Object.keys(scopedAgents).find((key) => key !== "workspace");
if (unsupportedAgentKey !== undefined) {
return policyShapeFinding(
params.policyPath,
`oc://${params.policyDocName}/${targetPrefix}/agents/${ocPathSegment(unsupportedAgentKey)}`,
`${params.policyPath} scopes.${scopeName}.agents.${unsupportedAgentKey} is not supported by the agentIds selector.`,
`Move the rule under agents.workspace or a supported scoped top-level section.`,
);
}
const workspaceFinding = agentWorkspacePolicyShapeFinding(scopedAgents.workspace, {
policyDocName: params.policyDocName,
policyPath: params.policyPath,
targetPrefix: `${targetPrefix}/agents/workspace`,
propertyPrefix: `scopes.${scopeName}.agents.workspace`,
});
if (workspaceFinding !== undefined) {
return workspaceFinding;
}
const scopedExecApprovalsFinding = execApprovalsPolicyShapeFinding(overlay.execApprovals, {
policyDocName: params.policyDocName,
policyPath: params.policyPath,
targetPrefix: `${targetPrefix}/execApprovals`,
propertyPrefix: `scopes.${scopeName}.execApprovals`,
allowDefaults: false,
});
if (scopedExecApprovalsFinding !== undefined) {
return scopedExecApprovalsFinding;
}
if (overlay.tools !== undefined && !isRecord(overlay.tools)) {
return policyShapeFinding(
params.policyPath,
`oc://${params.policyDocName}/${targetPrefix}/tools`,
`${params.policyPath} scopes.${scopeName}.tools must be an object.`,
`Fix ${params.policyPath} so the scoped tools policy overlay is an object.`,
);
}
if (isRecord(overlay.tools)) {
const toolsFinding = scopedToolsPolicyShapeFinding(overlay.tools, {
policyDocName: params.policyDocName,
policyPath: params.policyPath,
targetPrefix: `${targetPrefix}/tools`,
propertyPrefix: `scopes.${scopeName}.tools`,
});
if (toolsFinding !== undefined) {
return toolsFinding;
}
}
const sandboxFinding = sandboxPolicyShapeFinding(overlay.sandbox, {
policyDocName: params.policyDocName,
policyPath: params.policyPath,
targetPrefix: `${targetPrefix}/sandbox`,
propertyPrefix: `scopes.${scopeName}.sandbox`,
});
if (sandboxFinding !== undefined) {
return sandboxFinding;
}
const ingressFindingLocal = ingressPolicyShapeFinding(overlay.ingress, {
policyDocName: params.policyDocName,
policyPath: params.policyPath,
targetPrefix: `${targetPrefix}/ingress`,
propertyPrefix: `scopes.${scopeName}.ingress`,
allowSession: false,
});
if (ingressFindingLocal !== undefined) {
return ingressFindingLocal;
}
}
return duplicateScopedPolicyFieldFinding(value, {
policyDocName: params.policyDocName,
policyPath: params.policyPath,
policy: params.policy,
});
}
function scopedSelectorShapeFinding(
value: unknown,
params: {
readonly policyDocName: string;
readonly policyPath: string;
readonly property: string;
readonly target: string;
readonly valueName: string;
readonly normalize: (value: string) => string;
},
): HealthFinding | undefined {
const selectorFinding = policyStringArrayPropertyShapeFinding(value, {
policyDocName: params.policyDocName,
policyPath: params.policyPath,
property: params.property,
target: params.target,
valueName: params.valueName,
});
if (selectorFinding !== undefined) {
return selectorFinding;
}
if (value === undefined) {
return undefined;
}
if (Array.isArray(value) && value.length === 0) {
return policyShapeFinding(
params.policyPath,
`oc://${params.policyDocName}/${params.target}`,
`${params.policyPath} ${params.property} must include at least one ${params.valueName}.`,
`Add one or more ${params.valueName}s to ${params.policyPath} ${params.property}.`,
);
}
if (Array.isArray(value)) {
const seen = new Map<string, number>();
for (const [index, rawValue] of value.entries()) {
if (typeof rawValue !== "string") {
continue;
}
const normalized = params.normalize(rawValue);
const previous = seen.get(normalized);
if (previous !== undefined) {
return policyShapeFinding(
params.policyPath,
`oc://${params.policyDocName}/${params.target}/#${index}`,
`${params.policyPath} ${params.property}[${index}] duplicates ${params.property}[${previous}] after normalization.`,
`List each ${params.valueName} only once per named policy scope.`,
);
}
seen.set(normalized, index);
}
}
return undefined;
}
export function hasValidScopedPolicy(
policy: unknown,
policyPath: string,
policyDocName: string,
): boolean {
return (
isRecord(policy) &&
scopedPolicyShapeFinding(policy.scopes, { policyDocName, policyPath, policy }) === undefined
);
}
@@ -1,7 +1,7 @@
// Policy doctor health-check factories for one policy scope.
import type { HealthCheck } from "openclaw/plugin-sdk/health";
import { repairPolicyAutomaticNarrower } from "../automatic-repairs.js";
import { CHECK_IDS } from "../metadata.js";
import { CHECK_IDS } from "../check-ids.js";
import type { PolicyDoctorCheckDeps } from "../types.js";
export function createPolicyChannelProviderChecks(
+1 -1
View File
@@ -1,6 +1,6 @@
// Policy doctor health-check factories for one policy scope.
import type { HealthCheck } from "openclaw/plugin-sdk/health";
import { CHECK_IDS } from "../metadata.js";
import { CHECK_IDS } from "../check-ids.js";
import type { PolicyDoctorCheckDeps } from "../types.js";
export function createPolicyCoreChecks(deps: PolicyDoctorCheckDeps): readonly HealthCheck[] {
@@ -1,7 +1,7 @@
// Policy doctor health-check factories for one policy scope.
import type { HealthCheck } from "openclaw/plugin-sdk/health";
import { repairPolicyAutomaticNarrower } from "../automatic-repairs.js";
import { CHECK_IDS } from "../metadata.js";
import { CHECK_IDS } from "../check-ids.js";
import type { PolicyDoctorCheckDeps } from "../types.js";
export function createPolicyDataAuthChecks(deps: PolicyDoctorCheckDeps): readonly HealthCheck[] {
@@ -1,6 +1,6 @@
// Policy doctor health-check factories for one policy scope.
import type { HealthCheck } from "openclaw/plugin-sdk/health";
import { CHECK_IDS } from "../metadata.js";
import { CHECK_IDS } from "../check-ids.js";
import type { PolicyDoctorCheckDeps } from "../types.js";
export function createPolicyExecApprovalChecks(
@@ -2,7 +2,7 @@
import type { HealthCheck, HealthFinding } from "openclaw/plugin-sdk/health";
import type { PolicyEvidence } from "../../policy-state.js";
import { repairPolicyAutomaticNarrower } from "../automatic-repairs.js";
import { CHECK_IDS } from "../metadata.js";
import { CHECK_IDS } from "../check-ids.js";
import { previewPolicyReviewRequiredRepair } from "../review-required-repairs.js";
import type { PolicyDoctorCheckDeps } from "../types.js";
import { readPolicyBoolean, readStringList } from "../utils.js";
@@ -2,7 +2,7 @@
import type { HealthCheck, HealthFinding } from "openclaw/plugin-sdk/health";
import { normalizeProviderId } from "openclaw/plugin-sdk/provider-model-shared";
import type { PolicyEvidence } from "../../policy-state.js";
import { CHECK_IDS } from "../metadata.js";
import { CHECK_IDS } from "../check-ids.js";
import type { PolicyDoctorCheckDeps } from "../types.js";
import { readPolicyBoolean, readStringList } from "../utils.js";
@@ -1,6 +1,6 @@
// Policy doctor health-check factories for one policy scope.
import type { HealthCheck } from "openclaw/plugin-sdk/health";
import { CHECK_IDS } from "../metadata.js";
import { CHECK_IDS } from "../check-ids.js";
import type { PolicyDoctorCheckDeps } from "../types.js";
export function createPolicySandboxChecks(deps: PolicyDoctorCheckDeps): readonly HealthCheck[] {
+1 -1
View File
@@ -1,7 +1,7 @@
// Policy doctor health-check factories for one policy scope.
import type { HealthCheck } from "openclaw/plugin-sdk/health";
import { repairPolicyAutomaticNarrower } from "../automatic-repairs.js";
import { CHECK_IDS } from "../metadata.js";
import { CHECK_IDS } from "../check-ids.js";
import type { PolicyDoctorCheckDeps } from "../types.js";
export function createPolicyAgentToolChecks(deps: PolicyDoctorCheckDeps): readonly HealthCheck[] {
@@ -0,0 +1,129 @@
import type { HealthFinding } from "openclaw/plugin-sdk/health";
import { isRecord } from "openclaw/plugin-sdk/string-coerce-runtime";
import { CHECK_IDS } from "./check-ids.js";
import { ocPathSegment } from "./utils.js";
export function unsupportedPolicyKey(
value: Record<string, unknown>,
allowedKeys: readonly string[],
): string | undefined {
const allowed = new Set(allowedKeys);
return Object.keys(value).find((key) => !allowed.has(key));
}
export function policyStringArrayShapeFinding(
value: unknown,
params: {
readonly property: string;
readonly policyDocName: string;
readonly policyPath: string;
readonly target: string;
readonly valueName: string;
},
): HealthFinding | undefined {
if (value === undefined) {
return undefined;
}
if (!isRecord(value)) {
return policyShapeFinding(
params.policyPath,
`oc://${params.policyDocName}/${params.target}`,
`${params.policyPath} ${params.property} must be an object.`,
`Fix ${params.policyPath} so ${params.property} is an object.`,
);
}
const unsupportedKey = unsupportedPolicyKey(value, ["allow", "deny"]);
if (unsupportedKey !== undefined) {
return policyShapeFinding(
params.policyPath,
`oc://${params.policyDocName}/${params.target}/${ocPathSegment(unsupportedKey)}`,
`${params.policyPath} ${params.property}.${unsupportedKey} is not supported in policy.`,
`Remove ${params.property}.${unsupportedKey} or use ${params.property}.allow or ${params.property}.deny.`,
);
}
for (const key of ["allow", "deny"] as const) {
const entries = value[key];
if (entries === undefined) {
continue;
}
const target = `oc://${params.policyDocName}/${params.target}/${key}`;
if (!Array.isArray(entries)) {
return policyShapeFinding(
params.policyPath,
target,
`${params.policyPath} ${params.property}.${key} must be an array.`,
`Fix ${params.policyPath} so ${params.property}.${key} is an array of ${params.valueName}s.`,
);
}
const invalidIndex = entries.findIndex(
(entry) => typeof entry !== "string" || entry.trim() === "",
);
if (invalidIndex >= 0) {
return policyShapeFinding(
params.policyPath,
`${target}/#${invalidIndex}`,
`${params.policyPath} ${params.property}.${key}[${invalidIndex}] must be a non-empty string.`,
`Fix ${params.policyPath} so each ${params.property}.${key} entry is a ${params.valueName}.`,
);
}
}
return undefined;
}
export function policyStringArrayPropertyShapeFinding(
value: unknown,
params: {
readonly allowed?: readonly string[];
readonly property: string;
readonly policyDocName: string;
readonly policyPath: string;
readonly target: string;
readonly valueName: string;
},
): HealthFinding | undefined {
if (value === undefined) {
return undefined;
}
if (!Array.isArray(value)) {
return policyShapeFinding(
params.policyPath,
`oc://${params.policyDocName}/${params.target}`,
`${params.policyPath} ${params.property} must be an array.`,
`Fix ${params.policyPath} so ${params.property} is an array of ${params.valueName}s.`,
);
}
const invalidIndex = value.findIndex((entry) => {
if (typeof entry !== "string" || entry.trim() === "") {
return true;
}
return params.allowed !== undefined && !params.allowed.includes(entry.trim());
});
if (invalidIndex < 0) {
return undefined;
}
const allowedHint =
params.allowed === undefined ? "" : ` Supported values: ${params.allowed.join(", ")}.`;
return policyShapeFinding(
params.policyPath,
`oc://${params.policyDocName}/${params.target}/#${invalidIndex}`,
`${params.policyPath} ${params.property}[${invalidIndex}] must be a supported ${params.valueName}.`,
`Use non-empty ${params.valueName} entries.${allowedHint}`,
);
}
export function policyShapeFinding(
policyPath: string,
target: string,
message: string,
fixHint: string,
): HealthFinding {
return {
checkId: CHECK_IDS.policyInvalidFile,
severity: "error",
message,
source: "policy",
path: policyPath,
target,
fixHint,
};
}
@@ -0,0 +1,469 @@
import type { HealthFinding } from "openclaw/plugin-sdk/health";
import { isRecord, uniqueStrings } from "openclaw/plugin-sdk/string-coerce-runtime";
import type { PolicyEvidence, PolicyToolPostureEvidence } from "../policy-state.js";
import { toolPosturePolicyShapeFinding } from "./agent-tool-shapes.js";
import { CHECK_IDS, POLICY_CHECK_IDS } from "./check-ids.js";
import { KNOWN_RISK_LEVELS, KNOWN_SENSITIVITY_LEVELS } from "./policy-constants.js";
import { expandPolicyToolRequirement, toolListCoversTool } from "./policy-runtime.js";
import { agentScopedPolicyTargets, scopedToolAgentMatches } from "./policy-scope.js";
import { hasValidScopedPolicy } from "./scoped-policy-shape.js";
import { ocPathSegment, readPolicyBoolean, readStringList } from "./utils.js";
export function toolPostureFindings(
policy: unknown,
policyPath: string,
policyDocName: string,
evidence: PolicyEvidence,
): readonly HealthFinding[] {
const findings: HealthFinding[] = [];
if (
isRecord(policy) &&
isRecord(policy.tools) &&
toolPosturePolicyShapeFinding(policy.tools, { policyDocName, policyPath }) === undefined
) {
findings.push(
...toolPostureFindingsForRule(policy.tools, policyDocName, "tools", evidence, () => true),
);
}
if (!hasValidScopedPolicy(policy, policyPath, policyDocName)) {
return findings;
}
for (const target of agentScopedPolicyTargets(policy)) {
if (!isRecord(target.overlay.tools)) {
continue;
}
const requirementBase = `scopes/${ocPathSegment(target.scopeName)}/tools`;
if (
toolPosturePolicyShapeFinding(target.overlay.tools, {
policyDocName,
policyPath,
targetPrefix: requirementBase,
propertyPrefix: `scopes.${target.scopeName}.tools`,
}) !== undefined
) {
continue;
}
findings.push(
...toolPostureFindingsForRule(
target.overlay.tools,
policyDocName,
requirementBase,
evidence,
(entry) => scopedToolAgentMatches(entry, target.agentId, evidence.toolPosture ?? []),
),
);
}
return findings;
}
function toolPostureFindingsForRule(
toolsPolicy: Record<string, unknown>,
policyDocName: string,
requirementBase: string,
evidence: PolicyEvidence,
evidenceFilter: (entry: PolicyToolPostureEvidence) => boolean,
): readonly HealthFinding[] {
return [
...toolProfileFindings(toolsPolicy, policyDocName, requirementBase, evidence, evidenceFilter),
...toolFsWorkspaceOnlyFindings(
toolsPolicy,
policyDocName,
requirementBase,
evidence,
evidenceFilter,
),
...toolExecPostureFindings(
toolsPolicy,
policyDocName,
requirementBase,
evidence,
evidenceFilter,
),
...toolElevatedFindings(toolsPolicy, policyDocName, requirementBase, evidence, evidenceFilter),
...toolAlsoAllowExpectedFindings(
toolsPolicy,
policyDocName,
requirementBase,
evidence,
evidenceFilter,
),
...toolRequiredDenyFindings(
toolsPolicy,
policyDocName,
requirementBase,
evidence,
evidenceFilter,
),
];
}
function toolProfileFindings(
toolsPolicy: Record<string, unknown>,
policyDocName: string,
requirementBase: string,
evidence: PolicyEvidence,
evidenceFilter: (entry: PolicyToolPostureEvidence) => boolean,
): readonly HealthFinding[] {
const allowed = new Set(readStringList(toolsPolicy, ["profiles", "allow"]));
if (allowed.size === 0) {
return [];
}
return toolPostureEntries(evidence, "profile")
.filter(evidenceFilter)
.filter((entry) => typeof entry.value === "string" && !allowed.has(entry.value.toLowerCase()))
.map((entry): HealthFinding => {
return toolPostureFinding(entry, {
checkId: CHECK_IDS.policyToolsProfileUnapproved,
message: `${toolPostureLabel(entry)} uses unapproved tool profile '${entry.value ?? ""}'.`,
requirement: `oc://${policyDocName}/${requirementBase}/profiles/allow`,
fixHint: "Use an approved tools.profile value or update policy after review.",
});
});
}
function toolFsWorkspaceOnlyFindings(
toolsPolicy: Record<string, unknown>,
policyDocName: string,
requirementBase: string,
evidence: PolicyEvidence,
evidenceFilter: (entry: PolicyToolPostureEvidence) => boolean,
): readonly HealthFinding[] {
if (readPolicyBoolean(toolsPolicy, ["fs", "requireWorkspaceOnly"]) !== true) {
return [];
}
return toolPostureEntries(evidence, "fsWorkspaceOnly")
.filter(evidenceFilter)
.filter((entry) => entry.value !== true)
.map((entry): HealthFinding => {
return toolPostureFinding(entry, {
checkId: CHECK_IDS.policyToolsFsWorkspaceOnlyRequired,
message: `${toolPostureLabel(entry)} does not require workspace-only filesystem tools.`,
requirement: `oc://${policyDocName}/${requirementBase}/fs/requireWorkspaceOnly`,
fixHint: "Set tools.fs.workspaceOnly=true or update policy after review.",
});
});
}
function toolExecPostureFindings(
toolsPolicy: Record<string, unknown>,
policyDocName: string,
requirementBase: string,
evidence: PolicyEvidence,
evidenceFilter: (entry: PolicyToolPostureEvidence) => boolean,
): readonly HealthFinding[] {
return [
...toolStringPostureAllowFindings(toolsPolicy, policyDocName, requirementBase, evidence, {
checkId: CHECK_IDS.policyToolsExecSecurityUnapproved,
kind: "execSecurity",
policyPath: ["exec", "allowSecurity"],
requirementPath: "exec/allowSecurity",
settingLabel: "exec security",
evidenceFilter,
}),
...toolStringPostureAllowFindings(toolsPolicy, policyDocName, requirementBase, evidence, {
checkId: CHECK_IDS.policyToolsExecAskUnapproved,
kind: "execAsk",
policyPath: ["exec", "requireAsk"],
requirementPath: "exec/requireAsk",
settingLabel: "exec ask",
evidenceFilter,
}),
...toolStringPostureAllowFindings(toolsPolicy, policyDocName, requirementBase, evidence, {
checkId: CHECK_IDS.policyToolsExecHostUnapproved,
kind: "execHost",
policyPath: ["exec", "allowHosts"],
requirementPath: "exec/allowHosts",
settingLabel: "exec host",
evidenceFilter,
}),
];
}
function toolStringPostureAllowFindings(
toolsPolicy: Record<string, unknown>,
policyDocName: string,
requirementBase: string,
evidence: PolicyEvidence,
params: {
readonly checkId: (typeof POLICY_CHECK_IDS)[number];
readonly kind: PolicyToolPostureEvidence["kind"];
readonly policyPath: readonly string[];
readonly requirementPath: string;
readonly settingLabel: string;
readonly evidenceFilter: (entry: PolicyToolPostureEvidence) => boolean;
},
): readonly HealthFinding[] {
const allowed = new Set(readStringList(toolsPolicy, params.policyPath));
if (allowed.size === 0) {
return [];
}
return toolPostureEntries(evidence, params.kind)
.filter(params.evidenceFilter)
.filter((entry) => typeof entry.value === "string" && !allowed.has(entry.value.toLowerCase()))
.map((entry): HealthFinding => {
return toolPostureFinding(entry, {
checkId: params.checkId,
message: `${toolPostureLabel(entry)} uses unapproved ${params.settingLabel} '${entry.value ?? ""}'.`,
requirement: `oc://${policyDocName}/${requirementBase}/${params.requirementPath}`,
fixHint: "Adjust the configured tool posture or update policy after review.",
});
});
}
function toolElevatedFindings(
toolsPolicy: Record<string, unknown>,
policyDocName: string,
requirementBase: string,
evidence: PolicyEvidence,
evidenceFilter: (entry: PolicyToolPostureEvidence) => boolean,
): readonly HealthFinding[] {
if (readPolicyBoolean(toolsPolicy, ["elevated", "allow"]) !== false) {
return [];
}
return toolPostureEntries(evidence, "elevatedEnabled")
.filter(evidenceFilter)
.filter((entry) => entry.value !== false)
.map((entry): HealthFinding => {
return toolPostureFinding(entry, {
checkId: CHECK_IDS.policyToolsElevatedEnabled,
message: `${toolPostureLabel(entry)} permits elevated tool mode.`,
requirement: `oc://${policyDocName}/${requirementBase}/elevated/allow`,
fixHint: "Set tools.elevated.enabled=false or update policy after review.",
});
});
}
function toolAlsoAllowExpectedFindings(
toolsPolicy: Record<string, unknown>,
policyDocName: string,
requirementBase: string,
evidence: PolicyEvidence,
evidenceFilter: (entry: PolicyToolPostureEvidence) => boolean,
): readonly HealthFinding[] {
const alsoAllowPolicy = isRecord(toolsPolicy.alsoAllow) ? toolsPolicy.alsoAllow : {};
if (alsoAllowPolicy.expected === undefined) {
return [];
}
const expected = normalizedStringSet(readStringList(toolsPolicy, ["alsoAllow", "expected"]));
const findings: HealthFinding[] = [];
for (const entry of toolPostureEntries(evidence, "alsoAllow").filter(evidenceFilter)) {
const actual = normalizedStringSet(entry.entries ?? []);
for (const expectedTool of expected) {
if (actual.has(expectedTool)) {
continue;
}
findings.push(
toolPostureFinding(entry, {
checkId: CHECK_IDS.policyToolsAlsoAllowMissing,
message: `${toolPostureLabel(entry)} is missing expected tools.alsoAllow entry '${expectedTool}'.`,
requirement: `oc://${policyDocName}/${requirementBase}/alsoAllow/expected`,
fixHint: "Add the expected tools.alsoAllow entry or update policy after review.",
}),
);
}
for (const actualTool of actual) {
if (expected.has(actualTool)) {
continue;
}
findings.push(
toolPostureFinding(entry, {
checkId: CHECK_IDS.policyToolsAlsoAllowUnexpected,
message: `${toolPostureLabel(entry)} has unexpected tools.alsoAllow entry '${actualTool}'.`,
requirement: `oc://${policyDocName}/${requirementBase}/alsoAllow/expected`,
fixHint: "Remove the unexpected tools.alsoAllow entry or update policy after review.",
}),
);
}
}
return findings;
}
function normalizedStringSet(entries: readonly string[]): ReadonlySet<string> {
return new Set(
entries
.map((entry) => entry.trim().toLowerCase())
.filter(Boolean)
.toSorted(),
);
}
function toolRequiredDenyFindings(
toolsPolicy: Record<string, unknown>,
policyDocName: string,
requirementBase: string,
evidence: PolicyEvidence,
evidenceFilter: (entry: PolicyToolPostureEvidence) => boolean,
): readonly HealthFinding[] {
const required = readStringList(toolsPolicy, ["denyTools"]);
if (required.length === 0) {
return [];
}
const requiredTools = uniqueStrings(required.flatMap(expandPolicyToolRequirement));
const findings: HealthFinding[] = [];
for (const entry of toolPostureEntries(evidence, "deny").filter(evidenceFilter)) {
for (const tool of requiredTools) {
if (toolListCoversTool(entry.entries ?? [], tool)) {
continue;
}
findings.push(
toolPostureFinding(entry, {
checkId: CHECK_IDS.policyToolsRequiredDenyMissing,
message: `${toolPostureLabel(entry)} does not deny required tool '${tool}'.`,
requirement: `oc://${policyDocName}/${requirementBase}/denyTools`,
fixHint:
"Add the tool or group to tools.deny/agents.list[].tools.deny, or update policy after review.",
}),
);
}
}
return findings;
}
function toolPostureEntries(
evidence: PolicyEvidence,
kind: PolicyToolPostureEvidence["kind"],
): readonly PolicyToolPostureEvidence[] {
return (evidence.toolPosture ?? []).filter((entry) => entry.kind === kind);
}
function toolPostureFinding(
entry: PolicyToolPostureEvidence,
params: {
readonly checkId: (typeof POLICY_CHECK_IDS)[number];
readonly message: string;
readonly requirement: string;
readonly fixHint: string;
},
): HealthFinding {
return {
checkId: params.checkId,
severity: "error",
message: params.message,
source: "policy",
path: "openclaw config",
ocPath: entry.source,
target: entry.source,
requirement: params.requirement,
fixHint: params.fixHint,
};
}
function toolPostureLabel(entry: PolicyToolPostureEvidence): string {
return entry.agentId === undefined ? "global tools config" : `agent '${entry.agentId}'`;
}
export function toolRiskFindings(
policyDocName: string,
evidence: PolicyEvidence,
): readonly HealthFinding[] {
return (evidence.tools ?? [])
.filter((tool) => tool.risk === undefined)
.map((tool): HealthFinding => {
return {
checkId: CHECK_IDS.policyMissingToolRisk,
severity: "error",
message: `TOOLS.md tool '${tool.id}' has no explicit risk classification.`,
source: "policy",
path: "TOOLS.md",
line: tool.line,
ocPath: tool.source,
target: tool.source,
requirement: `oc://${policyDocName}/tools/requireMetadata`,
fixHint:
"Declare risk:low, risk:medium, risk:high, risk:critical, or an R0-R5 review alias.",
};
});
}
export function toolUnknownRiskFindings(
policyDocName: string,
evidence: PolicyEvidence,
): readonly HealthFinding[] {
return (evidence.tools ?? [])
.filter(
(tool) =>
tool.risk !== undefined &&
!KNOWN_RISK_LEVELS.includes(tool.risk as (typeof KNOWN_RISK_LEVELS)[number]),
)
.map((tool): HealthFinding => {
return {
checkId: CHECK_IDS.policyUnknownToolRisk,
severity: "error",
message: `TOOLS.md tool '${tool.id}' declares unknown risk '${tool.risk}'.`,
source: "policy",
path: "TOOLS.md",
line: tool.line,
ocPath: tool.source,
target: tool.source,
requirement: `oc://${policyDocName}/tools/requireMetadata`,
fixHint: `Use one of: ${KNOWN_RISK_LEVELS.join(", ")}.`,
};
});
}
export function toolSensitivityFindings(
policyDocName: string,
evidence: PolicyEvidence,
): readonly HealthFinding[] {
return (evidence.tools ?? []).flatMap((tool): HealthFinding[] => {
if (tool.sensitivity === undefined) {
return [
{
checkId: CHECK_IDS.policyMissingToolSensitivity,
severity: "error",
message: `TOOLS.md tool '${tool.id}' has no declared artifact sensitivity.`,
source: "policy",
path: "TOOLS.md",
line: tool.line,
ocPath: tool.source,
target: tool.source,
requirement: `oc://${policyDocName}/tools/requireMetadata`,
fixHint: `Declare sensitivity as one of: ${KNOWN_SENSITIVITY_LEVELS.join(", ")}.`,
},
];
}
if (
KNOWN_SENSITIVITY_LEVELS.includes(
tool.sensitivity as (typeof KNOWN_SENSITIVITY_LEVELS)[number],
)
) {
return [];
}
return [
{
checkId: CHECK_IDS.policyUnknownToolSensitivity,
severity: "error",
message: `TOOLS.md tool '${tool.id}' declares unknown sensitivity '${tool.sensitivity}'.`,
source: "policy",
path: "TOOLS.md",
line: tool.line,
ocPath: tool.source,
target: tool.source,
requirement: `oc://${policyDocName}/tools/requireMetadata`,
fixHint: `Use one of: ${KNOWN_SENSITIVITY_LEVELS.join(", ")}.`,
},
];
});
}
export function toolOwnerFindings(
policyDocName: string,
evidence: PolicyEvidence,
): readonly HealthFinding[] {
return (evidence.tools ?? [])
.filter((tool) => tool.owner === undefined)
.map((tool): HealthFinding => {
return {
checkId: CHECK_IDS.policyMissingToolOwner,
severity: "error",
message: `TOOLS.md tool '${tool.id}' has no declared owner.`,
source: "policy",
path: "TOOLS.md",
line: tool.line,
ocPath: tool.source,
target: tool.source,
requirement: `oc://${policyDocName}/tools/requireMetadata`,
fixHint: "Declare owner:<team-or-person> for this tool.",
};
});
}
+1 -1
View File
@@ -1,7 +1,7 @@
// Policy doctor shared types.
import type { HealthCheckContext, HealthFinding } from "openclaw/plugin-sdk/health";
import type { PolicyEvidence } from "../policy-state.js";
import type { POLICY_CHECK_IDS } from "./metadata.js";
import type { POLICY_CHECK_IDS } from "./check-ids.js";
export type PolicyEvaluation = {
readonly policyPath: string;