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:
Gio Della-Libera
2026-05-20 03:50:21 -07:00
committed by GitHub
parent 9c5e8eb495
commit cbf72e5e26
27 changed files with 2231 additions and 2 deletions

5
.github/labeler.yml vendored
View File

@@ -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:

View 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.

View File

@@ -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
View 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.

View File

@@ -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",

View File

@@ -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 |

View File

@@ -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 |

View 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)

View File

@@ -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
View File

@@ -0,0 +1 @@
export { registerPolicyDoctorChecks } from "./src/doctor/register.js";

View 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();
},
});

View File

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

View File

@@ -0,0 +1,27 @@
{
"name": "@openclaw/policy",
"version": "2026.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"
]
}
}

View 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" }),
]);
});
});

View 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 } : {}),
};
}

View 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([]);
});
});

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

View 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",
},
]);
});
});

View 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
View File

@@ -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:

View File

@@ -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"],
]);

View File

@@ -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"));

View File

@@ -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 } : {}),
};
}

View 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();
}
});
});

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

View File

@@ -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,

View File

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