mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-06 05:51:15 +08:00
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:
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
}),
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
581
extensions/policy/src/policy-conformance.ts
Normal file
581
extensions/policy/src/policy-conformance.ts
Normal 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);
|
||||
}
|
||||
Reference in New Issue
Block a user