Policy: add policy file comparison command (#86768)

Merged via squash.

Prepared head SHA: 2023e8cba1
Co-authored-by: giodl73-repo <235387111+giodl73-repo@users.noreply.github.com>
Reviewed-by: @giodl73-repo
This commit is contained in:
Gio Della-Libera
2026-05-28 23:10:27 -07:00
committed by GitHub
parent 8124fb4aa4
commit 08beb6b0e8
6 changed files with 1522 additions and 14 deletions

View File

@@ -400,8 +400,41 @@ openclaw policy check --severity-min error
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:
Compare an operator policy file to an authored baseline policy file:
```bash
openclaw policy compare --baseline official.policy.jsonc
openclaw policy compare --baseline official.policy.jsonc --policy policy.jsonc --json
```
`policy compare` compares policy file syntax to policy file syntax. It does not
inspect OpenClaw runtime state, evidence, credentials, or secrets. The command
uses the same policy rule metadata that governs scoped overlays: allowlists must
stay equal or narrower, denylists must stay equal or broader, required booleans
must keep their required value, ordered strings must move only toward the more
restrictive end of the configured order, and exact lists must match.
The baseline file can be an organization-authored policy. The checked policy can
use stricter values or add extra policy rules. A top-level checked rule can also
satisfy a scoped baseline rule when it is equally or more restrictive because
top-level policy applies broadly. Scope names do not need to match; scoped
comparison is keyed by selector value such as `agentIds` or `channelIds` and by
the policy field being checked.
Example clean compare JSON output reports only policy-file comparison state:
```json
{
"ok": true,
"baselinePath": "official.policy.jsonc",
"policyPath": "policy.jsonc",
"rulesChecked": 3,
"findings": []
}
```
Example clean `policy check --json` output includes stable hashes that can be
recorded by an operator or supervisor:
```json
{
@@ -647,6 +680,9 @@ Policy currently verifies:
| `policy/policy-jsonc-invalid` | Policy cannot be parsed or contains malformed rule entries. |
| `policy/policy-hash-mismatch` | Policy does not match configured `expectedHash`. |
| `policy/attestation-hash-mismatch` | Current policy evidence no longer matches the accepted attestation. |
| `policy/policy-conformance-invalid` | A baseline or checked policy file has invalid comparison syntax. |
| `policy/policy-conformance-missing` | A checked policy file is missing a rule required by the baseline policy file. |
| `policy/policy-conformance-weaker` | A checked policy file has a weaker value than the baseline policy file. |
| `policy/channels-denied-provider` | An enabled channel matches a channel deny rule. |
| `policy/mcp-denied-server` | A configured MCP server is denied by policy. |
| `policy/mcp-unapproved-server` | A configured MCP server is outside the allowlist. |
@@ -838,10 +874,11 @@ configured channel:
## Exit codes
| Command | `0` | `1` | `2` |
| -------------- | ----------------------------------------- | ------------------------------------------------ | ---------------------------- |
| `policy check` | No findings at the threshold. | One or more findings met the threshold. | Argument or runtime failure. |
| `policy watch` | No findings and accepted hash is current. | Findings exist or accepted attestation is stale. | Argument or runtime failure. |
| Command | `0` | `1` | `2` |
| ---------------- | ------------------------------------------------------ | ------------------------------------------------------------------- | ---------------------------- |
| `policy check` | No findings at the threshold. | One or more findings met the threshold. | Argument or runtime failure. |
| `policy compare` | The policy file is at least as strict as the baseline. | The policy file is invalid, missing, or weaker than baseline rules. | Argument or runtime failure. |
| `policy watch` | No findings and accepted hash is current. | Findings exist or accepted attestation is stale. | Argument or runtime failure. |
## Related

View File

@@ -34,6 +34,11 @@ through `openclaw policy check` and `openclaw doctor --lint`. A clean policy
check emits policy, evidence, findings, and attestation hashes that operators
can record for audit.
`openclaw policy compare --baseline <file>` compares one policy file to another
policy file. It is config-level conformance only: it uses policy rule metadata
to verify that the checked policy is not missing or weaker than the authored
baseline, and it does not inspect runtime state, credentials, or secret values.
Tool posture rules can require approved profiles, workspace-only filesystem
tools, bounded exec security/ask/host settings, disabled elevated mode, exact
`alsoAllow` entries, and required tool deny entries. The evidence records

View File

@@ -3,7 +3,7 @@ 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, policyWatchCommand } from "./cli.js";
import { policyCheckCommand, policyCompareCommand, policyWatchCommand } from "./cli.js";
import { resetPolicyDoctorChecksForTest } from "./doctor/register.js";
import {
policyAttestationHash,
@@ -49,6 +49,22 @@ async function runPolicyWatchJson(options: Parameters<typeof policyWatchCommand>
return { exitCode, parsed: JSON.parse(output.at(-1) ?? "{}"), output };
}
async function runPolicyCompareJson(options: Parameters<typeof policyCompareCommand>[0]) {
const output: string[] = [];
const exitCode = await policyCompareCommand(
{ 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-"));
@@ -442,4 +458,564 @@ describe("policy commands", () => {
expect.objectContaining({ checkId: "policy/config-invalid", severity: "error" }),
]);
});
it("checks policy file conformance with metadata-backed global rules", async () => {
await fs.writeFile(
join(workspaceDir, "baseline.policy.jsonc"),
JSON.stringify({
channels: { denyRules: [{ when: { provider: "telegram" } }] },
mcp: { servers: { allow: ["docs", "audit"], deny: ["untrusted"] } },
models: { providers: { allow: ["openai", "anthropic"], deny: ["openrouter"] } },
network: { privateNetwork: { allow: false } },
ingress: { session: { requireDmScope: "per-peer" } },
gateway: {
exposure: { allowNonLoopbackBind: false },
auth: { requireAuth: true },
http: { denyEndpoints: ["responses"] },
},
tools: { requireMetadata: ["risk"] },
secrets: { requireManagedProviders: true, denySources: ["env"] },
auth: { profiles: { allowModes: ["oauth", "token"], requireMetadata: ["provider"] } },
}),
"utf-8",
);
await fs.writeFile(
join(workspaceDir, "policy.jsonc"),
JSON.stringify({
channels: { denyRules: [{ when: { provider: "telegram" } }] },
mcp: { servers: { allow: ["docs"], deny: ["untrusted", "shadow"] } },
models: { providers: { allow: ["openai"], deny: ["openrouter", "local"] } },
network: { privateNetwork: { allow: false } },
ingress: { session: { requireDmScope: "per-channel-peer" } },
gateway: {
exposure: { allowNonLoopbackBind: false },
auth: { requireAuth: true },
http: { denyEndpoints: ["responses", "chatCompletions"] },
},
tools: { requireMetadata: ["risk", "owner"] },
secrets: { requireManagedProviders: true, denySources: ["env", "file"] },
auth: { profiles: { allowModes: ["oauth"], requireMetadata: ["provider", "mode"] } },
}),
"utf-8",
);
const { exitCode, parsed } = await runPolicyCompareJson({
baseline: "baseline.policy.jsonc",
});
expect(exitCode).toBe(0);
expect(parsed).toMatchObject({
ok: true,
baselinePath: "baseline.policy.jsonc",
policyPath: "policy.jsonc",
findings: [],
});
expect(parsed.rulesChecked).toBeGreaterThan(10);
});
it("reports missing and weaker policy file conformance rules", async () => {
await fs.writeFile(
join(workspaceDir, "baseline.policy.jsonc"),
JSON.stringify({
channels: { denyRules: [{ when: { provider: "telegram" } }] },
network: { privateNetwork: { allow: false } },
gateway: { auth: { requireAuth: true } },
secrets: { denySources: ["env"] },
}),
"utf-8",
);
await fs.writeFile(
join(workspaceDir, "candidate.policy.jsonc"),
JSON.stringify({
channels: { denyRules: [{ when: { provider: "Telegram" } }] },
network: { privateNetwork: { allow: true } },
secrets: { denySources: [] },
}),
"utf-8",
);
const { exitCode, parsed } = await runPolicyCompareJson({
baseline: "baseline.policy.jsonc",
policy: "candidate.policy.jsonc",
});
expect(exitCode).toBe(1);
expect(parsed.findings).toEqual(
expect.arrayContaining([
expect.objectContaining({
checkId: "policy/policy-conformance-weaker",
requirement: "oc://baseline.policy.jsonc/channels/denyRules",
}),
expect.objectContaining({
checkId: "policy/policy-conformance-weaker",
requirement: "oc://baseline.policy.jsonc/network/privateNetwork/allow",
}),
expect.objectContaining({
checkId: "policy/policy-conformance-missing",
requirement: "oc://baseline.policy.jsonc/gateway/auth/requireAuth",
}),
expect.objectContaining({
checkId: "policy/policy-conformance-weaker",
requirement: "oc://baseline.policy.jsonc/secrets/denySources",
}),
]),
);
});
it("returns JSON findings for malformed policy compare files", async () => {
await fs.writeFile(join(workspaceDir, "baseline.policy.jsonc"), "{", "utf-8");
await fs.writeFile(join(workspaceDir, "policy.jsonc"), JSON.stringify({}), "utf-8");
const { exitCode, parsed } = await runPolicyCompareJson({
baseline: "baseline.policy.jsonc",
});
expect(exitCode).toBe(1);
expect(parsed).toMatchObject({
ok: false,
rulesChecked: 0,
findings: [
{
checkId: "policy/policy-conformance-invalid",
target: "oc://baseline.policy.jsonc",
},
],
});
});
it("returns JSON findings for missing policy compare files", async () => {
await fs.writeFile(join(workspaceDir, "policy.jsonc"), JSON.stringify({}), "utf-8");
const { exitCode, parsed } = await runPolicyCompareJson({
baseline: "missing.policy.jsonc",
});
expect(exitCode).toBe(1);
expect(parsed).toMatchObject({
ok: false,
rulesChecked: 0,
findings: [
{
checkId: "policy/policy-conformance-invalid",
target: "oc://missing.policy.jsonc",
},
],
});
});
it("does not require candidate keys for baseline rules that impose no restriction", async () => {
await fs.writeFile(
join(workspaceDir, "baseline.policy.jsonc"),
JSON.stringify({
channels: { denyRules: [] },
gateway: { auth: { requireAuth: false } },
mcp: { servers: { allow: [] } },
}),
"utf-8",
);
await fs.writeFile(join(workspaceDir, "policy.jsonc"), JSON.stringify({}), "utf-8");
const { exitCode, parsed } = await runPolicyCompareJson({
baseline: "baseline.policy.jsonc",
});
expect(exitCode).toBe(0);
expect(parsed).toMatchObject({
ok: true,
findings: [],
});
});
it("rejects malformed baseline policy rules during policy file conformance", async () => {
await fs.writeFile(
join(workspaceDir, "baseline.policy.jsonc"),
JSON.stringify({
channels: { denyRules: [{ when: {} }] },
}),
"utf-8",
);
await fs.writeFile(join(workspaceDir, "policy.jsonc"), JSON.stringify({}), "utf-8");
const { exitCode, parsed } = await runPolicyCompareJson({
baseline: "baseline.policy.jsonc",
});
expect(exitCode).toBe(1);
expect(parsed.findings).toEqual([
expect.objectContaining({
checkId: "policy/policy-conformance-invalid",
target: "oc://baseline.policy.jsonc/channels/denyRules",
}),
]);
});
it("rejects malformed policy containers during policy file conformance", async () => {
await fs.writeFile(
join(workspaceDir, "baseline.policy.jsonc"),
JSON.stringify({
network: { privateNetwork: "bad" },
tools: "bad",
}),
"utf-8",
);
await fs.writeFile(join(workspaceDir, "policy.jsonc"), JSON.stringify({}), "utf-8");
const { exitCode, parsed } = await runPolicyCompareJson({
baseline: "baseline.policy.jsonc",
});
expect(exitCode).toBe(1);
expect(parsed.findings).toEqual(
expect.arrayContaining([
expect.objectContaining({
checkId: "policy/policy-conformance-invalid",
target: "oc://baseline.policy.jsonc/tools",
}),
]),
);
});
it("rejects scoped policy rules that do not have a valid supported selector", async () => {
await fs.writeFile(
join(workspaceDir, "baseline.policy.jsonc"),
JSON.stringify({
scopes: {
missingSelector: {
tools: { exec: { allowHosts: ["sandbox"] } },
},
wrongSelector: {
channelIds: ["telegram"],
tools: { exec: { allowHosts: ["sandbox"] } },
},
},
}),
"utf-8",
);
await fs.writeFile(join(workspaceDir, "policy.jsonc"), JSON.stringify({}), "utf-8");
const { exitCode, parsed } = await runPolicyCompareJson({
baseline: "baseline.policy.jsonc",
});
expect(exitCode).toBe(1);
expect(parsed.findings).toEqual(
expect.arrayContaining([
expect.objectContaining({
checkId: "policy/policy-conformance-invalid",
target: "oc://baseline.policy.jsonc/scopes/missingSelector/tools/exec/allowHosts",
}),
expect.objectContaining({
checkId: "policy/policy-conformance-invalid",
target: "oc://baseline.policy.jsonc/scopes/wrongSelector/tools/exec/allowHosts",
}),
]),
);
});
it("rejects unsupported enum values during policy file conformance", async () => {
await fs.writeFile(
join(workspaceDir, "baseline.policy.jsonc"),
JSON.stringify({
auth: { profiles: { allowModes: ["password"] } },
gateway: { http: { denyEndpoints: ["bogus"] } },
tools: { requireMetadata: ["custom"] },
}),
"utf-8",
);
await fs.writeFile(
join(workspaceDir, "policy.jsonc"),
JSON.stringify({
auth: { profiles: { allowModes: ["password"] } },
gateway: { http: { denyEndpoints: ["bogus"] } },
tools: { requireMetadata: ["custom"] },
}),
"utf-8",
);
const { exitCode, parsed } = await runPolicyCompareJson({
baseline: "baseline.policy.jsonc",
});
expect(exitCode).toBe(1);
expect(parsed.findings).toEqual(
expect.arrayContaining([
expect.objectContaining({
checkId: "policy/policy-conformance-invalid",
target: "oc://baseline.policy.jsonc/auth/profiles/allowModes",
}),
expect.objectContaining({
checkId: "policy/policy-conformance-invalid",
target: "oc://baseline.policy.jsonc/gateway/http/denyEndpoints",
}),
expect.objectContaining({
checkId: "policy/policy-conformance-invalid",
target: "oc://baseline.policy.jsonc/tools/requireMetadata",
}),
]),
);
});
it("normalizes model provider casing during policy file conformance", async () => {
await fs.writeFile(
join(workspaceDir, "baseline.policy.jsonc"),
JSON.stringify({
models: { providers: { allow: ["OpenAI"], deny: ["OpenRouter"] } },
}),
"utf-8",
);
await fs.writeFile(
join(workspaceDir, "policy.jsonc"),
JSON.stringify({
models: { providers: { allow: ["openai"], deny: ["openrouter"] } },
}),
"utf-8",
);
const { exitCode, parsed } = await runPolicyCompareJson({
baseline: "baseline.policy.jsonc",
});
expect(exitCode).toBe(0);
expect(parsed.findings).toEqual([]);
});
it("rejects gateway HTTP endpoint ids with invalid casing during policy file conformance", async () => {
await fs.writeFile(
join(workspaceDir, "baseline.policy.jsonc"),
JSON.stringify({
gateway: { http: { denyEndpoints: ["chatCompletions"] } },
}),
"utf-8",
);
await fs.writeFile(
join(workspaceDir, "policy.jsonc"),
JSON.stringify({
gateway: { http: { denyEndpoints: ["chatcompletions"] } },
}),
"utf-8",
);
const { exitCode, parsed } = await runPolicyCompareJson({
baseline: "baseline.policy.jsonc",
});
expect(exitCode).toBe(1);
expect(parsed.findings).toEqual(
expect.arrayContaining([
expect.objectContaining({
checkId: "policy/policy-conformance-invalid",
target: "oc://policy.jsonc/gateway/http/denyEndpoints/#0",
}),
]),
);
});
it("resolves the default compare policy path from the configured agent workspace", async () => {
const agentWorkspace = join(workspaceDir, "agent-workspace");
await fs.mkdir(agentWorkspace, { recursive: true });
const configPath = join(workspaceDir, "openclaw.jsonc");
vi.stubEnv("OPENCLAW_CONFIG_PATH", configPath);
await fs.writeFile(
configPath,
JSON.stringify({
agents: { defaults: { workspace: agentWorkspace } },
plugins: {
entries: {
policy: { enabled: true, config: { enabled: true, path: "policy.jsonc" } },
},
},
}),
"utf-8",
);
await fs.writeFile(
join(workspaceDir, "baseline.policy.jsonc"),
JSON.stringify({
network: { privateNetwork: { allow: false } },
}),
"utf-8",
);
await fs.writeFile(
join(agentWorkspace, "policy.jsonc"),
JSON.stringify({
network: { privateNetwork: { allow: false } },
}),
"utf-8",
);
const output: string[] = [];
const exitCode = await policyCompareCommand(
{ baseline: join(workspaceDir, "baseline.policy.jsonc"), json: true },
{
writeStdout(value) {
output.push(value);
},
error(value) {
output.push(value);
},
},
);
const parsed = JSON.parse(output.at(-1) ?? "{}");
expect(exitCode).toBe(0);
expect(parsed).toMatchObject({
ok: true,
policyPath: "policy.jsonc",
findings: [],
});
});
it("allows a top-level candidate rule to satisfy a scoped baseline rule", async () => {
await fs.writeFile(
join(workspaceDir, "baseline.policy.jsonc"),
JSON.stringify({
scopes: {
release: {
agentIds: ["main"],
tools: { exec: { allowHosts: ["sandbox"] } },
},
telegram: {
channelIds: ["telegram"],
ingress: { channels: { requireMentionInGroups: true } },
},
},
}),
"utf-8",
);
await fs.writeFile(
join(workspaceDir, "policy.jsonc"),
JSON.stringify({
tools: { exec: { allowHosts: ["sandbox"] } },
ingress: { channels: { requireMentionInGroups: true } },
}),
"utf-8",
);
const { exitCode, parsed } = await runPolicyCompareJson({
baseline: "baseline.policy.jsonc",
});
expect(exitCode).toBe(0);
expect(parsed.findings).toEqual([]);
});
it("rejects a weaker scoped candidate override even when top-level policy satisfies baseline", async () => {
await fs.writeFile(
join(workspaceDir, "baseline.policy.jsonc"),
JSON.stringify({
scopes: {
release: {
agentIds: ["main"],
tools: { exec: { allowHosts: ["sandbox"] } },
},
},
}),
"utf-8",
);
await fs.writeFile(
join(workspaceDir, "policy.jsonc"),
JSON.stringify({
tools: { exec: { allowHosts: ["sandbox"] } },
scopes: {
release: {
agentIds: ["main"],
tools: { exec: { allowHosts: ["sandbox", "node"] } },
},
},
}),
"utf-8",
);
const { exitCode, parsed } = await runPolicyCompareJson({
baseline: "baseline.policy.jsonc",
});
expect(exitCode).toBe(1);
expect(parsed.findings).toEqual([
expect.objectContaining({
checkId: "policy/policy-conformance-invalid",
requirement: "oc://policy.jsonc/scopes/release/tools/exec/allowHosts",
target: "oc://policy.jsonc/scopes/release/tools/exec/allowHosts",
}),
]);
});
it("rejects duplicate scoped candidates when any matching scoped value is weaker", async () => {
await fs.writeFile(
join(workspaceDir, "baseline.policy.jsonc"),
JSON.stringify({
scopes: {
release: {
agentIds: ["main"],
tools: { exec: { allowHosts: ["sandbox"] } },
},
},
}),
"utf-8",
);
await fs.writeFile(
join(workspaceDir, "policy.jsonc"),
JSON.stringify({
scopes: {
release: {
agentIds: ["main"],
tools: { exec: { allowHosts: ["sandbox"] } },
},
relaxed: {
agentIds: ["main"],
tools: { exec: { allowHosts: ["sandbox", "node"] } },
},
},
}),
"utf-8",
);
const { exitCode, parsed } = await runPolicyCompareJson({
baseline: "baseline.policy.jsonc",
});
expect(exitCode).toBe(1);
expect(parsed.findings).toEqual([
expect.objectContaining({
checkId: "policy/policy-conformance-invalid",
requirement: "oc://policy.jsonc/scopes/relaxed/tools/exec/allowHosts",
target: "oc://policy.jsonc/scopes/relaxed/tools/exec/allowHosts",
}),
]);
});
it("rejects a weaker scoped candidate override for a global baseline rule", async () => {
await fs.writeFile(
join(workspaceDir, "baseline.policy.jsonc"),
JSON.stringify({
tools: { exec: { allowHosts: ["sandbox"] } },
}),
"utf-8",
);
await fs.writeFile(
join(workspaceDir, "policy.jsonc"),
JSON.stringify({
tools: { exec: { allowHosts: ["sandbox"] } },
scopes: {
relaxed: {
agentIds: ["main"],
tools: { exec: { allowHosts: ["sandbox", "node"] } },
},
},
}),
"utf-8",
);
const { exitCode, parsed } = await runPolicyCompareJson({
baseline: "baseline.policy.jsonc",
});
expect(exitCode).toBe(1);
expect(parsed.findings).toEqual([
expect.objectContaining({
checkId: "policy/policy-conformance-invalid",
requirement: "oc://policy.jsonc/scopes/relaxed/tools/exec/allowHosts",
target: "oc://policy.jsonc/scopes/relaxed/tools/exec/allowHosts",
}),
]);
});
});

View File

@@ -1,3 +1,4 @@
import { isAbsolute, resolve } from "node:path";
import { setTimeout as sleep } from "node:timers/promises";
import type { Command } from "commander";
import {
@@ -11,6 +12,10 @@ import {
type HealthFinding,
} from "openclaw/plugin-sdk/health";
import { POLICY_CHECK_IDS, evaluatePolicy } from "./doctor/register.js";
import {
buildPolicyConformanceReport,
type PolicyConformanceReport,
} from "./policy-conformance.js";
import { createPolicyAttestation } from "./policy-state.js";
export type PolicyCommandRuntime = {
@@ -30,6 +35,13 @@ export interface PolicyWatchOptions extends PolicyCheckOptions {
readonly once?: boolean;
}
export interface PolicyCompareOptions {
readonly baseline?: string;
readonly policy?: string;
readonly json?: boolean;
readonly cwd?: string;
}
type PolicyCheckReport = {
readonly ok: boolean;
readonly attestation?: ReturnType<typeof createPolicyAttestation>;
@@ -56,6 +68,16 @@ const defaultRuntime: PolicyCommandRuntime = {
export function registerPolicyCli(program: Command): void {
const policy = program.command("policy").description("Verify workspace policy conformance");
policy
.command("compare")
.description("Compare policy.jsonc against an authored baseline policy file")
.requiredOption("--baseline <path>", "Baseline policy file to compare against")
.option("--policy <path>", "Policy file to check; defaults to configured policy path")
.option("--json", "Emit JSON output")
.action(async (options: PolicyCompareOptions) => {
process.exitCode = await policyCompareCommand(options);
});
policy
.command("check")
.description("Check policy requirements and emit an audit attestation")
@@ -77,6 +99,28 @@ export function registerPolicyCli(program: Command): void {
});
}
export async function policyCompareCommand(
options: PolicyCompareOptions,
runtime: PolicyCommandRuntime = defaultRuntime,
): Promise<number> {
try {
if (options.baseline === undefined || options.baseline.trim() === "") {
throw new Error("Missing required --baseline value.");
}
const policyPath = await policyCompareCandidatePath(options);
const report = await buildPolicyConformanceReport({
baselinePath: options.baseline,
policyPath,
cwd: options.cwd,
});
writePolicyConformanceReport(report, options, runtime);
return report.ok ? 0 : 1;
} catch (err) {
runtime.error(err instanceof Error ? err.message : String(err));
return 2;
}
}
export async function policyCheckCommand(
options: PolicyCheckOptions,
runtime: PolicyCommandRuntime = defaultRuntime,
@@ -220,6 +264,30 @@ function policyCommandConfig(cfg: HealthCheckContext["cfg"]): HealthCheckContext
};
}
async function policyCompareCandidatePath(options: PolicyCompareOptions): Promise<string> {
if (options.policy !== undefined && options.policy.trim() !== "") {
return options.policy.trim();
}
const snapshot = await readConfigFileSnapshot({ observe: false });
if (!snapshot.valid) {
return "policy.jsonc";
}
const pluginConfig = snapshot.config.plugins?.entries?.["policy"]?.config;
const configured =
typeof pluginConfig === "object" && pluginConfig !== null && "path" in pluginConfig
? pluginConfig.path
: undefined;
const policyPath =
typeof configured === "string" && configured.trim() !== "" ? configured.trim() : "policy.jsonc";
if (isAbsolute(policyPath)) {
return policyPath;
}
const cwd =
options.cwd ??
resolveAgentWorkspaceDir(snapshot.config, resolveDefaultAgentId(snapshot.config));
return resolve(cwd, policyPath);
}
function writePolicyCheckReport(
report: PolicyCheckReport,
options: PolicyCheckOptions,
@@ -255,6 +323,29 @@ function writePolicyCheckReport(
}
}
function writePolicyConformanceReport(
report: PolicyConformanceReport,
options: PolicyCompareOptions,
runtime: PolicyCommandRuntime,
): void {
if (options.json === true || !process.stdout.isTTY) {
runtime.writeStdout(JSON.stringify(report) + "\n");
return;
}
if (report.findings.length === 0) {
runtime.writeStdout(
`policy compare: no findings (${report.policyPath} is at least as strict as ${report.baselinePath}; ${report.rulesChecked} rule(s) checked)\n`,
);
return;
}
runtime.writeStdout(
`policy compare: ${report.findings.length} finding(s) (${report.rulesChecked} rule(s) checked)\n`,
);
for (const finding of report.findings) {
runtime.writeStdout(` [${finding.severity}] ${finding.checkId} - ${finding.message}\n`);
}
}
function writePolicyWatchReport(
report: PolicyCheckReport,
status: "clean" | "findings" | "stale",

View File

@@ -135,6 +135,7 @@ export const POLICY_CHECK_IDS = [
export type PolicyStrictnessKind =
| "allowlist-subset"
| "denylist-superset"
| "ordered-string"
| "requires-true"
| "requires-false"
| "exact-list";
@@ -146,10 +147,13 @@ export type PolicyScopeSelectorKind = "agentIds" | "channelIds";
export type PolicyRuleMetadata = {
readonly policyPath: readonly string[];
readonly strictness: PolicyStrictnessKind;
readonly valueType: "boolean" | "string" | "string-list";
readonly valueType: "boolean" | "channel-provider-deny-rules" | "string" | "string-list";
readonly checkIds: readonly (typeof POLICY_CHECK_IDS)[number][];
readonly emptyList?: PolicyEmptyListSemantics;
readonly allowedValues?: readonly string[];
readonly caseSensitive?: boolean;
readonly normalizeValues?: "model-provider";
readonly orderedValues?: readonly string[];
readonly scopeSelectors?: readonly PolicyScopeSelectorKind[];
};
@@ -188,6 +192,7 @@ const SANDBOX_POLICY_RULE_METADATA = [
valueType: "string-list",
checkIds: [CHECK_IDS.policySandboxModeUnapproved],
emptyList: "disabled",
allowedValues: ["off", "non-main", "all"],
scopeSelectors: ["agentIds"],
},
{
@@ -215,12 +220,114 @@ const SANDBOX_POLICY_RULE_METADATA = [
] as const satisfies readonly PolicyRuleMetadata[];
export const POLICY_RULE_METADATA = [
{
policyPath: ["channels", "denyRules"],
strictness: "denylist-superset",
valueType: "channel-provider-deny-rules",
checkIds: [CHECK_IDS.policyDeniedChannelProvider],
emptyList: "meaningful",
caseSensitive: true,
},
{
policyPath: ["mcp", "servers", "allow"],
strictness: "allowlist-subset",
valueType: "string-list",
checkIds: [CHECK_IDS.policyUnapprovedMcpServer],
emptyList: "disabled",
caseSensitive: true,
},
{
policyPath: ["mcp", "servers", "deny"],
strictness: "denylist-superset",
valueType: "string-list",
checkIds: [CHECK_IDS.policyDeniedMcpServer],
caseSensitive: true,
},
{
policyPath: ["models", "providers", "allow"],
strictness: "allowlist-subset",
valueType: "string-list",
checkIds: [CHECK_IDS.policyUnapprovedModelProvider],
emptyList: "disabled",
normalizeValues: "model-provider",
},
{
policyPath: ["models", "providers", "deny"],
strictness: "denylist-superset",
valueType: "string-list",
checkIds: [CHECK_IDS.policyDeniedModelProvider],
normalizeValues: "model-provider",
},
{
policyPath: ["network", "privateNetwork", "allow"],
strictness: "requires-false",
valueType: "boolean",
checkIds: [CHECK_IDS.policyPrivateNetworkAccess],
},
{
policyPath: ["ingress", "session", "requireDmScope"],
strictness: "ordered-string",
valueType: "string",
orderedValues: ["main", "per-peer", "per-channel-peer", "per-account-channel-peer"],
checkIds: [CHECK_IDS.policyIngressDmScopeUnapproved],
},
{
policyPath: ["gateway", "exposure", "allowNonLoopbackBind"],
strictness: "requires-false",
valueType: "boolean",
checkIds: [CHECK_IDS.policyGatewayNonLoopbackBind],
},
{
policyPath: ["gateway", "exposure", "allowTailscaleFunnel"],
strictness: "requires-false",
valueType: "boolean",
checkIds: [CHECK_IDS.policyGatewayTailscaleFunnel],
},
{
policyPath: ["gateway", "auth", "requireAuth"],
strictness: "requires-true",
valueType: "boolean",
checkIds: [CHECK_IDS.policyGatewayAuthDisabled],
},
{
policyPath: ["gateway", "auth", "requireExplicitRateLimit"],
strictness: "requires-true",
valueType: "boolean",
checkIds: [CHECK_IDS.policyGatewayRateLimitMissing],
},
{
policyPath: ["gateway", "controlUi", "allowInsecure"],
strictness: "requires-false",
valueType: "boolean",
checkIds: [CHECK_IDS.policyGatewayControlUiInsecure],
},
{
policyPath: ["gateway", "remote", "allow"],
strictness: "requires-false",
valueType: "boolean",
checkIds: [CHECK_IDS.policyGatewayRemoteEnabled],
},
{
policyPath: ["gateway", "http", "denyEndpoints"],
strictness: "denylist-superset",
valueType: "string-list",
checkIds: [CHECK_IDS.policyGatewayHttpEndpointEnabled],
allowedValues: ["chatCompletions", "responses"],
caseSensitive: true,
},
{
policyPath: ["gateway", "http", "requireUrlAllowlists"],
strictness: "requires-true",
valueType: "boolean",
checkIds: [CHECK_IDS.policyGatewayHttpUrlFetchUnrestricted],
},
{
policyPath: ["agents", "workspace", "allowedAccess"],
strictness: "allowlist-subset",
valueType: "string-list",
checkIds: [CHECK_IDS.policyAgentsWorkspaceAccessDenied],
emptyList: "disabled",
allowedValues: ["none", "ro", "rw"],
scopeSelectors: ["agentIds"],
},
{
@@ -228,6 +335,7 @@ export const POLICY_RULE_METADATA = [
strictness: "denylist-superset",
valueType: "string-list",
checkIds: [CHECK_IDS.policyAgentsToolNotDenied],
allowedValues: ["exec", "process", "write", "edit", "apply_patch"],
scopeSelectors: ["agentIds"],
},
{
@@ -236,6 +344,7 @@ export const POLICY_RULE_METADATA = [
valueType: "string-list",
checkIds: [CHECK_IDS.policyToolsProfileUnapproved],
emptyList: "disabled",
allowedValues: ["minimal", "coding", "messaging", "full"],
scopeSelectors: ["agentIds"],
},
{
@@ -251,6 +360,7 @@ export const POLICY_RULE_METADATA = [
valueType: "string-list",
checkIds: [CHECK_IDS.policyToolsExecSecurityUnapproved],
emptyList: "disabled",
allowedValues: ["deny", "allowlist", "full"],
scopeSelectors: ["agentIds"],
},
{
@@ -259,6 +369,7 @@ export const POLICY_RULE_METADATA = [
valueType: "string-list",
checkIds: [CHECK_IDS.policyToolsExecAskUnapproved],
emptyList: "disabled",
allowedValues: ["off", "on-miss", "always"],
scopeSelectors: ["agentIds"],
},
{
@@ -267,6 +378,7 @@ export const POLICY_RULE_METADATA = [
valueType: "string-list",
checkIds: [CHECK_IDS.policyToolsExecHostUnapproved],
emptyList: "disabled",
allowedValues: ["auto", "sandbox", "gateway", "node"],
scopeSelectors: ["agentIds"],
},
{
@@ -291,6 +403,17 @@ export const POLICY_RULE_METADATA = [
checkIds: [CHECK_IDS.policyToolsRequiredDenyMissing],
scopeSelectors: ["agentIds"],
},
{
policyPath: ["tools", "requireMetadata"],
strictness: "denylist-superset",
valueType: "string-list",
checkIds: [
CHECK_IDS.policyMissingToolRisk,
CHECK_IDS.policyMissingToolSensitivity,
CHECK_IDS.policyMissingToolOwner,
],
allowedValues: ["risk", "sensitivity", "owner"],
},
...SANDBOX_POLICY_RULE_METADATA,
{
policyPath: ["ingress", "channels", "allowDmPolicies"],
@@ -298,6 +421,7 @@ export const POLICY_RULE_METADATA = [
valueType: "string-list",
checkIds: [CHECK_IDS.policyIngressDmPolicyUnapproved],
emptyList: "disabled",
allowedValues: ["pairing", "allowlist", "open", "disabled"],
scopeSelectors: ["channelIds"],
},
{
@@ -314,8 +438,43 @@ export const POLICY_RULE_METADATA = [
checkIds: [CHECK_IDS.policyIngressGroupMentionRequired],
scopeSelectors: ["channelIds"],
},
{
policyPath: ["secrets", "requireManagedProviders"],
strictness: "requires-true",
valueType: "boolean",
checkIds: [CHECK_IDS.policySecretsUnmanagedProvider],
},
{
policyPath: ["secrets", "denySources"],
strictness: "denylist-superset",
valueType: "string-list",
checkIds: [CHECK_IDS.policySecretsDeniedProviderSource],
},
{
policyPath: ["secrets", "allowInsecureProviders"],
strictness: "requires-false",
valueType: "boolean",
checkIds: [CHECK_IDS.policySecretsInsecureProvider],
},
{
policyPath: ["auth", "profiles", "requireMetadata"],
strictness: "denylist-superset",
valueType: "string-list",
checkIds: [CHECK_IDS.policyAuthProfileInvalidMetadata],
allowedValues: ["provider", "mode"],
},
{
policyPath: ["auth", "profiles", "allowModes"],
strictness: "allowlist-subset",
valueType: "string-list",
checkIds: [CHECK_IDS.policyAuthProfileUnapprovedMode],
emptyList: "disabled",
allowedValues: ["api_key", "aws-sdk", "oauth", "token"],
},
] as const satisfies readonly PolicyRuleMetadata[];
const POLICY_RULES: readonly PolicyRuleMetadata[] = POLICY_RULE_METADATA;
const KNOWN_RISK_LEVELS = ["low", "medium", "high", "critical"] as const;
const KNOWN_SENSITIVITY_LEVELS = ["public", "internal", "confidential", "restricted"] as const;
const SUPPORTED_TOOL_METADATA = ["risk", "sensitivity", "owner"] as const;
@@ -1346,7 +1505,7 @@ function toolMetadataRequirementFindings(
];
}
function policyContainerShapeFindings(
export function policyContainerShapeFindings(
policy: unknown,
policyPath: string,
policyDocName: string,
@@ -4561,10 +4720,7 @@ function scopedPolicyFields(
selector: PolicyScopeSelectorKind,
): readonly ScopedPolicyField[] {
const prefix = `scopes/${ocPathSegment(scopeName)}`;
return POLICY_RULE_METADATA.filter((rule) => {
const selectors = rule.scopeSelectors as readonly PolicyScopeSelectorKind[] | undefined;
return selectors?.includes(selector) === true;
})
return POLICY_RULES.filter((rule) => rule.scopeSelectors?.includes(selector) === true)
.map((rule) => ({ rule, value: scopedPolicyValue(overlay, rule.policyPath) }))
.filter((entry) => entry.value !== undefined)
.map(({ rule, value }) => ({
@@ -4586,6 +4742,8 @@ export function isPolicyValueAtLeastAsStrict(
return isPolicyAllowlistSubset(metadata, candidate, baseline);
case "denylist-superset":
return isPolicyDenylistSuperset(metadata, candidate, baseline);
case "ordered-string":
return isPolicyOrderedStringAtLeastAsStrict(metadata, candidate, baseline);
case "requires-true":
return baseline !== true || candidate === true;
case "requires-false":
@@ -4596,6 +4754,28 @@ export function isPolicyValueAtLeastAsStrict(
return false;
}
function isPolicyOrderedStringAtLeastAsStrict(
metadata: PolicyRuleMetadata,
candidate: unknown,
baseline: unknown,
): boolean {
const candidateValue = policyString(candidate, metadata);
const baselineValue = policyString(baseline, metadata);
if (
candidateValue === undefined ||
baselineValue === undefined ||
metadata.orderedValues === undefined
) {
return false;
}
const orderedValues = metadata.orderedValues.map((entry) =>
metadata.caseSensitive === true ? entry : entry.toLowerCase(),
);
const candidateIndex = orderedValues.indexOf(candidateValue);
const baselineIndex = orderedValues.indexOf(baselineValue);
return candidateIndex >= 0 && baselineIndex >= 0 && candidateIndex >= baselineIndex;
}
function isPolicyAllowlistSubset(
metadata: PolicyRuleMetadata,
candidate: unknown,
@@ -4657,13 +4837,51 @@ function policyStringList(
value: unknown,
metadata: PolicyRuleMetadata,
): readonly string[] | undefined {
if (metadata.valueType === "channel-provider-deny-rules") {
return channelProviderDenyRuleList(value, metadata);
}
if (!Array.isArray(value) || !value.every((entry) => typeof entry === "string")) {
return undefined;
}
return value
.map((entry) => entry.trim())
.filter(Boolean)
.map((entry) => (metadata.caseSensitive === true ? entry : entry.toLowerCase()));
.map((entry) => normalizePolicyStringListEntry(entry, metadata));
}
function normalizePolicyStringListEntry(entry: string, metadata: PolicyRuleMetadata): string {
if (metadata.normalizeValues === "model-provider") {
return normalizeProviderId(entry);
}
return metadata.caseSensitive === true ? entry : entry.toLowerCase();
}
function channelProviderDenyRuleList(
value: unknown,
metadata: PolicyRuleMetadata,
): readonly string[] | undefined {
if (!Array.isArray(value)) {
return undefined;
}
const providers: string[] = [];
for (const entry of value) {
if (!isChannelDenyRule(entry)) {
return undefined;
}
const provider = entry.when?.provider?.trim();
if (provider !== undefined && provider !== "") {
providers.push(metadata.caseSensitive === true ? provider : provider.toLowerCase());
}
}
return providers;
}
function policyString(value: unknown, metadata: PolicyRuleMetadata): string | undefined {
if (typeof value !== "string" || value.trim() === "") {
return undefined;
}
const trimmed = value.trim();
return metadata.caseSensitive === true ? trimmed : trimmed.toLowerCase();
}
function scopedPolicyValue(overlay: Record<string, unknown>, path: readonly string[]): unknown {

View File

@@ -0,0 +1,581 @@
import { promises as fs } from "node:fs";
import { basename, isAbsolute, resolve } from "node:path";
import JSON5 from "json5";
import type { HealthFinding } from "openclaw/plugin-sdk/health";
import { normalizeAgentId } from "openclaw/plugin-sdk/routing";
import {
isPolicyValueAtLeastAsStrict,
policyContainerShapeFindings,
POLICY_RULE_METADATA as RAW_POLICY_RULE_METADATA,
type PolicyRuleMetadata,
type PolicyScopeSelectorKind,
} from "./doctor/register.js";
export const POLICY_CONFORMANCE_CHECK_IDS = {
missing: "policy/policy-conformance-missing",
weaker: "policy/policy-conformance-weaker",
invalid: "policy/policy-conformance-invalid",
} as const;
export type PolicyConformanceFinding = {
readonly checkId: (typeof POLICY_CONFORMANCE_CHECK_IDS)[keyof typeof POLICY_CONFORMANCE_CHECK_IDS];
readonly severity: "error";
readonly message: string;
readonly source: "policy";
readonly path: string;
readonly target: string;
readonly requirement: string;
readonly fixHint: string;
};
export type PolicyConformanceReport = {
readonly ok: boolean;
readonly baselinePath: string;
readonly policyPath: string;
readonly rulesChecked: number;
readonly findings: readonly PolicyConformanceFinding[];
};
type PolicyDocument = {
readonly displayName: string;
readonly value: unknown;
};
type PolicyDocumentReadResult =
| { readonly ok: true; readonly displayName: string; readonly document: PolicyDocument }
| {
readonly ok: false;
readonly displayName: string;
readonly message: string;
readonly target: string;
};
type PolicyRuleClaim = {
readonly key: string;
readonly metadata: PolicyRuleMetadata;
readonly value: unknown;
readonly target: string;
readonly propertyPath: string;
readonly selector?: {
readonly kind: PolicyScopeSelectorKind;
readonly value: string;
};
};
const POLICY_RULE_METADATA: readonly PolicyRuleMetadata[] = RAW_POLICY_RULE_METADATA;
export async function buildPolicyConformanceReport(params: {
readonly baselinePath: string;
readonly policyPath: string;
readonly cwd?: string;
}): Promise<PolicyConformanceReport> {
const baselinePath = resolvePolicyPath(params.baselinePath, params.cwd);
const policyPath = resolvePolicyPath(params.policyPath, params.cwd);
const baselineResult = await readPolicyDocument(baselinePath);
const policyResult = await readPolicyDocument(policyPath);
if (!baselineResult.ok || !policyResult.ok) {
const invalidFindings = [baselineResult, policyResult]
.filter((result): result is Extract<PolicyDocumentReadResult, { readonly ok: false }> => {
return !result.ok;
})
.map((result) => invalidParseConformanceFinding(result));
return {
ok: false,
baselinePath: baselineResult.displayName,
policyPath: policyResult.displayName,
rulesChecked: 0,
findings: invalidFindings,
};
}
const baseline = baselineResult.document;
const policy = policyResult.document;
const baselineClaims = collectPolicyRuleClaims(baseline);
const candidateClaims = collectPolicyRuleClaims(policy);
const invalidFindings = uniqueConformanceFindings([
...policyContainerShapeFindings(baseline.value, baseline.displayName, baseline.displayName).map(
(finding) => invalidShapeConformanceFinding(finding, baseline.displayName),
),
...policyContainerShapeFindings(policy.value, policy.displayName, policy.displayName).map(
(finding) => invalidShapeConformanceFinding(finding, policy.displayName),
),
...collectInvalidScopedPolicyFindings(baseline),
...collectInvalidScopedPolicyFindings(policy),
...baselineClaims
.filter((claim) => !policyRuleValueIsValid(claim.metadata, claim.value))
.map((claim) => invalidConformanceFinding(claim, baseline.displayName)),
...candidateClaims
.filter((claim) => !policyRuleValueIsValid(claim.metadata, claim.value))
.map((claim) => invalidConformanceFinding(claim, policy.displayName)),
]);
const validBaselineClaims = baselineClaims.filter((claim) =>
policyRuleValueIsValid(claim.metadata, claim.value),
);
const validCandidateClaims = candidateClaims.filter((claim) =>
policyRuleValueIsValid(claim.metadata, claim.value),
);
if (invalidFindings.length > 0) {
return {
ok: false,
baselinePath: baseline.displayName,
policyPath: policy.displayName,
rulesChecked: 0,
findings: invalidFindings,
};
}
const findings = validBaselineClaims
.map((claim) => conformanceFinding(claim, validCandidateClaims, policy.displayName))
.filter((finding): finding is PolicyConformanceFinding => finding !== undefined);
return {
ok: invalidFindings.length === 0 && findings.length === 0,
baselinePath: baseline.displayName,
policyPath: policy.displayName,
rulesChecked: validBaselineClaims.length,
findings: [...invalidFindings, ...findings],
};
}
function uniqueConformanceFindings(
findings: readonly PolicyConformanceFinding[],
): readonly PolicyConformanceFinding[] {
const seen = new Set<string>();
return findings.filter((finding) => {
const key = `${finding.checkId}\n${finding.target}`;
if (seen.has(key)) {
return false;
}
seen.add(key);
return true;
});
}
function invalidParseConformanceFinding(
result: Extract<PolicyDocumentReadResult, { readonly ok: false }>,
): PolicyConformanceFinding {
return {
checkId: POLICY_CONFORMANCE_CHECK_IDS.invalid,
severity: "error",
message: result.message,
source: "policy",
path: result.displayName,
target: result.target,
requirement: result.target,
fixHint: `Fix ${result.displayName} so it contains valid policy JSONC.`,
};
}
function invalidShapeConformanceFinding(
finding: HealthFinding,
displayName: string,
): PolicyConformanceFinding {
const target = finding.target ?? `oc://${displayName}`;
return {
checkId: POLICY_CONFORMANCE_CHECK_IDS.invalid,
severity: "error",
message: finding.message,
source: "policy",
path: displayName,
target,
requirement: target,
fixHint: finding.fixHint ?? `Fix ${displayName} so it uses the documented policy syntax.`,
};
}
function collectInvalidScopedPolicyFindings(
document: PolicyDocument,
): readonly PolicyConformanceFinding[] {
if (!isRecord(document.value) || document.value.scopes === undefined) {
return [];
}
if (!isRecord(document.value.scopes)) {
return [
invalidConformancePathFinding({
displayName: document.displayName,
message: `${document.displayName} scopes must be an object.`,
propertyPath: "scopes",
target: `oc://${document.displayName}/scopes`,
}),
];
}
const findings: PolicyConformanceFinding[] = [];
for (const [scopeName, overlay] of Object.entries(document.value.scopes)) {
const scopePath = `scopes.${scopeName}`;
const scopeTarget = `oc://${document.displayName}/scopes/${ocPathSegment(scopeName)}`;
if (!isRecord(overlay)) {
findings.push(
invalidConformancePathFinding({
displayName: document.displayName,
message: `${document.displayName} ${scopePath} must be an object.`,
propertyPath: scopePath,
target: scopeTarget,
}),
);
continue;
}
for (const metadata of POLICY_RULE_METADATA) {
const value = scopedPolicyValue(overlay, metadata.policyPath);
if (value === undefined) {
continue;
}
const selectorMatches = (metadata.scopeSelectors ?? []).some(
(selector) => normalizeSelectorValues(overlay[selector], selector).length > 0,
);
if (selectorMatches) {
continue;
}
const propertyPath = `${scopePath}.${metadata.policyPath.join(".")}`;
findings.push(
invalidConformancePathFinding({
displayName: document.displayName,
message: `${document.displayName} ${propertyPath} needs a valid selector for policy conformance.`,
propertyPath,
target: `${scopeTarget}/${metadata.policyPath.map(ocPathSegment).join("/")}`,
}),
);
}
}
return findings;
}
function invalidConformanceFinding(
claim: PolicyRuleClaim,
displayName: string,
): PolicyConformanceFinding {
return invalidConformancePathFinding({
displayName,
message: `${displayName} ${claim.propertyPath} is not valid policy conformance syntax.`,
propertyPath: claim.propertyPath,
target: claim.target,
});
}
function invalidConformancePathFinding(params: {
readonly displayName: string;
readonly message: string;
readonly propertyPath: string;
readonly target: string;
}): PolicyConformanceFinding {
return {
checkId: POLICY_CONFORMANCE_CHECK_IDS.invalid,
severity: "error",
message: params.message,
source: "policy",
path: params.displayName,
target: params.target,
requirement: params.target,
fixHint: `Fix ${params.propertyPath} so it uses the documented policy syntax.`,
};
}
function conformanceFinding(
baseline: PolicyRuleClaim,
candidateClaims: readonly PolicyRuleClaim[],
policyDisplayName: string,
): PolicyConformanceFinding | undefined {
if (baselineRuleIsNoOp(baseline.metadata, baseline.value)) {
return undefined;
}
if (baseline.selector === undefined) {
const globalCandidates = candidateClaims.filter((candidate) => candidate.key === baseline.key);
if (globalCandidates.length === 0) {
return missingConformanceFinding(baseline, policyDisplayName);
}
const weakerGlobal = globalCandidates.find(
(candidate) =>
!isPolicyValueAtLeastAsStrict(baseline.metadata, candidate.value, baseline.value),
);
if (weakerGlobal !== undefined) {
return weakerConformanceFinding(baseline, policyDisplayName, weakerGlobal);
}
const weakerScopedOverride = candidateClaims.find(
(candidate) =>
candidate.selector !== undefined &&
candidate.metadata.policyPath.join(".") === baseline.metadata.policyPath.join(".") &&
!isPolicyValueAtLeastAsStrict(baseline.metadata, candidate.value, baseline.value),
);
if (weakerScopedOverride !== undefined) {
return weakerConformanceFinding(baseline, policyDisplayName, weakerScopedOverride);
}
return undefined;
}
const exactCandidates = candidateClaims.filter((candidate) => candidate.key === baseline.key);
const candidates =
exactCandidates.length > 0
? exactCandidates
: candidateClaims.filter((candidate) => globallySatisfiesScopedClaim(candidate, baseline));
const weakerCandidate = candidates.find(
(candidate) =>
!isPolicyValueAtLeastAsStrict(baseline.metadata, candidate.value, baseline.value),
);
const matching = candidates.some((candidate) =>
isPolicyValueAtLeastAsStrict(baseline.metadata, candidate.value, baseline.value),
);
if (matching && (exactCandidates.length === 0 || weakerCandidate === undefined)) {
return undefined;
}
if (candidates.length === 0) {
return missingConformanceFinding(baseline, policyDisplayName);
}
return weakerConformanceFinding(baseline, policyDisplayName, weakerCandidate ?? candidates[0]);
}
function baselineRuleIsNoOp(metadata: PolicyRuleMetadata, baseline: unknown): boolean {
switch (metadata.strictness) {
case "allowlist-subset":
return metadata.emptyList === "disabled" && policyRuleListIsEmpty(baseline, metadata);
case "denylist-superset":
return policyRuleListIsEmpty(baseline, metadata);
case "requires-true":
return baseline !== true;
case "requires-false":
return baseline !== false;
case "exact-list":
case "ordered-string":
return false;
}
return false;
}
function policyRuleValueIsValid(metadata: PolicyRuleMetadata, value: unknown): boolean {
switch (metadata.valueType) {
case "boolean":
return typeof value === "boolean";
case "channel-provider-deny-rules":
return (
Array.isArray(value) &&
value.every((entry) => {
if (!isRecord(entry)) {
return false;
}
const when = entry.when;
return isRecord(when) && typeof when.provider === "string" && when.provider.trim() !== "";
})
);
case "string":
return typeof value === "string" && policyStringIsAllowed(metadata, value);
case "string-list":
return (
Array.isArray(value) &&
value.every(
(entry) =>
typeof entry === "string" &&
entry.trim() !== "" &&
policyStringIsAllowed(metadata, entry),
)
);
}
return false;
}
function policyStringIsAllowed(metadata: PolicyRuleMetadata, value: string): boolean {
const normalized = metadata.caseSensitive === true ? value.trim() : value.trim().toLowerCase();
if (normalized === "") {
return false;
}
if (metadata.allowedValues !== undefined) {
const allowed = metadata.allowedValues.map((entry) =>
metadata.caseSensitive === true ? entry : entry.toLowerCase(),
);
return allowed.includes(normalized);
}
if (metadata.orderedValues === undefined) {
return true;
}
const allowed = metadata.orderedValues.map((entry) =>
metadata.caseSensitive === true ? entry : entry.toLowerCase(),
);
return allowed.includes(normalized);
}
function policyRuleListIsEmpty(value: unknown, metadata: PolicyRuleMetadata): boolean {
if (!Array.isArray(value)) {
return false;
}
if (metadata.valueType === "channel-provider-deny-rules") {
return value.length === 0;
}
return value.length === 0;
}
function missingConformanceFinding(
baseline: PolicyRuleClaim,
policyDisplayName: string,
): PolicyConformanceFinding {
return {
checkId: POLICY_CONFORMANCE_CHECK_IDS.missing,
severity: "error",
message: `${policyDisplayName} is missing ${baseline.propertyPath}.`,
source: "policy",
path: policyDisplayName,
target: `oc://${policyDisplayName}/${baseline.propertyPath.replaceAll(".", "/")}`,
requirement: baseline.target,
fixHint: `Add an equally or more restrictive ${baseline.propertyPath} rule, or update the baseline policy after review.`,
};
}
function weakerConformanceFinding(
baseline: PolicyRuleClaim,
policyDisplayName: string,
candidate: PolicyRuleClaim | undefined,
): PolicyConformanceFinding {
return {
checkId: POLICY_CONFORMANCE_CHECK_IDS.weaker,
severity: "error",
message: `${policyDisplayName} ${baseline.propertyPath} is weaker than the baseline policy.`,
source: "policy",
path: policyDisplayName,
target: candidate?.target ?? `oc://${policyDisplayName}`,
requirement: baseline.target,
fixHint: `Use an equally or more restrictive ${baseline.propertyPath} value, or update the baseline policy after review.`,
};
}
function globallySatisfiesScopedClaim(
candidate: PolicyRuleClaim,
baseline: PolicyRuleClaim,
): boolean {
return (
baseline.selector !== undefined &&
candidate.selector === undefined &&
candidate.metadata.policyPath.join(".") === baseline.metadata.policyPath.join(".")
);
}
function collectPolicyRuleClaims(document: PolicyDocument): readonly PolicyRuleClaim[] {
return [...collectTopLevelPolicyRuleClaims(document), ...collectScopedPolicyRuleClaims(document)];
}
function collectTopLevelPolicyRuleClaims(document: PolicyDocument): readonly PolicyRuleClaim[] {
const claims: PolicyRuleClaim[] = [];
for (const metadata of POLICY_RULE_METADATA) {
const value = getPolicyPath(document.value, metadata.policyPath);
if (value === undefined) {
continue;
}
const propertyPath = metadata.policyPath.join(".");
claims.push({
key: `global:${propertyPath}`,
metadata,
value,
target: `oc://${document.displayName}/${metadata.policyPath.map(ocPathSegment).join("/")}`,
propertyPath,
});
}
return claims;
}
function collectScopedPolicyRuleClaims(document: PolicyDocument): readonly PolicyRuleClaim[] {
if (!isRecord(document.value) || !isRecord(document.value.scopes)) {
return [];
}
const claims: PolicyRuleClaim[] = [];
for (const [scopeName, overlay] of Object.entries(document.value.scopes)) {
if (!isRecord(overlay)) {
continue;
}
for (const selector of ["agentIds", "channelIds"] as const) {
const selectorValues = normalizeSelectorValues(overlay[selector], selector);
if (selectorValues.length === 0) {
continue;
}
const rules = POLICY_RULE_METADATA.filter(
(metadata) => metadata.scopeSelectors?.includes(selector) === true,
);
for (const metadata of rules) {
const value = scopedPolicyValue(overlay, metadata.policyPath);
if (value === undefined) {
continue;
}
const propertyPath = metadata.policyPath.join(".");
const targetPath = [
"scopes",
ocPathSegment(scopeName),
...metadata.policyPath.map(ocPathSegment),
].join("/");
for (const selectorValue of selectorValues) {
claims.push({
key: `${selector}:${selectorValue}:${propertyPath}`,
metadata,
value,
target: `oc://${document.displayName}/${targetPath}`,
propertyPath: `scopes.${scopeName}.${propertyPath}`,
selector: { kind: selector, value: selectorValue },
});
}
}
}
}
return claims;
}
function normalizeSelectorValues(
value: unknown,
selector: PolicyScopeSelectorKind,
): readonly string[] {
if (!Array.isArray(value)) {
return [];
}
return value
.filter((entry): entry is string => typeof entry === "string" && entry.trim() !== "")
.map((entry) =>
selector === "agentIds" ? normalizeAgentId(entry) : entry.trim().toLowerCase(),
);
}
function scopedPolicyValue(overlay: Record<string, unknown>, path: readonly string[]): unknown {
const scopedRoot = path[0] === "agents" ? overlay.agents : overlay[path[0]];
return getPolicyPath(scopedRoot, path.slice(1));
}
function getPolicyPath(value: unknown, path: readonly string[]): unknown {
let current = value;
for (const part of path) {
if (!isRecord(current)) {
return undefined;
}
current = current[part];
}
return current;
}
async function readPolicyDocument(path: string): Promise<PolicyDocumentReadResult> {
const displayName = basename(path);
let raw: string;
try {
raw = await fs.readFile(path, "utf-8");
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
return {
ok: false,
displayName,
message: `${displayName} could not be read: ${message}`,
target: `oc://${displayName}`,
};
}
try {
return { ok: true, displayName, document: { displayName, value: JSON5.parse(raw) } };
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
return {
ok: false,
displayName,
message: `${displayName} could not be parsed: ${message}`,
target: `oc://${displayName}`,
};
}
}
function resolvePolicyPath(path: string, cwd: string | undefined): string {
return isAbsolute(path) ? path : resolve(cwd ?? process.cwd(), path);
}
function ocPathSegment(value: string): string {
if (/^(?:[A-Za-z0-9_-]+|#\d+)$/.test(value)) {
return value;
}
return JSON.stringify(value);
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}