Vendor OpenClaw source as Adolf fork baseline
Some checks failed
ClawSweeper Dispatch / dispatch (push) Has been cancelled
CodeQL / Security High (actions) (push) Has been cancelled
CodeQL / Security High (channel-runtime-boundary) (push) Has been cancelled
CodeQL / Security High (core-auth-secrets) (push) Has been cancelled
CodeQL / Security High (mcp-process-tool-boundary) (push) Has been cancelled
CodeQL / Security High (network-ssrf-boundary) (push) Has been cancelled
CodeQL / Security High (plugin-trust-boundary) (push) Has been cancelled
CodeQL / Security High (process-exec-boundary) (push) Has been cancelled
Docs Sync Publish Repo / sync-publish-repo (push) Has been cancelled
Docs / docs (push) Has been cancelled
OpenClaw Stable Main Closeout / Resolve stable release closeout inputs (push) Has been cancelled
OpenClaw Stable Main Closeout / Verify stable main closeout (push) Has been cancelled
Workflow Sanity / no-tabs (push) Has been cancelled
Workflow Sanity / actionlint (push) Has been cancelled
Workflow Sanity / generated-doc-baselines (push) Has been cancelled
CI / runner-admission (push) Has been cancelled
CI / preflight (push) Has been cancelled
CI / security-fast (push) Has been cancelled
CI / pnpm-store-warmup (push) Has been cancelled
CI / build-artifacts (push) Has been cancelled
CI / native-i18n (push) Has been cancelled
CI / ${{ matrix.check_name }} (push) Has been cancelled
CI / ${{ matrix.checkName }} (push) Has been cancelled
CI / checks-node-compat-node22 (push) Has been cancelled
CI / check-bundled-channel-config-metadata (push) Has been cancelled
CI / check-dependencies (push) Has been cancelled
CI / check-guards (push) Has been cancelled
CI / check-lint (push) Has been cancelled
CI / check-prod-types (push) Has been cancelled
CI / check-shrinkwrap (push) Has been cancelled
CI / check-test-types (push) Has been cancelled
CI / check-additional-boundaries-a (push) Has been cancelled
CI / check-additional-boundaries-bcd (push) Has been cancelled
CI / check-additional-extension-bundled (push) Has been cancelled
CI / check-additional-extension-channels (push) Has been cancelled
CI / check-additional-extension-package-boundary (push) Has been cancelled
CI / check-additional-runtime-topology-architecture (push) Has been cancelled
CI / check-session-accessor-boundary (push) Has been cancelled
CI / check-session-transcript-reader-boundary (push) Has been cancelled
CI / check-docs (push) Has been cancelled
CI / skills-python (push) Has been cancelled
CI / macos-swift (push) Has been cancelled
CI / ios-build (push) Has been cancelled
CI / ci-timings-summary (push) Has been cancelled
Native App Locale Refresh / Refresh native fa (push) Has been cancelled
Native App Locale Refresh / Refresh native fr (push) Has been cancelled
Native App Locale Refresh / Refresh native hi (push) Has been cancelled
Native App Locale Refresh / Refresh native id (push) Has been cancelled
Native App Locale Refresh / Refresh native it (push) Has been cancelled
Native App Locale Refresh / Refresh native ja-JP (push) Has been cancelled
Control UI Locale Refresh / plan (push) Has been cancelled
Control UI Locale Refresh / Refresh ${{ matrix.locale }} (push) Has been cancelled
Control UI Locale Refresh / Commit control UI locale refresh (push) Has been cancelled
Live Media Runner Image / Build live media runner image (push) Has been cancelled
Native App Locale Refresh / Refresh native ar (push) Has been cancelled
Native App Locale Refresh / Refresh native de (push) Has been cancelled
Native App Locale Refresh / Refresh native es (push) Has been cancelled
Native App Locale Refresh / Refresh native ko (push) Has been cancelled
Native App Locale Refresh / Refresh native nl (push) Has been cancelled
Native App Locale Refresh / Refresh native pl (push) Has been cancelled
Native App Locale Refresh / Refresh native pt-BR (push) Has been cancelled
Native App Locale Refresh / Refresh native ru (push) Has been cancelled
Native App Locale Refresh / Refresh native sv (push) Has been cancelled
Native App Locale Refresh / Refresh native th (push) Has been cancelled
Native App Locale Refresh / Refresh native tr (push) Has been cancelled
Native App Locale Refresh / Refresh native uk (push) Has been cancelled
Native App Locale Refresh / Refresh native vi (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-CN (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-TW (push) Has been cancelled
Native App Locale Refresh / Commit native locale refresh (push) Has been cancelled
Plugin Init Scaffold Validation / Validate provider scaffold (push) Has been cancelled
Plugin NPM Release / preview_plugins_npm (push) Has been cancelled
Plugin NPM Release / Validate release publish approval (push) Has been cancelled
Plugin NPM Release / preview_plugin_pack (push) Has been cancelled
Plugin NPM Release / publish_plugins_npm (push) Has been cancelled
Sandbox Common Smoke / sandbox-common-smoke (push) Has been cancelled
Website Installer Sync / static (push) Has been cancelled
Website Installer Sync / linux-docker (push) Has been cancelled
Website Installer Sync / macos-installer (push) Has been cancelled
Website Installer Sync / windows-installer (push) Has been cancelled
Website Installer Sync / sync-website (push) Has been cancelled

Adolf is a fork/vendored clone of github.com/openclaw/openclaw (v2026.6.11),
free to diverge. Tree copied sans upstream .git; upstream remote added for
future syncs. Node pinned to 24 (.nvmrc); engines already require >=22.19.
Preserves docs/ARCHITECTURE.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LeqyaxJF2nbRXJtae2kNB2
This commit is contained in:
2026-07-05 09:36:54 +00:00
parent 3216769225
commit bedb527145
21108 changed files with 6010766 additions and 0 deletions

2
extensions/policy/api.ts Normal file
View File

@@ -0,0 +1,2 @@
// Policy API module exposes the plugin public contract.
export { registerPolicyDoctorChecks } from "./src/doctor/register.js";

View File

@@ -0,0 +1,27 @@
// Policy plugin entrypoint registers its OpenClaw integration.
import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry";
import { registerPolicyCli } from "./src/cli.js";
import { registerPolicyDoctorChecks } from "./src/doctor/register.js";
export default definePluginEntry({
id: "policy",
name: "Policy",
description: "Adds policy-backed doctor checks for workspace conformance.",
register(api) {
api.registerCli(
async ({ program }) => {
registerPolicyCli(program);
},
{
descriptors: [
{
name: "policy",
description: "Check policy requirements and emit audit evidence",
hasSubcommands: true,
},
],
},
);
registerPolicyDoctorChecks();
},
});

View File

@@ -0,0 +1,41 @@
{
"id": "policy",
"name": "Policy",
"description": "Adds policy-backed doctor checks for workspace conformance.",
"activation": {
"onStartup": true,
"onCommands": ["doctor", "policy"]
},
"commandAliases": [
{
"name": "policy",
"kind": "cli"
}
],
"configSchema": {
"type": "object",
"additionalProperties": false,
"properties": {
"enabled": {
"type": "boolean",
"description": "Enable policy doctor checks even before policy.jsonc exists."
},
"workspaceRepairs": {
"type": "boolean",
"description": "Allow doctor --fix to repair policy-managed workspace settings."
},
"expectedHash": {
"type": "string",
"description": "Optional sha256 hash for hash-locking the approved policy artifact."
},
"expectedAttestationHash": {
"type": "string",
"description": "Optional sha256 hash for the last accepted clean policy check."
},
"path": {
"type": "string",
"description": "Optional policy.jsonc path. Relative paths resolve from the active workspace."
}
}
}
}

View File

@@ -0,0 +1,27 @@
{
"name": "@openclaw/policy",
"version": "2026.6.11",
"private": true,
"description": "OpenClaw policy doctor checks for workspace conformance",
"type": "module",
"dependencies": {
"json5": "2.2.3"
},
"devDependencies": {
"@openclaw/plugin-sdk": "workspace:*",
"openclaw": "workspace:*"
},
"peerDependencies": {
"openclaw": ">=2026.6.11"
},
"peerDependenciesMeta": {
"openclaw": {
"optional": true
}
},
"openclaw": {
"extensions": [
"./index.ts"
]
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,459 @@
// Policy plugin module implements cli behavior.
import { isAbsolute, resolve } from "node:path";
import { setTimeout as sleep } from "node:timers/promises";
import type { Command } from "commander";
import {
exitCodeFromFindings,
healthFindingMeetsSeverity,
parseHealthFindingSeverity,
readConfigFileSnapshot,
resolveAgentWorkspaceDir,
resolveDefaultAgentId,
type HealthCheckContext,
type HealthFinding,
} from "openclaw/plugin-sdk/health";
import { POLICY_FIX_METADATA_BY_CHECK_ID } from "./doctor/fix-metadata.js";
import { POLICY_CHECK_IDS, evaluatePolicy } from "./doctor/register.js";
import {
buildPolicyConformanceReport,
type PolicyConformanceReport,
} from "./policy-conformance.js";
import { createPolicyAttestation } from "./policy-state.js";
export type PolicyCommandRuntime = {
writeStdout(value: string): void;
error(value: string): void;
sleep?(ms: number): Promise<void>;
};
export interface PolicyCheckOptions {
readonly json?: boolean;
readonly severityMin?: string;
readonly cwd?: string;
}
export interface PolicyWatchOptions extends PolicyCheckOptions {
readonly intervalMs?: string | number;
readonly once?: boolean;
}
export interface PolicyCompareOptions {
readonly baseline?: string;
readonly policy?: string;
readonly json?: boolean;
readonly cwd?: string;
}
type PolicyCheckReport = {
readonly ok: boolean;
readonly attestation?: ReturnType<typeof createPolicyAttestation>;
readonly evidence: unknown;
readonly checksRun: number;
readonly checksSkipped: number;
readonly findings: readonly Record<string, unknown>[];
readonly expectedAttestationHash?: string;
readonly exitCode: 0 | 1;
};
const defaultRuntime: PolicyCommandRuntime = {
writeStdout(value) {
process.stdout.write(value);
},
error(value) {
process.stderr.write(`${value}\n`);
},
sleep(ms) {
return sleep(ms);
},
};
export function registerPolicyCli(program: Command): void {
const policy = program.command("policy").description("Verify workspace policy conformance");
policy
.command("compare")
.description("Compare policy.jsonc against an authored baseline policy file")
.requiredOption("--baseline <path>", "Baseline policy file to compare against")
.option("--policy <path>", "Policy file to check; defaults to configured policy path")
.option("--json", "Emit JSON output")
.action(async (options: PolicyCompareOptions) => {
process.exitCode = await policyCompareCommand(options);
});
policy
.command("check")
.description("Check policy requirements and emit an audit attestation")
.option("--json", "Emit JSON output")
.option("--severity-min <severity>", "Minimum severity: info, warning, or error")
.action(async (options: PolicyCheckOptions) => {
process.exitCode = await policyCheckCommand(options);
});
policy
.command("watch")
.description("Watch policy evidence and report accepted-attestation drift")
.option("--json", "Emit JSON output")
.option("--severity-min <severity>", "Minimum severity: info, warning, or error")
.option("--interval-ms <ms>", "Polling interval in milliseconds")
.option("--once", "Run one watch evaluation and exit")
.action(async (options: PolicyWatchOptions) => {
process.exitCode = await policyWatchCommand(options);
});
}
export async function policyCompareCommand(
options: PolicyCompareOptions,
runtime: PolicyCommandRuntime = defaultRuntime,
): Promise<number> {
try {
if (options.baseline === undefined || options.baseline.trim() === "") {
throw new Error("Missing required --baseline value.");
}
const policyPath = await policyCompareCandidatePath(options);
const report = await buildPolicyConformanceReport({
baselinePath: options.baseline,
policyPath,
cwd: options.cwd,
});
writePolicyConformanceReport(report, options, runtime);
return report.ok ? 0 : 1;
} catch (err) {
runtime.error(err instanceof Error ? err.message : String(err));
return 2;
}
}
export async function policyCheckCommand(
options: PolicyCheckOptions,
runtime: PolicyCommandRuntime = defaultRuntime,
): Promise<number> {
try {
const report = await buildPolicyCheckReport(options, runtime);
writePolicyCheckReport(report, options, runtime);
return report.exitCode;
} catch (err) {
runtime.error(err instanceof Error ? err.message : String(err));
return 2;
}
}
export async function policyWatchCommand(
options: PolicyWatchOptions,
runtime: PolicyCommandRuntime = defaultRuntime,
): Promise<number> {
try {
const intervalMs = normalizeWatchIntervalMs(options.intervalMs);
let previousKey: string | undefined;
for (;;) {
const report = await buildPolicyCheckReport(options, runtime);
const status = policyWatchStatus(report);
const key = `${status}:${report.attestation?.attestationHash ?? ""}:${report.exitCode}`;
if (previousKey === undefined || previousKey !== key || options.once === true) {
writePolicyWatchReport(report, status, options, runtime);
previousKey = key;
}
if (options.once === true) {
return status === "stale" ? 1 : report.exitCode;
}
if (runtime.sleep !== undefined) {
await runtime.sleep(intervalMs);
} else {
await sleep(intervalMs);
}
}
} catch (err) {
runtime.error(err instanceof Error ? err.message : String(err));
return 2;
}
}
async function buildPolicyCheckReport(
options: PolicyCheckOptions,
runtime: PolicyCommandRuntime,
): Promise<PolicyCheckReport> {
const severityMin =
options.severityMin === undefined ? "info" : parseHealthFindingSeverity(options.severityMin);
if (severityMin === null) {
throw new Error("Invalid --severity-min value. Expected one of: info, warning, error.");
}
const snapshot = await readConfigFileSnapshot({ observe: false });
if (!snapshot.valid) {
const findings: HealthFinding[] = snapshot.issues.map((issue) => ({
checkId: "policy/config-invalid",
severity: "error",
message: issue.message,
source: "policy",
path: issue.path,
}));
const visibleFindings = findings.filter((finding) =>
healthFindingMeetsSeverity(finding, severityMin),
);
return {
ok: visibleFindings.length === 0,
evidence: { channels: [] },
checksRun: 1,
checksSkipped: POLICY_CHECK_IDS.length,
findings: visibleFindings.map(toJsonFinding),
exitCode: visibleFindings.length === 0 ? 0 : 1,
};
}
const cfg = snapshot.valid ? policyCommandConfig(snapshot.config) : {};
const cwd = options.cwd ?? resolveAgentWorkspaceDir(cfg, resolveDefaultAgentId(cfg));
const ctx: HealthCheckContext = {
mode: "lint",
runtime: {
log(value) {
runtime.writeStdout(`${String(value)}\n`);
},
error(value) {
runtime.error(String(value));
},
exit(code) {
process.exitCode = code;
},
},
cfg,
cwd,
...(snapshot.path !== undefined ? { configPath: snapshot.path } : {}),
};
const evaluation = await evaluatePolicy(ctx);
const findings = evaluation.findings.filter((finding) =>
healthFindingMeetsSeverity(finding, severityMin),
);
const jsonFindings = findings.map(toJsonFinding);
const attestedFindings = evaluation.attestedFindings.map(toAttestedJsonFinding);
const ok = exitCodeFromFindings(evaluation.findings, severityMin) === 0;
const attestation = createPolicyAttestation({
ok: evaluation.attestedFindings.length === 0,
checkedAt: new Date().toISOString(),
policyPath: evaluation.policyPath,
policyHash: evaluation.policy?.hash,
evidence: evaluation.evidence,
findings: attestedFindings,
});
return {
ok,
attestation,
evidence: evaluation.evidence,
checksRun: POLICY_CHECK_IDS.length,
checksSkipped: 0,
findings: jsonFindings,
expectedAttestationHash: evaluation.expectedAttestationHash,
exitCode: exitCodeFromFindings(evaluation.findings, severityMin),
};
}
function policyCommandConfig(cfg: HealthCheckContext["cfg"]): HealthCheckContext["cfg"] {
return {
...cfg,
plugins: {
...cfg.plugins,
entries: {
...cfg.plugins?.entries,
policy: {
...cfg.plugins?.entries?.["policy"],
enabled: true,
config: {
enabled: true,
...(typeof cfg.plugins?.entries?.["policy"]?.config === "object" &&
cfg.plugins.entries["policy"].config !== null
? cfg.plugins.entries["policy"].config
: {}),
},
},
},
},
};
}
async function policyCompareCandidatePath(options: PolicyCompareOptions): Promise<string> {
if (options.policy !== undefined && options.policy.trim() !== "") {
return options.policy.trim();
}
const snapshot = await readConfigFileSnapshot({ observe: false });
if (!snapshot.valid) {
return "policy.jsonc";
}
const pluginConfig = snapshot.config.plugins?.entries?.["policy"]?.config;
const configured =
typeof pluginConfig === "object" && pluginConfig !== null && "path" in pluginConfig
? pluginConfig.path
: undefined;
const policyPath =
typeof configured === "string" && configured.trim() !== "" ? configured.trim() : "policy.jsonc";
if (isAbsolute(policyPath)) {
return policyPath;
}
const cwd =
options.cwd ??
resolveAgentWorkspaceDir(snapshot.config, resolveDefaultAgentId(snapshot.config));
return resolve(cwd, policyPath);
}
function writePolicyCheckReport(
report: PolicyCheckReport,
options: PolicyCheckOptions,
runtime: PolicyCommandRuntime,
): void {
if (options.json === true || !process.stdout.isTTY) {
runtime.writeStdout(
JSON.stringify({
ok: report.ok,
attestation: report.attestation,
evidence: report.evidence,
checksRun: report.checksRun,
checksSkipped: report.checksSkipped,
findings: report.findings,
}) + "\n",
);
} else if (report.findings.length === 0) {
const policyHash = report.attestation?.policy?.hash ?? "missing";
const evidenceHash = report.attestation?.workspace.hash ?? "unavailable";
runtime.writeStdout(
`policy check: no findings (policy ${policyHash}, evidence ${evidenceHash})\n`,
);
} else {
runtime.writeStdout(`policy check: ${report.findings.length} finding(s)\n`);
for (const finding of report.findings) {
const where = typeof finding.path === "string" ? ` ${finding.path}` : "";
const line = typeof finding.line === "number" ? `:${finding.line}` : "";
const severity = typeof finding.severity === "string" ? finding.severity : "unknown";
const checkId = typeof finding.checkId === "string" ? finding.checkId : "unknown";
const message = typeof finding.message === "string" ? finding.message : "";
runtime.writeStdout(` [${severity}] ${checkId}${where}${line} - ${message}\n`);
}
}
}
function writePolicyConformanceReport(
report: PolicyConformanceReport,
options: PolicyCompareOptions,
runtime: PolicyCommandRuntime,
): void {
if (options.json === true || !process.stdout.isTTY) {
runtime.writeStdout(JSON.stringify(report) + "\n");
return;
}
if (report.findings.length === 0) {
runtime.writeStdout(
`policy compare: no findings (${report.policyPath} is at least as strict as ${report.baselinePath}; ${report.rulesChecked} rule(s) checked)\n`,
);
return;
}
runtime.writeStdout(
`policy compare: ${report.findings.length} finding(s) (${report.rulesChecked} rule(s) checked)\n`,
);
for (const finding of report.findings) {
runtime.writeStdout(` [${finding.severity}] ${finding.checkId} - ${finding.message}\n`);
}
}
function writePolicyWatchReport(
report: PolicyCheckReport,
status: "clean" | "findings" | "stale",
options: PolicyWatchOptions,
runtime: PolicyCommandRuntime,
): void {
if (options.json === true || !process.stdout.isTTY) {
runtime.writeStdout(
JSON.stringify({
status,
ok: report.ok,
expectedAttestationHash: report.expectedAttestationHash,
attestation: report.attestation,
findings: report.findings,
}) + "\n",
);
return;
}
if (status === "stale") {
runtime.writeStdout(
`policy watch: accepted attestation is stale (current ${report.attestation?.attestationHash}, expected ${report.expectedAttestationHash}). Review policy check output, then update the supervisor/gateway accepted attestation.\n`,
);
return;
}
if (status === "findings") {
runtime.writeStdout(
`policy watch: ${report.findings.length} finding(s); accepted attestation cannot be updated until policy check is clean.\n`,
);
return;
}
runtime.writeStdout(
`policy watch: clean (attestation ${report.attestation?.attestationHash}, evidence ${report.attestation?.workspace.hash})\n`,
);
}
function policyWatchStatus(report: PolicyCheckReport): "clean" | "findings" | "stale" {
if (
!report.ok &&
report.findings.some((finding) => finding.checkId !== "policy/attestation-hash-mismatch")
) {
return "findings";
}
const expected = report.expectedAttestationHash?.trim();
if (
expected &&
report.attestation !== undefined &&
report.attestation.attestationHash !== expected
) {
return "stale";
}
return report.ok ? "clean" : "findings";
}
function normalizeWatchIntervalMs(value: string | number | undefined): number {
if (value === undefined) {
return 2000;
}
const raw =
typeof value === "number"
? value
: /^\+?\d+$/.test(value.trim())
? Number(value.trim())
: Number.NaN;
if (!Number.isSafeInteger(raw) || raw < 250) {
throw new Error("--interval-ms must be an integer >= 250.");
}
return raw;
}
function toAttestedJsonFinding(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.ocPath !== undefined ? { ocPath: finding.ocPath } : {}),
...(finding.target !== undefined ? { target: finding.target } : {}),
...(finding.requirement !== undefined ? { requirement: finding.requirement } : {}),
...(finding.fixHint !== undefined ? { fixHint: finding.fixHint } : {}),
};
}
function toJsonFinding(finding: HealthFinding): Record<string, unknown> {
return {
...toAttestedJsonFinding(finding),
...policyFindingMetadata(finding),
};
}
function policyFindingMetadata(finding: HealthFinding): Record<string, unknown> {
const metadata = POLICY_FIX_METADATA_BY_CHECK_ID.get(
finding.checkId as (typeof POLICY_CHECK_IDS)[number],
);
if (metadata === undefined) {
return {};
}
return {
policy: {
fixRecommendation: {
fixClass: metadata.fixClass,
...(metadata.policyPath !== undefined ? { policyPath: metadata.policyPath } : {}),
...(metadata.configTargets !== undefined ? { configTargets: metadata.configTargets } : {}),
summary: metadata.summary,
},
},
};
}

View File

@@ -0,0 +1,26 @@
// Policy doctor health-check catalog.
import type { HealthCheck } from "openclaw/plugin-sdk/health";
import { createPolicyChannelProviderChecks, createPolicyIngressChecks } from "./scopes/channels.js";
import { createPolicyCoreChecks } from "./scopes/core.js";
import { createPolicyDataAuthChecks } from "./scopes/data-auth.js";
import { createPolicyExecApprovalChecks } from "./scopes/exec-approvals.js";
import { createPolicyGatewayChecks } from "./scopes/gateway.js";
import { createPolicyModelNetworkChecks } from "./scopes/model-network.js";
import { createPolicySandboxChecks } from "./scopes/sandbox.js";
import { createPolicyAgentToolChecks, createPolicyToolMetadataChecks } from "./scopes/tools.js";
import type { PolicyDoctorCheckDeps } from "./types.js";
export function createPolicyDoctorChecks(deps: PolicyDoctorCheckDeps): readonly HealthCheck[] {
return [
...createPolicyCoreChecks(deps),
...createPolicyChannelProviderChecks(deps),
...createPolicyModelNetworkChecks(deps),
...createPolicyIngressChecks(deps),
...createPolicyGatewayChecks(deps),
...createPolicyAgentToolChecks(deps),
...createPolicySandboxChecks(deps),
...createPolicyDataAuthChecks(deps),
...createPolicyExecApprovalChecks(deps),
...createPolicyToolMetadataChecks(deps),
];
}

View File

@@ -0,0 +1,453 @@
// Policy doctor fix metadata classifies findings before patch builders exist.
import { CHECK_IDS, POLICY_CHECK_IDS } from "./metadata.js";
export type PolicyFixClass =
| "automatic"
| "reviewRequired"
| "manual"
| "validateOnly"
| "unsupported";
export type PolicyFixMetadata = {
readonly checkId: (typeof POLICY_CHECK_IDS)[number];
readonly fixClass: PolicyFixClass;
readonly policyPath?: readonly string[];
readonly configTargets?: readonly string[];
readonly summary: string;
};
const m = (
checkId: (typeof POLICY_CHECK_IDS)[number],
fixClass: PolicyFixClass,
summary: string,
options: Omit<PolicyFixMetadata, "checkId" | "fixClass" | "summary"> = {},
): PolicyFixMetadata => ({
checkId,
fixClass,
summary,
...options,
});
export const POLICY_FIX_METADATA = [
m(CHECK_IDS.policyMissingFile, "manual", "Restore or author the approved policy artifact."),
m(CHECK_IDS.policyInvalidFile, "manual", "Repair the policy JSONC syntax or schema."),
m(
CHECK_IDS.policyHashMismatch,
"manual",
"Restore the approved artifact or update the expected hash after review.",
{ configTargets: ["plugins.entries.policy.config.expectedHash"] },
),
m(
CHECK_IDS.policyAttestationMismatch,
"manual",
"Review the current attestation and update accepted hashes after approval.",
{ configTargets: ["plugins.entries.policy.config.expectedAttestationHash"] },
),
m(
CHECK_IDS.policyDeniedChannelProvider,
"automatic",
"Disable product-managed channels matching the denied provider.",
{ policyPath: ["channels", "denyRules"], configTargets: ["channels"] },
),
m(CHECK_IDS.policyDeniedMcpServer, "reviewRequired", "Remove or disable the denied MCP server.", {
policyPath: ["mcp", "servers", "deny"],
configTargets: ["mcp.servers"],
}),
m(
CHECK_IDS.policyUnapprovedMcpServer,
"reviewRequired",
"Remove the unapproved MCP server or select an approved replacement.",
{ policyPath: ["mcp", "servers", "allow"], configTargets: ["mcp.servers"] },
),
m(
CHECK_IDS.policyDeniedModelProvider,
"reviewRequired",
"Remove the model provider or switch references to an approved provider.",
{ policyPath: ["models", "providers", "deny"], configTargets: ["models"] },
),
m(
CHECK_IDS.policyUnapprovedModelProvider,
"reviewRequired",
"Select an approved model provider.",
{ policyPath: ["models", "providers", "allow"], configTargets: ["models"] },
),
m(
CHECK_IDS.policyPrivateNetworkAccess,
"reviewRequired",
"Disable the concrete private-network access opt-in.",
{ policyPath: ["network", "privateNetwork", "allow"], configTargets: ["network"] },
),
m(
CHECK_IDS.policyIngressDmPolicyUnapproved,
"reviewRequired",
"Set channel DM policy to an allowed value.",
{ policyPath: ["ingress", "channels", "allowDmPolicies"], configTargets: ["channels"] },
),
m(
CHECK_IDS.policyIngressDmScopeUnapproved,
"reviewRequired",
"Move session DM scope to the required or stricter ordered value.",
{ policyPath: ["ingress", "session", "requireDmScope"], configTargets: ["ingress"] },
),
m(
CHECK_IDS.policyIngressOpenGroupsDenied,
"automatic",
"Disable product-managed open group ingress.",
{ policyPath: ["ingress", "channels", "denyOpenGroups"], configTargets: ["channels"] },
),
m(
CHECK_IDS.policyIngressGroupMentionRequired,
"automatic",
"Require mention in product-managed group channels.",
{ policyPath: ["ingress", "channels", "requireMentionInGroups"], configTargets: ["channels"] },
),
m(
CHECK_IDS.policyGatewayNonLoopbackBind,
"reviewRequired",
"Set gateway bind address to loopback when remote exposure is not intended.",
{
policyPath: ["gateway", "exposure", "allowNonLoopbackBind"],
configTargets: ["gateway.bind"],
},
),
m(
CHECK_IDS.policyGatewayAuthDisabled,
"manual",
"Configure token, password, or trusted-proxy auth.",
{ policyPath: ["gateway", "auth", "requireAuth"], configTargets: ["gateway.auth"] },
),
m(
CHECK_IDS.policyGatewayRateLimitMissing,
"reviewRequired",
"Add explicit gateway auth rate limits from product defaults.",
{
policyPath: ["gateway", "auth", "requireExplicitRateLimit"],
configTargets: ["gateway.auth.rateLimit"],
},
),
m(
CHECK_IDS.policyGatewayControlUiInsecure,
"automatic",
"Disable the insecure Control UI toggle.",
{ policyPath: ["gateway", "controlUi", "allowInsecure"], configTargets: ["gateway.controlUi"] },
),
m(
CHECK_IDS.policyGatewayTailscaleFunnel,
"reviewRequired",
"Disable Tailscale funnel or serve exposure.",
{ policyPath: ["gateway", "exposure", "allowTailscaleFunnel"], configTargets: ["tailscale"] },
),
m(
CHECK_IDS.policyGatewayRemoteEnabled,
"automatic",
"Disable product-managed remote gateway mode.",
{ policyPath: ["gateway", "remote", "allow"], configTargets: ["gateway.remote"] },
),
m(
CHECK_IDS.policyGatewayHttpEndpointEnabled,
"reviewRequired",
"Disable denied Gateway HTTP endpoints.",
{ policyPath: ["gateway", "http", "denyEndpoints"], configTargets: ["gateway.http"] },
),
m(
CHECK_IDS.policyGatewayHttpUrlFetchUnrestricted,
"manual",
"Add URL allowlists for each URL-fetch input.",
{ policyPath: ["gateway", "http", "requireUrlAllowlists"], configTargets: ["gateway.http"] },
),
m(
CHECK_IDS.policyGatewayNodeCommandDenied,
"reviewRequired",
"Add the command to gateway node denyCommands or update policy after review.",
{
policyPath: ["gateway", "nodes", "denyCommands"],
configTargets: ["gateway.nodes.denyCommands"],
},
),
m(
CHECK_IDS.policyAgentsWorkspaceAccessDenied,
"reviewRequired",
"Set agent workspace access to an allowed mode.",
{ policyPath: ["agents", "workspace", "allowedAccess"], configTargets: ["agents"] },
),
m(
CHECK_IDS.policyAgentsToolNotDenied,
"automatic",
"Merge required built-in workspace tool denies.",
{ policyPath: ["agents", "workspace", "denyTools"], configTargets: ["agents"] },
),
m(
CHECK_IDS.policyToolsProfileUnapproved,
"reviewRequired",
"Set the tool profile to an allowed profile.",
{ policyPath: ["tools", "profiles", "allow"], configTargets: ["tools.profile"] },
),
m(
CHECK_IDS.policyToolsFsWorkspaceOnlyRequired,
"reviewRequired",
"Set workspace-only filesystem posture when required assets remain readable.",
{
policyPath: ["tools", "fs", "requireWorkspaceOnly"],
configTargets: ["tools.fs.workspaceOnly"],
},
),
m(
CHECK_IDS.policyToolsExecSecurityUnapproved,
"reviewRequired",
"Set exec security to an allowed value.",
{ policyPath: ["tools", "exec", "allowSecurity"], configTargets: ["tools.exec.security"] },
),
m(
CHECK_IDS.policyToolsExecAskUnapproved,
"reviewRequired",
"Set exec ask mode to an allowed value.",
{ policyPath: ["tools", "exec", "requireAsk"], configTargets: ["tools.exec.ask"] },
),
m(
CHECK_IDS.policyToolsExecHostUnapproved,
"reviewRequired",
"Move exec host to an allowed host mode.",
{ policyPath: ["tools", "exec", "allowHosts"], configTargets: ["tools.exec.host"] },
),
m(CHECK_IDS.policyToolsElevatedEnabled, "automatic", "Set tools elevated mode to disabled.", {
policyPath: ["tools", "elevated", "allow"],
configTargets: ["tools.elevated.enabled"],
}),
m(
CHECK_IDS.policyToolsAlsoAllowMissing,
"reviewRequired",
"Add expected alsoAllow entries only when policy intentionally grants them.",
{ policyPath: ["tools", "alsoAllow", "expected"], configTargets: ["tools.alsoAllow"] },
),
m(
CHECK_IDS.policyToolsAlsoAllowUnexpected,
"reviewRequired",
"Remove unexpected alsoAllow entries.",
{ policyPath: ["tools", "alsoAllow", "expected"], configTargets: ["tools.alsoAllow"] },
),
m(
CHECK_IDS.policyToolsRequiredDenyMissing,
"automatic",
"Merge required built-in deny tool classes.",
{ policyPath: ["tools", "denyTools"], configTargets: ["tools.denyTools"] },
),
m(
CHECK_IDS.policySandboxModeUnapproved,
"reviewRequired",
"Set sandbox mode to an allowed value.",
{ policyPath: ["sandbox", "requireMode"], configTargets: ["sandbox.mode"] },
),
m(
CHECK_IDS.policySandboxBackendUnapproved,
"reviewRequired",
"Choose an approved sandbox backend that is installed.",
{ policyPath: ["sandbox", "allowBackends"], configTargets: ["sandbox.backend"] },
),
m(
CHECK_IDS.policySandboxContainerPostureUnobservable,
"unsupported",
"Add observable container posture evidence before patching.",
),
m(
CHECK_IDS.policySandboxContainerHostNetworkDenied,
"reviewRequired",
"Disable container host networking.",
{
policyPath: ["sandbox", "containers", "denyHostNetwork"],
configTargets: ["sandbox.containers"],
},
),
m(
CHECK_IDS.policySandboxContainerNamespaceJoinDenied,
"reviewRequired",
"Disable joining container namespaces.",
{
policyPath: ["sandbox", "containers", "denyContainerNamespaceJoin"],
configTargets: ["sandbox.containers"],
},
),
m(
CHECK_IDS.policySandboxContainerMountModeRequired,
"reviewRequired",
"Change required mounts to read-only.",
{
policyPath: ["sandbox", "containers", "requireReadOnlyMounts"],
configTargets: ["sandbox.containers"],
},
),
m(
CHECK_IDS.policySandboxContainerRuntimeSocketMount,
"reviewRequired",
"Remove container runtime socket binds.",
{
policyPath: ["sandbox", "containers", "denyContainerRuntimeSocketMounts"],
configTargets: ["sandbox.containers"],
},
),
m(
CHECK_IDS.policySandboxContainerUnconfinedProfile,
"reviewRequired",
"Remove unconfined container profiles.",
{
policyPath: ["sandbox", "containers", "denyUnconfinedProfiles"],
configTargets: ["sandbox.containers"],
},
),
m(
CHECK_IDS.policySandboxBrowserCdpSourceRangeMissing,
"manual",
"Add an explicit browser CDP source range.",
{
policyPath: ["sandbox", "browser", "requireCdpSourceRange"],
configTargets: ["agents.sandbox.browser"],
},
),
m(
CHECK_IDS.policyDataHandlingRedactionDisabled,
"automatic",
"Set sensitive logging to a redacting mode.",
{
policyPath: ["dataHandling", "sensitiveLogging", "requireRedaction"],
configTargets: ["logging.redactSensitive"],
},
),
m(
CHECK_IDS.policyDataHandlingTelemetryContentCapture,
"automatic",
"Disable telemetry content capture.",
{
policyPath: ["dataHandling", "telemetry", "denyContentCapture"],
configTargets: ["diagnostics.otel.captureContent"],
},
),
m(
CHECK_IDS.policyDataHandlingSessionRetentionNotEnforced,
"reviewRequired",
"Set session maintenance to enforced mode.",
{
policyPath: ["dataHandling", "retention", "requireSessionMaintenance"],
configTargets: ["session.maintenance.mode"],
},
),
m(
CHECK_IDS.policyDataHandlingSessionTranscriptMemory,
"reviewRequired",
"Disable transcript indexing for the affected agent scope.",
{
policyPath: ["dataHandling", "memory", "denySessionTranscriptIndexing"],
configTargets: ["memory"],
},
),
m(
CHECK_IDS.policySecretsUnmanagedProvider,
"manual",
"Migrate the secret to a managed provider.",
{ policyPath: ["secrets", "requireManagedProviders"], configTargets: ["secrets"] },
),
m(
CHECK_IDS.policySecretsDeniedProviderSource,
"reviewRequired",
"Move the secret out of the denied source.",
{ policyPath: ["secrets", "denySources"], configTargets: ["secrets"] },
),
m(
CHECK_IDS.policySecretsInsecureProvider,
"reviewRequired",
"Remove insecure provider overrides.",
{ policyPath: ["secrets", "allowInsecureProviders"], configTargets: ["secrets"] },
),
m(
CHECK_IDS.policyAuthProfileInvalidMetadata,
"manual",
"Add required provider and mode metadata to auth profiles.",
{ policyPath: ["auth", "profiles", "requireMetadata"], configTargets: ["auth.profiles"] },
),
m(
CHECK_IDS.policyAuthProfileUnapprovedMode,
"manual",
"Change auth mode and credentials through the auth owner flow.",
{ policyPath: ["auth", "profiles", "allowModes"], configTargets: ["auth.profiles"] },
),
m(
CHECK_IDS.policyExecApprovalsMissing,
"manual",
"Restore an attributable exec-approvals evidence file.",
{ policyPath: ["execApprovals", "requireFile"], configTargets: ["exec-approvals.json"] },
),
m(CHECK_IDS.policyExecApprovalsInvalid, "manual", "Repair the exec approvals evidence artifact."),
m(
CHECK_IDS.policyExecApprovalsDefaultSecurityUnapproved,
"manual",
"Update reviewed default approval evidence or policy.",
{
policyPath: ["execApprovals", "defaults", "allowSecurity"],
configTargets: ["exec-approvals.json"],
},
),
m(
CHECK_IDS.policyExecApprovalsAgentSecurityUnapproved,
"manual",
"Update reviewed agent approval evidence or policy.",
{
policyPath: ["execApprovals", "agents", "allowSecurity"],
configTargets: ["exec-approvals.json"],
},
),
m(
CHECK_IDS.policyExecApprovalsAutoAllowSkillsEnabled,
"reviewRequired",
"Disable auto-allow skills in the approval owner surface.",
{
policyPath: ["execApprovals", "agents", "allowAutoAllowSkills"],
configTargets: ["exec-approvals.json"],
},
),
m(
CHECK_IDS.policyExecApprovalsAllowlistMissing,
"manual",
"Add expected approval patterns through approval review.",
{
policyPath: ["execApprovals", "agents", "allowlist", "expected"],
configTargets: ["exec-approvals.json"],
},
),
m(
CHECK_IDS.policyExecApprovalsAllowlistUnexpected,
"manual",
"Remove unexpected approval patterns through approval review.",
{
policyPath: ["execApprovals", "agents", "allowlist", "expected"],
configTargets: ["exec-approvals.json"],
},
),
m(
CHECK_IDS.policyMissingToolRisk,
"manual",
"Add tool risk metadata in the owning tool declaration.",
{ policyPath: ["tools", "requireMetadata"], configTargets: ["tools"] },
),
m(CHECK_IDS.policyUnknownToolRisk, "manual", "Use a supported tool risk level.", {
policyPath: ["tools", "requireMetadata"],
configTargets: ["tools"],
}),
m(
CHECK_IDS.policyMissingToolSensitivity,
"manual",
"Add tool sensitivity metadata in the owning tool declaration.",
{ policyPath: ["tools", "requireMetadata"], configTargets: ["tools"] },
),
m(
CHECK_IDS.policyMissingToolOwner,
"manual",
"Add owner metadata in the owning tool declaration.",
{ policyPath: ["tools", "requireMetadata"], configTargets: ["tools"] },
),
m(CHECK_IDS.policyUnknownToolSensitivity, "manual", "Use a supported tool sensitivity token.", {
policyPath: ["tools", "requireMetadata"],
configTargets: ["tools"],
}),
] as const satisfies readonly PolicyFixMetadata[];
export const POLICY_FIX_METADATA_BY_CHECK_ID = new Map(
POLICY_FIX_METADATA.map((rule) => [rule.checkId, rule] as const),
);

View File

@@ -0,0 +1,283 @@
// Policy doctor metadata tests cover rule metadata.
import { describe, expect, it } from "vitest";
import {
POLICY_FIX_METADATA,
POLICY_FIX_METADATA_BY_CHECK_ID,
type PolicyFixMetadata,
} from "./fix-metadata.js";
import { POLICY_CHECK_IDS, POLICY_RULE_METADATA, type PolicyRuleMetadata } from "./metadata.js";
describe("policy doctor metadata", () => {
it("describes strictness for agent-scoped policy fields", () => {
expect(
(POLICY_RULE_METADATA as readonly PolicyRuleMetadata[])
.filter(
(rule) =>
rule.scopeSelectors?.includes("agentIds") ||
rule.scopeSelectors?.includes("channelIds"),
)
.map((rule) => {
const description: {
path: string;
strictness: PolicyRuleMetadata["strictness"];
selectors: PolicyRuleMetadata["scopeSelectors"];
emptyList?: PolicyRuleMetadata["emptyList"];
} = {
path: rule.policyPath.join("."),
strictness: rule.strictness,
selectors: rule.scopeSelectors,
};
if (rule.emptyList !== undefined) {
description.emptyList = rule.emptyList;
}
return description;
}),
).toEqual([
{
path: "agents.workspace.allowedAccess",
strictness: "allowlist-subset",
emptyList: "disabled",
selectors: ["agentIds"],
},
{
path: "agents.workspace.denyTools",
strictness: "denylist-superset",
selectors: ["agentIds"],
},
{
path: "tools.profiles.allow",
strictness: "allowlist-subset",
emptyList: "disabled",
selectors: ["agentIds"],
},
{
path: "tools.fs.requireWorkspaceOnly",
strictness: "requires-true",
selectors: ["agentIds"],
},
{
path: "tools.exec.allowSecurity",
strictness: "allowlist-subset",
emptyList: "disabled",
selectors: ["agentIds"],
},
{
path: "tools.exec.requireAsk",
strictness: "allowlist-subset",
emptyList: "disabled",
selectors: ["agentIds"],
},
{
path: "tools.exec.allowHosts",
strictness: "allowlist-subset",
emptyList: "disabled",
selectors: ["agentIds"],
},
{ path: "tools.elevated.allow", strictness: "requires-false", selectors: ["agentIds"] },
{
path: "tools.alsoAllow.expected",
strictness: "exact-list",
emptyList: "meaningful",
selectors: ["agentIds"],
},
{ path: "tools.denyTools", strictness: "denylist-superset", selectors: ["agentIds"] },
{
path: "sandbox.requireMode",
strictness: "allowlist-subset",
emptyList: "disabled",
selectors: ["agentIds"],
},
{
path: "sandbox.allowBackends",
strictness: "allowlist-subset",
emptyList: "disabled",
selectors: ["agentIds"],
},
{
path: "sandbox.containers.denyHostNetwork",
strictness: "requires-true",
selectors: ["agentIds"],
},
{
path: "sandbox.containers.denyContainerNamespaceJoin",
strictness: "requires-true",
selectors: ["agentIds"],
},
{
path: "sandbox.containers.requireReadOnlyMounts",
strictness: "requires-true",
selectors: ["agentIds"],
},
{
path: "sandbox.containers.denyContainerRuntimeSocketMounts",
strictness: "requires-true",
selectors: ["agentIds"],
},
{
path: "sandbox.containers.denyUnconfinedProfiles",
strictness: "requires-true",
selectors: ["agentIds"],
},
{
path: "sandbox.browser.requireCdpSourceRange",
strictness: "requires-true",
selectors: ["agentIds"],
},
{
path: "ingress.channels.allowDmPolicies",
strictness: "allowlist-subset",
emptyList: "disabled",
selectors: ["channelIds"],
},
{
path: "ingress.channels.denyOpenGroups",
strictness: "requires-true",
selectors: ["channelIds"],
},
{
path: "ingress.channels.requireMentionInGroups",
strictness: "requires-true",
selectors: ["channelIds"],
},
{
path: "dataHandling.memory.denySessionTranscriptIndexing",
strictness: "requires-true",
selectors: ["agentIds"],
},
{
path: "execApprovals.agents.allowSecurity",
strictness: "allowlist-subset",
emptyList: "disabled",
selectors: ["agentIds"],
},
{
path: "execApprovals.agents.allowAutoAllowSkills",
strictness: "requires-false",
selectors: ["agentIds"],
},
{
path: "execApprovals.agents.allowlist.expected",
strictness: "exact-list",
emptyList: "meaningful",
selectors: ["agentIds"],
},
]);
});
it("classifies every policy finding for fix recommendation coverage", () => {
expect(POLICY_FIX_METADATA.map((rule) => rule.checkId)).toHaveLength(
new Set(POLICY_FIX_METADATA.map((rule) => rule.checkId)).size,
);
expect([...POLICY_FIX_METADATA_BY_CHECK_ID.keys()].toSorted()).toEqual(
[...POLICY_CHECK_IDS].toSorted(),
);
});
it("keeps policy fix class assignments explicit", () => {
const grouped = new Map<PolicyFixMetadata["fixClass"], PolicyFixMetadata[]>();
for (const rule of POLICY_FIX_METADATA) {
const rules = grouped.get(rule.fixClass);
if (rules) {
rules.push(rule);
} else {
grouped.set(rule.fixClass, [rule]);
}
}
expect({
automatic: grouped
.get("automatic")
?.map((rule) => rule.checkId)
.toSorted(),
manual: grouped
.get("manual")
?.map((rule) => rule.checkId)
.toSorted(),
reviewRequired: grouped
.get("reviewRequired")
?.map((rule) => rule.checkId)
.toSorted(),
unsupported: grouped
.get("unsupported")
?.map((rule) => rule.checkId)
.toSorted(),
validateOnly:
grouped
.get("validateOnly")
?.map((rule) => rule.checkId)
.toSorted() ?? [],
}).toEqual({
automatic: [
"policy/agents-tool-not-denied",
"policy/channels-denied-provider",
"policy/data-handling-redaction-disabled",
"policy/data-handling-telemetry-content-capture",
"policy/gateway-control-ui-insecure",
"policy/gateway-remote-enabled",
"policy/ingress-group-mention-required",
"policy/ingress-open-groups-denied",
"policy/tools-elevated-enabled",
"policy/tools-required-deny-missing",
],
manual: [
"policy/attestation-hash-mismatch",
"policy/auth-profile-invalid-metadata",
"policy/auth-profile-unapproved-mode",
"policy/exec-approvals-agent-security-unapproved",
"policy/exec-approvals-allowlist-missing",
"policy/exec-approvals-allowlist-unexpected",
"policy/exec-approvals-default-security-unapproved",
"policy/exec-approvals-invalid",
"policy/exec-approvals-missing",
"policy/gateway-auth-disabled",
"policy/gateway-http-url-fetch-unrestricted",
"policy/policy-hash-mismatch",
"policy/policy-jsonc-invalid",
"policy/policy-jsonc-missing",
"policy/sandbox-browser-cdp-source-range-missing",
"policy/secrets-unmanaged-provider",
"policy/tools-missing-owner",
"policy/tools-missing-risk-level",
"policy/tools-missing-sensitivity-token",
"policy/tools-unknown-risk-level",
"policy/tools-unknown-sensitivity-token",
],
reviewRequired: [
"policy/agents-workspace-access-denied",
"policy/data-handling-session-retention-not-enforced",
"policy/data-handling-session-transcript-memory-enabled",
"policy/exec-approvals-auto-allow-skills-enabled",
"policy/gateway-http-endpoint-enabled",
"policy/gateway-node-command-denied",
"policy/gateway-non-loopback-bind",
"policy/gateway-rate-limit-missing",
"policy/gateway-tailscale-funnel",
"policy/ingress-dm-policy-unapproved",
"policy/ingress-dm-scope-unapproved",
"policy/mcp-denied-server",
"policy/mcp-unapproved-server",
"policy/models-denied-provider",
"policy/models-unapproved-provider",
"policy/network-private-access-enabled",
"policy/sandbox-backend-unapproved",
"policy/sandbox-container-host-network-denied",
"policy/sandbox-container-mount-mode-required",
"policy/sandbox-container-namespace-join-denied",
"policy/sandbox-container-runtime-socket-mount",
"policy/sandbox-container-unconfined-profile",
"policy/sandbox-mode-unapproved",
"policy/secrets-denied-provider-source",
"policy/secrets-insecure-provider",
"policy/tools-also-allow-missing",
"policy/tools-also-allow-unexpected",
"policy/tools-exec-ask-unapproved",
"policy/tools-exec-host-unapproved",
"policy/tools-exec-security-unapproved",
"policy/tools-fs-workspace-only-required",
"policy/tools-profile-unapproved",
],
unsupported: ["policy/sandbox-container-posture-unobservable"],
validateOnly: [],
});
});
});

View File

@@ -0,0 +1,552 @@
// 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;
export type PolicyStrictnessKind =
| "allowlist-subset"
| "denylist-superset"
| "ordered-string"
| "requires-true"
| "requires-false"
| "exact-list";
export type PolicyEmptyListSemantics = "disabled" | "meaningful";
export type PolicyScopeSelectorKind = "agentIds" | "channelIds";
export type PolicyRuleMetadata = {
readonly policyPath: readonly string[];
readonly strictness: PolicyStrictnessKind;
readonly valueType: "boolean" | "channel-provider-deny-rules" | "string" | "string-list";
readonly checkIds: readonly (typeof POLICY_CHECK_IDS)[number][];
readonly emptyList?: PolicyEmptyListSemantics;
readonly allowedValues?: readonly string[];
readonly caseSensitive?: boolean;
readonly normalizeValues?: "model-provider";
readonly orderedValues?: readonly string[];
readonly scopeSelectors?: readonly PolicyScopeSelectorKind[];
};
export const SANDBOX_CONTAINER_POLICY_RULES = [
{
key: "denyHostNetwork",
label: "host network posture",
checkIds: [CHECK_IDS.policySandboxContainerHostNetworkDenied],
},
{
key: "denyContainerNamespaceJoin",
label: "container namespace posture",
checkIds: [CHECK_IDS.policySandboxContainerNamespaceJoinDenied],
},
{
key: "requireReadOnlyMounts",
label: "container mount mode posture",
checkIds: [CHECK_IDS.policySandboxContainerMountModeRequired],
},
{
key: "denyContainerRuntimeSocketMounts",
label: "container runtime socket mount posture",
checkIds: [CHECK_IDS.policySandboxContainerRuntimeSocketMount],
},
{
key: "denyUnconfinedProfiles",
label: "container security profile posture",
checkIds: [CHECK_IDS.policySandboxContainerUnconfinedProfile],
},
] as const;
const SANDBOX_POLICY_RULE_METADATA = [
{
policyPath: ["sandbox", "requireMode"],
strictness: "allowlist-subset",
valueType: "string-list",
checkIds: [CHECK_IDS.policySandboxModeUnapproved],
emptyList: "disabled",
allowedValues: ["off", "non-main", "all"],
scopeSelectors: ["agentIds"],
},
{
policyPath: ["sandbox", "allowBackends"],
strictness: "allowlist-subset",
valueType: "string-list",
checkIds: [CHECK_IDS.policySandboxBackendUnapproved],
emptyList: "disabled",
scopeSelectors: ["agentIds"],
},
...SANDBOX_CONTAINER_POLICY_RULES.map((rule) => ({
policyPath: ["sandbox", "containers", rule.key] as const,
strictness: "requires-true" as const,
valueType: "boolean" as const,
checkIds: rule.checkIds,
scopeSelectors: ["agentIds"] as const,
})),
{
policyPath: ["sandbox", "browser", "requireCdpSourceRange"],
strictness: "requires-true",
valueType: "boolean",
checkIds: [CHECK_IDS.policySandboxBrowserCdpSourceRangeMissing],
scopeSelectors: ["agentIds"],
},
] as const satisfies readonly PolicyRuleMetadata[];
export const POLICY_RULE_METADATA = [
{
policyPath: ["channels", "denyRules"],
strictness: "denylist-superset",
valueType: "channel-provider-deny-rules",
checkIds: [CHECK_IDS.policyDeniedChannelProvider],
emptyList: "meaningful",
caseSensitive: true,
},
{
policyPath: ["mcp", "servers", "allow"],
strictness: "allowlist-subset",
valueType: "string-list",
checkIds: [CHECK_IDS.policyUnapprovedMcpServer],
emptyList: "disabled",
caseSensitive: true,
},
{
policyPath: ["mcp", "servers", "deny"],
strictness: "denylist-superset",
valueType: "string-list",
checkIds: [CHECK_IDS.policyDeniedMcpServer],
caseSensitive: true,
},
{
policyPath: ["models", "providers", "allow"],
strictness: "allowlist-subset",
valueType: "string-list",
checkIds: [CHECK_IDS.policyUnapprovedModelProvider],
emptyList: "disabled",
normalizeValues: "model-provider",
},
{
policyPath: ["models", "providers", "deny"],
strictness: "denylist-superset",
valueType: "string-list",
checkIds: [CHECK_IDS.policyDeniedModelProvider],
normalizeValues: "model-provider",
},
{
policyPath: ["network", "privateNetwork", "allow"],
strictness: "requires-false",
valueType: "boolean",
checkIds: [CHECK_IDS.policyPrivateNetworkAccess],
},
{
policyPath: ["ingress", "session", "requireDmScope"],
strictness: "ordered-string",
valueType: "string",
orderedValues: ["main", "per-peer", "per-channel-peer", "per-account-channel-peer"],
checkIds: [CHECK_IDS.policyIngressDmScopeUnapproved],
},
{
policyPath: ["gateway", "exposure", "allowNonLoopbackBind"],
strictness: "requires-false",
valueType: "boolean",
checkIds: [CHECK_IDS.policyGatewayNonLoopbackBind],
},
{
policyPath: ["gateway", "exposure", "allowTailscaleFunnel"],
strictness: "requires-false",
valueType: "boolean",
checkIds: [CHECK_IDS.policyGatewayTailscaleFunnel],
},
{
policyPath: ["gateway", "auth", "requireAuth"],
strictness: "requires-true",
valueType: "boolean",
checkIds: [CHECK_IDS.policyGatewayAuthDisabled],
},
{
policyPath: ["gateway", "auth", "requireExplicitRateLimit"],
strictness: "requires-true",
valueType: "boolean",
checkIds: [CHECK_IDS.policyGatewayRateLimitMissing],
},
{
policyPath: ["gateway", "controlUi", "allowInsecure"],
strictness: "requires-false",
valueType: "boolean",
checkIds: [CHECK_IDS.policyGatewayControlUiInsecure],
},
{
policyPath: ["gateway", "remote", "allow"],
strictness: "requires-false",
valueType: "boolean",
checkIds: [CHECK_IDS.policyGatewayRemoteEnabled],
},
{
policyPath: ["gateway", "http", "denyEndpoints"],
strictness: "denylist-superset",
valueType: "string-list",
checkIds: [CHECK_IDS.policyGatewayHttpEndpointEnabled],
allowedValues: ["chatCompletions", "responses"],
caseSensitive: true,
},
{
policyPath: ["gateway", "http", "requireUrlAllowlists"],
strictness: "requires-true",
valueType: "boolean",
checkIds: [CHECK_IDS.policyGatewayHttpUrlFetchUnrestricted],
},
{
policyPath: ["gateway", "nodes", "denyCommands"],
strictness: "denylist-superset",
valueType: "string-list",
checkIds: [CHECK_IDS.policyGatewayNodeCommandDenied],
caseSensitive: true,
},
{
policyPath: ["agents", "workspace", "allowedAccess"],
strictness: "allowlist-subset",
valueType: "string-list",
checkIds: [CHECK_IDS.policyAgentsWorkspaceAccessDenied],
emptyList: "disabled",
allowedValues: ["none", "ro", "rw"],
scopeSelectors: ["agentIds"],
},
{
policyPath: ["agents", "workspace", "denyTools"],
strictness: "denylist-superset",
valueType: "string-list",
checkIds: [CHECK_IDS.policyAgentsToolNotDenied],
allowedValues: ["exec", "process", "write", "edit", "apply_patch"],
scopeSelectors: ["agentIds"],
},
{
policyPath: ["tools", "profiles", "allow"],
strictness: "allowlist-subset",
valueType: "string-list",
checkIds: [CHECK_IDS.policyToolsProfileUnapproved],
emptyList: "disabled",
allowedValues: ["minimal", "coding", "messaging", "full"],
scopeSelectors: ["agentIds"],
},
{
policyPath: ["tools", "fs", "requireWorkspaceOnly"],
strictness: "requires-true",
valueType: "boolean",
checkIds: [CHECK_IDS.policyToolsFsWorkspaceOnlyRequired],
scopeSelectors: ["agentIds"],
},
{
policyPath: ["tools", "exec", "allowSecurity"],
strictness: "allowlist-subset",
valueType: "string-list",
checkIds: [CHECK_IDS.policyToolsExecSecurityUnapproved],
emptyList: "disabled",
allowedValues: ["deny", "allowlist", "full"],
scopeSelectors: ["agentIds"],
},
{
policyPath: ["tools", "exec", "requireAsk"],
strictness: "allowlist-subset",
valueType: "string-list",
checkIds: [CHECK_IDS.policyToolsExecAskUnapproved],
emptyList: "disabled",
allowedValues: ["off", "on-miss", "always"],
scopeSelectors: ["agentIds"],
},
{
policyPath: ["tools", "exec", "allowHosts"],
strictness: "allowlist-subset",
valueType: "string-list",
checkIds: [CHECK_IDS.policyToolsExecHostUnapproved],
emptyList: "disabled",
allowedValues: ["auto", "sandbox", "gateway", "node"],
scopeSelectors: ["agentIds"],
},
{
policyPath: ["tools", "elevated", "allow"],
strictness: "requires-false",
valueType: "boolean",
checkIds: [CHECK_IDS.policyToolsElevatedEnabled],
scopeSelectors: ["agentIds"],
},
{
policyPath: ["tools", "alsoAllow", "expected"],
strictness: "exact-list",
valueType: "string-list",
checkIds: [CHECK_IDS.policyToolsAlsoAllowMissing, CHECK_IDS.policyToolsAlsoAllowUnexpected],
emptyList: "meaningful",
scopeSelectors: ["agentIds"],
},
{
policyPath: ["tools", "denyTools"],
strictness: "denylist-superset",
valueType: "string-list",
checkIds: [CHECK_IDS.policyToolsRequiredDenyMissing],
scopeSelectors: ["agentIds"],
},
{
policyPath: ["tools", "requireMetadata"],
strictness: "denylist-superset",
valueType: "string-list",
checkIds: [
CHECK_IDS.policyMissingToolRisk,
CHECK_IDS.policyMissingToolSensitivity,
CHECK_IDS.policyMissingToolOwner,
],
allowedValues: ["risk", "sensitivity", "owner"],
},
...SANDBOX_POLICY_RULE_METADATA,
{
policyPath: ["ingress", "channels", "allowDmPolicies"],
strictness: "allowlist-subset",
valueType: "string-list",
checkIds: [CHECK_IDS.policyIngressDmPolicyUnapproved],
emptyList: "disabled",
allowedValues: ["pairing", "allowlist", "open", "disabled"],
scopeSelectors: ["channelIds"],
},
{
policyPath: ["ingress", "channels", "denyOpenGroups"],
strictness: "requires-true",
valueType: "boolean",
checkIds: [CHECK_IDS.policyIngressOpenGroupsDenied],
scopeSelectors: ["channelIds"],
},
{
policyPath: ["ingress", "channels", "requireMentionInGroups"],
strictness: "requires-true",
valueType: "boolean",
checkIds: [CHECK_IDS.policyIngressGroupMentionRequired],
scopeSelectors: ["channelIds"],
},
{
policyPath: ["dataHandling", "sensitiveLogging", "requireRedaction"],
strictness: "requires-true",
valueType: "boolean",
checkIds: [CHECK_IDS.policyDataHandlingRedactionDisabled],
},
{
policyPath: ["dataHandling", "telemetry", "denyContentCapture"],
strictness: "requires-true",
valueType: "boolean",
checkIds: [CHECK_IDS.policyDataHandlingTelemetryContentCapture],
},
{
policyPath: ["dataHandling", "retention", "requireSessionMaintenance"],
strictness: "requires-true",
valueType: "boolean",
checkIds: [CHECK_IDS.policyDataHandlingSessionRetentionNotEnforced],
},
{
policyPath: ["dataHandling", "memory", "denySessionTranscriptIndexing"],
strictness: "requires-true",
valueType: "boolean",
checkIds: [CHECK_IDS.policyDataHandlingSessionTranscriptMemory],
scopeSelectors: ["agentIds"],
},
{
policyPath: ["secrets", "requireManagedProviders"],
strictness: "requires-true",
valueType: "boolean",
checkIds: [CHECK_IDS.policySecretsUnmanagedProvider],
},
{
policyPath: ["secrets", "denySources"],
strictness: "denylist-superset",
valueType: "string-list",
checkIds: [CHECK_IDS.policySecretsDeniedProviderSource],
},
{
policyPath: ["secrets", "allowInsecureProviders"],
strictness: "requires-false",
valueType: "boolean",
checkIds: [CHECK_IDS.policySecretsInsecureProvider],
},
{
policyPath: ["execApprovals", "requireFile"],
strictness: "requires-true",
valueType: "boolean",
checkIds: [CHECK_IDS.policyExecApprovalsMissing],
},
{
policyPath: ["execApprovals", "defaults", "allowSecurity"],
strictness: "allowlist-subset",
valueType: "string-list",
checkIds: [CHECK_IDS.policyExecApprovalsDefaultSecurityUnapproved],
emptyList: "disabled",
allowedValues: ["deny", "allowlist", "full"],
},
{
policyPath: ["execApprovals", "agents", "allowSecurity"],
strictness: "allowlist-subset",
valueType: "string-list",
checkIds: [CHECK_IDS.policyExecApprovalsAgentSecurityUnapproved],
emptyList: "disabled",
allowedValues: ["deny", "allowlist", "full"],
scopeSelectors: ["agentIds"],
},
{
policyPath: ["execApprovals", "agents", "allowAutoAllowSkills"],
strictness: "requires-false",
valueType: "boolean",
checkIds: [CHECK_IDS.policyExecApprovalsAutoAllowSkillsEnabled],
scopeSelectors: ["agentIds"],
},
{
policyPath: ["execApprovals", "agents", "allowlist", "expected"],
strictness: "exact-list",
valueType: "string-list",
checkIds: [
CHECK_IDS.policyExecApprovalsAllowlistMissing,
CHECK_IDS.policyExecApprovalsAllowlistUnexpected,
],
emptyList: "meaningful",
caseSensitive: true,
scopeSelectors: ["agentIds"],
},
{
policyPath: ["auth", "profiles", "requireMetadata"],
strictness: "denylist-superset",
valueType: "string-list",
checkIds: [CHECK_IDS.policyAuthProfileInvalidMetadata],
allowedValues: ["provider", "mode"],
},
{
policyPath: ["auth", "profiles", "allowModes"],
strictness: "allowlist-subset",
valueType: "string-list",
checkIds: [CHECK_IDS.policyAuthProfileUnapprovedMode],
emptyList: "disabled",
allowedValues: ["api_key", "aws-sdk", "oauth", "token"],
},
] as const satisfies readonly PolicyRuleMetadata[];

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,107 @@
// Policy doctor health-check factories for one policy scope.
import type { HealthCheck } from "openclaw/plugin-sdk/health";
import { CHECK_IDS } from "../metadata.js";
import type { PolicyDoctorCheckDeps } from "../types.js";
export function createPolicyChannelProviderChecks(
deps: PolicyDoctorCheckDeps,
): readonly HealthCheck[] {
const {
channelIdsFromFindings,
disableChannels,
evaluatePolicy,
findingsForCheck,
workspaceRepairsDisabledResult,
workspaceRepairsEnabled,
} = deps;
const policyChannelsDeniedProviderCheck: HealthCheck = {
id: CHECK_IDS.policyDeniedChannelProvider,
kind: "plugin",
description: "Configured channels satisfy policy deny rules.",
source: "policy",
async detect(ctx) {
return findingsForCheck(await evaluatePolicy(ctx), CHECK_IDS.policyDeniedChannelProvider);
},
async repair(ctx, findings) {
if (!workspaceRepairsEnabled(ctx)) {
return workspaceRepairsDisabledResult("channel config");
}
const channelIds = channelIdsFromFindings(findings);
if (channelIds.length === 0) {
return {
status: "skipped",
reason: "no channel findings matched a configurable channel",
changes: [],
};
}
const next = disableChannels(ctx.cfg, channelIds);
if (next.changed.length === 0) {
return {
status: "skipped",
reason: "matching channels were already disabled or missing",
changes: [],
};
}
return {
config: next.config,
changes: next.changed.map(
(id) => `Disabled channels.${id}.enabled for policy conformance.`,
),
};
},
};
return [policyChannelsDeniedProviderCheck];
}
export function createPolicyIngressChecks(deps: PolicyDoctorCheckDeps): readonly HealthCheck[] {
const { evaluatePolicy, findingsForCheck } = deps;
const policyIngressDmPolicyUnapprovedCheck: HealthCheck = {
id: CHECK_IDS.policyIngressDmPolicyUnapproved,
kind: "plugin",
description: "Channel direct-message access policy matches ingress requirements.",
source: "policy",
async detect(ctx) {
return findingsForCheck(await evaluatePolicy(ctx), CHECK_IDS.policyIngressDmPolicyUnapproved);
},
};
const policyIngressDmScopeUnapprovedCheck: HealthCheck = {
id: CHECK_IDS.policyIngressDmScopeUnapproved,
kind: "plugin",
description: "Direct-message sessions use the policy-required isolation scope.",
source: "policy",
async detect(ctx) {
return findingsForCheck(await evaluatePolicy(ctx), CHECK_IDS.policyIngressDmScopeUnapproved);
},
};
const policyIngressOpenGroupsDeniedCheck: HealthCheck = {
id: CHECK_IDS.policyIngressOpenGroupsDenied,
kind: "plugin",
description: "Channel group access does not use open group policy when denied.",
source: "policy",
async detect(ctx) {
return findingsForCheck(await evaluatePolicy(ctx), CHECK_IDS.policyIngressOpenGroupsDenied);
},
};
const policyIngressGroupMentionRequiredCheck: HealthCheck = {
id: CHECK_IDS.policyIngressGroupMentionRequired,
kind: "plugin",
description: "Channel group access keeps mention gates enabled when required.",
source: "policy",
async detect(ctx) {
return findingsForCheck(
await evaluatePolicy(ctx),
CHECK_IDS.policyIngressGroupMentionRequired,
);
},
};
return [
policyIngressDmPolicyUnapprovedCheck,
policyIngressDmScopeUnapprovedCheck,
policyIngressOpenGroupsDeniedCheck,
policyIngressGroupMentionRequiredCheck,
];
}

View File

@@ -0,0 +1,52 @@
// Policy doctor health-check factories for one policy scope.
import type { HealthCheck } from "openclaw/plugin-sdk/health";
import { CHECK_IDS } from "../metadata.js";
import type { PolicyDoctorCheckDeps } from "../types.js";
export function createPolicyCoreChecks(deps: PolicyDoctorCheckDeps): readonly HealthCheck[] {
const { evaluatePolicy, findingsForCheck } = deps;
const policyMissingFileCheck: HealthCheck = {
id: CHECK_IDS.policyMissingFile,
kind: "plugin",
description: "The enabled Policy plugin has a policy file to verify.",
source: "policy",
async detect(ctx) {
return findingsForCheck(await evaluatePolicy(ctx), CHECK_IDS.policyMissingFile);
},
};
const policyHashMismatchCheck: HealthCheck = {
id: CHECK_IDS.policyHashMismatch,
kind: "plugin",
description: "The policy file matches the configured expected hash.",
source: "policy",
async detect(ctx) {
return findingsForCheck(await evaluatePolicy(ctx), CHECK_IDS.policyHashMismatch);
},
};
const policyAttestationMismatchCheck: HealthCheck = {
id: CHECK_IDS.policyAttestationMismatch,
kind: "plugin",
description: "The current policy check matches the accepted attestation.",
source: "policy",
async detect(ctx) {
return findingsForCheck(await evaluatePolicy(ctx), CHECK_IDS.policyAttestationMismatch);
},
};
const policyInvalidFileCheck: HealthCheck = {
id: CHECK_IDS.policyInvalidFile,
kind: "plugin",
description: "The enabled policy file parses before policy checks run.",
source: "policy",
async detect(ctx) {
return findingsForCheck(await evaluatePolicy(ctx), CHECK_IDS.policyInvalidFile);
},
};
return [
policyMissingFileCheck,
policyInvalidFileCheck,
policyHashMismatchCheck,
policyAttestationMismatchCheck,
];
}

View File

@@ -0,0 +1,123 @@
// Policy doctor health-check factories for one policy scope.
import type { HealthCheck } from "openclaw/plugin-sdk/health";
import { CHECK_IDS } from "../metadata.js";
import type { PolicyDoctorCheckDeps } from "../types.js";
export function createPolicyDataAuthChecks(deps: PolicyDoctorCheckDeps): readonly HealthCheck[] {
const { evaluatePolicy, findingsForCheck } = deps;
const policyDataHandlingRedactionDisabledCheck: HealthCheck = {
id: CHECK_IDS.policyDataHandlingRedactionDisabled,
kind: "plugin",
description: "Sensitive logging redaction remains enabled when policy requires it.",
source: "policy",
async detect(ctx) {
return findingsForCheck(
await evaluatePolicy(ctx),
CHECK_IDS.policyDataHandlingRedactionDisabled,
);
},
};
const policyDataHandlingTelemetryContentCaptureCheck: HealthCheck = {
id: CHECK_IDS.policyDataHandlingTelemetryContentCapture,
kind: "plugin",
description: "Telemetry content capture remains disabled when policy denies it.",
source: "policy",
async detect(ctx) {
return findingsForCheck(
await evaluatePolicy(ctx),
CHECK_IDS.policyDataHandlingTelemetryContentCapture,
);
},
};
const policyDataHandlingSessionRetentionNotEnforcedCheck: HealthCheck = {
id: CHECK_IDS.policyDataHandlingSessionRetentionNotEnforced,
kind: "plugin",
description: "Session retention maintenance is enforced when policy requires it.",
source: "policy",
async detect(ctx) {
return findingsForCheck(
await evaluatePolicy(ctx),
CHECK_IDS.policyDataHandlingSessionRetentionNotEnforced,
);
},
};
const policyDataHandlingSessionTranscriptMemoryCheck: HealthCheck = {
id: CHECK_IDS.policyDataHandlingSessionTranscriptMemory,
kind: "plugin",
description: "Session transcript memory indexing remains disabled when policy denies it.",
source: "policy",
async detect(ctx) {
return findingsForCheck(
await evaluatePolicy(ctx),
CHECK_IDS.policyDataHandlingSessionTranscriptMemory,
);
},
};
const policySecretsUnmanagedProviderCheck: HealthCheck = {
id: CHECK_IDS.policySecretsUnmanagedProvider,
kind: "plugin",
description:
"OpenClaw config SecretRefs use configured secret providers when policy requires managed providers.",
source: "policy",
async detect(ctx) {
return findingsForCheck(await evaluatePolicy(ctx), CHECK_IDS.policySecretsUnmanagedProvider);
},
};
const policySecretsDeniedProviderSourceCheck: HealthCheck = {
id: CHECK_IDS.policySecretsDeniedProviderSource,
kind: "plugin",
description:
"OpenClaw config secret providers and SecretRefs do not use sources denied by policy.",
source: "policy",
async detect(ctx) {
return findingsForCheck(
await evaluatePolicy(ctx),
CHECK_IDS.policySecretsDeniedProviderSource,
);
},
};
const policySecretsInsecureProviderCheck: HealthCheck = {
id: CHECK_IDS.policySecretsInsecureProvider,
kind: "plugin",
description:
"Configured secret providers do not opt into insecure posture unless policy allows it.",
source: "policy",
async detect(ctx) {
return findingsForCheck(await evaluatePolicy(ctx), CHECK_IDS.policySecretsInsecureProvider);
},
};
const policyAuthProfileInvalidMetadataCheck: HealthCheck = {
id: CHECK_IDS.policyAuthProfileInvalidMetadata,
kind: "plugin",
description: "OpenClaw config auth profiles declare required provider and mode metadata.",
source: "policy",
async detect(ctx) {
return findingsForCheck(
await evaluatePolicy(ctx),
CHECK_IDS.policyAuthProfileInvalidMetadata,
);
},
};
const policyAuthProfileUnapprovedModeCheck: HealthCheck = {
id: CHECK_IDS.policyAuthProfileUnapprovedMode,
kind: "plugin",
description: "OpenClaw config auth profile modes stay within the policy allowlist.",
source: "policy",
async detect(ctx) {
return findingsForCheck(await evaluatePolicy(ctx), CHECK_IDS.policyAuthProfileUnapprovedMode);
},
};
return [
policyDataHandlingRedactionDisabledCheck,
policyDataHandlingTelemetryContentCaptureCheck,
policyDataHandlingSessionRetentionNotEnforcedCheck,
policyDataHandlingSessionTranscriptMemoryCheck,
policySecretsUnmanagedProviderCheck,
policySecretsDeniedProviderSourceCheck,
policySecretsInsecureProviderCheck,
policyAuthProfileInvalidMetadataCheck,
policyAuthProfileUnapprovedModeCheck,
];
}

View File

@@ -0,0 +1,100 @@
// Policy doctor health-check factories for one policy scope.
import type { HealthCheck } from "openclaw/plugin-sdk/health";
import { CHECK_IDS } from "../metadata.js";
import type { PolicyDoctorCheckDeps } from "../types.js";
export function createPolicyExecApprovalChecks(
deps: PolicyDoctorCheckDeps,
): readonly HealthCheck[] {
const { evaluatePolicy, findingsForCheck } = deps;
const policyExecApprovalsMissingCheck: HealthCheck = {
id: CHECK_IDS.policyExecApprovalsMissing,
kind: "plugin",
description: "Required exec approvals artifact is present for policy conformance.",
source: "policy",
async detect(ctx) {
return findingsForCheck(await evaluatePolicy(ctx), CHECK_IDS.policyExecApprovalsMissing);
},
};
const policyExecApprovalsInvalidCheck: HealthCheck = {
id: CHECK_IDS.policyExecApprovalsInvalid,
kind: "plugin",
description: "Exec approvals artifact parses before policy checks run.",
source: "policy",
async detect(ctx) {
return findingsForCheck(await evaluatePolicy(ctx), CHECK_IDS.policyExecApprovalsInvalid);
},
};
const policyExecApprovalsDefaultSecurityUnapprovedCheck: HealthCheck = {
id: CHECK_IDS.policyExecApprovalsDefaultSecurityUnapproved,
kind: "plugin",
description: "Exec approval defaults use a policy-approved security mode.",
source: "policy",
async detect(ctx) {
return findingsForCheck(
await evaluatePolicy(ctx),
CHECK_IDS.policyExecApprovalsDefaultSecurityUnapproved,
);
},
};
const policyExecApprovalsAgentSecurityUnapprovedCheck: HealthCheck = {
id: CHECK_IDS.policyExecApprovalsAgentSecurityUnapproved,
kind: "plugin",
description: "Per-agent exec approval settings use policy-approved security modes.",
source: "policy",
async detect(ctx) {
return findingsForCheck(
await evaluatePolicy(ctx),
CHECK_IDS.policyExecApprovalsAgentSecurityUnapproved,
);
},
};
const policyExecApprovalsAutoAllowSkillsEnabledCheck: HealthCheck = {
id: CHECK_IDS.policyExecApprovalsAutoAllowSkillsEnabled,
kind: "plugin",
description:
"Exec approval agents do not implicitly auto-allow skill CLIs unless policy allows it.",
source: "policy",
async detect(ctx) {
return findingsForCheck(
await evaluatePolicy(ctx),
CHECK_IDS.policyExecApprovalsAutoAllowSkillsEnabled,
);
},
};
const policyExecApprovalsAllowlistMissingCheck: HealthCheck = {
id: CHECK_IDS.policyExecApprovalsAllowlistMissing,
kind: "plugin",
description: "Exec approval allowlists include every pattern required by policy.",
source: "policy",
async detect(ctx) {
return findingsForCheck(
await evaluatePolicy(ctx),
CHECK_IDS.policyExecApprovalsAllowlistMissing,
);
},
};
const policyExecApprovalsAllowlistUnexpectedCheck: HealthCheck = {
id: CHECK_IDS.policyExecApprovalsAllowlistUnexpected,
kind: "plugin",
description: "Exec approval allowlists do not contain patterns outside policy.",
source: "policy",
async detect(ctx) {
return findingsForCheck(
await evaluatePolicy(ctx),
CHECK_IDS.policyExecApprovalsAllowlistUnexpected,
);
},
};
return [
policyExecApprovalsMissingCheck,
policyExecApprovalsInvalidCheck,
policyExecApprovalsDefaultSecurityUnapprovedCheck,
policyExecApprovalsAgentSecurityUnapprovedCheck,
policyExecApprovalsAutoAllowSkillsEnabledCheck,
policyExecApprovalsAllowlistMissingCheck,
policyExecApprovalsAllowlistUnexpectedCheck,
];
}

View File

@@ -0,0 +1,399 @@
// Policy doctor checks and findings for gateway exposure policy.
import type { HealthCheck, HealthFinding } from "openclaw/plugin-sdk/health";
import type { PolicyEvidence } from "../../policy-state.js";
import { CHECK_IDS } from "../metadata.js";
import type { PolicyDoctorCheckDeps } from "../types.js";
import { readPolicyBoolean, readStringList } from "../utils.js";
export function createPolicyGatewayChecks(deps: PolicyDoctorCheckDeps): readonly HealthCheck[] {
const { evaluatePolicy, findingsForCheck } = deps;
const policyGatewayNonLoopbackBindCheck: HealthCheck = {
id: CHECK_IDS.policyGatewayNonLoopbackBind,
kind: "plugin",
description: "Gateway bind posture matches policy exposure requirements.",
source: "policy",
async detect(ctx) {
return findingsForCheck(await evaluatePolicy(ctx), CHECK_IDS.policyGatewayNonLoopbackBind);
},
};
const policyGatewayAuthDisabledCheck: HealthCheck = {
id: CHECK_IDS.policyGatewayAuthDisabled,
kind: "plugin",
description: "Gateway authentication remains enabled when required by policy.",
source: "policy",
async detect(ctx) {
return findingsForCheck(await evaluatePolicy(ctx), CHECK_IDS.policyGatewayAuthDisabled);
},
};
const policyGatewayRateLimitMissingCheck: HealthCheck = {
id: CHECK_IDS.policyGatewayRateLimitMissing,
kind: "plugin",
description: "Gateway authentication rate-limit posture is explicit when required by policy.",
source: "policy",
async detect(ctx) {
return findingsForCheck(await evaluatePolicy(ctx), CHECK_IDS.policyGatewayRateLimitMissing);
},
};
const policyGatewayControlUiInsecureCheck: HealthCheck = {
id: CHECK_IDS.policyGatewayControlUiInsecure,
kind: "plugin",
description: "Gateway Control UI insecure exposure toggles remain disabled by policy.",
source: "policy",
async detect(ctx) {
return findingsForCheck(await evaluatePolicy(ctx), CHECK_IDS.policyGatewayControlUiInsecure);
},
};
const policyGatewayTailscaleFunnelCheck: HealthCheck = {
id: CHECK_IDS.policyGatewayTailscaleFunnel,
kind: "plugin",
description: "Gateway Tailscale Funnel exposure matches policy.",
source: "policy",
async detect(ctx) {
return findingsForCheck(await evaluatePolicy(ctx), CHECK_IDS.policyGatewayTailscaleFunnel);
},
};
const policyGatewayRemoteEnabledCheck: HealthCheck = {
id: CHECK_IDS.policyGatewayRemoteEnabled,
kind: "plugin",
description: "Remote gateway mode matches policy.",
source: "policy",
async detect(ctx) {
return findingsForCheck(await evaluatePolicy(ctx), CHECK_IDS.policyGatewayRemoteEnabled);
},
};
const policyGatewayHttpEndpointEnabledCheck: HealthCheck = {
id: CHECK_IDS.policyGatewayHttpEndpointEnabled,
kind: "plugin",
description: "Gateway HTTP API endpoints match policy.",
source: "policy",
async detect(ctx) {
return findingsForCheck(
await evaluatePolicy(ctx),
CHECK_IDS.policyGatewayHttpEndpointEnabled,
);
},
};
const policyGatewayHttpUrlFetchUnrestrictedCheck: HealthCheck = {
id: CHECK_IDS.policyGatewayHttpUrlFetchUnrestricted,
kind: "plugin",
description: "Gateway HTTP URL-fetch inputs have allowlists when required by policy.",
source: "policy",
async detect(ctx) {
return findingsForCheck(
await evaluatePolicy(ctx),
CHECK_IDS.policyGatewayHttpUrlFetchUnrestricted,
);
},
};
const policyGatewayNodeCommandDeniedCheck: HealthCheck = {
id: CHECK_IDS.policyGatewayNodeCommandDenied,
kind: "plugin",
description: "Gateway node command allowlists match policy.",
source: "policy",
async detect(ctx) {
return findingsForCheck(await evaluatePolicy(ctx), CHECK_IDS.policyGatewayNodeCommandDenied);
},
};
return [
policyGatewayNonLoopbackBindCheck,
policyGatewayAuthDisabledCheck,
policyGatewayRateLimitMissingCheck,
policyGatewayControlUiInsecureCheck,
policyGatewayTailscaleFunnelCheck,
policyGatewayRemoteEnabledCheck,
policyGatewayHttpEndpointEnabledCheck,
policyGatewayHttpUrlFetchUnrestrictedCheck,
policyGatewayNodeCommandDeniedCheck,
];
}
export function gatewayExposureFindings(
policy: unknown,
policyDocName: string,
evidence: PolicyEvidence,
): readonly HealthFinding[] {
return [
...gatewayNonLoopbackBindFindings(policy, policyDocName, evidence),
...gatewayAuthFindings(policy, policyDocName, evidence),
...gatewayControlUiFindings(policy, policyDocName, evidence),
...gatewayTailscaleFindings(policy, policyDocName, evidence),
...gatewayRemoteFindings(policy, policyDocName, evidence),
...gatewayHttpEndpointFindings(policy, policyDocName, evidence),
...gatewayHttpUrlFetchFindings(policy, policyDocName, evidence),
...gatewayNodeCommandFindings(policy, policyDocName, evidence),
];
}
function gatewayNonLoopbackBindFindings(
policy: unknown,
policyDocName: string,
evidence: PolicyEvidence,
): readonly HealthFinding[] {
if (readPolicyBoolean(policy, ["gateway", "exposure", "allowNonLoopbackBind"]) !== false) {
return [];
}
return (evidence.gatewayExposure ?? [])
.filter((entry) => entry.kind === "bind" && entry.nonLoopback === true)
.map((entry): HealthFinding => {
return {
checkId: CHECK_IDS.policyGatewayNonLoopbackBind,
severity: "error",
message:
entry.explicit === false
? "Gateway bind is omitted while the runtime default can permit non-loopback exposure."
: `Gateway bind setting '${entry.id}' permits non-loopback exposure.`,
source: "policy",
path: "openclaw config",
ocPath: entry.source,
target: entry.source,
requirement: `oc://${policyDocName}/gateway/exposure/allowNonLoopbackBind`,
fixHint: "Use gateway.bind=loopback or update policy after review.",
};
});
}
function gatewayAuthFindings(
policy: unknown,
policyDocName: string,
evidence: PolicyEvidence,
): readonly HealthFinding[] {
const findings: HealthFinding[] = [];
if (readPolicyBoolean(policy, ["gateway", "auth", "requireAuth"]) === true) {
findings.push(
...(evidence.gatewayExposure ?? [])
.filter((entry) => entry.kind === "auth" && entry.value === "none")
.map((entry): HealthFinding => {
return {
checkId: CHECK_IDS.policyGatewayAuthDisabled,
severity: "error",
message: "Gateway authentication is disabled.",
source: "policy",
path: "openclaw config",
ocPath: entry.source,
target: entry.source,
requirement: `oc://${policyDocName}/gateway/auth/requireAuth`,
fixHint: "Set gateway.auth.mode to token, password, or trusted-proxy.",
};
}),
);
}
if (readPolicyBoolean(policy, ["gateway", "auth", "requireExplicitRateLimit"]) === true) {
findings.push(
...(evidence.gatewayExposure ?? [])
.filter((entry) => entry.kind === "authRateLimit" && entry.explicit !== true)
.map((entry): HealthFinding => {
return {
checkId: CHECK_IDS.policyGatewayRateLimitMissing,
severity: "error",
message: "Gateway authentication rate-limit posture is not explicit.",
source: "policy",
path: "openclaw config",
ocPath: entry.source,
target: entry.source,
requirement: `oc://${policyDocName}/gateway/auth/requireExplicitRateLimit`,
fixHint: "Configure gateway.auth.rateLimit or update policy after review.",
};
}),
);
}
return findings;
}
function gatewayControlUiFindings(
policy: unknown,
policyDocName: string,
evidence: PolicyEvidence,
): readonly HealthFinding[] {
if (readPolicyBoolean(policy, ["gateway", "controlUi", "allowInsecure"]) !== false) {
return [];
}
return (evidence.gatewayExposure ?? [])
.filter(
(entry) =>
entry.kind === "controlUi" &&
entry.value === true &&
(entry.id === "gateway-control-ui-insecure-auth" ||
entry.id === "gateway-control-ui-device-auth-disabled" ||
entry.id === "gateway-control-ui-host-origin-fallback"),
)
.map((entry): HealthFinding => {
return {
checkId: CHECK_IDS.policyGatewayControlUiInsecure,
severity: "error",
message: `Gateway Control UI insecure toggle '${entry.id}' is enabled.`,
source: "policy",
path: "openclaw config",
ocPath: entry.source,
target: entry.source,
requirement: `oc://${policyDocName}/gateway/controlUi/allowInsecure`,
fixHint: "Disable the insecure Control UI toggle or update policy after review.",
};
});
}
function gatewayTailscaleFindings(
policy: unknown,
policyDocName: string,
evidence: PolicyEvidence,
): readonly HealthFinding[] {
if (readPolicyBoolean(policy, ["gateway", "exposure", "allowTailscaleFunnel"]) !== false) {
return [];
}
return (evidence.gatewayExposure ?? [])
.filter((entry) => entry.kind === "tailscale" && entry.value === "funnel")
.map((entry): HealthFinding => {
return {
checkId: CHECK_IDS.policyGatewayTailscaleFunnel,
severity: "error",
message: "Gateway Tailscale Funnel exposure is enabled.",
source: "policy",
path: "openclaw config",
ocPath: entry.source,
target: entry.source,
requirement: `oc://${policyDocName}/gateway/exposure/allowTailscaleFunnel`,
fixHint: "Use tailscale serve/off or update policy after review.",
};
});
}
function gatewayRemoteFindings(
policy: unknown,
policyDocName: string,
evidence: PolicyEvidence,
): readonly HealthFinding[] {
if (readPolicyBoolean(policy, ["gateway", "remote", "allow"]) !== false) {
return [];
}
return (evidence.gatewayExposure ?? [])
.filter((entry) => entry.kind === "remote")
.map((entry): HealthFinding => {
return {
checkId: CHECK_IDS.policyGatewayRemoteEnabled,
severity: "error",
message: `Gateway remote posture '${entry.id}' is enabled.`,
source: "policy",
path: "openclaw config",
ocPath: entry.source,
target: entry.source,
requirement: `oc://${policyDocName}/gateway/remote/allow`,
fixHint: "Disable remote gateway mode/config or update policy after review.",
};
});
}
function gatewayHttpEndpointFindings(
policy: unknown,
policyDocName: string,
evidence: PolicyEvidence,
): readonly HealthFinding[] {
const denied = new Set(
readStringList(policy, ["gateway", "http", "denyEndpoints"]).map((endpoint) =>
endpoint.toLowerCase(),
),
);
if (denied.size === 0) {
return [];
}
return (evidence.gatewayExposure ?? [])
.filter(
(entry) =>
entry.kind === "httpEndpoint" &&
entry.endpoint !== undefined &&
denied.has(entry.endpoint.toLowerCase()),
)
.map((entry): HealthFinding => {
return {
checkId: CHECK_IDS.policyGatewayHttpEndpointEnabled,
severity: "error",
message: `Gateway HTTP endpoint '${entry.endpoint ?? entry.id}' is denied by policy.`,
source: "policy",
path: "openclaw config",
ocPath: entry.source,
target: entry.source,
requirement: `oc://${policyDocName}/gateway/http/denyEndpoints`,
fixHint: "Disable the HTTP endpoint or update policy after review.",
};
});
}
function gatewayHttpUrlFetchFindings(
policy: unknown,
policyDocName: string,
evidence: PolicyEvidence,
): readonly HealthFinding[] {
if (readPolicyBoolean(policy, ["gateway", "http", "requireUrlAllowlists"]) !== true) {
return [];
}
return (evidence.gatewayExposure ?? [])
.filter((entry) => entry.kind === "httpUrlFetch" && entry.hasAllowlist !== true)
.map((entry): HealthFinding => {
return {
checkId: CHECK_IDS.policyGatewayHttpUrlFetchUnrestricted,
severity: "error",
message: `Gateway HTTP URL-fetch input '${entry.id}' has no URL allowlist.`,
source: "policy",
path: "openclaw config",
ocPath: entry.source,
target: entry.source,
requirement: `oc://${policyDocName}/gateway/http/requireUrlAllowlists`,
fixHint: "Add a urlAllowlist for this URL-fetch input or update policy after review.",
};
});
}
function gatewayNodeCommandFindings(
policy: unknown,
policyDocName: string,
evidence: PolicyEvidence,
): readonly HealthFinding[] {
if (!hasValidOptionalStringList(policy, ["gateway", "nodes", "denyCommands"])) {
return [];
}
const policyDenied = readStringList(policy, ["gateway", "nodes", "denyCommands"], {
lowercase: false,
});
if (policyDenied.length === 0) {
return [];
}
const configDenied = new Set(
(evidence.gatewayExposure ?? [])
.filter((entry) => entry.kind === "nodeDenyCommand" && entry.command !== undefined)
.map((entry) => entry.command),
);
return policyDenied
.filter((command) => !configDenied.has(command))
.map((command): HealthFinding => {
return {
checkId: CHECK_IDS.policyGatewayNodeCommandDenied,
severity: "error",
message: `Gateway node command '${command}' is denied by policy but not denied by OpenClaw config.`,
source: "policy",
path: "openclaw config",
ocPath: "oc://openclaw.config/gateway/nodes/denyCommands",
target: "oc://openclaw.config/gateway/nodes/denyCommands",
requirement: `oc://${policyDocName}/gateway/nodes/denyCommands`,
fixHint: `Add '${command}' to gateway.nodes.denyCommands or update policy after review.`,
};
});
}
function hasValidOptionalStringList(policy: unknown, path: readonly string[]): boolean {
let current: unknown = policy;
for (const part of path) {
if (!isRecord(current)) {
return true;
}
current = current[part];
}
return (
current === undefined ||
(Array.isArray(current) &&
current.every((entry) => typeof entry === "string" && entry.trim() !== ""))
);
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}

View File

@@ -0,0 +1,232 @@
// Policy doctor checks and findings for MCP, model provider, and network policy.
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 type { PolicyDoctorCheckDeps } from "../types.js";
import { readPolicyBoolean, readStringList } from "../utils.js";
export function createPolicyModelNetworkChecks(
deps: PolicyDoctorCheckDeps,
): readonly HealthCheck[] {
const { evaluatePolicy, findingsForCheck } = deps;
const policyMcpDeniedServerCheck: HealthCheck = {
id: CHECK_IDS.policyDeniedMcpServer,
kind: "plugin",
description: "Configured MCP servers do not match policy deny rules.",
source: "policy",
async detect(ctx) {
return findingsForCheck(await evaluatePolicy(ctx), CHECK_IDS.policyDeniedMcpServer);
},
};
const policyMcpUnapprovedServerCheck: HealthCheck = {
id: CHECK_IDS.policyUnapprovedMcpServer,
kind: "plugin",
description: "Configured MCP servers do not match policy allow rules.",
source: "policy",
async detect(ctx) {
return findingsForCheck(await evaluatePolicy(ctx), CHECK_IDS.policyUnapprovedMcpServer);
},
};
const policyModelsDeniedProviderCheck: HealthCheck = {
id: CHECK_IDS.policyDeniedModelProvider,
kind: "plugin",
description: "Configured model providers do not match policy deny rules.",
source: "policy",
async detect(ctx) {
return findingsForCheck(await evaluatePolicy(ctx), CHECK_IDS.policyDeniedModelProvider);
},
};
const policyModelsUnapprovedProviderCheck: HealthCheck = {
id: CHECK_IDS.policyUnapprovedModelProvider,
kind: "plugin",
description: "Configured model providers do not match policy allow rules.",
source: "policy",
async detect(ctx) {
return findingsForCheck(await evaluatePolicy(ctx), CHECK_IDS.policyUnapprovedModelProvider);
},
};
const policyNetworkPrivateAccessCheck: HealthCheck = {
id: CHECK_IDS.policyPrivateNetworkAccess,
kind: "plugin",
description: "Network SSRF policy settings match private-network requirements.",
source: "policy",
async detect(ctx) {
return findingsForCheck(await evaluatePolicy(ctx), CHECK_IDS.policyPrivateNetworkAccess);
},
};
return [
policyMcpDeniedServerCheck,
policyMcpUnapprovedServerCheck,
policyModelsDeniedProviderCheck,
policyModelsUnapprovedProviderCheck,
policyNetworkPrivateAccessCheck,
];
}
export function mcpServerFindings(
policy: unknown,
policyDocName: string,
evidence: PolicyEvidence,
): readonly HealthFinding[] {
const denied = new Set(readStringList(policy, ["mcp", "servers", "deny"], { lowercase: false }));
const allowed = readStringList(policy, ["mcp", "servers", "allow"], { lowercase: false });
const allowedSet = new Set(allowed);
const findings: HealthFinding[] = [];
for (const server of evidence.mcpServers) {
if (denied.has(server.id)) {
findings.push({
checkId: CHECK_IDS.policyDeniedMcpServer,
severity: "error",
message: `MCP server '${server.id}' is denied by policy.`,
source: "policy",
path: "openclaw config",
ocPath: server.source,
target: server.source,
requirement: `oc://${policyDocName}/mcp/servers/deny`,
fixHint: "Remove this configured MCP server or update the policy after review.",
});
continue;
}
if (allowedSet.size > 0 && !allowedSet.has(server.id)) {
findings.push({
checkId: CHECK_IDS.policyUnapprovedMcpServer,
severity: "error",
message: `MCP server '${server.id}' is not in the policy allowlist.`,
source: "policy",
path: "openclaw config",
ocPath: server.source,
target: server.source,
requirement: `oc://${policyDocName}/mcp/servers/allow`,
fixHint: "Use an approved MCP server or update the policy after review.",
});
}
}
return findings;
}
export function modelProviderFindings(
policy: unknown,
policyDocName: string,
evidence: PolicyEvidence,
): readonly HealthFinding[] {
const denied = new Set(readModelProviderPolicyList(policy, ["models", "providers", "deny"]));
const allowed = readModelProviderPolicyList(policy, ["models", "providers", "allow"]);
const allowedSet = new Set(allowed);
const findings: HealthFinding[] = [];
for (const provider of evidence.modelProviders) {
findings.push(...modelProviderConformanceFindings(provider, denied, allowedSet, policyDocName));
}
for (const modelRef of evidence.modelRefs) {
findings.push(...modelRefConformanceFindings(modelRef, denied, allowedSet, policyDocName));
}
return findings;
}
function readModelProviderPolicyList(policy: unknown, path: readonly string[]): readonly string[] {
return readStringList(policy, path).map((provider) => normalizeProviderId(provider));
}
function modelProviderConformanceFindings(
provider: PolicyEvidence["modelProviders"][number],
denied: ReadonlySet<string>,
allowed: ReadonlySet<string>,
policyDocName: string,
): readonly HealthFinding[] {
const findings: HealthFinding[] = [];
if (denied.has(provider.id)) {
findings.push({
checkId: CHECK_IDS.policyDeniedModelProvider,
severity: "error",
message: `Model provider '${provider.id}' is denied by policy.`,
source: "policy",
path: "openclaw config",
ocPath: provider.source,
target: provider.source,
requirement: `oc://${policyDocName}/models/providers/deny`,
fixHint: "Remove this configured provider or update the policy after review.",
});
}
if (!denied.has(provider.id) && allowed.size > 0 && !allowed.has(provider.id)) {
findings.push({
checkId: CHECK_IDS.policyUnapprovedModelProvider,
severity: "error",
message: `Model provider '${provider.id}' is not in the policy allowlist.`,
source: "policy",
path: "openclaw config",
ocPath: provider.source,
target: provider.source,
requirement: `oc://${policyDocName}/models/providers/allow`,
fixHint: "Use an approved model provider or update the policy after review.",
});
}
return findings;
}
function modelRefConformanceFindings(
modelRef: PolicyEvidence["modelRefs"][number],
denied: ReadonlySet<string>,
allowed: ReadonlySet<string>,
policyDocName: string,
): readonly HealthFinding[] {
const findings: HealthFinding[] = [];
if (denied.has(modelRef.provider)) {
findings.push({
checkId: CHECK_IDS.policyDeniedModelProvider,
severity: "error",
message: `Model ref '${modelRef.ref}' uses denied provider '${modelRef.provider}'.`,
source: "policy",
path: "openclaw config",
ocPath: modelRef.source,
target: modelRef.source,
requirement: `oc://${policyDocName}/models/providers/deny`,
fixHint: "Select an approved model provider or update the policy after review.",
});
}
if (!denied.has(modelRef.provider) && allowed.size > 0 && !allowed.has(modelRef.provider)) {
findings.push({
checkId: CHECK_IDS.policyUnapprovedModelProvider,
severity: "error",
message: `Model ref '${modelRef.ref}' uses unapproved provider '${modelRef.provider}'.`,
source: "policy",
path: "openclaw config",
ocPath: modelRef.source,
target: modelRef.source,
requirement: `oc://${policyDocName}/models/providers/allow`,
fixHint: "Select an approved model provider or update the policy after review.",
});
}
return findings;
}
export function networkFindings(
policy: unknown,
policyDocName: string,
evidence: PolicyEvidence,
): readonly HealthFinding[] {
const allowPrivateNetwork = readPolicyBoolean(policy, ["network", "privateNetwork", "allow"]);
if (allowPrivateNetwork !== false) {
return [];
}
return evidence.network
.filter((setting) => setting.value)
.map((setting): HealthFinding => {
return {
checkId: CHECK_IDS.policyPrivateNetworkAccess,
severity: "error",
message: `Network setting '${setting.id}' allows private-network access.`,
source: "policy",
path: "openclaw config",
ocPath: setting.source,
target: setting.source,
requirement: `oc://${policyDocName}/network/privateNetwork/allow`,
fixHint: "Disable this private-network access setting or update policy after review.",
};
});
}

View File

@@ -0,0 +1,123 @@
// Policy doctor health-check factories for one policy scope.
import type { HealthCheck } from "openclaw/plugin-sdk/health";
import { CHECK_IDS } from "../metadata.js";
import type { PolicyDoctorCheckDeps } from "../types.js";
export function createPolicySandboxChecks(deps: PolicyDoctorCheckDeps): readonly HealthCheck[] {
const { evaluatePolicy, findingsForCheck } = deps;
const policySandboxModeUnapprovedCheck: HealthCheck = {
id: CHECK_IDS.policySandboxModeUnapproved,
kind: "plugin",
description: "Sandbox mode config satisfies policy requirements.",
source: "policy",
async detect(ctx) {
return findingsForCheck(await evaluatePolicy(ctx), CHECK_IDS.policySandboxModeUnapproved);
},
};
const policySandboxBackendUnapprovedCheck: HealthCheck = {
id: CHECK_IDS.policySandboxBackendUnapproved,
kind: "plugin",
description: "Sandbox backend config satisfies policy requirements.",
source: "policy",
async detect(ctx) {
return findingsForCheck(await evaluatePolicy(ctx), CHECK_IDS.policySandboxBackendUnapproved);
},
};
const policySandboxContainerPostureUnobservableCheck: HealthCheck = {
id: CHECK_IDS.policySandboxContainerPostureUnobservable,
kind: "plugin",
description: "Sandbox container posture policy only targets observable container backends.",
source: "policy",
async detect(ctx) {
return findingsForCheck(
await evaluatePolicy(ctx),
CHECK_IDS.policySandboxContainerPostureUnobservable,
);
},
};
const policySandboxContainerHostNetworkDeniedCheck: HealthCheck = {
id: CHECK_IDS.policySandboxContainerHostNetworkDenied,
kind: "plugin",
description: "Sandbox container config avoids host network mode.",
source: "policy",
async detect(ctx) {
return findingsForCheck(
await evaluatePolicy(ctx),
CHECK_IDS.policySandboxContainerHostNetworkDenied,
);
},
};
const policySandboxContainerNamespaceJoinDeniedCheck: HealthCheck = {
id: CHECK_IDS.policySandboxContainerNamespaceJoinDenied,
kind: "plugin",
description: "Sandbox container config avoids joining another container network namespace.",
source: "policy",
async detect(ctx) {
return findingsForCheck(
await evaluatePolicy(ctx),
CHECK_IDS.policySandboxContainerNamespaceJoinDenied,
);
},
};
const policySandboxContainerMountModeRequiredCheck: HealthCheck = {
id: CHECK_IDS.policySandboxContainerMountModeRequired,
kind: "plugin",
description: "Sandbox container mounts are read-only when policy requires it.",
source: "policy",
async detect(ctx) {
return findingsForCheck(
await evaluatePolicy(ctx),
CHECK_IDS.policySandboxContainerMountModeRequired,
);
},
};
const policySandboxContainerRuntimeSocketMountCheck: HealthCheck = {
id: CHECK_IDS.policySandboxContainerRuntimeSocketMount,
kind: "plugin",
description: "Sandbox container mounts avoid host container runtime sockets.",
source: "policy",
async detect(ctx) {
return findingsForCheck(
await evaluatePolicy(ctx),
CHECK_IDS.policySandboxContainerRuntimeSocketMount,
);
},
};
const policySandboxContainerUnconfinedProfileCheck: HealthCheck = {
id: CHECK_IDS.policySandboxContainerUnconfinedProfile,
kind: "plugin",
description: "Sandbox container profile config avoids unconfined profiles.",
source: "policy",
async detect(ctx) {
return findingsForCheck(
await evaluatePolicy(ctx),
CHECK_IDS.policySandboxContainerUnconfinedProfile,
);
},
};
const policySandboxBrowserCdpSourceRangeMissingCheck: HealthCheck = {
id: CHECK_IDS.policySandboxBrowserCdpSourceRangeMissing,
kind: "plugin",
description: "Sandbox browser CDP config includes a source range when policy requires it.",
source: "policy",
async detect(ctx) {
return findingsForCheck(
await evaluatePolicy(ctx),
CHECK_IDS.policySandboxBrowserCdpSourceRangeMissing,
);
},
};
return [
policySandboxModeUnapprovedCheck,
policySandboxBackendUnapprovedCheck,
policySandboxContainerPostureUnobservableCheck,
policySandboxContainerHostNetworkDeniedCheck,
policySandboxContainerNamespaceJoinDeniedCheck,
policySandboxContainerMountModeRequiredCheck,
policySandboxContainerRuntimeSocketMountCheck,
policySandboxContainerUnconfinedProfileCheck,
policySandboxBrowserCdpSourceRangeMissingCheck,
];
}

View File

@@ -0,0 +1,191 @@
// Policy doctor health-check factories for one policy scope.
import type { HealthCheck } from "openclaw/plugin-sdk/health";
import { CHECK_IDS } from "../metadata.js";
import type { PolicyDoctorCheckDeps } from "../types.js";
export function createPolicyAgentToolChecks(deps: PolicyDoctorCheckDeps): readonly HealthCheck[] {
const { evaluatePolicy, findingsForCheck } = deps;
const policyAgentsWorkspaceAccessDeniedCheck: HealthCheck = {
id: CHECK_IDS.policyAgentsWorkspaceAccessDenied,
kind: "plugin",
description: "Agent sandbox workspace access matches policy.",
source: "policy",
async detect(ctx) {
return findingsForCheck(
await evaluatePolicy(ctx),
CHECK_IDS.policyAgentsWorkspaceAccessDenied,
);
},
};
const policyAgentsToolNotDeniedCheck: HealthCheck = {
id: CHECK_IDS.policyAgentsToolNotDenied,
kind: "plugin",
description: "Agent workspace mutation/runtime tools are denied when policy requires it.",
source: "policy",
async detect(ctx) {
return findingsForCheck(await evaluatePolicy(ctx), CHECK_IDS.policyAgentsToolNotDenied);
},
};
const policyToolsProfileUnapprovedCheck: HealthCheck = {
id: CHECK_IDS.policyToolsProfileUnapproved,
kind: "plugin",
description: "Configured tool profiles match policy allow rules.",
source: "policy",
async detect(ctx) {
return findingsForCheck(await evaluatePolicy(ctx), CHECK_IDS.policyToolsProfileUnapproved);
},
};
const policyToolsFsWorkspaceOnlyRequiredCheck: HealthCheck = {
id: CHECK_IDS.policyToolsFsWorkspaceOnlyRequired,
kind: "plugin",
description: "Filesystem tools use workspace-only posture when policy requires it.",
source: "policy",
async detect(ctx) {
return findingsForCheck(
await evaluatePolicy(ctx),
CHECK_IDS.policyToolsFsWorkspaceOnlyRequired,
);
},
};
const policyToolsExecSecurityUnapprovedCheck: HealthCheck = {
id: CHECK_IDS.policyToolsExecSecurityUnapproved,
kind: "plugin",
description: "Exec tool security mode matches policy allow rules.",
source: "policy",
async detect(ctx) {
return findingsForCheck(
await evaluatePolicy(ctx),
CHECK_IDS.policyToolsExecSecurityUnapproved,
);
},
};
const policyToolsExecAskUnapprovedCheck: HealthCheck = {
id: CHECK_IDS.policyToolsExecAskUnapproved,
kind: "plugin",
description: "Exec tool ask mode matches policy allow rules.",
source: "policy",
async detect(ctx) {
return findingsForCheck(await evaluatePolicy(ctx), CHECK_IDS.policyToolsExecAskUnapproved);
},
};
const policyToolsExecHostUnapprovedCheck: HealthCheck = {
id: CHECK_IDS.policyToolsExecHostUnapproved,
kind: "plugin",
description: "Exec tool host routing matches policy allow rules.",
source: "policy",
async detect(ctx) {
return findingsForCheck(await evaluatePolicy(ctx), CHECK_IDS.policyToolsExecHostUnapproved);
},
};
const policyToolsElevatedEnabledCheck: HealthCheck = {
id: CHECK_IDS.policyToolsElevatedEnabled,
kind: "plugin",
description: "Elevated tool mode remains disabled when policy requires it.",
source: "policy",
async detect(ctx) {
return findingsForCheck(await evaluatePolicy(ctx), CHECK_IDS.policyToolsElevatedEnabled);
},
};
const policyToolsAlsoAllowMissingCheck: HealthCheck = {
id: CHECK_IDS.policyToolsAlsoAllowMissing,
kind: "plugin",
description: "Configured tools.alsoAllow entries include policy expected lists.",
source: "policy",
async detect(ctx) {
return findingsForCheck(await evaluatePolicy(ctx), CHECK_IDS.policyToolsAlsoAllowMissing);
},
};
const policyToolsAlsoAllowUnexpectedCheck: HealthCheck = {
id: CHECK_IDS.policyToolsAlsoAllowUnexpected,
kind: "plugin",
description: "Configured tools.alsoAllow entries match policy expected lists.",
source: "policy",
async detect(ctx) {
return findingsForCheck(await evaluatePolicy(ctx), CHECK_IDS.policyToolsAlsoAllowUnexpected);
},
};
const policyToolsRequiredDenyMissingCheck: HealthCheck = {
id: CHECK_IDS.policyToolsRequiredDenyMissing,
kind: "plugin",
description: "Configured tool deny lists include tools required by policy.",
source: "policy",
async detect(ctx) {
return findingsForCheck(await evaluatePolicy(ctx), CHECK_IDS.policyToolsRequiredDenyMissing);
},
};
return [
policyAgentsWorkspaceAccessDeniedCheck,
policyAgentsToolNotDeniedCheck,
policyToolsProfileUnapprovedCheck,
policyToolsFsWorkspaceOnlyRequiredCheck,
policyToolsExecSecurityUnapprovedCheck,
policyToolsExecAskUnapprovedCheck,
policyToolsExecHostUnapprovedCheck,
policyToolsElevatedEnabledCheck,
policyToolsAlsoAllowMissingCheck,
policyToolsAlsoAllowUnexpectedCheck,
policyToolsRequiredDenyMissingCheck,
];
}
export function createPolicyToolMetadataChecks(
deps: PolicyDoctorCheckDeps,
): readonly HealthCheck[] {
const { evaluatePolicy, findingsForCheck } = deps;
const policyToolsMissingRiskCheck: HealthCheck = {
id: CHECK_IDS.policyMissingToolRisk,
kind: "plugin",
description: "TOOLS.md policy entries declare explicit risk levels.",
source: "policy",
async detect(ctx) {
return findingsForCheck(await evaluatePolicy(ctx), CHECK_IDS.policyMissingToolRisk);
},
};
const policyToolsUnknownRiskCheck: HealthCheck = {
id: CHECK_IDS.policyUnknownToolRisk,
kind: "plugin",
description: "TOOLS.md policy entries use known risk levels.",
source: "policy",
async detect(ctx) {
return findingsForCheck(await evaluatePolicy(ctx), CHECK_IDS.policyUnknownToolRisk);
},
};
const policyToolsMissingSensitivityCheck: HealthCheck = {
id: CHECK_IDS.policyMissingToolSensitivity,
kind: "plugin",
description: "TOOLS.md policy entries declare default artifact sensitivity.",
source: "policy",
async detect(ctx) {
return findingsForCheck(await evaluatePolicy(ctx), CHECK_IDS.policyMissingToolSensitivity);
},
};
const policyToolsUnknownSensitivityCheck: HealthCheck = {
id: CHECK_IDS.policyUnknownToolSensitivity,
kind: "plugin",
description: "TOOLS.md policy entries use known sensitivity levels.",
source: "policy",
async detect(ctx) {
return findingsForCheck(await evaluatePolicy(ctx), CHECK_IDS.policyUnknownToolSensitivity);
},
};
const policyToolsMissingOwnerCheck: HealthCheck = {
id: CHECK_IDS.policyMissingToolOwner,
kind: "plugin",
description: "TOOLS.md policy entries declare an accountable owner.",
source: "policy",
async detect(ctx) {
return findingsForCheck(await evaluatePolicy(ctx), CHECK_IDS.policyMissingToolOwner);
},
};
return [
policyToolsMissingRiskCheck,
policyToolsUnknownRiskCheck,
policyToolsMissingSensitivityCheck,
policyToolsMissingOwnerCheck,
policyToolsUnknownSensitivityCheck,
];
}

View File

@@ -0,0 +1,55 @@
// Policy doctor strictness helper tests.
import { describe, expect, it } from "vitest";
import { POLICY_RULE_METADATA } from "./metadata.js";
import { isPolicyValueAtLeastAsStrict } from "./strictness.js";
describe("policy doctor strictness", () => {
it("compares policy values through strictness metadata", () => {
const allowHosts = POLICY_RULE_METADATA.find(
(rule) => rule.policyPath.join(".") === "tools.exec.allowHosts",
);
const denyTools = POLICY_RULE_METADATA.find(
(rule) => rule.policyPath.join(".") === "tools.denyTools",
);
const denyNodeCommands = POLICY_RULE_METADATA.find(
(rule) => rule.policyPath.join(".") === "gateway.nodes.denyCommands",
);
const fsWorkspaceOnly = POLICY_RULE_METADATA.find(
(rule) => rule.policyPath.join(".") === "tools.fs.requireWorkspaceOnly",
);
const denyHostNetwork = POLICY_RULE_METADATA.find(
(rule) => rule.policyPath.join(".") === "sandbox.containers.denyHostNetwork",
);
const alsoAllow = POLICY_RULE_METADATA.find(
(rule) => rule.policyPath.join(".") === "tools.alsoAllow.expected",
);
expect(allowHosts).toBeDefined();
expect(denyTools).toBeDefined();
expect(denyNodeCommands).toBeDefined();
expect(fsWorkspaceOnly).toBeDefined();
expect(denyHostNetwork).toBeDefined();
expect(alsoAllow).toBeDefined();
expect(isPolicyValueAtLeastAsStrict(allowHosts!, ["sandbox"], ["sandbox", "node"])).toBe(true);
expect(isPolicyValueAtLeastAsStrict(allowHosts!, ["sandbox", "node"], ["sandbox"])).toBe(false);
expect(isPolicyValueAtLeastAsStrict(allowHosts!, [], ["sandbox"])).toBe(false);
expect(isPolicyValueAtLeastAsStrict(allowHosts!, ["sandbox"], [])).toBe(true);
expect(isPolicyValueAtLeastAsStrict(denyTools!, ["exec", "write"], ["exec"])).toBe(true);
expect(isPolicyValueAtLeastAsStrict(denyTools!, ["write"], ["exec"])).toBe(false);
expect(isPolicyValueAtLeastAsStrict(denyTools!, ["group:runtime"], ["exec"])).toBe(true);
expect(isPolicyValueAtLeastAsStrict(denyTools!, ["exec"], ["group:runtime"])).toBe(false);
expect(isPolicyValueAtLeastAsStrict(denyNodeCommands!, ["system.run"], ["system.run"])).toBe(
true,
);
expect(isPolicyValueAtLeastAsStrict(denyNodeCommands!, [], ["system.run"])).toBe(false);
expect(isPolicyValueAtLeastAsStrict(denyNodeCommands!, ["system.Run"], ["system.run"])).toBe(
false,
);
expect(isPolicyValueAtLeastAsStrict(denyHostNetwork!, true, true)).toBe(true);
expect(isPolicyValueAtLeastAsStrict(denyHostNetwork!, false, true)).toBe(false);
expect(isPolicyValueAtLeastAsStrict(fsWorkspaceOnly!, true, true)).toBe(true);
expect(isPolicyValueAtLeastAsStrict(fsWorkspaceOnly!, false, true)).toBe(false);
expect(isPolicyValueAtLeastAsStrict(alsoAllow!, ["read"], ["read"])).toBe(true);
expect(isPolicyValueAtLeastAsStrict(alsoAllow!, [], ["read"])).toBe(false);
});
});

View File

@@ -0,0 +1,270 @@
// Policy doctor strictness comparisons for scoped policy overlays.
import { normalizeProviderId } from "openclaw/plugin-sdk/provider-model-shared";
import { isRecord } from "openclaw/plugin-sdk/string-coerce-runtime";
import { POLICY_TOOL_GROUPS } from "../tool-policy-conformance.js";
import type { PolicyRuleMetadata } from "./metadata.js";
type ExecApprovalAllowlistRequirement = {
readonly key: string;
readonly pattern: string;
readonly argPattern?: string;
};
export function isPolicyValueAtLeastAsStrict(
metadata: PolicyRuleMetadata,
candidate: unknown,
baseline: unknown,
): boolean {
switch (metadata.strictness) {
case "allowlist-subset":
return isPolicyAllowlistSubset(metadata, candidate, baseline);
case "denylist-superset":
return isPolicyDenylistSuperset(metadata, candidate, baseline);
case "ordered-string":
return isPolicyOrderedStringAtLeastAsStrict(metadata, candidate, baseline);
case "requires-true":
return baseline !== true || candidate === true;
case "requires-false":
return baseline !== false || candidate === false;
case "exact-list":
return samePolicyStringList(candidate, baseline, metadata);
}
return false;
}
function isPolicyOrderedStringAtLeastAsStrict(
metadata: PolicyRuleMetadata,
candidate: unknown,
baseline: unknown,
): boolean {
const candidateValue = policyString(candidate, metadata);
const baselineValue = policyString(baseline, metadata);
if (
candidateValue === undefined ||
baselineValue === undefined ||
metadata.orderedValues === undefined
) {
return false;
}
const orderedValues = metadata.orderedValues.map((entry) =>
metadata.caseSensitive === true ? entry : entry.toLowerCase(),
);
const candidateIndex = orderedValues.indexOf(candidateValue);
const baselineIndex = orderedValues.indexOf(baselineValue);
return candidateIndex >= 0 && baselineIndex >= 0 && candidateIndex >= baselineIndex;
}
function isPolicyAllowlistSubset(
metadata: PolicyRuleMetadata,
candidate: unknown,
baseline: unknown,
): boolean {
const candidateList = policyStringList(candidate, metadata);
const baselineList = policyStringList(baseline, metadata);
if (candidateList === undefined || baselineList === undefined) {
return false;
}
if (metadata.emptyList === "disabled" && baselineList.length === 0) {
return true;
}
if (metadata.emptyList === "disabled" && baselineList.length > 0 && candidateList.length === 0) {
return false;
}
const allowed = new Set(baselineList);
return candidateList.every((entry) => allowed.has(entry));
}
function isPolicyDenylistSuperset(
metadata: PolicyRuleMetadata,
candidate: unknown,
baseline: unknown,
): boolean {
const candidateList = policyStringList(candidate, metadata);
const baselineList = policyStringList(baseline, metadata);
if (candidateList === undefined || baselineList === undefined) {
return false;
}
if (metadata.policyPath.join(".") === "tools.denyTools") {
return baselineList
.flatMap(expandPolicyToolRequirement)
.every((tool) => toolListCoversTool(candidateList, tool));
}
const denied = new Set(candidateList);
return baselineList.every((entry) => denied.has(entry));
}
function samePolicyStringList(
candidate: unknown,
baseline: unknown,
metadata: PolicyRuleMetadata,
): boolean {
const candidateList = policyStringList(candidate, metadata);
const baselineList = policyStringList(baseline, metadata);
if (candidateList === undefined || baselineList === undefined) {
return false;
}
const candidateSorted = candidateList.toSorted();
const baselineSorted = baselineList.toSorted();
return (
candidateSorted.length === baselineSorted.length &&
candidateSorted.every((entry, index) => entry === baselineSorted[index])
);
}
function policyStringList(
value: unknown,
metadata: PolicyRuleMetadata,
): readonly string[] | undefined {
if (metadata.valueType === "channel-provider-deny-rules") {
return channelProviderDenyRuleList(value, metadata);
}
if (!Array.isArray(value)) {
return undefined;
}
if (metadata.policyPath.join(".") === "execApprovals.agents.allowlist.expected") {
const entries = value.map(execApprovalAllowlistRequirement);
if (!entries.every((entry): entry is ExecApprovalAllowlistRequirement => entry !== undefined)) {
return undefined;
}
return entries.map((entry) => entry.key);
}
if (!value.every((entry) => typeof entry === "string")) {
return undefined;
}
return value
.map((entry) => entry.trim())
.filter(Boolean)
.map((entry) => normalizePolicyStringListEntry(entry, metadata));
}
function normalizePolicyStringListEntry(entry: string, metadata: PolicyRuleMetadata): string {
if (metadata.normalizeValues === "model-provider") {
return normalizeProviderId(entry);
}
return metadata.caseSensitive === true ? entry : entry.toLowerCase();
}
function channelProviderDenyRuleList(
value: unknown,
metadata: PolicyRuleMetadata,
): readonly string[] | undefined {
if (!Array.isArray(value)) {
return undefined;
}
const providers: string[] = [];
for (const entry of value) {
if (!isChannelDenyRule(entry)) {
return undefined;
}
const provider = entry.when?.provider?.trim();
if (provider !== undefined && provider !== "") {
providers.push(metadata.caseSensitive === true ? provider : provider.toLowerCase());
}
}
return providers;
}
function policyString(value: unknown, metadata: PolicyRuleMetadata): string | undefined {
if (typeof value !== "string" || value.trim() === "") {
return undefined;
}
const trimmed = value.trim();
return metadata.caseSensitive === true ? trimmed : trimmed.toLowerCase();
}
function execApprovalAllowlistRequirement(
value: unknown,
): ExecApprovalAllowlistRequirement | undefined {
if (typeof value === "string") {
const pattern = value.trim();
return pattern === "" ? undefined : execApprovalAllowlistRequirementFromParts(pattern);
}
if (!isRecord(value)) {
return undefined;
}
const keys = Object.keys(value);
if (keys.some((key) => key !== "argPattern" && key !== "pattern")) {
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 }),
};
}
function execApprovalAllowlistRequirementKey(
pattern: string,
argPattern: string | undefined,
): string {
return `${pattern}\0${argPattern ?? ""}`;
}
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"
);
}
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;
}
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;
}
function policyToolGlobMatches(tool: string, pattern: string): boolean {
const escaped = pattern.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
return new RegExp(`^${escaped.replaceAll("\\*", ".*")}$`).test(tool);
}

View File

@@ -0,0 +1,35 @@
// 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";
export type PolicyEvaluation = {
readonly policyPath: string;
readonly policy?: {
readonly value: unknown;
readonly hash: string;
};
readonly evidence: PolicyEvidence;
readonly expectedAttestationHash?: string;
readonly findings: readonly HealthFinding[];
readonly attestedFindings: readonly HealthFinding[];
};
export type PolicyDoctorCheckDeps = {
readonly evaluatePolicy: (ctx: HealthCheckContext) => Promise<PolicyEvaluation>;
readonly findingsForCheck: (
evaluation: PolicyEvaluation,
checkId: (typeof POLICY_CHECK_IDS)[number],
) => readonly HealthFinding[];
readonly workspaceRepairsEnabled: (ctx: HealthCheckContext) => boolean;
readonly workspaceRepairsDisabledResult: (fileName: string) => {
readonly status: "skipped";
readonly reason: string;
readonly changes: readonly string[];
};
readonly channelIdsFromFindings: (findings: readonly HealthFinding[]) => readonly string[];
readonly disableChannels: (
cfg: HealthCheckContext["cfg"],
channelIds: readonly string[],
) => { readonly config: HealthCheckContext["cfg"]; readonly changed: readonly string[] };
};

View File

@@ -0,0 +1,63 @@
// Shared policy doctor value readers.
import { isRecord } from "openclaw/plugin-sdk/string-coerce-runtime";
export function readPolicyStringArray(
policy: unknown,
path: readonly string[],
options: { readonly lowercase?: boolean } = {},
): readonly string[] | undefined {
let current: unknown = policy;
for (const part of path) {
if (!isRecord(current)) {
return undefined;
}
current = current[part];
}
if (!Array.isArray(current) || !current.every((entry) => typeof entry === "string")) {
return undefined;
}
const lowercase = options.lowercase ?? true;
return current
.map((entry) => {
const trimmed = entry.trim();
return lowercase ? trimmed.toLowerCase() : trimmed;
})
.filter(Boolean);
}
export function readStringList(
policy: unknown,
path: readonly string[],
options?: { readonly lowercase?: boolean },
): readonly string[] {
return readPolicyStringArray(policy, path, options) ?? [];
}
export function readString(policy: unknown, path: readonly string[]): string | undefined {
let current: unknown = policy;
for (const part of path) {
if (!isRecord(current)) {
return undefined;
}
current = current[part];
}
return typeof current === "string" ? current.trim().toLowerCase() : undefined;
}
export function ocPathSegment(value: string): string {
if (/^(?:[A-Za-z0-9_-]+|#\d+)$/.test(value)) {
return value;
}
return JSON.stringify(value);
}
export function readPolicyBoolean(policy: unknown, path: readonly string[]): boolean | undefined {
let current: unknown = policy;
for (const part of path) {
if (!isRecord(current)) {
return undefined;
}
current = current[part];
}
return typeof current === "boolean" ? current : undefined;
}

View File

@@ -0,0 +1,627 @@
// Policy plugin module implements policy conformance behavior.
import { promises as fs } from "node:fs";
import { basename, isAbsolute, resolve } from "node:path";
import JSON5 from "json5";
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 {
isPolicyValueAtLeastAsStrict,
policyContainerShapeFindings,
POLICY_RULE_METADATA as RAW_POLICY_RULE_METADATA,
type PolicyRuleMetadata,
type PolicyScopeSelectorKind,
} from "./doctor/register.js";
export const POLICY_CONFORMANCE_CHECK_IDS = {
missing: "policy/policy-conformance-missing",
weaker: "policy/policy-conformance-weaker",
invalid: "policy/policy-conformance-invalid",
} as const;
export type PolicyConformanceFinding = {
readonly checkId: (typeof POLICY_CONFORMANCE_CHECK_IDS)[keyof typeof POLICY_CONFORMANCE_CHECK_IDS];
readonly severity: "error";
readonly message: string;
readonly source: "policy";
readonly path: string;
readonly target: string;
readonly requirement: string;
readonly fixHint: string;
};
export type PolicyConformanceReport = {
readonly ok: boolean;
readonly baselinePath: string;
readonly policyPath: string;
readonly rulesChecked: number;
readonly findings: readonly PolicyConformanceFinding[];
};
type PolicyDocument = {
readonly displayName: string;
readonly value: unknown;
};
type PolicyDocumentReadResult =
| { readonly ok: true; readonly displayName: string; readonly document: PolicyDocument }
| {
readonly ok: false;
readonly displayName: string;
readonly message: string;
readonly target: string;
};
type PolicyRuleClaim = {
readonly key: string;
readonly metadata: PolicyRuleMetadata;
readonly value: unknown;
readonly target: string;
readonly propertyPath: string;
readonly selector?: {
readonly kind: PolicyScopeSelectorKind;
readonly value: string;
};
};
const POLICY_RULE_METADATA: readonly PolicyRuleMetadata[] = RAW_POLICY_RULE_METADATA;
export async function buildPolicyConformanceReport(params: {
readonly baselinePath: string;
readonly policyPath: string;
readonly cwd?: string;
}): Promise<PolicyConformanceReport> {
const baselinePath = resolvePolicyPath(params.baselinePath, params.cwd);
const policyPath = resolvePolicyPath(params.policyPath, params.cwd);
const baselineResult = await readPolicyDocument(baselinePath);
const policyResult = await readPolicyDocument(policyPath);
if (!baselineResult.ok || !policyResult.ok) {
const invalidFindings = [baselineResult, policyResult]
.filter((result): result is Extract<PolicyDocumentReadResult, { readonly ok: false }> => {
return !result.ok;
})
.map((result) => invalidParseConformanceFinding(result));
return {
ok: false,
baselinePath: baselineResult.displayName,
policyPath: policyResult.displayName,
rulesChecked: 0,
findings: invalidFindings,
};
}
const baseline = baselineResult.document;
const policy = policyResult.document;
const baselineClaims = collectPolicyRuleClaims(baseline);
const candidateClaims = collectPolicyRuleClaims(policy);
const invalidFindings = uniqueConformanceFindings([
...policyContainerShapeFindings(baseline.value, baseline.displayName, baseline.displayName).map(
(finding) => invalidShapeConformanceFinding(finding, baseline.displayName),
),
...policyContainerShapeFindings(policy.value, policy.displayName, policy.displayName).map(
(finding) => invalidShapeConformanceFinding(finding, policy.displayName),
),
...collectInvalidScopedPolicyFindings(baseline),
...collectInvalidScopedPolicyFindings(policy),
...baselineClaims
.filter((claim) => !policyRuleValueIsValid(claim.metadata, claim.value))
.map((claim) => invalidConformanceFinding(claim, baseline.displayName)),
...candidateClaims
.filter((claim) => !policyRuleValueIsValid(claim.metadata, claim.value))
.map((claim) => invalidConformanceFinding(claim, policy.displayName)),
]);
const validBaselineClaims = baselineClaims.filter((claim) =>
policyRuleValueIsValid(claim.metadata, claim.value),
);
const validCandidateClaims = candidateClaims.filter((claim) =>
policyRuleValueIsValid(claim.metadata, claim.value),
);
if (invalidFindings.length > 0) {
return {
ok: false,
baselinePath: baseline.displayName,
policyPath: policy.displayName,
rulesChecked: 0,
findings: invalidFindings,
};
}
const findings = validBaselineClaims
.map((claim) => conformanceFinding(claim, validCandidateClaims, policy.displayName))
.filter((finding): finding is PolicyConformanceFinding => finding !== undefined);
return {
ok: invalidFindings.length === 0 && findings.length === 0,
baselinePath: baseline.displayName,
policyPath: policy.displayName,
rulesChecked: validBaselineClaims.length,
findings: [...invalidFindings, ...findings],
};
}
function uniqueConformanceFindings(
findings: readonly PolicyConformanceFinding[],
): readonly PolicyConformanceFinding[] {
const seen = new Set<string>();
return findings.filter((finding) => {
const key = `${finding.checkId}\n${finding.target}`;
if (seen.has(key)) {
return false;
}
seen.add(key);
return true;
});
}
function invalidParseConformanceFinding(
result: Extract<PolicyDocumentReadResult, { readonly ok: false }>,
): PolicyConformanceFinding {
return {
checkId: POLICY_CONFORMANCE_CHECK_IDS.invalid,
severity: "error",
message: result.message,
source: "policy",
path: result.displayName,
target: result.target,
requirement: result.target,
fixHint: `Fix ${result.displayName} so it contains valid policy JSONC.`,
};
}
function invalidShapeConformanceFinding(
finding: HealthFinding,
displayName: string,
): PolicyConformanceFinding {
const target = finding.target ?? `oc://${displayName}`;
return {
checkId: POLICY_CONFORMANCE_CHECK_IDS.invalid,
severity: "error",
message: finding.message,
source: "policy",
path: displayName,
target,
requirement: target,
fixHint: finding.fixHint ?? `Fix ${displayName} so it uses the documented policy syntax.`,
};
}
function collectInvalidScopedPolicyFindings(
document: PolicyDocument,
): readonly PolicyConformanceFinding[] {
if (!isRecord(document.value) || document.value.scopes === undefined) {
return [];
}
if (!isRecord(document.value.scopes)) {
return [
invalidConformancePathFinding({
displayName: document.displayName,
message: `${document.displayName} scopes must be an object.`,
propertyPath: "scopes",
target: `oc://${document.displayName}/scopes`,
}),
];
}
const findings: PolicyConformanceFinding[] = [];
for (const [scopeName, overlay] of Object.entries(document.value.scopes)) {
const scopePath = `scopes.${scopeName}`;
const scopeTarget = `oc://${document.displayName}/scopes/${ocPathSegment(scopeName)}`;
if (!isRecord(overlay)) {
findings.push(
invalidConformancePathFinding({
displayName: document.displayName,
message: `${document.displayName} ${scopePath} must be an object.`,
propertyPath: scopePath,
target: scopeTarget,
}),
);
continue;
}
for (const metadata of POLICY_RULE_METADATA) {
const value = scopedPolicyValue(overlay, metadata.policyPath);
if (value === undefined) {
continue;
}
const selectorMatches = (metadata.scopeSelectors ?? []).some(
(selector) => normalizeSelectorValues(overlay[selector], selector).length > 0,
);
if (selectorMatches) {
continue;
}
const propertyPath = `${scopePath}.${metadata.policyPath.join(".")}`;
findings.push(
invalidConformancePathFinding({
displayName: document.displayName,
message: `${document.displayName} ${propertyPath} needs a valid selector for policy conformance.`,
propertyPath,
target: `${scopeTarget}/${metadata.policyPath.map(ocPathSegment).join("/")}`,
}),
);
}
}
return findings;
}
function invalidConformanceFinding(
claim: PolicyRuleClaim,
displayName: string,
): PolicyConformanceFinding {
return invalidConformancePathFinding({
displayName,
message: `${displayName} ${claim.propertyPath} is not valid policy conformance syntax.`,
propertyPath: claim.propertyPath,
target: claim.target,
});
}
function invalidConformancePathFinding(params: {
readonly displayName: string;
readonly message: string;
readonly propertyPath: string;
readonly target: string;
}): PolicyConformanceFinding {
return {
checkId: POLICY_CONFORMANCE_CHECK_IDS.invalid,
severity: "error",
message: params.message,
source: "policy",
path: params.displayName,
target: params.target,
requirement: params.target,
fixHint: `Fix ${params.propertyPath} so it uses the documented policy syntax.`,
};
}
function conformanceFinding(
baseline: PolicyRuleClaim,
candidateClaims: readonly PolicyRuleClaim[],
policyDisplayName: string,
): PolicyConformanceFinding | undefined {
if (baselineRuleIsNoOp(baseline.metadata, baseline.value)) {
return undefined;
}
if (baseline.selector === undefined) {
const globalCandidates = candidateClaims.filter((candidate) => candidate.key === baseline.key);
if (globalCandidates.length === 0) {
return missingConformanceFinding(baseline, policyDisplayName);
}
const weakerGlobal = globalCandidates.find(
(candidate) =>
!isPolicyValueAtLeastAsStrict(baseline.metadata, candidate.value, baseline.value),
);
if (weakerGlobal !== undefined) {
return weakerConformanceFinding(baseline, policyDisplayName, weakerGlobal);
}
const weakerScopedOverride = candidateClaims.find(
(candidate) =>
candidate.selector !== undefined &&
candidate.metadata.policyPath.join(".") === baseline.metadata.policyPath.join(".") &&
!isPolicyValueAtLeastAsStrict(baseline.metadata, candidate.value, baseline.value),
);
if (weakerScopedOverride !== undefined) {
return weakerConformanceFinding(baseline, policyDisplayName, weakerScopedOverride);
}
return undefined;
}
const exactCandidates = candidateClaims.filter((candidate) => candidate.key === baseline.key);
const candidates =
exactCandidates.length > 0
? exactCandidates
: candidateClaims.filter((candidate) => globallySatisfiesScopedClaim(candidate, baseline));
const weakerCandidate = candidates.find(
(candidate) =>
!isPolicyValueAtLeastAsStrict(baseline.metadata, candidate.value, baseline.value),
);
const matching = candidates.some((candidate) =>
isPolicyValueAtLeastAsStrict(baseline.metadata, candidate.value, baseline.value),
);
if (matching && (exactCandidates.length === 0 || weakerCandidate === undefined)) {
return undefined;
}
if (candidates.length === 0) {
return missingConformanceFinding(baseline, policyDisplayName);
}
return weakerConformanceFinding(baseline, policyDisplayName, weakerCandidate ?? candidates[0]);
}
function baselineRuleIsNoOp(metadata: PolicyRuleMetadata, baseline: unknown): boolean {
switch (metadata.strictness) {
case "allowlist-subset":
return metadata.emptyList === "disabled" && policyRuleListIsEmpty(baseline, metadata);
case "denylist-superset":
return policyRuleListIsEmpty(baseline, metadata);
case "requires-true":
return baseline !== true;
case "requires-false":
return baseline !== false;
case "exact-list":
case "ordered-string":
return false;
}
return false;
}
function policyRuleValueIsValid(metadata: PolicyRuleMetadata, value: unknown): boolean {
switch (metadata.valueType) {
case "boolean":
return typeof value === "boolean";
case "channel-provider-deny-rules":
return (
Array.isArray(value) &&
value.every((entry) => {
if (!isRecord(entry)) {
return false;
}
const when = entry.when;
return isRecord(when) && typeof when.provider === "string" && when.provider.trim() !== "";
})
);
case "string":
return typeof value === "string" && policyStringIsAllowed(metadata, value);
case "string-list":
if (!Array.isArray(value)) {
return false;
}
if (isExecApprovalAllowlistExpectedRule(metadata)) {
return value.every(isExecApprovalAllowlistRequirement);
}
return value.every(
(entry) =>
typeof entry === "string" &&
entry.trim() !== "" &&
policyStringIsAllowed(metadata, entry),
);
}
return false;
}
function isExecApprovalAllowlistExpectedRule(metadata: PolicyRuleMetadata): boolean {
return metadata.policyPath.join(".") === "execApprovals.agents.allowlist.expected";
}
function unsupportedPolicyKey(
value: Record<string, unknown>,
supported: readonly string[],
): string | undefined {
return Object.keys(value).find((key) => !supported.includes(key));
}
function isExecApprovalAllowlistRequirement(value: unknown): boolean {
if (typeof value === "string") {
return value.trim() !== "";
}
if (!isRecord(value)) {
return false;
}
if (unsupportedPolicyKey(value, ["argPattern", "pattern"]) !== undefined) {
return false;
}
if (typeof value.pattern !== "string" || value.pattern.trim() === "") {
return false;
}
return value.argPattern === undefined || typeof value.argPattern === "string";
}
function policyStringIsAllowed(metadata: PolicyRuleMetadata, value: string): boolean {
const normalized = metadata.caseSensitive === true ? value.trim() : value.trim().toLowerCase();
if (normalized === "") {
return false;
}
if (metadata.allowedValues !== undefined) {
const allowed = metadata.allowedValues.map((entry) =>
metadata.caseSensitive === true ? entry : entry.toLowerCase(),
);
return allowed.includes(normalized);
}
if (metadata.orderedValues === undefined) {
return true;
}
const allowed = metadata.orderedValues.map((entry) =>
metadata.caseSensitive === true ? entry : entry.toLowerCase(),
);
return allowed.includes(normalized);
}
function policyRuleListIsEmpty(value: unknown, metadata: PolicyRuleMetadata): boolean {
if (!Array.isArray(value)) {
return false;
}
if (metadata.valueType === "channel-provider-deny-rules") {
return value.length === 0;
}
return value.length === 0;
}
function missingConformanceFinding(
baseline: PolicyRuleClaim,
policyDisplayName: string,
): PolicyConformanceFinding {
return {
checkId: POLICY_CONFORMANCE_CHECK_IDS.missing,
severity: "error",
message: `${policyDisplayName} is missing ${baseline.propertyPath}.`,
source: "policy",
path: policyDisplayName,
target: `oc://${policyDisplayName}/${baseline.propertyPath.replaceAll(".", "/")}`,
requirement: baseline.target,
fixHint: `Add an equally or more restrictive ${baseline.propertyPath} rule, or update the baseline policy after review.`,
};
}
function weakerConformanceFinding(
baseline: PolicyRuleClaim,
policyDisplayName: string,
candidate: PolicyRuleClaim | undefined,
): PolicyConformanceFinding {
return {
checkId: POLICY_CONFORMANCE_CHECK_IDS.weaker,
severity: "error",
message: `${policyDisplayName} ${baseline.propertyPath} is weaker than the baseline policy.`,
source: "policy",
path: policyDisplayName,
target: candidate?.target ?? `oc://${policyDisplayName}`,
requirement: baseline.target,
fixHint: `Use an equally or more restrictive ${baseline.propertyPath} value, or update the baseline policy after review.`,
};
}
function globallySatisfiesScopedClaim(
candidate: PolicyRuleClaim,
baseline: PolicyRuleClaim,
): boolean {
return (
baseline.selector !== undefined &&
candidate.selector === undefined &&
candidate.metadata.policyPath.join(".") === baseline.metadata.policyPath.join(".")
);
}
function collectPolicyRuleClaims(document: PolicyDocument): readonly PolicyRuleClaim[] {
return [...collectTopLevelPolicyRuleClaims(document), ...collectScopedPolicyRuleClaims(document)];
}
function collectTopLevelPolicyRuleClaims(document: PolicyDocument): readonly PolicyRuleClaim[] {
const claims: PolicyRuleClaim[] = [];
for (const metadata of POLICY_RULE_METADATA) {
const value = getPolicyPath(document.value, metadata.policyPath);
if (value === undefined) {
continue;
}
const propertyPath = metadata.policyPath.join(".");
claims.push({
key: `global:${propertyPath}`,
metadata,
value,
target: `oc://${document.displayName}/${metadata.policyPath.map(ocPathSegment).join("/")}`,
propertyPath,
});
}
return claims;
}
function collectScopedPolicyRuleClaims(document: PolicyDocument): readonly PolicyRuleClaim[] {
if (!isRecord(document.value) || !isRecord(document.value.scopes)) {
return [];
}
const claims: PolicyRuleClaim[] = [];
for (const [scopeName, overlay] of Object.entries(document.value.scopes)) {
if (!isRecord(overlay)) {
continue;
}
for (const selector of ["agentIds", "channelIds"] as const) {
const selectorValues = normalizeSelectorValues(overlay[selector], selector);
if (selectorValues.length === 0) {
continue;
}
const rules = POLICY_RULE_METADATA.filter(
(metadata) => metadata.scopeSelectors?.includes(selector) === true,
);
for (const metadata of rules) {
const value = scopedPolicyValue(overlay, metadata.policyPath);
if (value === undefined) {
continue;
}
const propertyPath = metadata.policyPath.join(".");
const targetPath = [
"scopes",
ocPathSegment(scopeName),
...metadata.policyPath.map(ocPathSegment),
].join("/");
for (const selectorValue of selectorValues) {
claims.push({
key: `${selector}:${selectorValue}:${propertyPath}`,
metadata,
value,
target: `oc://${document.displayName}/${targetPath}`,
propertyPath: `scopes.${scopeName}.${propertyPath}`,
selector: { kind: selector, value: selectorValue },
});
}
}
}
}
return coalesceScopedPolicyRuleClaims(claims);
}
function coalesceScopedPolicyRuleClaims(
claims: readonly PolicyRuleClaim[],
): readonly PolicyRuleClaim[] {
const byKey = new Map<string, PolicyRuleClaim>();
for (const claim of claims) {
const previous = byKey.get(claim.key);
if (
previous !== undefined &&
isPolicyValueAtLeastAsStrict(previous.metadata, claim.value, previous.value)
) {
byKey.set(claim.key, claim);
continue;
}
byKey.set(claim.key, previous ?? claim);
}
return [...byKey.values()];
}
function normalizeSelectorValues(
value: unknown,
selector: PolicyScopeSelectorKind,
): readonly string[] {
if (!Array.isArray(value)) {
return [];
}
return value
.filter((entry): entry is string => typeof entry === "string" && entry.trim() !== "")
.map((entry) =>
selector === "agentIds" ? normalizeAgentId(entry) : entry.trim().toLowerCase(),
);
}
function scopedPolicyValue(overlay: Record<string, unknown>, path: readonly string[]): unknown {
const scopedRoot = path[0] === "agents" ? overlay.agents : overlay[path[0]];
return getPolicyPath(scopedRoot, path.slice(1));
}
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;
}
async function readPolicyDocument(path: string): Promise<PolicyDocumentReadResult> {
const displayName = basename(path);
let raw: string;
try {
raw = await fs.readFile(path, "utf-8");
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
return {
ok: false,
displayName,
message: `${displayName} could not be read: ${message}`,
target: `oc://${displayName}`,
};
}
try {
return { ok: true, displayName, document: { displayName, value: JSON5.parse(raw) } };
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
return {
ok: false,
displayName,
message: `${displayName} could not be parsed: ${message}`,
target: `oc://${displayName}`,
};
}
}
function resolvePolicyPath(path: string, cwd: string | undefined): string {
return isAbsolute(path) ? path : resolve(cwd ?? process.cwd(), path);
}
function ocPathSegment(value: string): string {
if (/^(?:[A-Za-z0-9_-]+|#\d+)$/.test(value)) {
return value;
}
return JSON.stringify(value);
}

View File

@@ -0,0 +1,206 @@
// Policy tests cover policy state plugin behavior.
import { describe, expect, it } from "vitest";
import { scanPolicyChannels, scanPolicyExecApprovals, scanPolicyTools } from "./policy-state.js";
describe("scanPolicyChannels", () => {
it("ignores reserved channel config namespaces", () => {
expect(
scanPolicyChannels({
channels: {
defaults: {
provider: "telegram",
},
modelByChannel: {
telegram: "openai/gpt-5.5",
},
telegram: {
enabled: true,
},
},
}),
).toEqual([
{
enabled: true,
id: "telegram",
provider: "telegram",
source: "oc://openclaw.config/channels/telegram",
},
]);
});
it("does not treat channel arrays as channel config maps", () => {
expect(
scanPolicyChannels({
channels: [{ enabled: true }],
}),
).toEqual([]);
});
});
describe("scanPolicyTools", () => {
it("scans documented bullet tool declarations", async () => {
await expect(
scanPolicyTools(
[
"## Tools",
"- deploy_tool: risk: critical sensitivity: restricted owner: ops IRREVERSIBLE_EXTERNAL",
"- inspect: risk: low",
" sensitivity: public",
" owner: support",
].join("\n"),
),
).resolves.toEqual([
{
id: "deploy-tool",
source: "oc://TOOLS.md/tools/deploy-tool",
line: 2,
risk: "critical",
sensitivity: "restricted",
owner: "ops",
capabilities: ["IRREVERSIBLE_EXTERNAL"],
},
{
id: "inspect",
source: "oc://TOOLS.md/tools/inspect",
line: 3,
risk: "low",
sensitivity: "public",
owner: "support",
},
]);
});
it("does not treat indented metadata bullets as tool declarations", async () => {
await expect(
scanPolicyTools(["## Tools", "- deploy: risk: critical", " - owner: ops"].join("\n")),
).resolves.toEqual([
{
id: "deploy",
source: "oc://TOOLS.md/tools/deploy",
line: 2,
risk: "critical",
owner: "ops",
},
]);
});
});
describe("scanPolicyExecApprovals", () => {
it("scans redacted exec approvals posture and allowlist metadata", () => {
const evidence = scanPolicyExecApprovals(
JSON.stringify({
version: 1,
socket: { path: "/tmp/openclaw.sock", token: "secret-token" },
defaults: { security: "full", ask: "off", askFallback: "full", autoAllowSkills: true },
agents: {
sebby: {
security: "allowlist",
ask: "on-miss",
allowlist: [
{
pattern: "deploy",
argPattern: "^--prod$",
source: "allow-always",
commandText: "deploy --prod",
lastUsedCommand: "deploy --prod",
},
{
pattern: "inspect",
source: "free-form text that must not leak",
},
],
},
},
}),
);
expect(evidence).toEqual([
expect.objectContaining({
id: "defaults",
kind: "defaults",
security: "full",
autoAllowSkills: true,
}),
expect.objectContaining({
id: "agent:sebby",
kind: "agent",
agentId: "sebby",
security: "allowlist",
ask: "on-miss",
}),
expect.objectContaining({
id: "agent:sebby:allowlist:0",
kind: "allowlist",
agentId: "sebby",
pattern: "deploy",
argPattern: "^--prod$",
entrySource: "allow-always",
}),
expect.not.objectContaining({
entrySource: "free-form text that must not leak",
}),
]);
expect(JSON.stringify(evidence)).not.toContain("secret-token");
expect(JSON.stringify(evidence)).not.toContain("deploy --prod");
expect(JSON.stringify(evidence)).not.toContain("free-form text that must not leak");
});
it("omits malformed exec approval mode fields", () => {
expect(
scanPolicyExecApprovals(
JSON.stringify({
version: 1,
defaults: { security: "bogus", ask: "bad", askFallback: "nope" },
agents: {
sebby: { security: "bogus", ask: "bad", askFallback: "nope" },
},
}),
),
).toEqual([
expect.not.objectContaining({ security: expect.any(String) }),
expect.not.objectContaining({ security: expect.any(String) }),
]);
});
it("normalizes legacy default agents and string allowlist entries", () => {
expect(
scanPolicyExecApprovals(
JSON.stringify({
version: 1,
agents: {
default: {
security: "allowlist",
allowlist: ["legacy", { pattern: "doctor" }],
},
},
}),
),
).toEqual([
expect.objectContaining({
id: "defaults",
kind: "defaults",
}),
expect.objectContaining({
id: "agent:main",
kind: "agent",
agentId: "main",
security: "allowlist",
source: "oc://exec-approvals.json/agents/default",
}),
expect.objectContaining({
id: "agent:main:allowlist:0",
kind: "allowlist",
agentId: "main",
pattern: "legacy",
source: "oc://exec-approvals.json/agents/default/allowlist/#0",
}),
expect.objectContaining({
id: "agent:main:allowlist:1",
kind: "allowlist",
agentId: "main",
pattern: "doctor",
source: "oc://exec-approvals.json/agents/default/allowlist/#1",
}),
]);
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,50 @@
// Policy plugin module implements tool policy conformance behavior.
export const POLICY_TOOL_GROUPS: Record<string, readonly string[]> = {
"group:openclaw": [
"code_execution",
"web_search",
"web_fetch",
"x_search",
"memory_search",
"memory_get",
"sessions_list",
"sessions_history",
"sessions_send",
"sessions_spawn",
"sessions_yield",
"subagents",
"session_status",
"browser",
"message",
"heartbeat_respond",
"cron",
"gateway",
"nodes",
"agents_list",
"update_plan",
"image",
"image_generate",
"music_generate",
"video_generate",
"tts",
],
"group:fs": ["read", "write", "edit", "apply_patch"],
"group:runtime": ["exec", "process", "code_execution"],
"group:web": ["web_search", "web_fetch", "x_search"],
"group:memory": ["memory_search", "memory_get"],
"group:sessions": [
"sessions_list",
"sessions_history",
"sessions_send",
"sessions_spawn",
"sessions_yield",
"subagents",
"session_status",
],
"group:ui": ["browser", "canvas"],
"group:messaging": ["message"],
"group:automation": ["heartbeat_respond", "cron", "gateway"],
"group:nodes": ["nodes"],
"group:agents": ["agents_list", "update_plan"],
"group:media": ["image", "image_generate", "music_generate", "video_generate", "tts"],
} as const;