Skip to content

Commit a30ac3f

Browse files
authored
Policy: add tool metadata conformance (#80056)
* feat(policy): add tool metadata conformance checks * Add policy trusted tool runtime gate * Use requireMetadata for tool policy Make tools.requireMetadata the canonical policy schema for risk, sensitivity, and owner requirements. Update runtime enforcement, doctor findings, evidence parsing, tests, and policy docs to use the new schema. * fix(policy): persist approval metadata * fix(policy): refresh approval metadata artifacts * docs(policy): list all tool finding checks * fix(policy): parse multiline tool metadata * test(policy): cover unparseable policy check output * fix(policy): resolve oc-path api in packaged dist * fix(policy): clear post-rebase CI failures * test(policy): clear post-rebase CI failures * fix(policy): restore watch and align validation * fix(policy): clear ci gate failures * Simplify policy tool evidence parsing
1 parent 6745fe8 commit a30ac3f

7 files changed

Lines changed: 1321 additions & 42 deletions

File tree

docs/cli/policy.md

Lines changed: 149 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
---
2-
summary: "CLI reference for `openclaw policy` channel conformance checks"
2+
summary: "CLI reference for `openclaw policy` conformance checks"
33
read_when:
44
- You want to check OpenClaw settings against an authored policy.jsonc
55
- You want policy findings in doctor lint
@@ -10,14 +10,23 @@ title: "Policy"
1010
# `openclaw policy`
1111

1212
`openclaw policy` is provided by the bundled Policy plugin. Policy is an
13-
enterprise conformance layer over existing OpenClaw settings: `policy.jsonc`
14-
defines authored requirements, OpenClaw observes the active workspace as
15-
evidence, and policy health checks report drift through `doctor --lint`.
16-
17-
This first policy slice manages configured channels. For example, IT can record
18-
that Telegram is not approved, then `doctor --lint` reports any enabled Telegram
19-
channel and `doctor --fix` can turn it off when workspace repairs are explicitly
20-
enabled.
13+
enterprise conformance layer over existing OpenClaw settings. It does not add a
14+
second configuration system. `policy.jsonc` defines authored requirements,
15+
OpenClaw observes the active workspace as evidence, and policy health checks
16+
report drift through `doctor --lint`. The final conformance signal is a clean
17+
`doctor --lint` run; policy contributes findings to that shared lint surface
18+
instead of creating a separate health gate.
19+
20+
Policy currently manages configured channels and governed tool declarations.
21+
For example, IT or a workspace operator can record that Telegram is not an
22+
approved channel provider, require governed tools to carry risk and sensitivity
23+
metadata, then use `doctor --lint` as the shared conformance gate.
24+
25+
Use policy when a workspace needs a durable statement such as "these channels
26+
must not be enabled" or "governed tools must declare approval metadata" and a
27+
repeatable way to prove that OpenClaw still conforms to that statement. Use
28+
regular config and workspace docs alone when you only need local behavior and
29+
do not need policy findings or attestation output.
2130

2231
## Quick start
2332

@@ -32,7 +41,7 @@ arbitrary plugins. The plugin remains enabled if `policy.jsonc` is missing, so
3241
doctor can report the missing artifact.
3342

3443
Policy is authored, not generated from the user's current settings. A minimal
35-
channel policy looks like this:
44+
policy for channels and tool metadata looks like this:
3645

3746
```jsonc
3847
{
@@ -45,12 +54,16 @@ channel policy looks like this:
4554
},
4655
],
4756
},
57+
"tools": {
58+
"requireMetadata": ["risk", "sensitivity", "owner"],
59+
},
4860
}
4961
```
5062

5163
The rules are the authority. A category block is only a namespace; checks run
5264
when a concrete rule is present. OpenClaw reads current `channels.*` settings
53-
and reports settings that do not conform.
65+
and `TOOLS.md` declarations as evidence, then reports observed state that does
66+
not conform.
5467

5568
Run policy-only checks during authoring:
5669

@@ -122,12 +135,64 @@ Policy config lives under `plugins.entries.policy.config`.
122135
Set `plugins.entries.policy.config.enabled` to `false` to disable policy checks
123136
for a workspace while leaving the plugin installed.
124137

138+
Tool metadata requirements are authored in `policy.jsonc` with
139+
`tools.requireMetadata`, for example `["risk", "sensitivity", "owner"]`.
140+
125141
## Accept policy state
126142

127-
The attestation hash identifies the stable claim: policy hash, evidence hash,
128-
findings hash, and whether the result was clean. It intentionally does not
129-
include `checkedAt`, so the same policy state produces the same attestation
130-
across repeated checks.
143+
Example JSON output:
144+
145+
```json
146+
{
147+
"ok": true,
148+
"attestation": {
149+
"checkedAt": "2026-05-10T20:00:00.000Z",
150+
"policy": {
151+
"path": "policy.jsonc",
152+
"hash": "sha256:..."
153+
},
154+
"workspace": {
155+
"scope": "policy",
156+
"hash": "sha256:..."
157+
},
158+
"findingsHash": "sha256:...",
159+
"attestationHash": "sha256:..."
160+
},
161+
"evidence": {
162+
"channels": [
163+
{
164+
"id": "telegram",
165+
"provider": "telegram",
166+
"source": "oc://openclaw.config/channels/telegram",
167+
"enabled": false
168+
}
169+
],
170+
"tools": [
171+
{
172+
"id": "deploy",
173+
"source": "oc://TOOLS.md/tools/deploy",
174+
"line": 12,
175+
"risk": "critical",
176+
"sensitivity": "restricted",
177+
"capabilities": ["IRREVERSIBLE_EXTERNAL"]
178+
}
179+
]
180+
},
181+
"checksRun": 6,
182+
"checksSkipped": 0,
183+
"findings": []
184+
}
185+
```
186+
187+
The policy hash identifies the authored rule artifact. The evidence block
188+
records the observed OpenClaw state used by the policy checks. The
189+
`workspace.hash` value identifies that evidence payload for the checked scope.
190+
The findings hash identifies the exact finding set returned by the check.
191+
`checkedAt` records when the evaluation ran. The attestation hash identifies
192+
the stable claim: policy hash, evidence hash, findings hash, and whether the
193+
result was clean. It intentionally does not include `checkedAt`, so the same
194+
policy state produces the same attestation across repeated checks. Together,
195+
these form the audit tuple for this policy check.
131196

132197
If a later gateway or supervisor uses policy to block, approve, or annotate a
133198
runtime action, it should record the attestation hash from the last clean policy
@@ -146,20 +211,71 @@ If policy rules change intentionally, update both accepted hashes from a clean
146211
check. If workspace settings change intentionally but policy stays the same,
147212
only `expectedAttestationHash` usually changes.
148213

214+
`openclaw policy watch` runs the same check repeatedly and reports when the
215+
current evidence no longer matches `expectedAttestationHash`:
216+
217+
```bash
218+
openclaw policy watch --json
219+
```
220+
221+
Use `--once` in CI or scripts that only need one drift evaluation. Without
222+
`--once`, the command polls every two seconds by default; use `--interval-ms` to
223+
choose a different interval.
224+
149225
## Findings
150226

151227
Policy currently verifies:
152228

153-
| Check id | Finding |
154-
| ---------------------------------- | ------------------------------------------------------------------- |
155-
| `policy/policy-jsonc-missing` | Policy is enabled but `policy.jsonc` is missing. |
156-
| `policy/policy-jsonc-invalid` | Policy cannot be parsed or has malformed rules. |
157-
| `policy/policy-hash-mismatch` | Policy does not match configured `expectedHash`. |
158-
| `policy/attestation-hash-mismatch` | Current policy evidence no longer matches the accepted attestation. |
159-
| `policy/channels-denied-provider` | An enabled channel matches a channel deny rule. |
229+
| Check id | Finding |
230+
| ---------------------------------------- | ------------------------------------------------------------------- |
231+
| `policy/policy-jsonc-missing` | Policy is enabled but `policy.jsonc` is missing. |
232+
| `policy/policy-jsonc-invalid` | Policy cannot be parsed or has malformed rules. |
233+
| `policy/policy-hash-mismatch` | Policy does not match configured `expectedHash`. |
234+
| `policy/attestation-hash-mismatch` | Current policy evidence no longer matches the accepted attestation. |
235+
| `policy/channels-denied-provider` | An enabled channel matches a channel deny rule. |
236+
| `policy/tools-missing-owner` | A governed tool declaration is missing owner metadata. |
237+
| `policy/tools-missing-risk-level` | A governed tool declaration is missing risk metadata. |
238+
| `policy/tools-missing-sensitivity-token` | A governed tool declaration is missing sensitivity metadata. |
239+
| `policy/tools-unknown-risk-level` | A governed tool declaration uses an unknown risk value. |
240+
| `policy/tools-unknown-sensitivity-token` | A governed tool declaration uses an unknown sensitivity value. |
241+
242+
Policy findings can include both `target` and `requirement`. `target` is the
243+
observed workspace thing that does not conform. `requirement` is the authored
244+
policy rule that made it a finding. Both values are addresses today, usually
245+
`oc://` paths, but the field names describe their policy role rather than the
246+
address format.
247+
248+
Example JSON finding:
249+
250+
```json
251+
{
252+
"checkId": "policy/channels-denied-provider",
253+
"severity": "error",
254+
"message": "Channel 'telegram' uses denied provider 'telegram'.",
255+
"source": "policy",
256+
"path": "openclaw config",
257+
"ocPath": "oc://openclaw.config/channels/telegram",
258+
"target": "oc://openclaw.config/channels/telegram",
259+
"requirement": "oc://policy.jsonc/channels/denyRules/#0",
260+
"fixHint": "Telegram is not approved for this workspace."
261+
}
262+
```
160263

161-
Policy findings can include `target` and `requirement`: the observed workspace
162-
thing that does not conform, and the authored rule that made it a finding.
264+
Example tool finding:
265+
266+
```json
267+
{
268+
"checkId": "policy/tools-missing-risk-level",
269+
"severity": "error",
270+
"message": "TOOLS.md tool 'deploy' has no explicit risk classification.",
271+
"source": "policy",
272+
"path": "TOOLS.md",
273+
"line": 12,
274+
"ocPath": "oc://TOOLS.md/tools/deploy",
275+
"target": "oc://TOOLS.md/tools/deploy",
276+
"requirement": "oc://policy.jsonc/tools/requireMetadata"
277+
}
278+
```
163279

164280
## Repair
165281

@@ -190,5 +306,12 @@ configured channel:
190306

191307
## Exit codes
192308

193-
`policy check` exits `0` when there are no findings at the threshold, `1` when
194-
findings are present, and `2` for argument or runtime failures.
309+
| Command | `0` | `1` | `2` |
310+
| -------------- | ----------------------------------------- | ------------------------------------------------ | ---------------------------- |
311+
| `policy check` | No findings at the threshold. | One or more findings met the threshold. | Argument or runtime failure. |
312+
| `policy watch` | No findings and accepted hash is current. | Findings exist or accepted attestation is stale. | Argument or runtime failure. |
313+
314+
## Related
315+
316+
- [Doctor lint mode](/cli/doctor#lint-mode)
317+
- [Path CLI](/cli/path)

extensions/policy/src/cli.test.ts

Lines changed: 139 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { tmpdir } from "node:os";
33
import { join } from "node:path";
44
import { clearConfigCache } from "openclaw/plugin-sdk/runtime-config-snapshot";
55
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
6-
import { policyCheckCommand } from "./cli.js";
6+
import { policyCheckCommand, policyWatchCommand } from "./cli.js";
77
import { resetPolicyDoctorChecksForTest } from "./doctor/register.js";
88
import {
99
policyAttestationHash,
@@ -30,6 +30,25 @@ async function runPolicyCheckJson(options: Parameters<typeof policyCheckCommand>
3030
return { exitCode, parsed: JSON.parse(output.at(-1) ?? "{}"), output };
3131
}
3232

33+
async function runPolicyWatchJson(options: Parameters<typeof policyWatchCommand>[0] = {}) {
34+
const output: string[] = [];
35+
const exitCode = await policyWatchCommand(
36+
{ cwd: workspaceDir, json: true, once: true, ...options },
37+
{
38+
writeStdout(value) {
39+
output.push(value);
40+
},
41+
error(value) {
42+
output.push(value);
43+
},
44+
async sleep() {
45+
throw new Error("policy watch should not sleep in --once mode");
46+
},
47+
},
48+
);
49+
return { exitCode, parsed: JSON.parse(output.at(-1) ?? "{}"), output };
50+
}
51+
3352
describe("policy commands", () => {
3453
beforeEach(async () => {
3554
workspaceDir = await fs.mkdtemp(join(tmpdir(), "policy-cli-"));
@@ -102,6 +121,39 @@ describe("policy commands", () => {
102121
});
103122
});
104123

124+
it("reports malformed policy containers in policy check output", async () => {
125+
await fs.writeFile(join(workspaceDir, "policy.jsonc"), JSON.stringify({ tools: [] }), "utf-8");
126+
const { exitCode, parsed } = await runPolicyCheckJson();
127+
128+
expect(exitCode).toBe(1);
129+
expect(parsed).toMatchObject({
130+
ok: false,
131+
findings: [
132+
{
133+
checkId: "policy/policy-jsonc-invalid",
134+
target: "oc://policy.jsonc/tools",
135+
},
136+
],
137+
});
138+
});
139+
140+
it("reports unparseable policy files in policy check output", async () => {
141+
await fs.writeFile(join(workspaceDir, "policy.jsonc"), "{ channels: ", "utf-8");
142+
const { exitCode, parsed } = await runPolicyCheckJson();
143+
144+
expect(exitCode).toBe(1);
145+
expect(parsed).toMatchObject({
146+
ok: false,
147+
findings: [
148+
{
149+
checkId: "policy/policy-jsonc-invalid",
150+
severity: "error",
151+
target: "oc://policy.jsonc",
152+
},
153+
],
154+
});
155+
});
156+
105157
it("links policy findings to evidence and policy requirement refs", async () => {
106158
const configPath = join(workspaceDir, "openclaw.jsonc");
107159
vi.stubEnv("OPENCLAW_CONFIG_PATH", configPath);
@@ -193,6 +245,92 @@ describe("policy commands", () => {
193245
);
194246
});
195247

248+
it("reports stale accepted attestations in policy watch", async () => {
249+
const configPath = join(workspaceDir, "openclaw.jsonc");
250+
vi.stubEnv("OPENCLAW_CONFIG_PATH", configPath);
251+
await fs.writeFile(
252+
configPath,
253+
JSON.stringify({
254+
plugins: {
255+
entries: {
256+
policy: {
257+
enabled: true,
258+
config: { enabled: true, expectedAttestationHash: "sha256:not-current" },
259+
},
260+
},
261+
},
262+
}),
263+
"utf-8",
264+
);
265+
await fs.writeFile(
266+
join(workspaceDir, "policy.jsonc"),
267+
JSON.stringify({ channels: { denyRules: [] } }),
268+
"utf-8",
269+
);
270+
271+
const { exitCode, parsed } = await runPolicyWatchJson();
272+
273+
expect(exitCode).toBe(1);
274+
expect(parsed).toMatchObject({
275+
status: "stale",
276+
expectedAttestationHash: "sha256:not-current",
277+
findings: [
278+
{
279+
checkId: "policy/attestation-hash-mismatch",
280+
},
281+
],
282+
});
283+
});
284+
285+
it("reports findings instead of stale when policy watch has no attestation to compare", async () => {
286+
await fs.writeFile(join(workspaceDir, "policy.jsonc"), "{ channels: ", "utf-8");
287+
288+
const { exitCode, parsed } = await runPolicyWatchJson();
289+
290+
expect(exitCode).toBe(1);
291+
expect(parsed).toMatchObject({
292+
status: "findings",
293+
findings: [
294+
{
295+
checkId: "policy/policy-jsonc-invalid",
296+
},
297+
],
298+
});
299+
});
300+
301+
it("reports findings before stale when accepted attestation exists", async () => {
302+
const configPath = join(workspaceDir, "openclaw.jsonc");
303+
vi.stubEnv("OPENCLAW_CONFIG_PATH", configPath);
304+
await fs.writeFile(
305+
configPath,
306+
JSON.stringify({
307+
plugins: {
308+
entries: {
309+
policy: {
310+
enabled: true,
311+
config: { enabled: true, expectedAttestationHash: "sha256:not-current" },
312+
},
313+
},
314+
},
315+
}),
316+
"utf-8",
317+
);
318+
await fs.writeFile(join(workspaceDir, "policy.jsonc"), "{ channels: ", "utf-8");
319+
320+
const { exitCode, parsed } = await runPolicyWatchJson();
321+
322+
expect(exitCode).toBe(1);
323+
expect(parsed).toMatchObject({
324+
status: "findings",
325+
expectedAttestationHash: "sha256:not-current",
326+
findings: [
327+
{
328+
checkId: "policy/policy-jsonc-invalid",
329+
},
330+
],
331+
});
332+
});
333+
196334
it("rejects invalid severity thresholds", async () => {
197335
const errors: string[] = [];
198336

0 commit comments

Comments
 (0)