mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-22 03:24:03 +00:00
feat(policy): add channel conformance checks (#80407)
Summary:
- Add the bundled Policy plugin with policy-backed doctor checks for channel conformance.
- Add `openclaw policy check` attestations, accepted-attestation drift checks, and opt-in doctor repair.
- Add policy CLI docs, generated plugin inventory/reference docs, and changelog credit.
Verification:
- node --import tsx scripts/sync-plugin-versions.ts --check
- pnpm plugins:inventory:check
- pnpm docs:list
- git diff --check origin/main..HEAD
- node scripts/run-vitest.mjs extensions/policy/src/policy-state.test.ts extensions/policy/src/cli.test.ts extensions/policy/src/doctor/register.test.ts src/flows/bundled-health-checks.test.ts src/cli/program/register.maintenance.test.ts
- codex review --uncommitted; accepted finding fixed, reran clean
- codex review --commit HEAD
- GitHub CI for 4e09b067f4: CI, Workflow Sanity, CodeQL, CodeQL Critical Quality, OpenGrep PR Diff, Real behavior proof, Dependency Change Awareness all green; reran failed Windows Node setup job successfully
Co-authored-by: Gio Della-Libera <giodl73@gmail.com>
Co-authored-by: Gio Della-Libera <giodl@microsoft.com>
This commit is contained in:
5
.github/labeler.yml
vendored
5
.github/labeler.yml
vendored
@@ -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:
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
194
docs/cli/policy.md
Normal file
194
docs/cli/policy.md
Normal file
@@ -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.
|
||||
@@ -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",
|
||||
|
||||
@@ -109,6 +109,7 @@ commands.
|
||||
| [opencode-go](/plugins/reference/opencode-go) | Adds OpenCode Go model provider support to OpenClaw. | `@openclaw/opencode-go-provider`<br />included in OpenClaw | providers: opencode-go; contracts: mediaUnderstandingProviders |
|
||||
| [openrouter](/plugins/reference/openrouter) | Adds OpenRouter model provider support to OpenClaw. | `@openclaw/openrouter-provider`<br />included in OpenClaw | providers: openrouter; contracts: imageGenerationProviders, mediaUnderstandingProviders, musicGenerationProviders, speechProviders, videoGenerationProviders |
|
||||
| [perplexity](/plugins/reference/perplexity) | Adds web search provider support. | `@openclaw/perplexity-plugin`<br />included in OpenClaw | contracts: webSearchProviders |
|
||||
| [policy](/plugins/reference/policy) | Adds policy-backed doctor checks for workspace conformance. | `@openclaw/policy`<br />included in OpenClaw | plugin |
|
||||
| [qianfan](/plugins/reference/qianfan) | Adds Qianfan model provider support to OpenClaw. | `@openclaw/qianfan-provider`<br />included in OpenClaw | providers: qianfan |
|
||||
| [qwen](/plugins/reference/qwen) | Adds Qwen, Qwen Cloud, Model Studio, DashScope model provider support to OpenClaw. | `@openclaw/qwen-provider`<br />included in OpenClaw | providers: qwen, qwencloud, modelstudio, dashscope; contracts: mediaUnderstandingProviders, videoGenerationProviders |
|
||||
| [runway](/plugins/reference/runway) | Adds video generation provider support. | `@openclaw/runway-provider`<br />included in OpenClaw | contracts: videoGenerationProviders |
|
||||
|
||||
@@ -96,6 +96,7 @@ pnpm plugins:inventory:gen
|
||||
| [openrouter](/plugins/reference/openrouter) | Adds OpenRouter model provider support to OpenClaw. | `@openclaw/openrouter-provider`<br />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`<br />npm; ClawHub | plugin |
|
||||
| [perplexity](/plugins/reference/perplexity) | Adds web search provider support. | `@openclaw/perplexity-plugin`<br />included in OpenClaw | contracts: webSearchProviders |
|
||||
| [policy](/plugins/reference/policy) | Adds policy-backed doctor checks for workspace conformance. | `@openclaw/policy`<br />included in OpenClaw | plugin |
|
||||
| [qa-channel](/plugins/reference/qa-channel) | Adds the QA Channel surface for sending and receiving OpenClaw messages. | `@openclaw/qa-channel`<br />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`<br />source checkout only | plugin |
|
||||
| [qa-matrix](/plugins/reference/qa-matrix) | Matrix QA transport runner and substrate. | `@openclaw/qa-matrix`<br />source checkout only | plugin |
|
||||
|
||||
23
docs/plugins/reference/policy.md
Normal file
23
docs/plugins/reference/policy.md
Normal file
@@ -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)
|
||||
@@ -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
|
||||
|
||||
|
||||
1
extensions/policy/api.ts
Normal file
1
extensions/policy/api.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { registerPolicyDoctorChecks } from "./src/doctor/register.js";
|
||||
26
extensions/policy/index.ts
Normal file
26
extensions/policy/index.ts
Normal file
@@ -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();
|
||||
},
|
||||
});
|
||||
41
extensions/policy/openclaw.plugin.json
Normal file
41
extensions/policy/openclaw.plugin.json
Normal file
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"id": "policy",
|
||||
"name": "Policy",
|
||||
"description": "Adds policy-backed doctor checks for workspace conformance.",
|
||||
"activation": {
|
||||
"onStartup": true,
|
||||
"onCommands": ["doctor", "policy"]
|
||||
},
|
||||
"commandAliases": [
|
||||
{
|
||||
"name": "policy",
|
||||
"kind": "cli"
|
||||
}
|
||||
],
|
||||
"configSchema": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"enabled": {
|
||||
"type": "boolean",
|
||||
"description": "Enable policy doctor checks even before policy.jsonc exists."
|
||||
},
|
||||
"workspaceRepairs": {
|
||||
"type": "boolean",
|
||||
"description": "Allow doctor --fix to repair policy-managed workspace settings."
|
||||
},
|
||||
"expectedHash": {
|
||||
"type": "string",
|
||||
"description": "Optional sha256 hash for hash-locking the approved policy artifact."
|
||||
},
|
||||
"expectedAttestationHash": {
|
||||
"type": "string",
|
||||
"description": "Optional sha256 hash for the last accepted clean policy check."
|
||||
},
|
||||
"path": {
|
||||
"type": "string",
|
||||
"description": "Optional policy.jsonc path. Relative paths resolve from the active workspace."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
27
extensions/policy/package.json
Normal file
27
extensions/policy/package.json
Normal file
@@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
227
extensions/policy/src/cli.test.ts
Normal file
227
extensions/policy/src/cli.test.ts
Normal file
@@ -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<typeof policyCheckCommand>[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" }),
|
||||
]);
|
||||
});
|
||||
});
|
||||
220
extensions/policy/src/cli.ts
Normal file
220
extensions/policy/src/cli.ts
Normal file
@@ -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<typeof createPolicyAttestation>;
|
||||
readonly evidence: unknown;
|
||||
readonly checksRun: number;
|
||||
readonly checksSkipped: number;
|
||||
readonly findings: readonly Record<string, unknown>[];
|
||||
readonly expectedAttestationHash?: string;
|
||||
readonly exitCode: 0 | 1;
|
||||
};
|
||||
|
||||
const defaultRuntime: PolicyCommandRuntime = {
|
||||
writeStdout(value) {
|
||||
process.stdout.write(value);
|
||||
},
|
||||
error(value) {
|
||||
process.stderr.write(`${value}\n`);
|
||||
},
|
||||
};
|
||||
|
||||
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 <severity>", "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<number> {
|
||||
try {
|
||||
const report = await buildPolicyCheckReport(options, runtime);
|
||||
writePolicyCheckReport(report, options, runtime);
|
||||
return report.exitCode;
|
||||
} catch (err) {
|
||||
runtime.error(err instanceof Error ? err.message : String(err));
|
||||
return 2;
|
||||
}
|
||||
}
|
||||
|
||||
async function buildPolicyCheckReport(
|
||||
options: PolicyCheckOptions,
|
||||
runtime: PolicyCommandRuntime,
|
||||
): Promise<PolicyCheckReport> {
|
||||
const severityMin =
|
||||
options.severityMin === undefined ? "info" : parseHealthFindingSeverity(options.severityMin);
|
||||
if (severityMin === null) {
|
||||
throw new Error("Invalid --severity-min value. Expected one of: info, warning, error.");
|
||||
}
|
||||
const snapshot = await readConfigFileSnapshot({ observe: false });
|
||||
if (!snapshot.valid) {
|
||||
const findings: HealthFinding[] = snapshot.issues.map((issue) => ({
|
||||
checkId: "policy/config-invalid",
|
||||
severity: "error",
|
||||
message: issue.message,
|
||||
source: "policy",
|
||||
path: issue.path,
|
||||
}));
|
||||
const visibleFindings = findings.filter((finding) =>
|
||||
healthFindingMeetsSeverity(finding, severityMin),
|
||||
);
|
||||
return {
|
||||
ok: visibleFindings.length === 0,
|
||||
evidence: { channels: [] },
|
||||
checksRun: 1,
|
||||
checksSkipped: POLICY_CHECK_IDS.length,
|
||||
findings: visibleFindings.map(toJsonFinding),
|
||||
exitCode: visibleFindings.length === 0 ? 0 : 1,
|
||||
};
|
||||
}
|
||||
const cfg = snapshot.valid ? policyCommandConfig(snapshot.config) : {};
|
||||
const cwd = options.cwd ?? resolveAgentWorkspaceDir(cfg, resolveDefaultAgentId(cfg));
|
||||
const ctx: HealthCheckContext = {
|
||||
mode: "lint",
|
||||
runtime: {
|
||||
log(value) {
|
||||
runtime.writeStdout(`${String(value)}\n`);
|
||||
},
|
||||
error(value) {
|
||||
runtime.error(String(value));
|
||||
},
|
||||
exit(code) {
|
||||
process.exitCode = code;
|
||||
},
|
||||
},
|
||||
cfg,
|
||||
cwd,
|
||||
...(snapshot.path !== undefined ? { configPath: snapshot.path } : {}),
|
||||
};
|
||||
const evaluation = await evaluatePolicy(ctx);
|
||||
const findings = evaluation.findings.filter((finding) =>
|
||||
healthFindingMeetsSeverity(finding, severityMin),
|
||||
);
|
||||
const jsonFindings = findings.map(toJsonFinding);
|
||||
const attestedFindings = evaluation.attestedFindings.map(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<string, unknown> {
|
||||
return {
|
||||
checkId: finding.checkId,
|
||||
severity: finding.severity,
|
||||
message: finding.message,
|
||||
...(finding.source !== undefined ? { source: finding.source } : {}),
|
||||
...(finding.path !== undefined ? { path: finding.path } : {}),
|
||||
...(finding.line !== undefined ? { line: finding.line } : {}),
|
||||
...(finding.ocPath !== undefined ? { ocPath: finding.ocPath } : {}),
|
||||
...(finding.target !== undefined ? { target: finding.target } : {}),
|
||||
...(finding.requirement !== undefined ? { requirement: finding.requirement } : {}),
|
||||
...(finding.fixHint !== undefined ? { fixHint: finding.fixHint } : {}),
|
||||
};
|
||||
}
|
||||
524
extensions/policy/src/doctor/register.test.ts
Normal file
524
extensions/policy/src/doctor/register.test.ts
Normal file
@@ -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<string, unknown> = {}): 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([]);
|
||||
});
|
||||
});
|
||||
620
extensions/policy/src/doctor/register.ts
Normal file
620
extensions/policy/src/doctor/register.ts
Normal file
@@ -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<HealthCheckContext, Promise<PolicyEvaluation>>();
|
||||
|
||||
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<PolicyEvaluation> {
|
||||
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<PolicyEvaluation> {
|
||||
const settings = policySettings(ctx);
|
||||
const policyPath = policyDisplayName(ctx);
|
||||
const evidence = collectPolicyEvidence(ctx.cfg as Record<string, unknown>);
|
||||
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<string, unknown> {
|
||||
return {
|
||||
checkId: finding.checkId,
|
||||
severity: finding.severity,
|
||||
message: finding.message,
|
||||
...(finding.source !== undefined ? { source: finding.source } : {}),
|
||||
...(finding.path !== undefined ? { path: finding.path } : {}),
|
||||
...(finding.line !== undefined ? { line: finding.line } : {}),
|
||||
...(finding.column !== undefined ? { column: finding.column } : {}),
|
||||
...(finding.ocPath !== undefined ? { ocPath: finding.ocPath } : {}),
|
||||
...(finding.target !== undefined ? { target: finding.target } : {}),
|
||||
...(finding.requirement !== undefined ? { requirement: finding.requirement } : {}),
|
||||
...(finding.fixHint !== undefined ? { fixHint: finding.fixHint } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function 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<string, unknown> = { ...cfg.channels };
|
||||
const changed: string[] = [];
|
||||
for (const id of channelIds) {
|
||||
const current = channels[id];
|
||||
if (!isRecord(current) || current.enabled === false) {
|
||||
continue;
|
||||
}
|
||||
channels[id] = { ...current, enabled: false };
|
||||
changed.push(id);
|
||||
}
|
||||
if (changed.length === 0) {
|
||||
return { config: cfg, changed };
|
||||
}
|
||||
return { config: { ...cfg, channels }, changed };
|
||||
}
|
||||
|
||||
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<string, unknown> {
|
||||
return typeof value === "object" && value !== null;
|
||||
}
|
||||
29
extensions/policy/src/policy-state.test.ts
Normal file
29
extensions/policy/src/policy-state.test.ts
Normal file
@@ -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",
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
136
extensions/policy/src/policy-state.ts
Normal file
136
extensions/policy/src/policy-state.ts
Normal file
@@ -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<string, unknown>): PolicyEvidence {
|
||||
return {
|
||||
channels: scanPolicyChannels(cfg),
|
||||
};
|
||||
}
|
||||
|
||||
export function scanPolicyChannels(cfg: Record<string, unknown>): 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<string, unknown>): Record<string, unknown> {
|
||||
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<string, unknown> {
|
||||
return typeof value === "object" && value !== null;
|
||||
}
|
||||
13
pnpm-lock.yaml
generated
13
pnpm-lock.yaml
generated
@@ -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:
|
||||
|
||||
@@ -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"],
|
||||
]);
|
||||
|
||||
@@ -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"));
|
||||
|
||||
|
||||
@@ -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<string, unknown> {
|
||||
...(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 } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
80
src/flows/bundled-health-checks.test.ts
Normal file
80
src/flows/bundled-health-checks.test.ts
Normal file
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
40
src/flows/bundled-health-checks.ts
Normal file
40
src/flows/bundled-health-checks.ts
Normal file
@@ -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<BundledHealthApi>({
|
||||
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<string, unknown> {
|
||||
return typeof value === "object" && value !== null;
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user