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; }