From 646974b7d8fbedcd4eee4ca5de455165737a77c4 Mon Sep 17 00:00:00 2001 From: Gio Della-Libera Date: Tue, 2 Jun 2026 15:01:57 -0700 Subject: [PATCH] fix(policy): reject unsupported policy keys (#87074) Merged via squash. Prepared head SHA: 3ab4ff1d8f770a75da665b9502a4ff9d3b8d9519 Co-authored-by: giodl73-repo <235387111+giodl73-repo@users.noreply.github.com> Co-authored-by: giodl73-repo <235387111+giodl73-repo@users.noreply.github.com> Reviewed-by: @giodl73-repo --- docs/cli/policy.md | 2 + extensions/policy/src/doctor/register.test.ts | 177 +++++++- extensions/policy/src/doctor/register.ts | 399 +++++++++++++++++- 3 files changed, 557 insertions(+), 21 deletions(-) diff --git a/docs/cli/policy.md b/docs/cli/policy.md index c627ab9654a1..228197c6ccc5 100644 --- a/docs/cli/policy.md +++ b/docs/cli/policy.md @@ -205,6 +205,8 @@ Each policy field below is optional. A check runs only when the matching rule is present in `policy.jsonc`. The observed state is existing OpenClaw config or workspace metadata; policy reports drift but does not rewrite runtime behavior unless a repair path is explicitly available and enabled. +Policy files are strict: unsupported sections or rule keys are reported as +`policy/policy-jsonc-invalid` instead of being ignored. Policy overlays keep broad top-level rules global, then let named scope blocks add stricter normal policy sections for explicit selectors. A scope name is a diff --git a/extensions/policy/src/doctor/register.test.ts b/extensions/policy/src/doctor/register.test.ts index d3d3956d42ae..bc301dbed2bf 100644 --- a/extensions/policy/src/doctor/register.test.ts +++ b/extensions/policy/src/doctor/register.test.ts @@ -661,7 +661,6 @@ describe("registerPolicyDoctorChecks", () => { ["tools settings array", { tools: { settings: [] } }, "oc://policy.jsonc/tools/settings"], ["tools entries object", { tools: { entries: {} } }, "oc://policy.jsonc/tools/entries"], ["tools profiles array", { tools: { profiles: [] } }, "oc://policy.jsonc/tools/profiles"], - [ "tools profiles allow string", { tools: { profiles: { allow: "coding" } } }, @@ -994,6 +993,182 @@ describe("registerPolicyDoctorChecks", () => { ]); }); + it("rejects unsupported policy keys across policy namespaces", async () => { + const cases: readonly { + readonly label: string; + readonly policy: unknown; + readonly target: string; + }[] = [ + { label: "top-level", policy: { channel: {} }, target: "oc://policy.jsonc/channel" }, + { + label: "tools top-level", + policy: { tools: { execPolicy: { allowHosts: ["sandbox"] } } }, + target: "oc://policy.jsonc/tools/execPolicy", + }, + { + label: "tools settings", + policy: { tools: { settings: {} } }, + target: "oc://policy.jsonc/tools/settings", + }, + { + label: "tools entries", + policy: { tools: { entries: [] } }, + target: "oc://policy.jsonc/tools/entries", + }, + { + label: "tools profile", + policy: { tools: { profiles: { deny: ["full"] } } }, + target: "oc://policy.jsonc/tools/profiles/deny", + }, + { + label: "tools exec", + policy: { tools: { exec: { allowShells: ["bash"] } } }, + target: "oc://policy.jsonc/tools/exec/allowShells", + }, + { + label: "tools fs", + policy: { tools: { fs: { allowOutsideWorkspace: true } } }, + target: "oc://policy.jsonc/tools/fs/allowOutsideWorkspace", + }, + { + label: "tools alsoAllow", + policy: { tools: { alsoAllow: { denied: ["exec"] } } }, + target: "oc://policy.jsonc/tools/alsoAllow/denied", + }, + { + label: "channels", + policy: { channels: { allowRules: [] } }, + target: "oc://policy.jsonc/channels/allowRules", + }, + { + label: "channel deny rule", + policy: { channels: { denyRules: [{ when: { provider: "telegram" }, action: "deny" }] } }, + target: "oc://policy.jsonc/channels/denyRules/#0/action", + }, + { + label: "channel deny selector", + policy: { + channels: { denyRules: [{ when: { provider: "telegram", channel: "stable" } }] }, + }, + target: "oc://policy.jsonc/channels/denyRules/#0/when/channel", + }, + { + label: "ingress top-level", + policy: { ingress: { directMessages: {} } }, + target: "oc://policy.jsonc/ingress/directMessages", + }, + { + label: "ingress session", + policy: { ingress: { session: { requiredScope: "per-channel-peer" } } }, + target: "oc://policy.jsonc/ingress/session/requiredScope", + }, + { + label: "ingress channels", + policy: { ingress: { channels: { allowOpenGroups: false } } }, + target: "oc://policy.jsonc/ingress/channels/allowOpenGroups", + }, + { label: "mcp", policy: { mcp: { clients: {} } }, target: "oc://policy.jsonc/mcp/clients" }, + { + label: "mcp servers", + policy: { mcp: { servers: { require: ["docs"] } } }, + target: "oc://policy.jsonc/mcp/servers/require", + }, + { + label: "models", + policy: { models: { modelRefs: {} } }, + target: "oc://policy.jsonc/models/modelRefs", + }, + { + label: "models providers", + policy: { models: { providers: { require: ["openai"] } } }, + target: "oc://policy.jsonc/models/providers/require", + }, + { + label: "network", + policy: { network: { publicNetwork: {} } }, + target: "oc://policy.jsonc/network/publicNetwork", + }, + { + label: "network privateNetwork", + policy: { network: { privateNetwork: { deny: true } } }, + target: "oc://policy.jsonc/network/privateNetwork/deny", + }, + { + label: "gateway top-level", + policy: { gateway: { bind: { allowNonLoopback: false } } }, + target: "oc://policy.jsonc/gateway/bind", + }, + { + label: "gateway exposure", + policy: { gateway: { exposure: { allowPublicBind: false } } }, + target: "oc://policy.jsonc/gateway/exposure/allowPublicBind", + }, + { + label: "gateway auth", + policy: { gateway: { auth: { allowDisabled: false } } }, + target: "oc://policy.jsonc/gateway/auth/allowDisabled", + }, + { + label: "agents", + policy: { agents: { tools: {} } }, + target: "oc://policy.jsonc/agents/tools", + }, + { + label: "agents workspace", + policy: { agents: { workspace: { requireReadOnly: true } } }, + target: "oc://policy.jsonc/agents/workspace/requireReadOnly", + }, + { + label: "dataHandling", + policy: { dataHandling: { logs: { requireRedaction: true } } }, + target: "oc://policy.jsonc/dataHandling/logs", + }, + { + label: "dataHandling nested", + policy: { dataHandling: { telemetry: { allowCaptureContent: false } } }, + target: "oc://policy.jsonc/dataHandling/telemetry/allowCaptureContent", + }, + { + label: "secrets", + policy: { secrets: { requireVault: true } }, + target: "oc://policy.jsonc/secrets/requireVault", + }, + { + label: "auth", + policy: { auth: { providers: {} } }, + target: "oc://policy.jsonc/auth/providers", + }, + { + label: "auth profiles", + policy: { auth: { profiles: { requireProvider: true } } }, + target: "oc://policy.jsonc/auth/profiles/requireProvider", + }, + ]; + + for (const testCase of cases) { + const configPath = join(workspaceDir, `${testCase.label.replaceAll(" ", "-")}.jsonc`); + await fs.writeFile(configPath, "{}", "utf-8"); + await fs.writeFile( + join(workspaceDir, "policy.jsonc"), + JSON.stringify(testCase.policy), + "utf-8", + ); + clearHealthChecksForTest(); + resetPolicyDoctorChecksForTest(); + + const result = await runPolicyChecks(ctx(configPath, cfgWithPolicy())); + + expect(result.findings, testCase.label).toEqual([ + expect.objectContaining({ + checkId: "policy/policy-jsonc-invalid", + severity: "error", + path: "policy.jsonc", + target: testCase.target, + }), + ]); + } + }); + it("reports a policy hash mismatch when expectedHash is configured", async () => { const configPath = join(workspaceDir, "openclaw.jsonc"); await fs.writeFile(configPath, "{}", "utf-8"); diff --git a/extensions/policy/src/doctor/register.ts b/extensions/policy/src/doctor/register.ts index b87aa02860ce..9082e2dd0fe0 100644 --- a/extensions/policy/src/doctor/register.ts +++ b/extensions/policy/src/doctor/register.ts @@ -523,6 +523,28 @@ const KNOWN_SENSITIVITY_LEVELS = ["public", "internal", "confidential", "restric const SUPPORTED_TOOL_METADATA = ["risk", "sensitivity", "owner"] as const; const SUPPORTED_AUTH_PROFILE_METADATA = ["provider", "mode"] as const; const SUPPORTED_AUTH_PROFILE_MODES = ["api_key", "aws-sdk", "oauth", "token"] as const; +const SUPPORTED_POLICY_SECTIONS = [ + "auth", + "agents", + "channels", + "dataHandling", + "gateway", + "ingress", + "mcp", + "models", + "network", + "sandbox", + "scopes", + "secrets", + "tools", +] as const; +const SUPPORTED_GATEWAY_POLICY_SECTIONS = [ + "auth", + "controlUi", + "exposure", + "http", + "remote", +] as const; const SUPPORTED_GATEWAY_HTTP_ENDPOINTS = ["chatCompletions", "responses"] as const; const SUPPORTED_DM_POLICIES = ["pairing", "allowlist", "open", "disabled"] as const; const SUPPORTED_DM_SCOPES = [ @@ -1623,6 +1645,17 @@ export function policyContainerShapeFindings( ), ]; } + 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( @@ -1634,26 +1667,6 @@ export function policyContainerShapeFindings( ]; } if (isRecord(policy.tools)) { - if (policy.tools.settings !== undefined && !isRecord(policy.tools.settings)) { - return [ - policyShapeFinding( - policyPath, - `oc://${policyDocName}/tools/settings`, - `${policyPath} tools.settings must be an object.`, - `Fix ${policyPath} so tools.settings is an object.`, - ), - ]; - } - if (policy.tools.entries !== undefined && !Array.isArray(policy.tools.entries)) { - return [ - policyShapeFinding( - policyPath, - `oc://${policyDocName}/tools/entries`, - `${policyPath} tools.entries must be an array.`, - `Fix ${policyPath} so tools.entries is an array.`, - ), - ]; - } const postureFinding = toolPosturePolicyShapeFinding(policy.tools, { policyDocName, policyPath, @@ -1672,6 +1685,19 @@ export function policyContainerShapeFindings( ), ]; } + 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( @@ -1682,6 +1708,19 @@ export function policyContainerShapeFindings( ), ]; } + 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( @@ -1714,6 +1753,19 @@ export function policyContainerShapeFindings( ), ]; } + 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", @@ -1737,6 +1789,17 @@ export function policyContainerShapeFindings( ]; } 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( @@ -1747,6 +1810,21 @@ export function policyContainerShapeFindings( ), ]; } + 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 && @@ -1772,6 +1850,23 @@ export function policyContainerShapeFindings( ), ]; } + 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( @@ -1782,6 +1877,19 @@ export function policyContainerShapeFindings( ), ]; } + 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 && @@ -1796,6 +1904,22 @@ export function policyContainerShapeFindings( ), ]; } + 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 sandboxFinding = sandboxPolicyShapeFinding(policy.sandbox, { policyDocName, policyPath, @@ -1867,6 +1991,15 @@ function ingressPolicyShapeFinding( `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( @@ -1878,6 +2011,15 @@ function ingressPolicyShapeFinding( } } 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]) @@ -1890,6 +2032,19 @@ function ingressPolicyShapeFinding( ); } 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, @@ -1932,6 +2087,15 @@ function agentsPolicyShapeFinding( `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, @@ -2309,6 +2473,15 @@ function agentWorkspacePolicyShapeFinding( `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( @@ -2371,6 +2544,24 @@ function toolPosturePolicyShapeFinding( ): 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( @@ -2383,6 +2574,15 @@ function toolPosturePolicyShapeFinding( } 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, @@ -2396,6 +2596,15 @@ function toolPosturePolicyShapeFinding( } 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, @@ -2406,6 +2615,19 @@ function toolPosturePolicyShapeFinding( } 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"], @@ -2426,6 +2648,15 @@ function toolPosturePolicyShapeFinding( } 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, @@ -2436,6 +2667,15 @@ function toolPosturePolicyShapeFinding( } 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, @@ -2608,12 +2848,38 @@ function gatewayPolicyShapeFinding( ); } } + 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 : {}; + for (const [section, sectionValue, allowedKeys] of [ + ["exposure", exposure, ["allowNonLoopbackBind", "allowTailscaleFunnel"]], + ["auth", auth, ["requireAuth", "requireExplicitRateLimit"]], + ["controlUi", controlUi, ["allowInsecure"]], + ["remote", remote, ["allow"]], + ["http", http, ["denyEndpoints", "requireUrlAllowlists"]], + ] 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", @@ -2700,6 +2966,15 @@ function policyStringArrayShapeFinding( `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) { @@ -2857,6 +3132,41 @@ function invalidChannelDenyRuleFindings( }, ]; } + 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 []; @@ -4742,6 +5052,14 @@ function dataHandlingPolicyShapeFindings( return []; } return [ + policySectionUnsupportedKeyFinding(policy.dataHandling, { + policyPath, + policyDocName, + propertyPath: "dataHandling", + targetPath: "dataHandling", + sectionName: "data-handling", + allowedKeys: ["memory", "retention", "sensitiveLogging", "telemetry"], + }), dataHandlingSectionShapeFinding(policy.dataHandling, { policyPath, policyDocName, @@ -4801,6 +5119,29 @@ function dataHandlingPolicyShapeFindings( ].filter((finding): finding is HealthFinding => finding !== undefined); } +function policySectionUnsupportedKeyFinding( + value: Record, + 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, params: { @@ -4834,6 +5175,24 @@ function dataHandlingBooleanShapeFinding( }, ): 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; }