diff --git a/.github/labeler.yml b/.github/labeler.yml
index 584bea1a4ef..bb7a93c4e5b 100644
--- a/.github/labeler.yml
+++ b/.github/labeler.yml
@@ -286,6 +286,11 @@
- changed-files:
- any-glob-to-any-file:
- "extensions/oc-path/**"
+"extensions: policy":
+ - changed-files:
+ - any-glob-to-any-file:
+ - "extensions/policy/**"
+ - "docs/cli/policy.md"
"extensions: open-prose":
- changed-files:
- any-glob-to-any-file:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 26492439873..a50895bf7d4 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,7 @@ Docs: https://docs.openclaw.ai
### Changes
- Dependencies: bump the bundled Codex harness to `@openai/codex` `0.132.0` and refresh the app-server model-list docs for the new catalog.
+- CLI/policy: add the bundled Policy plugin for policy-backed channel conformance checks, doctor lint findings, and opt-in workspace repair. (#80407) Thanks @giodl73-repo.
- Agents/config: allow `agents.list[].experimental.localModelLean` so lean local-model mode can be enabled for one configured agent instead of globally.
- Providers/xAI: add device-code OAuth login so remote and headless setups can authorize xAI without a localhost browser callback. (#84005) Thanks @fuller-stack-dev.
diff --git a/docs/cli/index.md b/docs/cli/index.md
index 57dc671a0f2..cc27b14c681 100644
--- a/docs/cli/index.md
+++ b/docs/cli/index.md
@@ -35,7 +35,7 @@ Use the setup commands by intent:
| Pairing and channels | [`pairing`](/cli/pairing) · [`qr`](/cli/qr) · [`channels`](/cli/channels) |
| Security and plugins | [`security`](/cli/security) · [`secrets`](/cli/secrets) · [`skills`](/cli/skills) · [`plugins`](/cli/plugins) · [`proxy`](/cli/proxy) |
| Legacy aliases | [`daemon`](/cli/daemon) (gateway service) · [`clawbot`](/cli/clawbot) (namespace) |
-| Plugins (optional) | [`path`](/cli/path) · [`voicecall`](/cli/voicecall) (if installed) |
+| Plugins (optional) | [`path`](/cli/path) · [`policy`](/cli/policy) · [`voicecall`](/cli/voicecall) (if installed) |
## Global flags
diff --git a/docs/cli/policy.md b/docs/cli/policy.md
new file mode 100644
index 00000000000..7c96abb905a
--- /dev/null
+++ b/docs/cli/policy.md
@@ -0,0 +1,194 @@
+---
+summary: "CLI reference for `openclaw policy` channel conformance checks"
+read_when:
+ - You want to check OpenClaw settings against an authored policy.jsonc
+ - You want policy findings in doctor lint
+ - You need a policy attestation hash for audit evidence
+title: "Policy"
+---
+
+# `openclaw policy`
+
+`openclaw policy` is provided by the bundled Policy plugin. Policy is an
+enterprise conformance layer over existing OpenClaw settings: `policy.jsonc`
+defines authored requirements, OpenClaw observes the active workspace as
+evidence, and policy health checks report drift through `doctor --lint`.
+
+This first policy slice manages configured channels. For example, IT can record
+that Telegram is not approved, then `doctor --lint` reports any enabled Telegram
+channel and `doctor --fix` can turn it off when workspace repairs are explicitly
+enabled.
+
+## Quick start
+
+Enable the bundled Policy plugin before first use:
+
+```bash
+openclaw plugins enable policy
+```
+
+When policy is enabled, doctor can load policy health checks without activating
+arbitrary plugins. The plugin remains enabled if `policy.jsonc` is missing, so
+doctor can report the missing artifact.
+
+Policy is authored, not generated from the user's current settings. A minimal
+channel policy looks like this:
+
+```jsonc
+{
+ "channels": {
+ "denyRules": [
+ {
+ "id": "no-telegram",
+ "when": { "provider": "telegram" },
+ "reason": "Telegram is not approved for this workspace.",
+ },
+ ],
+ },
+}
+```
+
+The rules are the authority. A category block is only a namespace; checks run
+when a concrete rule is present. OpenClaw reads current `channels.*` settings
+and reports settings that do not conform.
+
+Run policy-only checks during authoring:
+
+```bash
+openclaw policy check
+openclaw policy check --json
+openclaw policy check --severity-min error
+```
+
+`policy check` runs only the policy check set and emits evidence, findings, and
+attestation hashes. The same findings also appear in `openclaw doctor --lint`
+when the Policy plugin is enabled.
+
+Example clean JSON output includes stable hashes that can be recorded by an
+operator or supervisor:
+
+```json
+{
+ "ok": true,
+ "attestation": {
+ "policy": {
+ "path": "policy.jsonc",
+ "hash": "sha256:..."
+ },
+ "workspace": {
+ "scope": "policy",
+ "hash": "sha256:..."
+ },
+ "findingsHash": "sha256:...",
+ "attestationHash": "sha256:..."
+ },
+ "checksRun": 5,
+ "checksSkipped": 0,
+ "findings": []
+}
+```
+
+## Configure policy
+
+Policy config lives under `plugins.entries.policy.config`.
+
+```jsonc
+{
+ "plugins": {
+ "entries": {
+ "policy": {
+ "enabled": true,
+ "config": {
+ "enabled": true,
+ "path": "policy.jsonc",
+ "workspaceRepairs": false,
+ "expectedHash": "sha256:...",
+ "expectedAttestationHash": "sha256:...",
+ },
+ },
+ },
+ },
+}
+```
+
+| Setting | Purpose |
+| ------------------------- | --------------------------------------------------------------- |
+| `enabled` | Enable policy checks even before `policy.jsonc` exists. |
+| `workspaceRepairs` | Allow `doctor --fix` to edit policy-managed workspace settings. |
+| `expectedHash` | Optional hash-lock for the approved policy artifact. |
+| `expectedAttestationHash` | Optional hash-lock for the last accepted clean policy check. |
+| `path` | Workspace-relative location of the policy artifact. |
+
+Set `plugins.entries.policy.config.enabled` to `false` to disable policy checks
+for a workspace while leaving the plugin installed.
+
+## Accept policy state
+
+The attestation hash identifies the stable claim: policy hash, evidence hash,
+findings hash, and whether the result was clean. It intentionally does not
+include `checkedAt`, so the same policy state produces the same attestation
+across repeated checks.
+
+If a later gateway or supervisor uses policy to block, approve, or annotate a
+runtime action, it should record the attestation hash from the last clean policy
+check. `checkedAt` stays in JSON output for audit logs, but is not part of the
+stable attestation hash.
+
+Use this lifecycle when accepting policy state:
+
+1. Author or review `policy.jsonc`.
+2. Run `openclaw policy check --json`.
+3. If the result is clean, record `attestation.policy.hash` as `expectedHash`.
+4. Record `attestation.attestationHash` as `expectedAttestationHash`.
+5. Re-run `openclaw doctor --lint` in CI or release gates.
+
+If policy rules change intentionally, update both accepted hashes from a clean
+check. If workspace settings change intentionally but policy stays the same,
+only `expectedAttestationHash` usually changes.
+
+## Findings
+
+Policy currently verifies:
+
+| Check id | Finding |
+| ---------------------------------- | ------------------------------------------------------------------- |
+| `policy/policy-jsonc-missing` | Policy is enabled but `policy.jsonc` is missing. |
+| `policy/policy-jsonc-invalid` | Policy cannot be parsed or has malformed rules. |
+| `policy/policy-hash-mismatch` | Policy does not match configured `expectedHash`. |
+| `policy/attestation-hash-mismatch` | Current policy evidence no longer matches the accepted attestation. |
+| `policy/channels-denied-provider` | An enabled channel matches a channel deny rule. |
+
+Policy findings can include `target` and `requirement`: the observed workspace
+thing that does not conform, and the authored rule that made it a finding.
+
+## Repair
+
+`doctor --lint` and `policy check` are read-only.
+
+`doctor --fix` only edits policy-managed workspace settings when
+`workspaceRepairs` is explicitly enabled. Without that opt-in, policy checks
+report what they would repair and leave settings unchanged.
+
+In this version, repair can disable channels that are enabled in OpenClaw config
+but denied by `channels.denyRules`. Enable `workspaceRepairs` only after the
+policy file has been reviewed, because a valid deny rule can turn off a
+configured channel:
+
+```jsonc
+{
+ "plugins": {
+ "entries": {
+ "policy": {
+ "config": {
+ "workspaceRepairs": true,
+ },
+ },
+ },
+ },
+}
+```
+
+## Exit codes
+
+`policy check` exits `0` when there are no findings at the threshold, `1` when
+findings are present, and `2` for argument or runtime failures.
diff --git a/docs/docs.json b/docs/docs.json
index f13eb5f83ce..d95325df00f 100644
--- a/docs/docs.json
+++ b/docs/docs.json
@@ -1669,7 +1669,7 @@
},
{
"group": "Plugins and skills",
- "pages": ["cli/plugins", "cli/path", "cli/skills"]
+ "pages": ["cli/plugins", "cli/path", "cli/policy", "cli/skills"]
},
{
"group": "Interfaces",
diff --git a/docs/plugins/plugin-inventory.md b/docs/plugins/plugin-inventory.md
index df0ab8c0569..5b06cd02541 100644
--- a/docs/plugins/plugin-inventory.md
+++ b/docs/plugins/plugin-inventory.md
@@ -109,6 +109,7 @@ commands.
| [opencode-go](/plugins/reference/opencode-go) | Adds OpenCode Go model provider support to OpenClaw. | `@openclaw/opencode-go-provider`
included in OpenClaw | providers: opencode-go; contracts: mediaUnderstandingProviders |
| [openrouter](/plugins/reference/openrouter) | Adds OpenRouter model provider support to OpenClaw. | `@openclaw/openrouter-provider`
included in OpenClaw | providers: openrouter; contracts: imageGenerationProviders, mediaUnderstandingProviders, musicGenerationProviders, speechProviders, videoGenerationProviders |
| [perplexity](/plugins/reference/perplexity) | Adds web search provider support. | `@openclaw/perplexity-plugin`
included in OpenClaw | contracts: webSearchProviders |
+| [policy](/plugins/reference/policy) | Adds policy-backed doctor checks for workspace conformance. | `@openclaw/policy`
included in OpenClaw | plugin |
| [qianfan](/plugins/reference/qianfan) | Adds Qianfan model provider support to OpenClaw. | `@openclaw/qianfan-provider`
included in OpenClaw | providers: qianfan |
| [qwen](/plugins/reference/qwen) | Adds Qwen, Qwen Cloud, Model Studio, DashScope model provider support to OpenClaw. | `@openclaw/qwen-provider`
included in OpenClaw | providers: qwen, qwencloud, modelstudio, dashscope; contracts: mediaUnderstandingProviders, videoGenerationProviders |
| [runway](/plugins/reference/runway) | Adds video generation provider support. | `@openclaw/runway-provider`
included in OpenClaw | contracts: videoGenerationProviders |
diff --git a/docs/plugins/reference.md b/docs/plugins/reference.md
index ae4924f9dda..b7f238796d6 100644
--- a/docs/plugins/reference.md
+++ b/docs/plugins/reference.md
@@ -96,6 +96,7 @@ pnpm plugins:inventory:gen
| [openrouter](/plugins/reference/openrouter) | Adds OpenRouter model provider support to OpenClaw. | `@openclaw/openrouter-provider`
included in OpenClaw | providers: openrouter; contracts: imageGenerationProviders, mediaUnderstandingProviders, musicGenerationProviders, speechProviders, videoGenerationProviders |
| [openshell](/plugins/reference/openshell) | Sandbox backend powered by OpenShell with mirrored local workspaces and SSH-based command execution. | `@openclaw/openshell-sandbox`
npm; ClawHub | plugin |
| [perplexity](/plugins/reference/perplexity) | Adds web search provider support. | `@openclaw/perplexity-plugin`
included in OpenClaw | contracts: webSearchProviders |
+| [policy](/plugins/reference/policy) | Adds policy-backed doctor checks for workspace conformance. | `@openclaw/policy`
included in OpenClaw | plugin |
| [qa-channel](/plugins/reference/qa-channel) | Adds the QA Channel surface for sending and receiving OpenClaw messages. | `@openclaw/qa-channel`
source checkout only | channels: qa-channel |
| [qa-lab](/plugins/reference/qa-lab) | OpenClaw QA lab plugin with private debugger UI and scenario runner. | `@openclaw/qa-lab`
source checkout only | plugin |
| [qa-matrix](/plugins/reference/qa-matrix) | Matrix QA transport runner and substrate. | `@openclaw/qa-matrix`
source checkout only | plugin |
diff --git a/docs/plugins/reference/policy.md b/docs/plugins/reference/policy.md
new file mode 100644
index 00000000000..ab9285c1a12
--- /dev/null
+++ b/docs/plugins/reference/policy.md
@@ -0,0 +1,23 @@
+---
+summary: "Adds policy-backed doctor checks for workspace conformance."
+read_when:
+ - You are installing, configuring, or auditing the policy plugin
+title: "Policy plugin"
+---
+
+# Policy plugin
+
+Adds policy-backed doctor checks for workspace conformance.
+
+## Distribution
+
+- Package: `@openclaw/policy`
+- Install route: included in OpenClaw
+
+## Surface
+
+plugin
+
+## Related docs
+
+- [policy](/cli/policy)
diff --git a/docs/plugins/sdk-subpaths.md b/docs/plugins/sdk-subpaths.md
index 909ba209242..a462b4edfdb 100644
--- a/docs/plugins/sdk-subpaths.md
+++ b/docs/plugins/sdk-subpaths.md
@@ -29,6 +29,7 @@ For the plugin authoring guide, see [Plugin SDK overview](/plugins/sdk-overview)
| `plugin-sdk/provider-entry` | `defineSingleProviderPluginEntry` |
| `plugin-sdk/migration` | Migration provider item helpers such as `createMigrationItem`, reason constants, item status markers, redaction helpers, and `summarizeMigrationItems` |
| `plugin-sdk/migration-runtime` | Runtime migration helpers such as `copyMigrationFileItem`, `withCachedMigrationConfigRuntime`, and `writeMigrationReport` |
+| `plugin-sdk/health` | Doctor health-check registration, detection, repair, selection, severity, and finding types for bundled health consumers |
### Deprecated compatibility and test helpers
diff --git a/extensions/policy/api.ts b/extensions/policy/api.ts
new file mode 100644
index 00000000000..29801f01659
--- /dev/null
+++ b/extensions/policy/api.ts
@@ -0,0 +1 @@
+export { registerPolicyDoctorChecks } from "./src/doctor/register.js";
diff --git a/extensions/policy/index.ts b/extensions/policy/index.ts
new file mode 100644
index 00000000000..76c4e900c4b
--- /dev/null
+++ b/extensions/policy/index.ts
@@ -0,0 +1,26 @@
+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();
+ },
+});
diff --git a/extensions/policy/openclaw.plugin.json b/extensions/policy/openclaw.plugin.json
new file mode 100644
index 00000000000..225baa3c5dd
--- /dev/null
+++ b/extensions/policy/openclaw.plugin.json
@@ -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."
+ }
+ }
+ }
+}
diff --git a/extensions/policy/package.json b/extensions/policy/package.json
new file mode 100644
index 00000000000..55d231cd547
--- /dev/null
+++ b/extensions/policy/package.json
@@ -0,0 +1,27 @@
+{
+ "name": "@openclaw/policy",
+ "version": "2026.5.19",
+ "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.5.19"
+ },
+ "peerDependenciesMeta": {
+ "openclaw": {
+ "optional": true
+ }
+ },
+ "openclaw": {
+ "extensions": [
+ "./index.ts"
+ ]
+ }
+}
diff --git a/extensions/policy/src/cli.test.ts b/extensions/policy/src/cli.test.ts
new file mode 100644
index 00000000000..9e301fa82f1
--- /dev/null
+++ b/extensions/policy/src/cli.test.ts
@@ -0,0 +1,227 @@
+import { promises as fs } from "node:fs";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import { clearConfigCache } from "openclaw/plugin-sdk/runtime-config-snapshot";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { policyCheckCommand } from "./cli.js";
+import { resetPolicyDoctorChecksForTest } from "./doctor/register.js";
+import {
+ policyAttestationHash,
+ policyWorkspaceHash,
+ policyDocumentHash,
+ policyFindingsHash,
+} from "./policy-state.js";
+
+let workspaceDir: string;
+
+async function runPolicyCheckJson(options: Parameters[0] = {}) {
+ const output: string[] = [];
+ const exitCode = await policyCheckCommand(
+ { cwd: workspaceDir, json: true, ...options },
+ {
+ writeStdout(value) {
+ output.push(value);
+ },
+ error(value) {
+ output.push(value);
+ },
+ },
+ );
+ return { exitCode, parsed: JSON.parse(output.at(-1) ?? "{}"), output };
+}
+
+describe("policy commands", () => {
+ beforeEach(async () => {
+ workspaceDir = await fs.mkdtemp(join(tmpdir(), "policy-cli-"));
+ });
+
+ afterEach(async () => {
+ vi.unstubAllEnvs();
+ clearConfigCache();
+ await fs.rm(workspaceDir, { recursive: true, force: true });
+ resetPolicyDoctorChecksForTest();
+ });
+
+ it("checks policy rules and emits an attestation", async () => {
+ const policy = {
+ channels: {
+ denyRules: [{ id: "no-telegram", when: { provider: "telegram" } }],
+ },
+ };
+ await fs.writeFile(join(workspaceDir, "policy.jsonc"), JSON.stringify(policy), "utf-8");
+ const { exitCode, parsed } = await runPolicyCheckJson();
+
+ expect(exitCode).toBe(0);
+ const policyHash = policyDocumentHash(policy);
+ const evidence = { channels: [] };
+ const workspaceHash = policyWorkspaceHash(evidence);
+ const findingsHash = policyFindingsHash([]);
+ expect(typeof parsed.attestation.checkedAt).toBe("string");
+ expect(parsed).toMatchObject({
+ ok: true,
+ attestation: {
+ checkedAt: parsed.attestation.checkedAt,
+ policy: {
+ path: "policy.jsonc",
+ hash: policyHash,
+ },
+ workspace: {
+ scope: "policy",
+ hash: workspaceHash,
+ },
+ findingsHash,
+ attestationHash: policyAttestationHash({
+ ok: true,
+ policyHash,
+ workspaceHash,
+ findingsHash,
+ }),
+ },
+ evidence,
+ findings: [],
+ });
+ });
+
+ it("reports malformed policy rules in policy check output", async () => {
+ await fs.writeFile(
+ join(workspaceDir, "policy.jsonc"),
+ JSON.stringify({ channels: { denyRules: [{ when: {} }] } }),
+ "utf-8",
+ );
+ const { exitCode, parsed } = await runPolicyCheckJson();
+
+ expect(exitCode).toBe(1);
+ expect(parsed).toMatchObject({
+ ok: false,
+ findings: [
+ {
+ checkId: "policy/policy-jsonc-invalid",
+ target: "oc://policy.jsonc/channels/denyRules/#0",
+ },
+ ],
+ });
+ });
+
+ it("links policy findings to evidence and policy requirement refs", async () => {
+ const configPath = join(workspaceDir, "openclaw.jsonc");
+ vi.stubEnv("OPENCLAW_CONFIG_PATH", configPath);
+ await fs.writeFile(
+ configPath,
+ JSON.stringify({
+ plugins: {
+ entries: {
+ policy: { enabled: true, config: { enabled: true } },
+ },
+ },
+ channels: { telegram: { enabled: true } },
+ }),
+ "utf-8",
+ );
+ await fs.writeFile(
+ join(workspaceDir, "policy.jsonc"),
+ JSON.stringify({
+ channels: {
+ denyRules: [{ id: "no-telegram", when: { provider: "telegram" } }],
+ },
+ }),
+ "utf-8",
+ );
+ const { exitCode, parsed } = await runPolicyCheckJson();
+
+ expect(exitCode).toBe(1);
+ expect(parsed).toMatchObject({
+ evidence: {
+ channels: [
+ {
+ id: "telegram",
+ source: "oc://openclaw.config/channels/telegram",
+ },
+ ],
+ },
+ findings: [
+ {
+ checkId: "policy/channels-denied-provider",
+ ocPath: "oc://openclaw.config/channels/telegram",
+ target: "oc://openclaw.config/channels/telegram",
+ requirement: "oc://policy.jsonc/channels/denyRules/#0",
+ },
+ ],
+ });
+ });
+
+ it("attests underlying policy findings when the accepted attestation is stale", async () => {
+ const configPath = join(workspaceDir, "openclaw.jsonc");
+ vi.stubEnv("OPENCLAW_CONFIG_PATH", configPath);
+ await fs.writeFile(
+ configPath,
+ JSON.stringify({
+ plugins: {
+ entries: {
+ policy: {
+ enabled: true,
+ config: { enabled: true, expectedAttestationHash: "sha256:not-current" },
+ },
+ },
+ },
+ channels: { telegram: { enabled: true } },
+ }),
+ "utf-8",
+ );
+ await fs.writeFile(
+ join(workspaceDir, "policy.jsonc"),
+ JSON.stringify({
+ channels: {
+ denyRules: [{ id: "no-telegram", when: { provider: "telegram" } }],
+ },
+ }),
+ "utf-8",
+ );
+ const { exitCode, parsed } = await runPolicyCheckJson();
+
+ expect(exitCode).toBe(1);
+ expect(parsed.findings).toEqual([
+ expect.objectContaining({ checkId: "policy/attestation-hash-mismatch" }),
+ ]);
+ expect(parsed.attestation.findingsHash).not.toBe(policyFindingsHash([]));
+ expect(parsed.attestation.attestationHash).toBe(
+ policyAttestationHash({
+ ok: false,
+ policyHash: parsed.attestation.policy.hash,
+ workspaceHash: parsed.attestation.workspace.hash,
+ findingsHash: parsed.attestation.findingsHash,
+ }),
+ );
+ });
+
+ it("rejects invalid severity thresholds", async () => {
+ const errors: string[] = [];
+
+ const exitCode = await policyCheckCommand(
+ { cwd: workspaceDir, severityMin: "warnng" },
+ {
+ writeStdout() {},
+ error(value) {
+ errors.push(value);
+ },
+ },
+ );
+
+ expect(exitCode).toBe(2);
+ expect(errors).toEqual([
+ "Invalid --severity-min value. Expected one of: info, warning, error.",
+ ]);
+ });
+
+ it("fails closed when the OpenClaw config is invalid", async () => {
+ const configPath = join(workspaceDir, "openclaw.jsonc");
+ vi.stubEnv("OPENCLAW_CONFIG_PATH", configPath);
+ await fs.writeFile(configPath, "{", "utf-8");
+ const { exitCode, parsed } = await runPolicyCheckJson();
+
+ expect(exitCode).toBe(1);
+ expect(parsed.attestation).toBeUndefined();
+ expect(parsed.findings).toEqual([
+ expect.objectContaining({ checkId: "policy/config-invalid", severity: "error" }),
+ ]);
+ });
+});
diff --git a/extensions/policy/src/cli.ts b/extensions/policy/src/cli.ts
new file mode 100644
index 00000000000..3680b1e677f
--- /dev/null
+++ b/extensions/policy/src/cli.ts
@@ -0,0 +1,220 @@
+import type { Command } from "commander";
+import {
+ exitCodeFromFindings,
+ healthFindingMeetsSeverity,
+ parseHealthFindingSeverity,
+ readConfigFileSnapshot,
+ resolveAgentWorkspaceDir,
+ resolveDefaultAgentId,
+ type HealthCheckContext,
+ type HealthFinding,
+} from "openclaw/plugin-sdk/health";
+import { POLICY_CHECK_IDS, evaluatePolicy } from "./doctor/register.js";
+import { createPolicyAttestation } from "./policy-state.js";
+
+export type PolicyCommandRuntime = {
+ writeStdout(value: string): void;
+ error(value: string): void;
+};
+
+export interface PolicyCheckOptions {
+ readonly json?: boolean;
+ readonly severityMin?: string;
+ readonly cwd?: string;
+}
+
+type PolicyCheckReport = {
+ readonly ok: boolean;
+ readonly attestation?: ReturnType;
+ readonly evidence: unknown;
+ readonly checksRun: number;
+ readonly checksSkipped: number;
+ readonly findings: readonly Record[];
+ readonly expectedAttestationHash?: string;
+ readonly exitCode: 0 | 1;
+};
+
+const defaultRuntime: PolicyCommandRuntime = {
+ writeStdout(value) {
+ process.stdout.write(value);
+ },
+ error(value) {
+ process.stderr.write(`${value}\n`);
+ },
+};
+
+export function registerPolicyCli(program: Command): void {
+ const policy = program.command("policy").description("Verify workspace policy conformance");
+
+ policy
+ .command("check")
+ .description("Check policy requirements and emit an audit attestation")
+ .option("--json", "Emit JSON output")
+ .option("--severity-min ", "Minimum severity: info, warning, or error")
+ .action(async (options: PolicyCheckOptions) => {
+ process.exitCode = await policyCheckCommand(options);
+ });
+}
+
+export async function policyCheckCommand(
+ options: PolicyCheckOptions,
+ runtime: PolicyCommandRuntime = defaultRuntime,
+): Promise {
+ 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;
+ }
+}
+
+async function buildPolicyCheckReport(
+ options: PolicyCheckOptions,
+ runtime: PolicyCommandRuntime,
+): Promise {
+ 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(toJsonFinding);
+ 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
+ : {}),
+ },
+ },
+ },
+ },
+ };
+}
+
+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 toJsonFinding(finding: HealthFinding): Record {
+ 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 } : {}),
+ };
+}
diff --git a/extensions/policy/src/doctor/register.test.ts b/extensions/policy/src/doctor/register.test.ts
new file mode 100644
index 00000000000..5775033f3d1
--- /dev/null
+++ b/extensions/policy/src/doctor/register.test.ts
@@ -0,0 +1,524 @@
+import { promises as fs } from "node:fs";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import {
+ type HealthCheck,
+ type HealthCheckContext,
+ type HealthFinding,
+ type HealthRepairContext,
+ type OpenClawConfig,
+} from "openclaw/plugin-sdk/health";
+import { afterEach, beforeEach, describe, expect, it } from "vitest";
+import { createPolicyAttestation, policyDocumentHash } from "../policy-state.js";
+import { registerPolicyDoctorChecks, resetPolicyDoctorChecksForTest } from "./register.js";
+
+let workspaceDir: string;
+
+function cfgWithPolicy(settings: Record = {}): OpenClawConfig {
+ return {
+ plugins: {
+ entries: {
+ policy: {
+ enabled: true,
+ config: { enabled: true, ...settings },
+ },
+ },
+ },
+ };
+}
+
+function ctx(configPath: string, cfg: OpenClawConfig = {}): HealthCheckContext {
+ return {
+ mode: "lint",
+ runtime: {
+ log() {},
+ error() {},
+ exit() {},
+ },
+ cfg,
+ cwd: workspaceDir,
+ configPath,
+ };
+}
+
+function repairCtx(configPath: string, cfg: OpenClawConfig = {}): HealthRepairContext {
+ return {
+ ...ctx(configPath, cfg),
+ mode: "fix",
+ };
+}
+
+function registerChecks(): readonly HealthCheck[] {
+ const checks: HealthCheck[] = [];
+ registerPolicyDoctorChecks({
+ registerHealthCheck(check) {
+ checks.push(check);
+ },
+ });
+ return checks;
+}
+
+async function runPolicyChecks(checkCtx: HealthCheckContext): Promise<{
+ readonly findings: readonly HealthFinding[];
+}> {
+ const checks = registerChecks();
+ const findings: HealthFinding[] = [];
+ for (const check of checks) {
+ findings.push(...(check.detect === undefined ? [] : await check.detect(checkCtx)));
+ }
+ return { findings };
+}
+
+async function runDeniedChannelRepair(repairCheckCtx: HealthRepairContext) {
+ const check = registerChecks().find((entry) => entry.id === "policy/channels-denied-provider");
+ if (check?.detect === undefined || check.repair === undefined) {
+ throw new Error("policy channel repair check was not registered");
+ }
+ const findings = await check.detect(repairCheckCtx);
+ const result = await check.repair(repairCheckCtx, findings);
+ const config = result.config ?? repairCheckCtx.cfg;
+ const remainingFindings = await check.detect({ ...repairCheckCtx, cfg: config });
+ return { ...result, config, remainingFindings };
+}
+
+describe("registerPolicyDoctorChecks", () => {
+ beforeEach(async () => {
+ workspaceDir = await fs.mkdtemp(join(tmpdir(), "policy-doctor-"));
+ });
+
+ afterEach(async () => {
+ await fs.rm(workspaceDir, { recursive: true, force: true });
+ resetPolicyDoctorChecksForTest();
+ });
+
+ it("registers policy health checks once", () => {
+ const checks = registerChecks();
+ const duplicateChecks: HealthCheck[] = [];
+ registerPolicyDoctorChecks({
+ registerHealthCheck(check) {
+ duplicateChecks.push(check);
+ },
+ });
+
+ expect(checks.map((check) => check.id)).toEqual([
+ "policy/policy-jsonc-missing",
+ "policy/policy-jsonc-invalid",
+ "policy/policy-hash-mismatch",
+ "policy/attestation-hash-mismatch",
+ "policy/channels-denied-provider",
+ ]);
+ expect(duplicateChecks).toEqual([]);
+ });
+
+ it("reports a missing policy file when the policy extension is enabled", async () => {
+ const configPath = join(workspaceDir, "openclaw.jsonc");
+ await fs.writeFile(configPath, "{}", "utf-8");
+
+ const result = await runPolicyChecks(ctx(configPath, cfgWithPolicy()));
+
+ expect(result.findings).toEqual([
+ expect.objectContaining({
+ checkId: "policy/policy-jsonc-missing",
+ severity: "warning",
+ path: "policy.jsonc",
+ }),
+ ]);
+ });
+
+ it("does not report a missing policy file when policy is disabled", async () => {
+ const configPath = join(workspaceDir, "openclaw.jsonc");
+ await fs.writeFile(configPath, "{}", "utf-8");
+
+ const result = await runPolicyChecks(ctx(configPath, cfgWithPolicy({ enabled: false })));
+
+ expect(result.findings).toEqual([]);
+ });
+
+ it("reports invalid policy files as errors", async () => {
+ const configPath = join(workspaceDir, "openclaw.jsonc");
+ await fs.writeFile(configPath, "{}", "utf-8");
+ await fs.writeFile(join(workspaceDir, "policy.jsonc"), "{ channels: ", "utf-8");
+
+ const result = await runPolicyChecks(ctx(configPath, cfgWithPolicy()));
+
+ expect(result.findings).toEqual([
+ expect.objectContaining({
+ checkId: "policy/policy-jsonc-invalid",
+ severity: "error",
+ path: "policy.jsonc",
+ }),
+ ]);
+ });
+
+ it("reports malformed channel deny rules as policy errors", async () => {
+ const configPath = join(workspaceDir, "openclaw.jsonc");
+ await fs.writeFile(configPath, "{}", "utf-8");
+ await fs.writeFile(
+ join(workspaceDir, "policy.jsonc"),
+ JSON.stringify({ channels: { denyRules: [{ when: {} }] } }),
+ "utf-8",
+ );
+
+ const result = await runPolicyChecks(ctx(configPath, cfgWithPolicy()));
+
+ expect(result.findings).toEqual([
+ expect.objectContaining({
+ checkId: "policy/policy-jsonc-invalid",
+ severity: "error",
+ path: "policy.jsonc",
+ target: "oc://policy.jsonc/channels/denyRules/#0",
+ }),
+ ]);
+ });
+
+ it("reports malformed channel deny rules against a configured policy path", async () => {
+ const configPath = join(workspaceDir, "openclaw.jsonc");
+ await fs.writeFile(configPath, "{}", "utf-8");
+ await fs.writeFile(
+ join(workspaceDir, "workspace.policy.jsonc"),
+ JSON.stringify({ channels: { denyRules: [{ when: {} }] } }),
+ "utf-8",
+ );
+
+ const result = await runPolicyChecks(
+ ctx(configPath, cfgWithPolicy({ path: "workspace.policy.jsonc" })),
+ );
+
+ expect(result.findings).toEqual([
+ expect.objectContaining({
+ checkId: "policy/policy-jsonc-invalid",
+ path: "workspace.policy.jsonc",
+ target: "oc://workspace.policy.jsonc/channels/denyRules/#0",
+ }),
+ ]);
+ });
+
+ it("reports a policy hash mismatch when expectedHash is configured", async () => {
+ const configPath = join(workspaceDir, "openclaw.jsonc");
+ await fs.writeFile(configPath, "{}", "utf-8");
+ await fs.writeFile(
+ join(workspaceDir, "policy.jsonc"),
+ JSON.stringify({ channels: { denyRules: [] } }),
+ "utf-8",
+ );
+
+ const result = await runPolicyChecks(
+ ctx(configPath, cfgWithPolicy({ expectedHash: "sha256:not-the-policy" })),
+ );
+
+ expect(result.findings).toEqual([
+ expect.objectContaining({
+ checkId: "policy/policy-hash-mismatch",
+ severity: "error",
+ path: "policy.jsonc",
+ }),
+ ]);
+ });
+
+ it("does not emit repairable channel findings when the policy hash is not accepted", async () => {
+ const configPath = join(workspaceDir, "openclaw.jsonc");
+ const cfg = {
+ ...cfgWithPolicy({ expectedHash: "sha256:not-the-policy", workspaceRepairs: true }),
+ channels: { telegram: { enabled: true } },
+ } as OpenClawConfig;
+ await fs.writeFile(configPath, "{}", "utf-8");
+ await fs.writeFile(
+ join(workspaceDir, "policy.jsonc"),
+ JSON.stringify({
+ channels: {
+ denyRules: [{ id: "no-telegram", when: { provider: "telegram" } }],
+ },
+ }),
+ "utf-8",
+ );
+
+ const result = await runPolicyChecks(ctx(configPath, cfg));
+
+ expect(result.findings.map((finding) => finding.checkId)).toEqual([
+ "policy/policy-hash-mismatch",
+ ]);
+ });
+
+ it("accepts a policy file that matches the configured expectedHash", async () => {
+ const configPath = join(workspaceDir, "openclaw.jsonc");
+ const policy = { channels: { denyRules: [] } };
+ await fs.writeFile(configPath, "{}", "utf-8");
+ await fs.writeFile(join(workspaceDir, "policy.jsonc"), JSON.stringify(policy), "utf-8");
+
+ const result = await runPolicyChecks(
+ ctx(configPath, cfgWithPolicy({ expectedHash: policyDocumentHash(policy) })),
+ );
+
+ expect(result.findings).toEqual([]);
+ });
+
+ it("reports an attestation mismatch when expectedAttestationHash is configured", async () => {
+ const configPath = join(workspaceDir, "openclaw.jsonc");
+ await fs.writeFile(configPath, "{}", "utf-8");
+ await fs.writeFile(
+ join(workspaceDir, "policy.jsonc"),
+ JSON.stringify({ channels: { denyRules: [] } }),
+ "utf-8",
+ );
+
+ const result = await runPolicyChecks(
+ ctx(configPath, cfgWithPolicy({ expectedAttestationHash: "sha256:not-current" })),
+ );
+
+ expect(result.findings).toEqual([
+ expect.objectContaining({
+ checkId: "policy/attestation-hash-mismatch",
+ severity: "error",
+ path: "policy attestation",
+ }),
+ ]);
+ });
+
+ it("reports policy validation errors before attestation drift", async () => {
+ const configPath = join(workspaceDir, "openclaw.jsonc");
+ await fs.writeFile(configPath, "{}", "utf-8");
+ await fs.writeFile(
+ join(workspaceDir, "policy.jsonc"),
+ JSON.stringify({ channels: { denyRules: [{ when: {} }] } }),
+ "utf-8",
+ );
+
+ const result = await runPolicyChecks(
+ ctx(configPath, cfgWithPolicy({ expectedAttestationHash: "sha256:not-current" })),
+ );
+
+ expect(result.findings).toEqual([
+ expect.objectContaining({
+ checkId: "policy/policy-jsonc-invalid",
+ target: "oc://policy.jsonc/channels/denyRules/#0",
+ }),
+ ]);
+ });
+
+ it("does not emit repairable channel findings when the accepted attestation changed", async () => {
+ const configPath = join(workspaceDir, "openclaw.jsonc");
+ const cfg = {
+ ...cfgWithPolicy({ expectedAttestationHash: "sha256:not-current", workspaceRepairs: true }),
+ channels: { telegram: { enabled: true } },
+ } as OpenClawConfig;
+ await fs.writeFile(configPath, "{}", "utf-8");
+ await fs.writeFile(
+ join(workspaceDir, "policy.jsonc"),
+ JSON.stringify({
+ channels: {
+ denyRules: [{ id: "no-telegram", when: { provider: "telegram" } }],
+ },
+ }),
+ "utf-8",
+ );
+
+ const result = await runPolicyChecks(ctx(configPath, cfg));
+
+ expect(result.findings.map((finding) => finding.checkId)).toEqual([
+ "policy/attestation-hash-mismatch",
+ ]);
+ });
+
+ it("accepts a policy check that matches the configured expectedAttestationHash", async () => {
+ const configPath = join(workspaceDir, "openclaw.jsonc");
+ const policy = { channels: { denyRules: [] } };
+ const policyHash = policyDocumentHash(policy);
+ const acceptedAttestationHash = createPolicyAttestation({
+ ok: true,
+ checkedAt: "2026-05-10T20:00:00.000Z",
+ policyPath: "policy.jsonc",
+ policyHash,
+ evidence: { channels: [] },
+ findings: [],
+ }).attestationHash;
+ await fs.writeFile(configPath, "{}", "utf-8");
+ await fs.writeFile(join(workspaceDir, "policy.jsonc"), JSON.stringify(policy), "utf-8");
+
+ const result = await runPolicyChecks(
+ ctx(configPath, cfgWithPolicy({ expectedAttestationHash: acceptedAttestationHash })),
+ );
+
+ expect(result.findings).toEqual([]);
+ });
+
+ it("reports configured channels denied by policy", async () => {
+ const configPath = join(workspaceDir, "openclaw.jsonc");
+ const cfg = {
+ ...cfgWithPolicy(),
+ channels: { telegram: { enabled: true } },
+ } as OpenClawConfig;
+ await fs.writeFile(configPath, "{}", "utf-8");
+ await fs.writeFile(
+ join(workspaceDir, "policy.jsonc"),
+ JSON.stringify(
+ {
+ channels: {
+ denyRules: [
+ {
+ id: "no-telegram",
+ when: { provider: "telegram" },
+ reason: "Telegram is not approved for this workspace.",
+ },
+ ],
+ },
+ },
+ null,
+ 2,
+ ),
+ "utf-8",
+ );
+
+ const result = await runPolicyChecks(ctx(configPath, cfg));
+
+ expect(result.findings).toEqual([
+ expect.objectContaining({
+ checkId: "policy/channels-denied-provider",
+ severity: "error",
+ path: "openclaw config",
+ ocPath: "oc://openclaw.config/channels/telegram",
+ target: "oc://openclaw.config/channels/telegram",
+ requirement: "oc://policy.jsonc/channels/denyRules/#0",
+ fixHint: "Telegram is not approved for this workspace.",
+ }),
+ ]);
+ });
+
+ it("repairs denied enabled channels by disabling them when workspace repairs are enabled", async () => {
+ const configPath = join(workspaceDir, "openclaw.jsonc");
+ const cfg = {
+ ...cfgWithPolicy({ workspaceRepairs: true }),
+ channels: { telegram: { enabled: true } },
+ } as OpenClawConfig;
+ await fs.writeFile(configPath, "{}", "utf-8");
+ await fs.writeFile(
+ join(workspaceDir, "policy.jsonc"),
+ JSON.stringify(
+ {
+ channels: {
+ denyRules: [{ id: "no-telegram", when: { provider: "telegram" } }],
+ },
+ },
+ null,
+ 2,
+ ),
+ "utf-8",
+ );
+
+ const result = await runDeniedChannelRepair(repairCtx(configPath, cfg));
+
+ expect(result.changes).toEqual(["Disabled channels.telegram.enabled for policy conformance."]);
+ expect(result.remainingFindings).toEqual([]);
+ expect(result.config.channels?.telegram).toEqual({ enabled: false });
+ });
+
+ it("does not repair denied channels without workspace repair opt-in", async () => {
+ const configPath = join(workspaceDir, "openclaw.jsonc");
+ const cfg = {
+ ...cfgWithPolicy({ workspaceRepairs: false }),
+ channels: { telegram: { enabled: true } },
+ } as OpenClawConfig;
+ await fs.writeFile(configPath, "{}", "utf-8");
+ await fs.writeFile(
+ join(workspaceDir, "policy.jsonc"),
+ JSON.stringify(
+ {
+ channels: {
+ denyRules: [{ id: "no-telegram", when: { provider: "telegram" } }],
+ },
+ },
+ null,
+ 2,
+ ),
+ "utf-8",
+ );
+
+ const result = await runDeniedChannelRepair(repairCtx(configPath, cfg));
+
+ expect(result.changes).toEqual([]);
+ expect(result.warnings).toEqual([
+ "Skipped channel config repair. Enable plugins.entries.policy.config.workspaceRepairs to let doctor --fix edit workspace files.",
+ ]);
+ expect(result.config.channels?.telegram).toEqual({ enabled: true });
+ });
+
+ it("does not let policy.jsonc enable workspace repairs", async () => {
+ const configPath = join(workspaceDir, "openclaw.jsonc");
+ const cfg = {
+ ...cfgWithPolicy(),
+ channels: { telegram: { enabled: true } },
+ } as OpenClawConfig;
+ await fs.writeFile(configPath, "{}", "utf-8");
+ await fs.writeFile(
+ join(workspaceDir, "policy.jsonc"),
+ JSON.stringify(
+ {
+ workspaceRepairs: true,
+ channels: {
+ denyRules: [{ id: "no-telegram", when: { provider: "telegram" } }],
+ },
+ },
+ null,
+ 2,
+ ),
+ "utf-8",
+ );
+
+ const result = await runDeniedChannelRepair(repairCtx(configPath, cfg));
+
+ expect(result.changes).toEqual([]);
+ expect(result.warnings).toContain(
+ "Skipped channel config repair. Enable plugins.entries.policy.config.workspaceRepairs to let doctor --fix edit workspace files.",
+ );
+ expect(result.config.channels?.telegram).toEqual({ enabled: true });
+ });
+
+ it("does not report denied providers for disabled channels", async () => {
+ const configPath = join(workspaceDir, "openclaw.jsonc");
+ const cfg = {
+ ...cfgWithPolicy(),
+ channels: { telegram: { enabled: false } },
+ } as OpenClawConfig;
+ await fs.writeFile(configPath, "{}", "utf-8");
+ await fs.writeFile(
+ join(workspaceDir, "policy.jsonc"),
+ JSON.stringify(
+ {
+ channels: {
+ denyRules: [
+ {
+ id: "no-telegram",
+ when: { provider: "telegram" },
+ reason: "Telegram is not approved for this workspace.",
+ },
+ ],
+ },
+ },
+ null,
+ 2,
+ ),
+ "utf-8",
+ );
+
+ await expect(runPolicyChecks(ctx(configPath, cfg))).resolves.toMatchObject({
+ findings: [],
+ });
+ });
+
+ it("does not run channel checks for an empty category namespace", async () => {
+ const configPath = join(workspaceDir, "openclaw.jsonc");
+ const cfg = {
+ ...cfgWithPolicy(),
+ channels: { telegram: { enabled: true } },
+ } as OpenClawConfig;
+ await fs.writeFile(configPath, "{}", "utf-8");
+ await fs.writeFile(
+ join(workspaceDir, "policy.jsonc"),
+ JSON.stringify({ channels: {} }),
+ "utf-8",
+ );
+
+ const result = await runPolicyChecks(ctx(configPath, cfg));
+
+ expect(result.findings).toEqual([]);
+ });
+});
diff --git a/extensions/policy/src/doctor/register.ts b/extensions/policy/src/doctor/register.ts
new file mode 100644
index 00000000000..8ec13c9faf3
--- /dev/null
+++ b/extensions/policy/src/doctor/register.ts
@@ -0,0 +1,620 @@
+import { basename, isAbsolute, resolve } from "node:path";
+import JSON5 from "json5";
+import {
+ registerHealthCheck as registerPluginHealthCheck,
+ type HealthCheck,
+ type HealthCheckContext,
+ type HealthFinding,
+} from "openclaw/plugin-sdk/health";
+import {
+ collectPolicyEvidence,
+ createPolicyAttestation,
+ policyDocumentHash,
+ type PolicyEvidence,
+} from "../policy-state.js";
+
+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",
+} as const;
+
+export const POLICY_CHECK_IDS = [
+ CHECK_IDS.policyMissingFile,
+ CHECK_IDS.policyInvalidFile,
+ CHECK_IDS.policyHashMismatch,
+ CHECK_IDS.policyAttestationMismatch,
+ CHECK_IDS.policyDeniedChannelProvider,
+] as const;
+
+let registered = false;
+const policyEvaluationCache = new WeakMap>();
+
+export type PolicyDoctorRegistrationHost = {
+ readonly registerHealthCheck: (check: HealthCheck) => void;
+};
+
+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 function registerPolicyDoctorChecks(host?: PolicyDoctorRegistrationHost): void {
+ if (registered) {
+ return;
+ }
+ const registerHealthCheck = host?.registerHealthCheck ?? registerPluginHealthCheck;
+ registerHealthCheck(policyMissingFileCheck);
+ registerHealthCheck(policyInvalidFileCheck);
+ registerHealthCheck(policyHashMismatchCheck);
+ registerHealthCheck(policyAttestationMismatchCheck);
+ registerHealthCheck(policyChannelsDeniedProviderCheck);
+ registered = true;
+}
+
+export function resetPolicyDoctorChecksForTest(): void {
+ registered = false;
+}
+
+export function evaluatePolicy(ctx: HealthCheckContext): Promise {
+ const cached = policyEvaluationCache.get(ctx);
+ if (cached !== undefined) {
+ return cached;
+ }
+ const next = evaluatePolicyUncached(ctx);
+ policyEvaluationCache.set(ctx, next);
+ return next;
+}
+
+const policyMissingFileCheck: HealthCheck = {
+ id: CHECK_IDS.policyMissingFile,
+ kind: "plugin",
+ description: "The enabled policy extension 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);
+ },
+};
+
+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.`),
+ };
+ },
+};
+
+async function evaluatePolicyUncached(ctx: HealthCheckContext): Promise {
+ const settings = policySettings(ctx);
+ const policyPath = policyDisplayName(ctx);
+ const evidence = collectPolicyEvidence(ctx.cfg as Record);
+ const findings: HealthFinding[] = [];
+
+ if (!policyChecksEnabled(ctx, settings)) {
+ return {
+ policyPath,
+ evidence,
+ expectedAttestationHash: settings.expectedAttestationHash,
+ findings,
+ attestedFindings: findings,
+ };
+ }
+
+ const policyFile = await readPolicyFile(ctx);
+ if (policyFile === null) {
+ findings.push({
+ checkId: CHECK_IDS.policyMissingFile,
+ severity: "warning",
+ message: `${policyPath} is missing for the enabled policy extension.`,
+ source: "policy",
+ path: policyPath,
+ fixHint: `Restore ${policyPath} or add the policy artifact for this workspace.`,
+ });
+ return {
+ policyPath,
+ evidence,
+ expectedAttestationHash: settings.expectedAttestationHash,
+ findings,
+ attestedFindings: findings,
+ };
+ }
+
+ const parsedPolicy = parsePolicyFile(policyFile.raw);
+ if (!parsedPolicy.ok) {
+ findings.push(policyParseFinding(policyFile.displayName, policyFile.ocDocName, parsedPolicy));
+ return {
+ policyPath,
+ evidence,
+ expectedAttestationHash: settings.expectedAttestationHash,
+ findings,
+ attestedFindings: findings,
+ };
+ }
+
+ const policy = parsedPolicy.value;
+ const policyHash = policyDocumentHash(policy);
+ const expectedHash = settings.expectedHash;
+ if (
+ typeof expectedHash === "string" &&
+ expectedHash.trim() !== "" &&
+ policyHash !== expectedHash.trim()
+ ) {
+ findings.push({
+ checkId: CHECK_IDS.policyHashMismatch,
+ severity: "error",
+ message: `${policyFile.displayName} does not match the configured policy hash.`,
+ source: "policy",
+ path: policyFile.displayName,
+ target: `oc://${policyFile.ocDocName}`,
+ requirement: "oc://openclaw.config/plugins/entries/policy/config/expectedHash",
+ fixHint: `Restore the approved policy artifact or update plugins.entries.policy.config.expectedHash after review.`,
+ });
+ return {
+ policyPath,
+ policy: { value: policy, hash: policyHash },
+ evidence,
+ expectedAttestationHash: settings.expectedAttestationHash,
+ findings,
+ attestedFindings: findings,
+ };
+ }
+
+ const policyFindings = channelFindings(
+ policy,
+ policyFile.displayName,
+ policyFile.ocDocName,
+ evidence,
+ );
+ const attestationFindings = policyAttestationFindings(
+ policyFile.displayName,
+ policyHash,
+ evidence,
+ policyFindings,
+ settings,
+ );
+ if (hasPolicyValidationFinding(policyFindings)) {
+ findings.push(...policyFindings);
+ } else if (attestationFindings.length > 0) {
+ findings.push(...attestationFindings);
+ } else {
+ findings.push(...policyFindings);
+ }
+
+ return {
+ policyPath,
+ policy: { value: policy, hash: policyHash },
+ evidence,
+ expectedAttestationHash: settings.expectedAttestationHash,
+ findings,
+ attestedFindings: policyFindings,
+ };
+}
+
+function policyParseFinding(
+ policyPath: string,
+ policyDocName: string,
+ parseError: { readonly message: string },
+): HealthFinding {
+ return {
+ checkId: CHECK_IDS.policyInvalidFile,
+ severity: "error",
+ message: `${policyPath} could not be parsed: ${parseError.message}`,
+ source: "policy",
+ path: policyPath,
+ target: `oc://${policyDocName}`,
+ fixHint: `Fix ${policyPath} so policy conformance checks can run.`,
+ };
+}
+
+function findingsForCheck(
+ evaluation: PolicyEvaluation,
+ checkId: (typeof POLICY_CHECK_IDS)[number],
+): readonly HealthFinding[] {
+ return evaluation.findings.filter((finding) => finding.checkId === checkId);
+}
+
+function hasPolicyValidationFinding(findings: readonly HealthFinding[]): boolean {
+ return findings.some((finding) => finding.checkId === CHECK_IDS.policyInvalidFile);
+}
+
+function channelFindings(
+ policy: unknown,
+ policyPath: string,
+ policyDocName: string,
+ evidence: PolicyEvidence,
+): readonly HealthFinding[] {
+ const invalidRules = invalidChannelDenyRuleFindings(policy, policyPath, policyDocName);
+ if (invalidRules.length > 0) {
+ return invalidRules;
+ }
+ const denyRules = readChannelDenyRules(policy, policyDocName);
+ if (denyRules.length === 0) {
+ return [];
+ }
+ return evidence.channels.flatMap((channel): HealthFinding[] => {
+ if (channel.enabled === false) {
+ return [];
+ }
+ const rule = denyRules.find((candidate) => candidate.when?.provider === channel.provider);
+ if (rule === undefined) {
+ return [];
+ }
+ return [
+ {
+ checkId: CHECK_IDS.policyDeniedChannelProvider,
+ severity: "error",
+ message: `Channel '${channel.id}' uses denied provider '${channel.provider}'.`,
+ source: "policy",
+ path: "openclaw config",
+ ocPath: channel.source,
+ target: channel.source,
+ requirement: rule.requirement,
+ fixHint:
+ rule.reason ??
+ "Disable this channel, remove it from config, or update the policy deny rule.",
+ },
+ ];
+ });
+}
+
+function policyAttestationFindings(
+ policyPath: string,
+ policyHash: string,
+ evidence: PolicyEvidence,
+ findings: readonly HealthFinding[],
+ settings: PolicySettings,
+): readonly HealthFinding[] {
+ const expected = settings.expectedAttestationHash?.trim();
+ if (!expected) {
+ return [];
+ }
+ const current = createPolicyAttestation({
+ ok: findings.length === 0,
+ checkedAt: new Date(0).toISOString(),
+ policyPath,
+ policyHash,
+ evidence,
+ findings: findings.map(toAttestedFinding),
+ });
+ if (current.attestationHash === expected) {
+ return [];
+ }
+ return [
+ {
+ checkId: CHECK_IDS.policyAttestationMismatch,
+ severity: "error",
+ message: "The current policy check no longer matches the accepted policy attestation.",
+ source: "policy",
+ path: "policy attestation",
+ target: "oc://policy/attestation/current",
+ requirement: "oc://openclaw.config/plugins/entries/policy/config/expectedAttestationHash",
+ fixHint: `Run policy check, review attestation ${current.attestationHash}, then update plugins.entries.policy.config.expectedAttestationHash and the supervisor/gateway accepted attestation.`,
+ },
+ ];
+}
+
+function toAttestedFinding(finding: HealthFinding): Record {
+ return {
+ checkId: finding.checkId,
+ severity: finding.severity,
+ message: finding.message,
+ ...(finding.source !== undefined ? { source: finding.source } : {}),
+ ...(finding.path !== undefined ? { path: finding.path } : {}),
+ ...(finding.line !== undefined ? { line: finding.line } : {}),
+ ...(finding.column !== undefined ? { column: finding.column } : {}),
+ ...(finding.ocPath !== undefined ? { ocPath: finding.ocPath } : {}),
+ ...(finding.target !== undefined ? { target: finding.target } : {}),
+ ...(finding.requirement !== undefined ? { requirement: finding.requirement } : {}),
+ ...(finding.fixHint !== undefined ? { fixHint: finding.fixHint } : {}),
+ };
+}
+
+function invalidChannelDenyRuleFindings(
+ policy: unknown,
+ policyPath: string,
+ policyDocName: string,
+): readonly HealthFinding[] {
+ if (!isRecord(policy) || !isRecord(policy.channels) || policy.channels.denyRules === undefined) {
+ return [];
+ }
+ if (!Array.isArray(policy.channels.denyRules)) {
+ return [
+ {
+ checkId: CHECK_IDS.policyInvalidFile,
+ severity: "error",
+ message: `${policyPath} channels.denyRules must be an array.`,
+ source: "policy",
+ path: policyPath,
+ target: `oc://${policyDocName}/channels/denyRules`,
+ fixHint: `Fix ${policyPath} so channel deny rules are an array.`,
+ },
+ ];
+ }
+ const invalid = policy.channels.denyRules.findIndex((rule) => !isChannelDenyRule(rule));
+ if (invalid < 0) {
+ return [];
+ }
+ return [
+ {
+ checkId: CHECK_IDS.policyInvalidFile,
+ severity: "error",
+ message: `${policyPath} channels.denyRules[${invalid}] must define when.provider as a string.`,
+ source: "policy",
+ path: policyPath,
+ target: `oc://${policyDocName}/channels/denyRules/#${invalid}`,
+ fixHint: `Fix ${policyPath} so each channel deny rule has a provider match.`,
+ },
+ ];
+}
+
+async function readPolicyFile(
+ ctx: HealthCheckContext,
+): Promise<{ raw: string; path: string; displayName: string; ocDocName: string } | null> {
+ const displayName = policyDisplayName(ctx);
+ const path = resolveWorkspacePath(ctx, policyPathSetting(ctx));
+ try {
+ const fs = await import("node:fs/promises");
+ return {
+ raw: await fs.readFile(path, "utf-8"),
+ path,
+ displayName,
+ ocDocName: basename(displayName),
+ };
+ } catch (err) {
+ if (isNotFound(err)) {
+ return null;
+ }
+ throw err;
+ }
+}
+
+function resolveWorkspacePath(ctx: HealthCheckContext, fileName: string): string {
+ if (isAbsolute(fileName)) {
+ return fileName;
+ }
+ return resolve(ctx.cwd ?? process.cwd(), fileName);
+}
+
+function isNotFound(err: unknown): boolean {
+ return typeof err === "object" && err !== null && "code" in err && err.code === "ENOENT";
+}
+
+function parsePolicyFile(
+ raw: string,
+):
+ | { readonly ok: true; readonly value: unknown }
+ | { readonly ok: false; readonly message: string } {
+ try {
+ return { ok: true, value: JSON5.parse(raw) };
+ } catch (err) {
+ return {
+ ok: false,
+ message: err instanceof Error ? err.message : String(err),
+ };
+ }
+}
+
+function workspaceRepairsEnabled(ctx: HealthCheckContext): boolean {
+ return policySettings(ctx).workspaceRepairs === true;
+}
+
+function workspaceRepairsDisabledResult(fileName: string): {
+ readonly status: "skipped";
+ readonly reason: string;
+ readonly changes: readonly string[];
+ readonly warnings: readonly string[];
+} {
+ const reason = "workspace repairs are disabled";
+ return {
+ status: "skipped",
+ reason,
+ changes: [],
+ warnings: [
+ `Skipped ${fileName} repair. Enable plugins.entries.policy.config.workspaceRepairs to let doctor --fix edit workspace files.`,
+ ],
+ };
+}
+
+function readChannelDenyRules(
+ policy: unknown,
+ policyDocName: string,
+): readonly {
+ readonly id?: string;
+ readonly when?: { readonly provider?: string };
+ readonly reason?: string;
+ readonly requirement: string;
+}[] {
+ if (
+ !isRecord(policy) ||
+ !isRecord(policy.channels) ||
+ !Array.isArray(policy.channels.denyRules)
+ ) {
+ return [];
+ }
+ return policy.channels.denyRules
+ .map((rule, index) => ({ rule, index }))
+ .filter(
+ (
+ entry,
+ ): entry is {
+ readonly index: number;
+ readonly rule: {
+ readonly id?: string;
+ readonly when?: { readonly provider?: string };
+ readonly reason?: string;
+ };
+ } => isChannelDenyRule(entry.rule),
+ )
+ .map(({ rule, index }) => {
+ const next: {
+ id?: string;
+ when?: { readonly provider?: string };
+ reason?: string;
+ requirement: string;
+ } = {
+ when: rule.when,
+ requirement: `oc://${policyDocName}/channels/denyRules/#${index}`,
+ };
+ if (rule.id !== undefined) {
+ next.id = rule.id;
+ }
+ if (rule.reason !== undefined) {
+ next.reason = rule.reason;
+ }
+ return next;
+ });
+}
+
+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 channelIdsFromFindings(findings: readonly HealthFinding[]): readonly string[] {
+ return [
+ ...new Set(
+ findings
+ .filter((finding) => finding.checkId === CHECK_IDS.policyDeniedChannelProvider)
+ .map((finding) => finding.ocPath?.match(/^oc:\/\/openclaw\.config\/channels\/(.+)$/)?.[1])
+ .filter((id): id is string => id !== undefined && id !== ""),
+ ),
+ ];
+}
+
+function disableChannels(
+ cfg: HealthCheckContext["cfg"],
+ channelIds: readonly string[],
+): { readonly config: HealthCheckContext["cfg"]; readonly changed: readonly string[] } {
+ if (!isRecord(cfg.channels)) {
+ return { config: cfg, changed: [] };
+ }
+ const channels: Record = { ...cfg.channels };
+ const changed: string[] = [];
+ for (const id of channelIds) {
+ const current = channels[id];
+ if (!isRecord(current) || current.enabled === false) {
+ continue;
+ }
+ channels[id] = { ...current, enabled: false };
+ changed.push(id);
+ }
+ if (changed.length === 0) {
+ return { config: cfg, changed };
+ }
+ return { config: { ...cfg, channels }, changed };
+}
+
+type PolicySettings = {
+ readonly enabled?: boolean;
+ readonly workspaceRepairs?: boolean;
+ readonly expectedHash?: string;
+ readonly expectedAttestationHash?: string;
+ readonly path?: string;
+};
+
+function policySettings(ctx: HealthCheckContext): PolicySettings {
+ const pluginConfig = ctx.cfg.plugins?.entries?.["policy"]?.config;
+ if (!isRecord(pluginConfig)) {
+ return {};
+ }
+ return pluginConfig;
+}
+
+function policyChecksEnabled(ctx: HealthCheckContext, settings: PolicySettings): boolean {
+ const entry = ctx.cfg.plugins?.entries?.["policy"];
+ if (!isRecord(entry) || entry.enabled === false) {
+ return false;
+ }
+ return settings.enabled !== false;
+}
+
+function policyPathSetting(ctx: HealthCheckContext): string {
+ const configured = policySettings(ctx).path;
+ return typeof configured === "string" && configured.trim() !== ""
+ ? configured.trim()
+ : "policy.jsonc";
+}
+
+function policyDisplayName(ctx: HealthCheckContext): string {
+ const configured = policyPathSetting(ctx);
+ return isAbsolute(configured) ? basename(configured) : configured;
+}
+
+function isRecord(value: unknown): value is Record {
+ return typeof value === "object" && value !== null;
+}
diff --git a/extensions/policy/src/policy-state.test.ts b/extensions/policy/src/policy-state.test.ts
new file mode 100644
index 00000000000..f18aa76982f
--- /dev/null
+++ b/extensions/policy/src/policy-state.test.ts
@@ -0,0 +1,29 @@
+import { describe, expect, it } from "vitest";
+import { scanPolicyChannels } 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",
+ },
+ ]);
+ });
+});
diff --git a/extensions/policy/src/policy-state.ts b/extensions/policy/src/policy-state.ts
new file mode 100644
index 00000000000..05bb17590f3
--- /dev/null
+++ b/extensions/policy/src/policy-state.ts
@@ -0,0 +1,136 @@
+import { createHash } from "node:crypto";
+
+export type PolicyAttestation = {
+ readonly checkedAt: string;
+ readonly policy?: {
+ readonly path: string;
+ readonly hash: string;
+ };
+ readonly workspace: {
+ readonly scope: "policy";
+ readonly hash: string;
+ };
+ readonly findingsHash?: string;
+ readonly attestationHash?: string;
+};
+
+export type PolicyEvidence = {
+ readonly channels: readonly PolicyChannelEvidence[];
+};
+
+export type PolicyChannelEvidence = {
+ readonly id: string;
+ readonly provider: string;
+ readonly source: string;
+ readonly enabled?: boolean;
+};
+
+const RESERVED_CHANNEL_CONFIG_KEYS = new Set(["defaults", "modelByChannel"]);
+
+export function policyDocumentHash(policy: unknown): string {
+ return sha256(stableJson(policy));
+}
+
+export function policyWorkspaceHash(evidence: PolicyEvidence): string {
+ return sha256(stableJson(evidence));
+}
+
+export function policyFindingsHash(findings: readonly unknown[]): string {
+ return sha256(stableJson(findings));
+}
+
+export function policyAttestationHash(input: {
+ readonly ok: boolean;
+ readonly policyHash?: string;
+ readonly workspaceHash: string;
+ readonly findingsHash: string;
+}): string {
+ return sha256(stableJson(input));
+}
+
+export function createPolicyAttestation(input: {
+ readonly ok: boolean;
+ readonly checkedAt: string;
+ readonly policyPath: string;
+ readonly policyHash?: string;
+ readonly evidence: PolicyEvidence;
+ readonly findings: readonly unknown[];
+}): PolicyAttestation {
+ const workspaceHash = policyWorkspaceHash(input.evidence);
+ const findingsHash = policyFindingsHash(input.findings);
+ return {
+ checkedAt: input.checkedAt,
+ ...(input.policyHash === undefined
+ ? {}
+ : {
+ policy: {
+ path: input.policyPath,
+ hash: input.policyHash,
+ },
+ }),
+ workspace: {
+ scope: "policy",
+ hash: workspaceHash,
+ },
+ findingsHash,
+ attestationHash: policyAttestationHash({
+ ok: input.ok,
+ policyHash: input.policyHash,
+ workspaceHash,
+ findingsHash,
+ }),
+ };
+}
+
+export function collectPolicyEvidence(cfg: Record): PolicyEvidence {
+ return {
+ channels: scanPolicyChannels(cfg),
+ };
+}
+
+export function scanPolicyChannels(cfg: Record): readonly PolicyChannelEvidence[] {
+ return Object.entries(configuredChannels(cfg))
+ .filter(([id]) => !RESERVED_CHANNEL_CONFIG_KEYS.has(id))
+ .toSorted(([a], [b]) => a.localeCompare(b))
+ .map(([id, value]) => {
+ const entry: {
+ id: string;
+ provider: string;
+ source: string;
+ enabled?: boolean;
+ } = {
+ id,
+ provider: id,
+ source: `oc://openclaw.config/channels/${id}`,
+ };
+ if (isRecord(value) && typeof value.enabled === "boolean") {
+ entry.enabled = value.enabled;
+ }
+ return entry;
+ });
+}
+
+function configuredChannels(cfg: Record): Record {
+ return isRecord(cfg.channels) ? cfg.channels : {};
+}
+
+function sha256(value: string): string {
+ return `sha256:${createHash("sha256").update(value).digest("hex")}`;
+}
+
+function stableJson(value: unknown): string {
+ if (Array.isArray(value)) {
+ return `[${value.map(stableJson).join(",")}]`;
+ }
+ if (isRecord(value)) {
+ return `{${Object.entries(value)
+ .toSorted(([a], [b]) => a.localeCompare(b))
+ .map(([key, child]) => `${JSON.stringify(key)}:${stableJson(child)}`)
+ .join(",")}}`;
+ }
+ return JSON.stringify(value);
+}
+
+function isRecord(value: unknown): value is Record {
+ return typeof value === "object" && value !== null;
+}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 3c1014952aa..441f0d47fcc 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -1235,6 +1235,19 @@ importers:
specifier: workspace:*
version: link:../../packages/plugin-sdk
+ extensions/policy:
+ dependencies:
+ json5:
+ specifier: 2.2.3
+ version: 2.2.3
+ devDependencies:
+ '@openclaw/plugin-sdk':
+ specifier: workspace:*
+ version: link:../../packages/plugin-sdk
+ openclaw:
+ specifier: workspace:*
+ version: link:../..
+
extensions/qa-channel:
dependencies:
typebox:
diff --git a/scripts/generate-plugin-inventory-doc.mjs b/scripts/generate-plugin-inventory-doc.mjs
index 3dc8985205a..95cfc9ccb66 100644
--- a/scripts/generate-plugin-inventory-doc.mjs
+++ b/scripts/generate-plugin-inventory-doc.mjs
@@ -25,6 +25,7 @@ const PLUGIN_DOC_ALIASES = new Map([
["exa", "/tools/exa-search"],
["firecrawl", "/tools/firecrawl"],
["perplexity", "/tools/perplexity-search"],
+ ["policy", "/cli/policy"],
["tavily", "/tools/tavily"],
["tokenjuice", "/tools/tokenjuice"],
]);
diff --git a/src/cli/program/register.maintenance.test.ts b/src/cli/program/register.maintenance.test.ts
index 0f2ffafef31..eaf49ea9af7 100644
--- a/src/cli/program/register.maintenance.test.ts
+++ b/src/cli/program/register.maintenance.test.ts
@@ -126,6 +126,16 @@ describe("registerMaintenanceCommands doctor action", () => {
expect(runtime.exit).toHaveBeenCalledWith(1);
});
+ it("rejects lint selectors outside doctor lint mode", async () => {
+ await runMaintenanceCli(["doctor", "--fix", "--only", "policy/channels-denied-provider"]);
+
+ expect(doctorCommand).not.toHaveBeenCalled();
+ expect(runtime.error).toHaveBeenCalledWith(
+ "doctor lint options require --lint. Use `openclaw doctor --lint ...`.",
+ );
+ expect(runtime.exit).toHaveBeenCalledWith(2);
+ });
+
it("exits with code 2 when doctor lint mode fails before findings are emitted", async () => {
runDoctorLintCli.mockRejectedValue(new Error("lint failed"));
diff --git a/src/commands/doctor-lint.ts b/src/commands/doctor-lint.ts
index b2bcc4e9b7b..7b25a2c8ff6 100644
--- a/src/commands/doctor-lint.ts
+++ b/src/commands/doctor-lint.ts
@@ -1,5 +1,6 @@
import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../agents/agent-scope.js";
import { readConfigFileSnapshot } from "../config/config.js";
+import { registerBundledHealthChecks } from "../flows/bundled-health-checks.js";
import {
configValidationIssuesToHealthFindings,
registerCoreHealthChecks,
@@ -70,6 +71,7 @@ export async function runDoctorLintCli(
cwd: resolveAgentWorkspaceDir(snapshot.config, resolveDefaultAgentId(snapshot.config)),
...(snapshot.path !== undefined ? { configPath: snapshot.path } : {}),
};
+ registerBundledHealthChecks({ cfg: snapshot.config, cwd: ctx.cwd });
const runOpts: DoctorLintRunOptions = {
...(opts.skipIds && opts.skipIds.length > 0 ? { skipIds: opts.skipIds } : {}),
@@ -133,6 +135,8 @@ function toJsonFinding(f: HealthFinding): Record {
...(f.line !== undefined ? { line: f.line } : {}),
...(f.column !== undefined ? { column: f.column } : {}),
...(f.ocPath !== undefined ? { ocPath: f.ocPath } : {}),
+ ...(f.target !== undefined ? { target: f.target } : {}),
+ ...(f.requirement !== undefined ? { requirement: f.requirement } : {}),
...(f.fixHint !== undefined ? { fixHint: f.fixHint } : {}),
};
}
diff --git a/src/flows/bundled-health-checks.test.ts b/src/flows/bundled-health-checks.test.ts
new file mode 100644
index 00000000000..b43c1cde65d
--- /dev/null
+++ b/src/flows/bundled-health-checks.test.ts
@@ -0,0 +1,80 @@
+import { mkdirSync, rmSync, writeFileSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { registerBundledHealthChecks } from "./bundled-health-checks.js";
+
+const mocks = vi.hoisted(() => ({
+ registerPolicyDoctorChecks: vi.fn(),
+ loadBundledPluginPublicArtifactModuleSync: vi.fn(() => ({
+ registerPolicyDoctorChecks: mocks.registerPolicyDoctorChecks,
+ })),
+}));
+
+vi.mock("../plugins/public-surface-loader.js", () => ({
+ loadBundledPluginPublicArtifactModuleSync: mocks.loadBundledPluginPublicArtifactModuleSync,
+}));
+
+let workspaceDir: string;
+
+describe("registerBundledHealthChecks", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ workspaceDir = join(tmpdir(), `bundled-health-${process.pid}-${Date.now()}`);
+ mkdirSync(workspaceDir, { recursive: true });
+ });
+
+ afterEach(() => {
+ rmSync(workspaceDir, { recursive: true, force: true });
+ });
+
+ it("does not load bundled policy health checks without policy opt-in", () => {
+ registerBundledHealthChecks({ cfg: {}, cwd: workspaceDir });
+
+ expect(mocks.loadBundledPluginPublicArtifactModuleSync).not.toHaveBeenCalled();
+ });
+
+ it("loads bundled policy health checks when policy extension is enabled", () => {
+ registerBundledHealthChecks({
+ cfg: { plugins: { entries: { policy: { enabled: true } } } },
+ cwd: workspaceDir,
+ });
+
+ expect(mocks.loadBundledPluginPublicArtifactModuleSync).toHaveBeenCalledWith({
+ dirName: "policy",
+ artifactBasename: "api.js",
+ });
+ expect(mocks.registerPolicyDoctorChecks).toHaveBeenCalled();
+ });
+
+ it("does not use policy.jsonc existence as extension activation", () => {
+ writeFileSync(join(workspaceDir, "policy.jsonc"), "{}\n", "utf-8");
+
+ registerBundledHealthChecks({ cfg: {}, cwd: workspaceDir });
+
+ expect(mocks.loadBundledPluginPublicArtifactModuleSync).not.toHaveBeenCalled();
+ });
+
+ it("honors explicit policy disablement", () => {
+ registerBundledHealthChecks({
+ cfg: { plugins: { entries: { policy: { enabled: true, config: { enabled: false } } } } },
+ cwd: workspaceDir,
+ });
+
+ expect(mocks.loadBundledPluginPublicArtifactModuleSync).not.toHaveBeenCalled();
+ });
+
+ it("honors plugin control-plane disablement for policy checks", () => {
+ for (const plugins of [
+ { enabled: false, entries: { policy: { enabled: true } } },
+ { deny: ["policy"], entries: { policy: { enabled: true } } },
+ { allow: ["telegram"], entries: { policy: { enabled: true } } },
+ ]) {
+ vi.clearAllMocks();
+
+ registerBundledHealthChecks({ cfg: { plugins }, cwd: workspaceDir });
+
+ expect(mocks.loadBundledPluginPublicArtifactModuleSync).not.toHaveBeenCalled();
+ }
+ });
+});
diff --git a/src/flows/bundled-health-checks.ts b/src/flows/bundled-health-checks.ts
new file mode 100644
index 00000000000..9b0a088a890
--- /dev/null
+++ b/src/flows/bundled-health-checks.ts
@@ -0,0 +1,40 @@
+import type { OpenClawConfig } from "../config/types.openclaw.js";
+import { normalizePluginsConfig } from "../plugins/config-state.js";
+import { passesManifestOwnerBasePolicy } from "../plugins/manifest-owner-policy.js";
+import { loadBundledPluginPublicArtifactModuleSync } from "../plugins/public-surface-loader.js";
+import { registerHealthCheck } from "./health-check-registry.js";
+
+type BundledHealthApi = {
+ registerPolicyDoctorChecks?: (host: { registerHealthCheck: typeof registerHealthCheck }) => void;
+};
+
+export function registerBundledHealthChecks(params: { cfg: OpenClawConfig; cwd?: string }): void {
+ if (!shouldRegisterPolicyHealth(params)) {
+ return;
+ }
+ loadBundledPluginPublicArtifactModuleSync({
+ dirName: "policy",
+ artifactBasename: "api.js",
+ }).registerPolicyDoctorChecks?.({ registerHealthCheck });
+}
+
+function shouldRegisterPolicyHealth(params: { cfg: OpenClawConfig; cwd?: string }): boolean {
+ const entry = params.cfg.plugins?.entries?.policy;
+ const config = isRecord(entry?.config) ? entry.config : {};
+ if (entry === undefined || entry.enabled === false || config.enabled === false) {
+ return false;
+ }
+ if (
+ !passesManifestOwnerBasePolicy({
+ plugin: { id: "policy" },
+ normalizedConfig: normalizePluginsConfig(params.cfg.plugins),
+ })
+ ) {
+ return false;
+ }
+ return entry.enabled === true || config.enabled === true;
+}
+
+function isRecord(value: unknown): value is Record {
+ return typeof value === "object" && value !== null;
+}
diff --git a/src/flows/doctor-health-contributions.ts b/src/flows/doctor-health-contributions.ts
index d1ca0097c7c..14d69aab14b 100644
--- a/src/flows/doctor-health-contributions.ts
+++ b/src/flows/doctor-health-contributions.ts
@@ -242,6 +242,7 @@ async function runStructuredHealthRepairs(ctx: DoctorHealthFlowContext): Promise
return;
}
const { registerCoreHealthChecks } = await import("./doctor-core-checks.js");
+ const { registerBundledHealthChecks } = await import("./bundled-health-checks.js");
const { runDoctorHealthRepairs } = await import("./doctor-repair-flow.js");
const { resolveAgentWorkspaceDir, resolveDefaultAgentId } =
await import("../agents/agent-scope.js");
@@ -249,6 +250,7 @@ async function runStructuredHealthRepairs(ctx: DoctorHealthFlowContext): Promise
registerCoreHealthChecks();
const workspaceDir = resolveAgentWorkspaceDir(ctx.cfg, resolveDefaultAgentId(ctx.cfg));
+ registerBundledHealthChecks({ cfg: ctx.cfg, cwd: workspaceDir });
const result = await runDoctorHealthRepairs({
mode: "fix",
runtime: ctx.runtime,
diff --git a/src/flows/health-checks.ts b/src/flows/health-checks.ts
index b22924ae0f3..6a229a729bd 100644
--- a/src/flows/health-checks.ts
+++ b/src/flows/health-checks.ts
@@ -36,6 +36,8 @@ export interface HealthFinding {
readonly line?: number;
readonly column?: number;
readonly ocPath?: string;
+ readonly target?: string;
+ readonly requirement?: string;
readonly fixHint?: string;
}