diff --git a/docs/cli/policy.md b/docs/cli/policy.md index 19c23d8779a4..9152591d81af 100644 --- a/docs/cli/policy.md +++ b/docs/cli/policy.md @@ -172,6 +172,22 @@ present in `policy.jsonc`. The observed state is existing OpenClaw config or workspace metadata; policy reports drift but does not rewrite runtime behavior unless a repair path is explicitly available and enabled. +Agent-specific policy overlays keep broad `tools.*` and `agents.workspace` +posture global, then let named scope blocks add stricter normal policy sections +for explicit `agentIds` under `scopes.`. The initial scoped +sections are `tools` and `agents.workspace`; sandbox and ingress can use the +same container once their evidence is attributable to an agent. Scoped fields +carry strictness metadata such as allowlist subset, denylist superset, required +boolean, and exact-list semantics so future policy-file conformance can reuse +the same rule inventory instead of guessing. The overlay is additive: global +claims still run, and a scoped claim can emit its own finding against the same +observed config. See [Agent-scoped policy overlays](/plan/policy-agent-scoped-overlays). +Every scope present in `policy.jsonc` must be valid and enforceable. Scopes +currently require `agentIds`, and that selector supports only `tools.*` and +`agents.workspace.*`. If an `agentIds` entry is not present in `agents.list[]`, +the scoped rule is evaluated against the inherited global/default posture for +that runtime agent id instead of being skipped. + #### Channels | Policy field | Observed state | Use when | @@ -250,6 +266,7 @@ unless a repair path is explicitly available and enabled. | `tools.exec.requireAsk` | `tools.exec.ask` and per-agent exec ask mode | Require approval posture such as `always`. | | `tools.exec.allowHosts` | `tools.exec.host` and per-agent exec host routing | Allow only exec host routing modes such as `sandbox`. | | `tools.elevated.allow` | `tools.elevated.enabled` and per-agent elevated posture | Set to `false` to require elevated tool mode to stay disabled. | +| `tools.alsoAllow.expected` | `tools.alsoAllow` and per-agent `tools.alsoAllow` | Require exact `alsoAllow` entries and report missing or unexpected additive tool grants. | | `tools.denyTools` | `tools.deny` and `agents.list[].tools.deny` | Require configured tool deny lists to include tool ids or groups such as `group:runtime` and `group:fs`. | Run policy-only checks during authoring: @@ -533,6 +550,8 @@ Policy currently verifies: | `policy/tools-exec-ask-unapproved` | Exec ask mode is outside the policy allowlist. | | `policy/tools-exec-host-unapproved` | Exec host routing is outside the policy allowlist. | | `policy/tools-elevated-enabled` | Elevated tool mode is enabled when policy denies it. | +| `policy/tools-also-allow-missing` | A configured `alsoAllow` list is missing an entry required by policy. | +| `policy/tools-also-allow-unexpected` | A configured `alsoAllow` list includes an entry not expected by policy. | | `policy/tools-required-deny-missing` | A global or per-agent tool deny list does not include a required denied tool. | | `policy/secrets-unmanaged-provider` | A config SecretRef references a provider not declared under `secrets.providers`. | | `policy/secrets-denied-provider-source` | A config secret provider or SecretRef uses a source denied by policy. | diff --git a/docs/plan/policy-agent-scoped-overlays.md b/docs/plan/policy-agent-scoped-overlays.md new file mode 100644 index 000000000000..a2d6ef70e1b1 --- /dev/null +++ b/docs/plan/policy-agent-scoped-overlays.md @@ -0,0 +1,205 @@ +--- +summary: "Per-agent Policy plugin overlays layered on top of global policy rules." +read_when: + - You are designing per-agent policy requirements + - You need to distinguish tool posture policy from workspace policy + - You are configuring stricter policy for one named agent +title: "Agent-scoped policy overlays" +--- + +# Agent-scoped policy overlays + +OpenClaw policy supports global requirements and stricter requirements for +explicit runtime agent ids. Some deployments need one agent to use a tighter +workspace and tool posture than other agents, but deployment-wide rules should +not force every agent to use the same posture. + +This page describes the agent-scoped overlay model. The field reference remains +[`openclaw policy`](/cli/policy). + +## Design goals + +- Keep global policy as the deployment baseline. +- Let a named agent add stricter requirements without weakening global rules. +- Reuse existing policy section shapes where the evidence can be attributed to + an agent. +- Avoid making `agents.workspace` a second tool-permission system. +- Leave global-only checks global until their evidence can be mapped to an + agent. + +## Shape + +Use `scopes.` for purpose-named agent policy scopes. Each +scope lists the runtime `agentIds` it applies to, then reuses the normal +top-level policy section grammar where the section evidence can be attributed to +those agents. The initial shipped scoped sections are `tools` and +`agents.workspace`; sandbox and ingress stay out of this PR and can join the +same container once those policy PRs land and their evidence carries agent +identity. The scoped field inventory is backed by policy rule metadata that +records each field's strictness semantics for later policy-file conformance. + +```jsonc +{ + "tools": { + "denyTools": ["process"], + }, + "agents": { + "workspace": { + "allowedAccess": ["none", "ro"], + }, + }, + "scopes": { + "release-agent-lockdown": { + "agentIds": ["release-agent"], + "agents": { + "workspace": { + "allowedAccess": ["none", "ro"], + }, + }, + "tools": { + "profiles": { "allow": ["minimal", "messaging"] }, + "fs": { "requireWorkspaceOnly": true }, + "exec": { + "allowSecurity": ["deny", "allowlist"], + "requireAsk": ["always"], + "allowHosts": ["sandbox"], + }, + "elevated": { "allow": false }, + "alsoAllow": { "expected": ["message", "read"] }, + "denyTools": ["exec", "process", "write", "edit", "apply_patch"], + }, + }, + }, +} +``` + +`agents.workspace` remains the existing all-agent workspace baseline. +`scopes.` is a scoped overlay, not a replacement for global +policy. The scope name is descriptive only; matching uses `agentIds`, not +display names. It deliberately contains normal section names instead of a +bespoke per-agent mini-grammar. +Every scope present in `policy.jsonc` must be valid and enforceable. In this +PR, the only supported selector is `agentIds`, and it supports only `tools.*` +and `agents.workspace.*`. + +## Layering semantics + +Policy evaluation is additive: + +1. Top-level policy applies to all matching evidence. +2. Existing `agents.workspace` applies to defaults and every listed agent. +3. `scopes.` applies to evidence for each normalized runtime + id in `agentIds`. +4. Multiple scope blocks may target the same agent when they govern + different fields, or when a later value for the same field is equally or + more restrictive according to policy metadata. +5. A named-agent overlay can tighten policy, but it cannot make a global + violation acceptable. + +If both global and agent-scoped rules fail, findings should point at the rule +that was violated: + +```text +oc://policy.jsonc/tools/denyTools +oc://policy.jsonc/scopes/release-agent-lockdown/tools/denyTools +oc://policy.jsonc/scopes/release-agent-lockdown/agents/workspace/allowedAccess +``` + +That keeps broad tool posture, named-agent tool posture, and workspace posture +auditable as separate requirements even when they observe the same config +fields. + +Exact-list claims such as `tools.alsoAllow.expected` compare the configured list +to the expected list and report both missing expected entries and unexpected +extra entries. This is intended for additive posture such as `alsoAllow`, where +one extra entry can widen an agent beyond its reviewed role. + +## Policy and config layering + +The overlay model separates where policy is authored from where OpenClaw config +is observed: + +| Policy scope | Observed config | Applies to | Example result | +| --------------------------------------- | ---------------------------------------------------- | --------------------------------- | ----------------------------------------------------------------------------- | +| Top-level `tools.*` | Global `tools.*` and inherited agent tool posture | All agents using matching posture | Deny `gateway` exec host for every agent unless the global policy allows it. | +| Top-level `tools.*` | `agents.list[].tools.*` overrides | Any agent with an override | Flag one agent that overrides `tools.exec.host` to an unapproved value. | +| `scopes..tools.*` | Matching `agents.list[]` entry and inherited posture | Only that named agent | Let most agents use `node` exec host while one agent must use only `sandbox`. | +| `agents.workspace` | Defaults and every listed agent workspace posture | Defaults and all listed agents | Require every agent workspace access to be `none` or `ro`. | +| `scopes..agents.workspace.*` | Matching `agents.list[]` workspace posture | Only that named agent | Require one agent to be read-only without requiring the same for `main`. | + +Per-agent overlays are additive. A named-agent rule can be stricter than the +top-level rule, but it cannot make a global violation acceptable. For allow-list +rules, the effective allowed set is the intersection of the global rule and the +named-agent overlay when both are present. + +For example, if top-level `tools.exec.allowHosts` permits `["sandbox", "node"]` +and `scopes.release-agent-lockdown.tools.exec.allowHosts` permits only +`["sandbox"]`, `release-agent` fails when its effective exec host is `node`; +another agent can still pass +with `node`. + +## Tool posture versus workspace posture + +Tool posture belongs under `tools` because it describes what tool behavior a +configuration may expose. The existing `tools.*` policy observes both global +`tools.*` config and per-agent `agents.list[].tools.*` overrides. + +Workspace posture belongs under `workspace` because it describes sandbox mode +and workspace access. The workspace section should not grow into a general tool +policy namespace. If one agent needs stricter tool restrictions to make its +workspace posture meaningful, put those restrictions in the same agent overlay +under `scopes..tools`. + +For a restricted release agent, the intended split is: + +```jsonc +{ + "scopes": { + "release-agent-lockdown": { + "agentIds": ["release-agent"], + "agents": { + "workspace": { "allowedAccess": ["none", "ro"] }, + }, + "tools": { + "denyTools": ["exec", "process", "write", "edit", "apply_patch"], + }, + }, + }, +} +``` + +## Section eligibility + +An agent-scoped section should be added only when policy evidence carries an +agent id or can be attributed to one without guessing. + +| Section | Initial agent-scoped status | Reason | +| ----------- | --------------------------- | ------------------------------------------------------------------------ | +| `workspace` | Include | Agent sandbox/workspace evidence already has agent identity. | +| `tools` | Include | Tool posture evidence includes global and per-agent tool config. | +| `sandbox` | Pipeline follow-up | Keep out until the sandbox posture PR lands and evidence can be scoped. | +| `ingress` | Pipeline follow-up | Keep out until ingress/channel posture lands with agent attribution. | +| `models` | Include when mapped | Selected model refs can be agent-specific. | +| `mcp` | Include when mapped | Use only when MCP server evidence is attributable to an agent. | +| `auth` | Defer | Auth profile metadata is a config catalog unless agent binding is clear. | +| `channels` | Defer | Channel provider posture is deployment-level until routing is scoped. | +| `gateway` | Keep global | Gateway exposure/auth/http posture is process-level. | +| `network` | Keep global | Private-network SSRF posture is runtime-level. | +| `secrets` | Keep global first | Secret provider posture is shared unless refs are agent-attributed. | + +## Compatibility + +The implementation is additive: + +- keep all existing top-level policy fields valid; +- keep `agents.workspace` semantics unchanged; +- validate `scopes` before evaluating scoped rules; +- reject unsupported scoped sections clearly until their evidence and policy + contracts are implemented; +- do not reinterpret top-level `tools.requireMetadata` as agent-scoped, because + tool metadata describes the declared workspace tool catalog; +- include agent-scoped evidence in the attestation hash when any scoped rule is + present. + +This lets broad tool posture remain a top-level policy contract while named +agents add stricter observable claims without weakening the global baseline. diff --git a/docs/plugins/reference/policy.md b/docs/plugins/reference/policy.md index ab9285c1a121..a8b0a3c31f31 100644 --- a/docs/plugins/reference/policy.md +++ b/docs/plugins/reference/policy.md @@ -18,6 +18,39 @@ Adds policy-backed doctor checks for workspace conformance. plugin +## Behavior + +The Policy plugin contributes doctor health checks for policy-managed OpenClaw +settings and governed workspace declarations. Policy currently covers channel +conformance, governed tool metadata, MCP server posture, model-provider posture, +private-network access posture, Gateway exposure posture, agent workspace/tool +posture, configured global/per-agent tool posture, and OpenClaw config secret +provider/auth profile posture. + +Policy stores authored requirements in `policy.jsonc`, observes existing +OpenClaw settings and workspace declarations as evidence, and reports drift +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. + +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 +additive `alsoAllow` entries because they can widen effective tool posture. +These checks observe config conformance only; they do not read runtime approval +state or add runtime enforcement. + +Named agent policy scopes under `scopes.` can add stricter +normal policy sections for the runtime agent ids listed in `agentIds`. The +initial scoped sections are `tools` and `agents.workspace`; future sections such +as sandbox or ingress can join the same container after their evidence carries +agent identity. Every scope present in `policy.jsonc` must be valid and +enforceable for its selector. Overlay rules are additional claims, so they do +not weaken top-level policy and can produce their own findings when the same +observed config violates both scopes. Runtime agent ids that are not explicitly +listed in `agents.list[]` are checked against inherited global/default posture +rather than silently passing with no evidence. + ## Related docs - [policy](/cli/policy) diff --git a/extensions/policy/src/doctor/register.test.ts b/extensions/policy/src/doctor/register.test.ts index 6ff039012184..0deeebecff61 100644 --- a/extensions/policy/src/doctor/register.test.ts +++ b/extensions/policy/src/doctor/register.test.ts @@ -17,7 +17,13 @@ import { policyDocumentHash, scanPolicyMcpServers, } from "../policy-state.js"; -import { registerPolicyDoctorChecks, resetPolicyDoctorChecksForTest } from "./register.js"; +import { + POLICY_RULE_METADATA, + isPolicyValueAtLeastAsStrict, + registerPolicyDoctorChecks, + resetPolicyDoctorChecksForTest, + type PolicyRuleMetadata, +} from "./register.js"; let workspaceDir: string; @@ -105,6 +111,225 @@ describe("registerPolicyDoctorChecks", () => { resetPolicyDoctorChecksForTest(); }); + it("describes strictness for agent-scoped policy fields", () => { + expect( + POLICY_RULE_METADATA.filter((rule) => rule.scopeSelectors?.includes("agentIds")).map( + (rule: PolicyRuleMetadata) => ({ + path: rule.policyPath.join("."), + strictness: rule.strictness, + emptyList: rule.emptyList, + }), + ), + ).toEqual([ + { + path: "agents.workspace.allowedAccess", + strictness: "allowlist-subset", + emptyList: "disabled", + }, + { path: "agents.workspace.denyTools", strictness: "denylist-superset" }, + { path: "tools.profiles.allow", strictness: "allowlist-subset", emptyList: "disabled" }, + { path: "tools.fs.requireWorkspaceOnly", strictness: "requires-true" }, + { path: "tools.exec.allowSecurity", strictness: "allowlist-subset", emptyList: "disabled" }, + { path: "tools.exec.requireAsk", strictness: "allowlist-subset", emptyList: "disabled" }, + { path: "tools.exec.allowHosts", strictness: "allowlist-subset", emptyList: "disabled" }, + { path: "tools.elevated.allow", strictness: "requires-false" }, + { path: "tools.alsoAllow.expected", strictness: "exact-list", emptyList: "meaningful" }, + { path: "tools.denyTools", strictness: "denylist-superset" }, + ]); + }); + + it("compares policy values through strictness metadata", () => { + const allowHosts = POLICY_RULE_METADATA.find( + (rule) => rule.policyPath.join(".") === "tools.exec.allowHosts", + ); + const denyTools = POLICY_RULE_METADATA.find( + (rule) => rule.policyPath.join(".") === "tools.denyTools", + ); + const fsWorkspaceOnly = POLICY_RULE_METADATA.find( + (rule) => rule.policyPath.join(".") === "tools.fs.requireWorkspaceOnly", + ); + const alsoAllow = POLICY_RULE_METADATA.find( + (rule) => rule.policyPath.join(".") === "tools.alsoAllow.expected", + ); + + expect(allowHosts).toBeDefined(); + expect(denyTools).toBeDefined(); + expect(fsWorkspaceOnly).toBeDefined(); + expect(alsoAllow).toBeDefined(); + expect(isPolicyValueAtLeastAsStrict(allowHosts!, ["sandbox"], ["sandbox", "node"])).toBe(true); + expect(isPolicyValueAtLeastAsStrict(allowHosts!, ["sandbox", "node"], ["sandbox"])).toBe(false); + expect(isPolicyValueAtLeastAsStrict(allowHosts!, [], ["sandbox"])).toBe(false); + expect(isPolicyValueAtLeastAsStrict(allowHosts!, ["sandbox"], [])).toBe(true); + expect(isPolicyValueAtLeastAsStrict(denyTools!, ["exec", "write"], ["exec"])).toBe(true); + expect(isPolicyValueAtLeastAsStrict(denyTools!, ["write"], ["exec"])).toBe(false); + expect(isPolicyValueAtLeastAsStrict(denyTools!, ["group:runtime"], ["exec"])).toBe(true); + expect(isPolicyValueAtLeastAsStrict(denyTools!, ["exec"], ["group:runtime"])).toBe(false); + expect(isPolicyValueAtLeastAsStrict(fsWorkspaceOnly!, true, true)).toBe(true); + expect(isPolicyValueAtLeastAsStrict(fsWorkspaceOnly!, false, true)).toBe(false); + expect(isPolicyValueAtLeastAsStrict(alsoAllow!, ["read"], ["read"])).toBe(true); + expect(isPolicyValueAtLeastAsStrict(alsoAllow!, [], ["read"])).toBe(false); + }); + + it("allows scoped overrides that are stricter than top-level policy", async () => { + const configPath = join(workspaceDir, "openclaw.jsonc"); + await fs.writeFile(configPath, "{}", "utf-8"); + await fs.writeFile( + join(workspaceDir, "policy.jsonc"), + JSON.stringify({ + tools: { exec: { allowHosts: ["sandbox", "node"] } }, + scopes: { + sebby: { + agentIds: ["sebby"], + tools: { exec: { allowHosts: ["sandbox"] } }, + }, + }, + }), + "utf-8", + ); + + const result = await runPolicyChecks(ctx(configPath, cfgWithPolicy())); + + expect(result.findings).not.toEqual( + expect.arrayContaining([expect.objectContaining({ checkId: "policy/policy-jsonc-invalid" })]), + ); + }); + + it("allows scoped allowlists when an empty top-level allowlist is disabled", async () => { + const configPath = join(workspaceDir, "openclaw.jsonc"); + await fs.writeFile(configPath, "{}", "utf-8"); + await fs.writeFile( + join(workspaceDir, "policy.jsonc"), + JSON.stringify({ + tools: { exec: { allowHosts: [] } }, + scopes: { + sebby: { + agentIds: ["sebby"], + tools: { exec: { allowHosts: ["sandbox"] } }, + }, + }, + }), + "utf-8", + ); + + const result = await runPolicyChecks(ctx(configPath, cfgWithPolicy())); + + expect(result.findings).not.toEqual( + expect.arrayContaining([expect.objectContaining({ checkId: "policy/policy-jsonc-invalid" })]), + ); + }); + + it("allows scoped denyTools groups that cover top-level required denies", async () => { + const configPath = join(workspaceDir, "openclaw.jsonc"); + await fs.writeFile(configPath, "{}", "utf-8"); + await fs.writeFile( + join(workspaceDir, "policy.jsonc"), + JSON.stringify({ + tools: { denyTools: ["exec"] }, + scopes: { + sebby: { + agentIds: ["sebby"], + tools: { denyTools: ["group:runtime"] }, + }, + }, + }), + "utf-8", + ); + + const result = await runPolicyChecks(ctx(configPath, cfgWithPolicy())); + + expect(result.findings).not.toEqual( + expect.arrayContaining([expect.objectContaining({ checkId: "policy/policy-jsonc-invalid" })]), + ); + }); + + it("rejects scoped overrides that are weaker than top-level policy", async () => { + const configPath = join(workspaceDir, "openclaw.jsonc"); + await fs.writeFile(configPath, "{}", "utf-8"); + await fs.writeFile( + join(workspaceDir, "policy.jsonc"), + JSON.stringify({ + tools: { exec: { allowHosts: ["sandbox"] } }, + scopes: { + sebby: { + agentIds: ["sebby"], + tools: { exec: { allowHosts: ["sandbox", "node"] } }, + }, + }, + }), + "utf-8", + ); + + const result = await runPolicyChecks(ctx(configPath, cfgWithPolicy())); + + expect(result.findings).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + checkId: "policy/policy-jsonc-invalid", + target: "oc://policy.jsonc/scopes/sebby/tools/exec/allowHosts", + }), + ]), + ); + }); + + it("allows overlapping scoped fields when later scopes are stricter", async () => { + const configPath = join(workspaceDir, "openclaw.jsonc"); + await fs.writeFile(configPath, "{}", "utf-8"); + await fs.writeFile( + join(workspaceDir, "policy.jsonc"), + JSON.stringify({ + scopes: { + team: { + agentIds: ["sebby"], + tools: { exec: { allowHosts: ["sandbox", "node"] } }, + }, + lockdown: { + agentIds: ["sebby"], + tools: { exec: { allowHosts: ["sandbox"] } }, + }, + }, + }), + "utf-8", + ); + + const result = await runPolicyChecks(ctx(configPath, cfgWithPolicy())); + + expect(result.findings).not.toEqual( + expect.arrayContaining([expect.objectContaining({ checkId: "policy/policy-jsonc-invalid" })]), + ); + }); + + it("rejects overlapping scoped fields when later scopes are weaker", async () => { + const configPath = join(workspaceDir, "openclaw.jsonc"); + await fs.writeFile(configPath, "{}", "utf-8"); + await fs.writeFile( + join(workspaceDir, "policy.jsonc"), + JSON.stringify({ + scopes: { + lockdown: { + agentIds: ["sebby"], + tools: { exec: { allowHosts: ["sandbox"] } }, + }, + team: { + agentIds: ["sebby"], + tools: { exec: { allowHosts: ["sandbox", "node"] } }, + }, + }, + }), + "utf-8", + ); + + const result = await runPolicyChecks(ctx(configPath, cfgWithPolicy())); + + expect(result.findings).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + checkId: "policy/policy-jsonc-invalid", + target: "oc://policy.jsonc/scopes/team/tools/exec/allowHosts", + }), + ]), + ); + }); + it("registers policy health checks once", () => { const checks = registerChecks(); const duplicateChecks: HealthCheck[] = []; @@ -141,6 +366,8 @@ describe("registerPolicyDoctorChecks", () => { "policy/tools-exec-ask-unapproved", "policy/tools-exec-host-unapproved", "policy/tools-elevated-enabled", + "policy/tools-also-allow-missing", + "policy/tools-also-allow-unexpected", "policy/tools-required-deny-missing", "policy/secrets-unmanaged-provider", "policy/secrets-denied-provider-source", @@ -271,11 +498,125 @@ describe("registerPolicyDoctorChecks", () => { { tools: { elevated: { allow: "false" } } }, "oc://policy.jsonc/tools/elevated/allow", ], + [ + "tools alsoAllow array", + { tools: { alsoAllow: ["read"] } }, + "oc://policy.jsonc/tools/alsoAllow", + ], [ "tools denyTools blank entry", { tools: { denyTools: ["exec", " "] } }, "oc://policy.jsonc/tools/denyTools/#1", ], + ["scopes array", { scopes: [] }, "oc://policy.jsonc/scopes"], + [ + "scopes unsupported section for agentIds selector", + { scopes: { sebby: { agentIds: ["sebby"], channels: {} } } }, + "oc://policy.jsonc/scopes/sebby/channels", + ], + ["scopes named scope array", { scopes: { coding: [] } }, "oc://policy.jsonc/scopes/coding"], + [ + "scopes agent missing agentIds", + { scopes: { coding: { tools: { exec: { allowHosts: ["sandbox"] } } } } }, + "oc://policy.jsonc/scopes/coding/agentIds", + ], + [ + "scopes agent empty agentIds", + { scopes: { coding: { agentIds: [] } } }, + "oc://policy.jsonc/scopes/coding/agentIds", + ], + [ + "scopes agent duplicate normalized agentIds", + { scopes: { coding: { agentIds: ["Sebby", "sebby"] } } }, + "oc://policy.jsonc/scopes/coding/agentIds/#1", + ], + [ + "scopes agent workspace invalid access", + { + scopes: { + sebby: { + agentIds: ["sebby"], + agents: { workspace: { allowedAccess: ["readonly"] } }, + }, + }, + }, + "oc://policy.jsonc/scopes/sebby/agents/workspace/allowedAccess/#0", + ], + [ + "scopes agent tools exec allowHosts invalid", + { + scopes: { + sebby: { + agentIds: ["sebby"], + tools: { exec: { allowHosts: ["shell"] } }, + }, + }, + }, + "oc://policy.jsonc/scopes/sebby/tools/exec/allowHosts/#0", + ], + [ + "scopes agent tools unsupported top-level key", + { + scopes: { + sebby: { + agentIds: ["sebby"], + tools: { requireMetadata: ["owner"] }, + }, + }, + }, + "oc://policy.jsonc/scopes/sebby/tools/requireMetadata", + ], + [ + "scopes agent tools unsupported nested key", + { + scopes: { + sebby: { + agentIds: ["sebby"], + tools: { exec: { requireMetadata: ["owner"] } }, + }, + }, + }, + "oc://policy.jsonc/scopes/sebby/tools/exec/requireMetadata", + ], + [ + "scopes agent tools alsoAllow expected invalid", + { + scopes: { + sebby: { + agentIds: ["sebby"], + tools: { alsoAllow: { expected: ["read", ""] } }, + }, + }, + }, + "oc://policy.jsonc/scopes/sebby/tools/alsoAllow/expected/#1", + ], + [ + "scopes agent tools alsoAllow array", + { + scopes: { + sebby: { agentIds: ["sebby"], tools: { alsoAllow: ["read"] } }, + }, + }, + "oc://policy.jsonc/scopes/sebby/tools/alsoAllow", + ], + [ + "scopes agent quoted segment tools invalid", + { + scopes: { + "team/sebby": { agentIds: ["team/sebby"], tools: { exec: { allowHosts: ["shell"] } } }, + }, + }, + 'oc://policy.jsonc/scopes/"team/sebby"/tools/exec/allowHosts/#0', + ], + [ + "scopes agent unsupported section", + { + scopes: { + sebby: { agentIds: ["sebby"], sandbox: { allow: true } }, + }, + }, + "oc://policy.jsonc/scopes/sebby/sandbox", + ], ["channels array", { channels: [] }, "oc://policy.jsonc/channels"], ["mcp array", { mcp: [] }, "oc://policy.jsonc/mcp"], ["mcp servers array", { mcp: { servers: [] } }, "oc://policy.jsonc/mcp/servers"], @@ -2505,6 +2846,415 @@ describe("registerPolicyDoctorChecks", () => { ]); }); + it("reports global and agent-scoped workspace claims independently", async () => { + const configPath = join(workspaceDir, "openclaw.jsonc"); + const cfg = { + ...cfgWithPolicy(), + agents: { + defaults: { + sandbox: { mode: "all", workspaceAccess: "ro" }, + }, + list: [ + { id: "sebby", sandbox: { mode: "all", workspaceAccess: "rw" } }, + { id: "buddy", sandbox: { mode: "all", workspaceAccess: "ro" } }, + ], + }, + } as unknown as OpenClawConfig; + await fs.writeFile(configPath, "{}", "utf-8"); + await fs.writeFile( + join(workspaceDir, "policy.jsonc"), + JSON.stringify({ + agents: { + workspace: { + allowedAccess: ["none", "ro"], + }, + }, + scopes: { + sebby: { + agentIds: ["sebby"], + agents: { + workspace: { + allowedAccess: ["none"], + }, + }, + }, + }, + }), + "utf-8", + ); + + registerPolicyDoctorChecks(); + const result = await runDoctorLintChecks(ctx(configPath, cfg)); + + expect(result.findings).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + checkId: "policy/agents-workspace-access-denied", + ocPath: "oc://openclaw.config/agents/list/#0/sandbox/workspaceAccess", + requirement: "oc://policy.jsonc/agents/workspace/allowedAccess", + }), + expect.objectContaining({ + checkId: "policy/agents-workspace-access-denied", + ocPath: "oc://openclaw.config/agents/list/#0/sandbox/workspaceAccess", + requirement: "oc://policy.jsonc/scopes/sebby/agents/workspace/allowedAccess", + }), + ]), + ); + expect(result.findings).not.toEqual( + expect.arrayContaining([ + expect.objectContaining({ + ocPath: "oc://openclaw.config/agents/list/#1/sandbox/workspaceAccess", + }), + ]), + ); + }); + + it("allows purpose-named agent scopes to target multiple agents", async () => { + const configPath = join(workspaceDir, "openclaw.jsonc"); + const cfg = { + ...cfgWithPolicy(), + agents: { + list: [ + { id: "sebby", sandbox: { mode: "all", workspaceAccess: "rw" } }, + { id: "buddy", sandbox: { mode: "all", workspaceAccess: "rw" } }, + ], + }, + } as unknown as OpenClawConfig; + await fs.writeFile(configPath, "{}", "utf-8"); + await fs.writeFile( + join(workspaceDir, "policy.jsonc"), + JSON.stringify({ + scopes: { + "workspace-lockdown": { + agentIds: ["sebby", "buddy"], + agents: { + workspace: { + allowedAccess: ["ro"], + }, + }, + }, + }, + }), + "utf-8", + ); + + registerPolicyDoctorChecks(); + const result = await runDoctorLintChecks(ctx(configPath, cfg)); + + expect(result.findings).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + ocPath: "oc://openclaw.config/agents/list/#0/sandbox/workspaceAccess", + requirement: "oc://policy.jsonc/scopes/workspace-lockdown/agents/workspace/allowedAccess", + }), + expect.objectContaining({ + ocPath: "oc://openclaw.config/agents/list/#1/sandbox/workspaceAccess", + requirement: "oc://policy.jsonc/scopes/workspace-lockdown/agents/workspace/allowedAccess", + }), + ]), + ); + }); + + it("allows overlapping agent scopes when they govern different fields", async () => { + const configPath = join(workspaceDir, "openclaw.jsonc"); + const cfg = { + ...cfgWithPolicy(), + agents: { + list: [ + { + id: "sebby", + sandbox: { mode: "all", workspaceAccess: "rw" }, + tools: { exec: { host: "node" } }, + }, + ], + }, + } as unknown as OpenClawConfig; + await fs.writeFile(configPath, "{}", "utf-8"); + await fs.writeFile( + join(workspaceDir, "policy.jsonc"), + JSON.stringify({ + scopes: { + "workspace-lockdown": { + agentIds: ["sebby"], + agents: { + workspace: { + allowedAccess: ["ro"], + }, + }, + }, + "exec-posture": { + agentIds: ["sebby"], + tools: { + exec: { allowHosts: ["sandbox"] }, + }, + }, + }, + }), + "utf-8", + ); + + registerPolicyDoctorChecks(); + const result = await runDoctorLintChecks(ctx(configPath, cfg)); + + expect(result.findings).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + requirement: "oc://policy.jsonc/scopes/workspace-lockdown/agents/workspace/allowedAccess", + }), + expect.objectContaining({ + requirement: "oc://policy.jsonc/scopes/exec-posture/tools/exec/allowHosts", + }), + ]), + ); + }); + + it("rejects overlapping agent scopes that govern the same field", async () => { + const configPath = join(workspaceDir, "openclaw.jsonc"); + await fs.writeFile(configPath, "{}", "utf-8"); + await fs.writeFile( + join(workspaceDir, "policy.jsonc"), + JSON.stringify({ + scopes: { + "coding-posture": { + agentIds: ["Sebby"], + tools: { + exec: { allowHosts: ["sandbox"] }, + }, + }, + "strict-exec": { + agentIds: ["sebby"], + tools: { + exec: { allowHosts: ["gateway"] }, + }, + }, + }, + }), + "utf-8", + ); + + registerPolicyDoctorChecks(); + const result = await runDoctorLintChecks(ctx(configPath, cfgWithPolicy())); + + expect(result.findings).toEqual([ + expect.objectContaining({ + checkId: "policy/policy-jsonc-invalid", + target: "oc://policy.jsonc/scopes/strict-exec/tools/exec/allowHosts", + }), + ]); + }); + + it("does not apply agent-scoped workspace claims to other agents", async () => { + const configPath = join(workspaceDir, "openclaw.jsonc"); + const cfg = { + ...cfgWithPolicy(), + agents: { + list: [ + { id: "sebby", sandbox: { mode: "all", workspaceAccess: "ro" } }, + { id: "buddy", sandbox: { mode: "all", workspaceAccess: "rw" } }, + ], + }, + } as unknown as OpenClawConfig; + await fs.writeFile(configPath, "{}", "utf-8"); + await fs.writeFile( + join(workspaceDir, "policy.jsonc"), + JSON.stringify({ + scopes: { + sebby: { + agentIds: ["sebby"], + agents: { + workspace: { + allowedAccess: ["ro"], + }, + }, + }, + }, + }), + "utf-8", + ); + + registerPolicyDoctorChecks(); + const result = await runDoctorLintChecks(ctx(configPath, cfg)); + + expect(result.findings).toEqual([]); + }); + + it("matches agent-scoped claims against normalized agent ids", async () => { + const configPath = join(workspaceDir, "openclaw.jsonc"); + const cfg = { + ...cfgWithPolicy(), + agents: { + list: [ + { + id: "Sebby", + sandbox: { mode: "all", workspaceAccess: "rw" }, + tools: { exec: { host: "node" } }, + }, + ], + }, + } as unknown as OpenClawConfig; + await fs.writeFile(configPath, "{}", "utf-8"); + await fs.writeFile( + join(workspaceDir, "policy.jsonc"), + JSON.stringify({ + scopes: { + sebby: { + agentIds: ["sebby"], + agents: { + workspace: { + allowedAccess: ["ro"], + }, + }, + tools: { + exec: { allowHosts: ["sandbox"] }, + }, + }, + }, + }), + "utf-8", + ); + + registerPolicyDoctorChecks(); + const result = await runDoctorLintChecks(ctx(configPath, cfg)); + + expect(result.findings).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + checkId: "policy/agents-workspace-access-denied", + ocPath: "oc://openclaw.config/agents/list/#0/sandbox/workspaceAccess", + requirement: "oc://policy.jsonc/scopes/sebby/agents/workspace/allowedAccess", + }), + expect.objectContaining({ + checkId: "policy/tools-exec-host-unapproved", + ocPath: "oc://openclaw.config/agents/list/#0/tools/exec/host", + requirement: "oc://policy.jsonc/scopes/sebby/tools/exec/allowHosts", + }), + ]), + ); + }); + + it("applies main agent-scoped claims to implicit default agent posture", async () => { + const configPath = join(workspaceDir, "openclaw.jsonc"); + const cfg = { + ...cfgWithPolicy(), + tools: { exec: { host: "node" } }, + agents: { + defaults: { + sandbox: { mode: "all", workspaceAccess: "rw" }, + }, + list: [ + { + id: "support", + sandbox: { mode: "all", workspaceAccess: "ro" }, + tools: { exec: { host: "sandbox" } }, + }, + ], + }, + } as unknown as OpenClawConfig; + await fs.writeFile(configPath, "{}", "utf-8"); + await fs.writeFile( + join(workspaceDir, "policy.jsonc"), + JSON.stringify({ + scopes: { + main: { + agentIds: ["main"], + agents: { + workspace: { + allowedAccess: ["ro"], + }, + }, + tools: { + exec: { allowHosts: ["sandbox"] }, + }, + }, + }, + }), + "utf-8", + ); + + registerPolicyDoctorChecks(); + const result = await runDoctorLintChecks(ctx(configPath, cfg)); + + expect(result.findings).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + checkId: "policy/agents-workspace-access-denied", + ocPath: "oc://openclaw.config/agents/defaults/sandbox/workspaceAccess", + requirement: "oc://policy.jsonc/scopes/main/agents/workspace/allowedAccess", + }), + expect.objectContaining({ + checkId: "policy/tools-exec-host-unapproved", + ocPath: "oc://openclaw.config/tools/exec/host", + requirement: "oc://policy.jsonc/scopes/main/tools/exec/allowHosts", + }), + ]), + ); + }); + + it("applies non-main agent-scoped claims to inherited default posture", async () => { + const configPath = join(workspaceDir, "openclaw.jsonc"); + const cfg = { + ...cfgWithPolicy(), + tools: { exec: { host: "node" } }, + agents: { + defaults: { + sandbox: { mode: "all", workspaceAccess: "rw" }, + }, + list: [ + { + id: "support", + sandbox: { mode: "all", workspaceAccess: "ro" }, + tools: { exec: { host: "sandbox" } }, + }, + ], + }, + } as unknown as OpenClawConfig; + await fs.writeFile(configPath, "{}", "utf-8"); + await fs.writeFile( + join(workspaceDir, "policy.jsonc"), + JSON.stringify({ + scopes: { + "release-lockdown": { + agentIds: ["release-agent"], + agents: { + workspace: { + allowedAccess: ["ro"], + }, + }, + tools: { + exec: { allowHosts: ["sandbox"] }, + }, + }, + }, + }), + "utf-8", + ); + + registerPolicyDoctorChecks(); + const result = await runDoctorLintChecks(ctx(configPath, cfg)); + + expect(result.findings).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + checkId: "policy/agents-workspace-access-denied", + ocPath: "oc://openclaw.config/agents/defaults/sandbox/workspaceAccess", + requirement: "oc://policy.jsonc/scopes/release-lockdown/agents/workspace/allowedAccess", + }), + expect.objectContaining({ + checkId: "policy/tools-exec-host-unapproved", + ocPath: "oc://openclaw.config/tools/exec/host", + requirement: "oc://policy.jsonc/scopes/release-lockdown/tools/exec/allowHosts", + }), + ]), + ); + expect(result.findings).not.toEqual( + expect.arrayContaining([ + expect.objectContaining({ + ocPath: "oc://openclaw.config/agents/list/#0/sandbox/workspaceAccess", + }), + ]), + ); + }); + it("reports tool posture denied by policy", async () => { const configPath = join(workspaceDir, "openclaw.jsonc"); const cfg = { @@ -2663,6 +3413,195 @@ describe("registerPolicyDoctorChecks", () => { expect(result.findings).toEqual([]); }); + it("reports global and agent-scoped tool claims independently", async () => { + const configPath = join(workspaceDir, "openclaw.jsonc"); + const cfg = { + ...cfgWithPolicy(), + tools: { + exec: { host: "sandbox" }, + }, + agents: { + list: [ + { id: "sebby", tools: { exec: { host: "node" } } }, + { id: "buddy", tools: { exec: { host: "sandbox" } } }, + ], + }, + } as unknown as OpenClawConfig; + await fs.writeFile(configPath, "{}", "utf-8"); + await fs.writeFile( + join(workspaceDir, "policy.jsonc"), + JSON.stringify({ + tools: { + exec: { allowHosts: ["sandbox", "gateway"] }, + }, + scopes: { + sebby: { + agentIds: ["sebby"], + tools: { + exec: { allowHosts: ["gateway"] }, + }, + }, + }, + }), + "utf-8", + ); + + registerPolicyDoctorChecks(); + const result = await runDoctorLintChecks(ctx(configPath, cfg)); + + expect(result.findings).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + checkId: "policy/tools-exec-host-unapproved", + ocPath: "oc://openclaw.config/agents/list/#0/tools/exec/host", + requirement: "oc://policy.jsonc/tools/exec/allowHosts", + }), + expect.objectContaining({ + checkId: "policy/tools-exec-host-unapproved", + ocPath: "oc://openclaw.config/agents/list/#0/tools/exec/host", + requirement: "oc://policy.jsonc/scopes/sebby/tools/exec/allowHosts", + }), + ]), + ); + expect(result.findings).not.toEqual( + expect.arrayContaining([ + expect.objectContaining({ + ocPath: "oc://openclaw.config/agents/list/#1/tools/exec/host", + }), + ]), + ); + }); + + it("does not apply agent-scoped tool claims to other agents", async () => { + const configPath = join(workspaceDir, "openclaw.jsonc"); + const cfg = { + ...cfgWithPolicy(), + agents: { + list: [ + { id: "sebby", tools: { exec: { host: "sandbox" } } }, + { id: "buddy", tools: { exec: { host: "node" } } }, + ], + }, + } as unknown as OpenClawConfig; + await fs.writeFile(configPath, "{}", "utf-8"); + await fs.writeFile( + join(workspaceDir, "policy.jsonc"), + JSON.stringify({ + scopes: { + sebby: { + agentIds: ["sebby"], + tools: { + exec: { allowHosts: ["sandbox"] }, + }, + }, + }, + }), + "utf-8", + ); + + registerPolicyDoctorChecks(); + const result = await runDoctorLintChecks(ctx(configPath, cfg)); + + expect(result.findings).toEqual([]); + }); + + it("reports global and agent-scoped alsoAllow drift", async () => { + const configPath = join(workspaceDir, "openclaw.jsonc"); + const cfg = { + ...cfgWithPolicy(), + tools: { alsoAllow: ["read", "cron"] }, + agents: { + list: [ + { id: "sebby", tools: { alsoAllow: ["read", "gateway"] } }, + { id: "buddy", tools: { alsoAllow: ["read"] } }, + ], + }, + } as unknown as OpenClawConfig; + await fs.writeFile(configPath, "{}", "utf-8"); + await fs.writeFile( + join(workspaceDir, "policy.jsonc"), + JSON.stringify({ + tools: { + alsoAllow: { expected: ["read", "message"] }, + }, + scopes: { + sebby: { + agentIds: ["sebby"], + tools: { + alsoAllow: { expected: ["read", "message"] }, + }, + }, + }, + }), + "utf-8", + ); + + registerPolicyDoctorChecks(); + const result = await runDoctorLintChecks(ctx(configPath, cfg)); + + expect(result.findings).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + checkId: "policy/tools-also-allow-missing", + ocPath: "oc://openclaw.config/tools/alsoAllow", + requirement: "oc://policy.jsonc/tools/alsoAllow/expected", + }), + expect.objectContaining({ + checkId: "policy/tools-also-allow-unexpected", + ocPath: "oc://openclaw.config/tools/alsoAllow", + requirement: "oc://policy.jsonc/tools/alsoAllow/expected", + }), + expect.objectContaining({ + checkId: "policy/tools-also-allow-missing", + ocPath: "oc://openclaw.config/agents/list/#0/tools/alsoAllow", + requirement: "oc://policy.jsonc/scopes/sebby/tools/alsoAllow/expected", + }), + expect.objectContaining({ + checkId: "policy/tools-also-allow-unexpected", + ocPath: "oc://openclaw.config/agents/list/#0/tools/alsoAllow", + requirement: "oc://policy.jsonc/scopes/sebby/tools/alsoAllow/expected", + }), + ]), + ); + expect(result.findings).not.toEqual( + expect.arrayContaining([ + expect.objectContaining({ + requirement: "oc://policy.jsonc/scopes/sebby/tools/alsoAllow/expected", + ocPath: "oc://openclaw.config/agents/list/#1/tools/alsoAllow", + }), + ]), + ); + }); + + it("reports unexpected alsoAllow entries when policy expects none", async () => { + const configPath = join(workspaceDir, "openclaw.jsonc"); + const cfg = { + ...cfgWithPolicy(), + tools: { alsoAllow: ["read"] }, + } as unknown as OpenClawConfig; + await fs.writeFile(configPath, "{}", "utf-8"); + await fs.writeFile( + join(workspaceDir, "policy.jsonc"), + JSON.stringify({ + tools: { + alsoAllow: { expected: [] }, + }, + }), + "utf-8", + ); + + registerPolicyDoctorChecks(); + const result = await runDoctorLintChecks(ctx(configPath, cfg)); + + expect(result.findings).toEqual([ + expect.objectContaining({ + checkId: "policy/tools-also-allow-unexpected", + ocPath: "oc://openclaw.config/tools/alsoAllow", + requirement: "oc://policy.jsonc/tools/alsoAllow/expected", + }), + ]); + }); + it("uses config-level exec defaults and normalizes required deny aliases", async () => { const configPath = join(workspaceDir, "openclaw.jsonc"); const cfg = { diff --git a/extensions/policy/src/doctor/register.ts b/extensions/policy/src/doctor/register.ts index 5e34a4afb15c..33ff4d5a0764 100644 --- a/extensions/policy/src/doctor/register.ts +++ b/extensions/policy/src/doctor/register.ts @@ -7,11 +7,13 @@ import { type HealthFinding, } from "openclaw/plugin-sdk/health"; import { normalizeProviderId } from "openclaw/plugin-sdk/provider-model-shared"; +import { normalizeAgentId } from "openclaw/plugin-sdk/routing"; import { collectPolicyEvidence, createPolicyAttestation, policyDocumentHash, type PolicyAuthProfileEvidence, + type PolicyAgentWorkspaceEvidence, type PolicyEvidence, type PolicyToolPostureEvidence, } from "../policy-state.js"; @@ -39,6 +41,8 @@ const CHECK_IDS = { policyAgentsWorkspaceAccessDenied: "policy/agents-workspace-access-denied", policyAgentsToolNotDenied: "policy/agents-tool-not-denied", policyToolsElevatedEnabled: "policy/tools-elevated-enabled", + policyToolsAlsoAllowMissing: "policy/tools-also-allow-missing", + policyToolsAlsoAllowUnexpected: "policy/tools-also-allow-unexpected", policyToolsExecAskUnapproved: "policy/tools-exec-ask-unapproved", policyToolsExecHostUnapproved: "policy/tools-exec-host-unapproved", policyToolsExecSecurityUnapproved: "policy/tools-exec-security-unapproved", @@ -84,6 +88,8 @@ export const POLICY_CHECK_IDS = [ CHECK_IDS.policyToolsExecAskUnapproved, CHECK_IDS.policyToolsExecHostUnapproved, CHECK_IDS.policyToolsElevatedEnabled, + CHECK_IDS.policyToolsAlsoAllowMissing, + CHECK_IDS.policyToolsAlsoAllowUnexpected, CHECK_IDS.policyToolsRequiredDenyMissing, CHECK_IDS.policySecretsUnmanagedProvider, CHECK_IDS.policySecretsDeniedProviderSource, @@ -97,6 +103,106 @@ export const POLICY_CHECK_IDS = [ CHECK_IDS.policyUnknownToolSensitivity, ] as const; +export type PolicyStrictnessKind = + | "allowlist-subset" + | "denylist-superset" + | "requires-true" + | "requires-false" + | "exact-list"; + +export type PolicyEmptyListSemantics = "disabled" | "meaningful"; + +export type PolicyScopeSelectorKind = "agentIds"; + +export type PolicyRuleMetadata = { + readonly policyPath: readonly string[]; + readonly strictness: PolicyStrictnessKind; + readonly valueType: "boolean" | "string-list"; + readonly checkIds: readonly (typeof POLICY_CHECK_IDS)[number][]; + readonly emptyList?: PolicyEmptyListSemantics; + readonly caseSensitive?: boolean; + readonly scopeSelectors?: readonly PolicyScopeSelectorKind[]; +}; + +export const POLICY_RULE_METADATA = [ + { + policyPath: ["agents", "workspace", "allowedAccess"], + strictness: "allowlist-subset", + valueType: "string-list", + checkIds: [CHECK_IDS.policyAgentsWorkspaceAccessDenied], + emptyList: "disabled", + scopeSelectors: ["agentIds"], + }, + { + policyPath: ["agents", "workspace", "denyTools"], + strictness: "denylist-superset", + valueType: "string-list", + checkIds: [CHECK_IDS.policyAgentsToolNotDenied], + scopeSelectors: ["agentIds"], + }, + { + policyPath: ["tools", "profiles", "allow"], + strictness: "allowlist-subset", + valueType: "string-list", + checkIds: [CHECK_IDS.policyToolsProfileUnapproved], + emptyList: "disabled", + scopeSelectors: ["agentIds"], + }, + { + policyPath: ["tools", "fs", "requireWorkspaceOnly"], + strictness: "requires-true", + valueType: "boolean", + checkIds: [CHECK_IDS.policyToolsFsWorkspaceOnlyRequired], + scopeSelectors: ["agentIds"], + }, + { + policyPath: ["tools", "exec", "allowSecurity"], + strictness: "allowlist-subset", + valueType: "string-list", + checkIds: [CHECK_IDS.policyToolsExecSecurityUnapproved], + emptyList: "disabled", + scopeSelectors: ["agentIds"], + }, + { + policyPath: ["tools", "exec", "requireAsk"], + strictness: "allowlist-subset", + valueType: "string-list", + checkIds: [CHECK_IDS.policyToolsExecAskUnapproved], + emptyList: "disabled", + scopeSelectors: ["agentIds"], + }, + { + policyPath: ["tools", "exec", "allowHosts"], + strictness: "allowlist-subset", + valueType: "string-list", + checkIds: [CHECK_IDS.policyToolsExecHostUnapproved], + emptyList: "disabled", + scopeSelectors: ["agentIds"], + }, + { + policyPath: ["tools", "elevated", "allow"], + strictness: "requires-false", + valueType: "boolean", + checkIds: [CHECK_IDS.policyToolsElevatedEnabled], + scopeSelectors: ["agentIds"], + }, + { + policyPath: ["tools", "alsoAllow", "expected"], + strictness: "exact-list", + valueType: "string-list", + checkIds: [CHECK_IDS.policyToolsAlsoAllowMissing, CHECK_IDS.policyToolsAlsoAllowUnexpected], + emptyList: "meaningful", + scopeSelectors: ["agentIds"], + }, + { + policyPath: ["tools", "denyTools"], + strictness: "denylist-superset", + valueType: "string-list", + checkIds: [CHECK_IDS.policyToolsRequiredDenyMissing], + scopeSelectors: ["agentIds"], + }, +] as const satisfies readonly PolicyRuleMetadata[]; + 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; @@ -164,6 +270,8 @@ export function registerPolicyDoctorChecks(host?: PolicyDoctorRegistrationHost): registerHealthCheck(policyToolsExecAskUnapprovedCheck); registerHealthCheck(policyToolsExecHostUnapprovedCheck); registerHealthCheck(policyToolsElevatedEnabledCheck); + registerHealthCheck(policyToolsAlsoAllowMissingCheck); + registerHealthCheck(policyToolsAlsoAllowUnexpectedCheck); registerHealthCheck(policyToolsRequiredDenyMissingCheck); registerHealthCheck(policySecretsUnmanagedProviderCheck); registerHealthCheck(policySecretsDeniedProviderSourceCheck); @@ -483,6 +591,26 @@ const policyToolsElevatedEnabledCheck: HealthCheck = { }, }; +const policyToolsAlsoAllowMissingCheck: HealthCheck = { + id: CHECK_IDS.policyToolsAlsoAllowMissing, + kind: "plugin", + description: "Configured tools.alsoAllow entries include policy expected lists.", + source: "policy", + async detect(ctx) { + return findingsForCheck(await evaluatePolicy(ctx), CHECK_IDS.policyToolsAlsoAllowMissing); + }, +}; + +const policyToolsAlsoAllowUnexpectedCheck: HealthCheck = { + id: CHECK_IDS.policyToolsAlsoAllowUnexpected, + kind: "plugin", + description: "Configured tools.alsoAllow entries match policy expected lists.", + source: "policy", + async detect(ctx) { + return findingsForCheck(await evaluatePolicy(ctx), CHECK_IDS.policyToolsAlsoAllowUnexpected); + }, +}; + const policyToolsRequiredDenyMissingCheck: HealthCheck = { id: CHECK_IDS.policyToolsRequiredDenyMissing, kind: "plugin", @@ -1115,6 +1243,14 @@ function policyContainerShapeFindings( if (agentsFinding !== undefined) { return [agentsFinding]; } + const scopesFinding = scopedPolicyShapeFinding(policy.scopes, { + policyDocName, + policyPath, + policy, + }); + if (scopesFinding !== undefined) { + return [scopesFinding]; + } return []; } @@ -1136,21 +1272,227 @@ function agentsPolicyShapeFinding( `Fix ${params.policyPath} so agents is an object.`, ); } - if (value.workspace !== undefined && !isRecord(value.workspace)) { + const workspaceFinding = agentWorkspacePolicyShapeFinding(value.workspace, { + policyDocName: params.policyDocName, + policyPath: params.policyPath, + targetPrefix: "agents/workspace", + propertyPrefix: "agents.workspace", + }); + if (workspaceFinding !== undefined) { + return workspaceFinding; + } + return undefined; +} + +function scopedPolicyShapeFinding( + value: unknown, + params: { + readonly policyDocName: string; + readonly policyPath: string; + readonly policy: Record; + }, +): HealthFinding | undefined { + if (value === undefined) { + return undefined; + } + if (!isRecord(value)) { return policyShapeFinding( params.policyPath, - `oc://${params.policyDocName}/agents/workspace`, - `${params.policyPath} agents.workspace must be an object.`, - `Fix ${params.policyPath} so agents.workspace is an object.`, + `oc://${params.policyDocName}/scopes`, + `${params.policyPath} scopes must be an object.`, + `Fix ${params.policyPath} so scopes maps scope names to policy overlays with selectors such as agentIds.`, ); } - const workspace = isRecord(value.workspace) ? value.workspace : {}; - const allowedAccess = workspace.allowedAccess; + for (const [scopeName, overlay] of Object.entries(value)) { + const targetPrefix = `scopes/${ocPathSegment(scopeName)}`; + if (!isRecord(overlay)) { + return policyShapeFinding( + params.policyPath, + `oc://${params.policyDocName}/${targetPrefix}`, + `${params.policyPath} scopes.${scopeName} must be an object.`, + `Fix ${params.policyPath} so the named policy scope is an object.`, + ); + } + if (overlay.agentIds === undefined) { + return policyShapeFinding( + params.policyPath, + `oc://${params.policyDocName}/${targetPrefix}/agentIds`, + `${params.policyPath} scopes.${scopeName}.agentIds is required for scoped tools or agent workspace policy.`, + `List the runtime agent ids that this named policy scope applies to.`, + ); + } + const agentIdsFinding = policyStringArrayPropertyShapeFinding(overlay.agentIds, { + policyDocName: params.policyDocName, + policyPath: params.policyPath, + property: `scopes.${scopeName}.agentIds`, + target: `${targetPrefix}/agentIds`, + valueName: "agent id", + }); + if (agentIdsFinding !== undefined) { + return agentIdsFinding; + } + if (Array.isArray(overlay.agentIds) && overlay.agentIds.length === 0) { + return policyShapeFinding( + params.policyPath, + `oc://${params.policyDocName}/${targetPrefix}/agentIds`, + `${params.policyPath} scopes.${scopeName}.agentIds must include at least one agent id.`, + `Add one or more runtime agent ids to ${params.policyPath} scopes.${scopeName}.agentIds.`, + ); + } + if (Array.isArray(overlay.agentIds)) { + const seen = new Map(); + for (const [index, agentId] of overlay.agentIds.entries()) { + if (typeof agentId !== "string") { + continue; + } + const normalized = normalizeAgentId(agentId); + const previous = seen.get(normalized); + if (previous !== undefined) { + return policyShapeFinding( + params.policyPath, + `oc://${params.policyDocName}/${targetPrefix}/agentIds/#${index}`, + `${params.policyPath} scopes.${scopeName}.agentIds[${index}] duplicates agentIds[${previous}] after normalization.`, + `List each runtime agent id only once per named policy scope.`, + ); + } + seen.set(normalized, index); + } + } + const unsupportedKey = Object.keys(overlay).find( + (key) => key !== "agentIds" && key !== "agents" && key !== "tools", + ); + if (unsupportedKey !== undefined) { + return policyShapeFinding( + params.policyPath, + `oc://${params.policyDocName}/${targetPrefix}/${ocPathSegment(unsupportedKey)}`, + `${params.policyPath} scopes.${scopeName}.${unsupportedKey} is not supported by the agentIds selector.`, + `Use only agentIds with agents.workspace or tools in this policy scope.`, + ); + } + if (overlay.agents !== undefined && !isRecord(overlay.agents)) { + return policyShapeFinding( + params.policyPath, + `oc://${params.policyDocName}/${targetPrefix}/agents`, + `${params.policyPath} scopes.${scopeName}.agents must be an object.`, + `Fix ${params.policyPath} so the scoped agents policy section is an object.`, + ); + } + const scopedAgents = isRecord(overlay.agents) ? overlay.agents : {}; + const unsupportedAgentKey = Object.keys(scopedAgents).find((key) => key !== "workspace"); + if (unsupportedAgentKey !== undefined) { + return policyShapeFinding( + params.policyPath, + `oc://${params.policyDocName}/${targetPrefix}/agents/${ocPathSegment(unsupportedAgentKey)}`, + `${params.policyPath} scopes.${scopeName}.agents.${unsupportedAgentKey} is not supported by the agentIds selector.`, + `Move the rule under agents.workspace or a supported scoped top-level section.`, + ); + } + const workspaceFinding = agentWorkspacePolicyShapeFinding(scopedAgents.workspace, { + policyDocName: params.policyDocName, + policyPath: params.policyPath, + targetPrefix: `${targetPrefix}/agents/workspace`, + propertyPrefix: `scopes.${scopeName}.agents.workspace`, + }); + if (workspaceFinding !== undefined) { + return workspaceFinding; + } + if (overlay.tools !== undefined && !isRecord(overlay.tools)) { + return policyShapeFinding( + params.policyPath, + `oc://${params.policyDocName}/${targetPrefix}/tools`, + `${params.policyPath} scopes.${scopeName}.tools must be an object.`, + `Fix ${params.policyPath} so the scoped tools policy overlay is an object.`, + ); + } + if (isRecord(overlay.tools)) { + const toolsFinding = scopedToolsPolicyShapeFinding(overlay.tools, { + policyDocName: params.policyDocName, + policyPath: params.policyPath, + targetPrefix: `${targetPrefix}/tools`, + propertyPrefix: `scopes.${scopeName}.tools`, + }); + if (toolsFinding !== undefined) { + return toolsFinding; + } + } + } + return duplicateScopedAgentFieldFinding(value, { + policyDocName: params.policyDocName, + policyPath: params.policyPath, + policy: params.policy, + }); +} + +function scopedToolsPolicyShapeFinding( + value: Record, + params: { + readonly policyDocName: string; + readonly policyPath: string; + readonly targetPrefix: string; + readonly propertyPrefix: string; + }, +): HealthFinding | undefined { + const allowedTopLevel = new Set(["profiles", "fs", "exec", "elevated", "alsoAllow", "denyTools"]); + const unsupportedTopLevel = Object.keys(value).find((key) => !allowedTopLevel.has(key)); + if (unsupportedTopLevel !== undefined) { + return policyShapeFinding( + params.policyPath, + `oc://${params.policyDocName}/${params.targetPrefix}/${ocPathSegment(unsupportedTopLevel)}`, + `${params.policyPath} ${params.propertyPrefix}.${unsupportedTopLevel} is not supported in agent-scoped tools policy.`, + `Move ${params.propertyPrefix}.${unsupportedTopLevel} to top-level tools or use a supported scoped tools posture rule.`, + ); + } + for (const [section, allowedKeys] of [ + ["profiles", ["allow"]], + ["fs", ["requireWorkspaceOnly"]], + ["exec", ["allowSecurity", "requireAsk", "allowHosts"]], + ["elevated", ["allow"]], + ["alsoAllow", ["expected"]], + ] as const) { + const sectionValue = value[section]; + if (!isRecord(sectionValue)) { + continue; + } + const allowed = new Set(allowedKeys); + const unsupportedKey = Object.keys(sectionValue).find((key) => !allowed.has(key)); + if (unsupportedKey !== undefined) { + return policyShapeFinding( + params.policyPath, + `oc://${params.policyDocName}/${params.targetPrefix}/${section}/${ocPathSegment(unsupportedKey)}`, + `${params.policyPath} ${params.propertyPrefix}.${section}.${unsupportedKey} is not supported in agent-scoped tools policy.`, + `Move ${params.propertyPrefix}.${section}.${unsupportedKey} to top-level tools or use a supported scoped tools posture rule.`, + ); + } + } + return toolPosturePolicyShapeFinding(value, params); +} + +function agentWorkspacePolicyShapeFinding( + value: unknown, + params: { + readonly policyDocName: string; + readonly policyPath: string; + readonly targetPrefix: string; + readonly propertyPrefix: string; + }, +): HealthFinding | undefined { + if (value === undefined) { + return undefined; + } + if (!isRecord(value)) { + return policyShapeFinding( + params.policyPath, + `oc://${params.policyDocName}/${params.targetPrefix}`, + `${params.policyPath} ${params.propertyPrefix} must be an object.`, + `Fix ${params.policyPath} so ${params.propertyPrefix} is an object.`, + ); + } + const allowedAccess = value.allowedAccess; if (allowedAccess !== undefined && !Array.isArray(allowedAccess)) { return policyShapeFinding( params.policyPath, - `oc://${params.policyDocName}/agents/workspace/allowedAccess`, - `${params.policyPath} agents.workspace.allowedAccess must be an array.`, + `oc://${params.policyDocName}/${params.targetPrefix}/allowedAccess`, + `${params.policyPath} ${params.propertyPrefix}.allowedAccess must be an array.`, 'Use workspace access values such as ["none", "ro"].', ); } @@ -1161,18 +1503,18 @@ function agentsPolicyShapeFinding( if (invalidIndex >= 0) { return policyShapeFinding( params.policyPath, - `oc://${params.policyDocName}/agents/workspace/allowedAccess/#${invalidIndex}`, - `${params.policyPath} agents.workspace.allowedAccess[${invalidIndex}] must be none, ro, or rw.`, + `oc://${params.policyDocName}/${params.targetPrefix}/allowedAccess/#${invalidIndex}`, + `${params.policyPath} ${params.propertyPrefix}.allowedAccess[${invalidIndex}] must be none, ro, or rw.`, 'Use workspace access values such as ["none", "ro"].', ); } } - const denyTools = workspace.denyTools; + const denyTools = value.denyTools; if (denyTools !== undefined && !Array.isArray(denyTools)) { return policyShapeFinding( params.policyPath, - `oc://${params.policyDocName}/agents/workspace/denyTools`, - `${params.policyPath} agents.workspace.denyTools must be an array.`, + `oc://${params.policyDocName}/${params.targetPrefix}/denyTools`, + `${params.policyPath} ${params.propertyPrefix}.denyTools must be an array.`, 'Use tool ids such as ["exec", "process", "write", "edit", "apply_patch"].', ); } @@ -1187,8 +1529,8 @@ function agentsPolicyShapeFinding( if (invalidIndex >= 0) { return policyShapeFinding( params.policyPath, - `oc://${params.policyDocName}/agents/workspace/denyTools/#${invalidIndex}`, - `${params.policyPath} agents.workspace.denyTools[${invalidIndex}] must be a supported agent workspace tool id.`, + `oc://${params.policyDocName}/${params.targetPrefix}/denyTools/#${invalidIndex}`, + `${params.policyPath} ${params.propertyPrefix}.denyTools[${invalidIndex}] must be a supported agent workspace tool id.`, `Use supported tool ids: ${SUPPORTED_AGENT_WORKSPACE_DENY_TOOLS.join(", ")}.`, ); } @@ -1201,15 +1543,19 @@ function toolPosturePolicyShapeFinding( params: { readonly policyDocName: string; readonly policyPath: string; + readonly targetPrefix?: string; + readonly propertyPrefix?: string; }, ): HealthFinding | undefined { - for (const section of ["profiles", "fs", "exec", "elevated"] as const) { + const targetPrefix = params.targetPrefix ?? "tools"; + const propertyPrefix = params.propertyPrefix ?? "tools"; + for (const section of ["profiles", "fs", "exec", "elevated", "alsoAllow"] as const) { if (tools[section] !== undefined && !isRecord(tools[section])) { return policyShapeFinding( params.policyPath, - `oc://${params.policyDocName}/tools/${section}`, - `${params.policyPath} tools.${section} must be an object.`, - `Fix ${params.policyPath} so tools.${section} is an object.`, + `oc://${params.policyDocName}/${targetPrefix}/${section}`, + `${params.policyPath} ${propertyPrefix}.${section} must be an object.`, + `Fix ${params.policyPath} so ${propertyPrefix}.${section} is an object.`, ); } } @@ -1219,8 +1565,8 @@ function toolPosturePolicyShapeFinding( allowed: SUPPORTED_TOOL_PROFILES, policyDocName: params.policyDocName, policyPath: params.policyPath, - property: "tools.profiles.allow", - target: "tools/profiles/allow", + property: `${propertyPrefix}.profiles.allow`, + target: `${targetPrefix}/profiles/allow`, valueName: "tool profile id", }); if (profileAllowFinding !== undefined) { @@ -1231,9 +1577,9 @@ function toolPosturePolicyShapeFinding( if (fs.requireWorkspaceOnly !== undefined && typeof fs.requireWorkspaceOnly !== "boolean") { return policyShapeFinding( params.policyPath, - `oc://${params.policyDocName}/tools/fs/requireWorkspaceOnly`, - `${params.policyPath} tools.fs.requireWorkspaceOnly must be a boolean.`, - "Set tools.fs.requireWorkspaceOnly to true or false.", + `oc://${params.policyDocName}/${targetPrefix}/fs/requireWorkspaceOnly`, + `${params.policyPath} ${propertyPrefix}.fs.requireWorkspaceOnly must be a boolean.`, + `Set ${propertyPrefix}.fs.requireWorkspaceOnly to true or false.`, ); } @@ -1248,8 +1594,8 @@ function toolPosturePolicyShapeFinding( allowed: supported, policyDocName: params.policyDocName, policyPath: params.policyPath, - property: `tools.exec.${key}`, - target: `tools/exec/${key}`, + property: `${propertyPrefix}.exec.${key}`, + target: `${targetPrefix}/exec/${key}`, valueName, }); if (finding !== undefined) { @@ -1261,17 +1607,29 @@ function toolPosturePolicyShapeFinding( if (elevated.allow !== undefined && typeof elevated.allow !== "boolean") { return policyShapeFinding( params.policyPath, - `oc://${params.policyDocName}/tools/elevated/allow`, - `${params.policyPath} tools.elevated.allow must be a boolean.`, - "Set tools.elevated.allow to true or false.", + `oc://${params.policyDocName}/${targetPrefix}/elevated/allow`, + `${params.policyPath} ${propertyPrefix}.elevated.allow must be a boolean.`, + `Set ${propertyPrefix}.elevated.allow to true or false.`, ); } + const alsoAllow = isRecord(tools.alsoAllow) ? tools.alsoAllow : {}; + const alsoAllowExpectedFinding = policyStringArrayPropertyShapeFinding(alsoAllow.expected, { + policyDocName: params.policyDocName, + policyPath: params.policyPath, + property: `${propertyPrefix}.alsoAllow.expected`, + target: `${targetPrefix}/alsoAllow/expected`, + valueName: "tool id", + }); + if (alsoAllowExpectedFinding !== undefined) { + return alsoAllowExpectedFinding; + } + const denyToolsFinding = policyStringArrayPropertyShapeFinding(tools.denyTools, { policyDocName: params.policyDocName, policyPath: params.policyPath, - property: "tools.denyTools", - target: "tools/denyTools", + property: `${propertyPrefix}.denyTools`, + target: `${targetPrefix}/denyTools`, valueName: "tool id or group", }); return denyToolsFinding; @@ -1985,21 +2343,40 @@ function agentWorkspaceFindings( return []; } return [ - ...agentWorkspaceAccessFindings(policy, policyDocName, evidence), - ...agentWorkspaceToolDenyFindings(policy, policyDocName, evidence), + ...agentWorkspaceAccessFindings( + policy, + ["agents", "workspace", "allowedAccess"], + policyDocName, + "agents/workspace/allowedAccess", + evidence, + () => true, + ), + ...agentWorkspaceToolDenyFindings( + policy, + ["agents", "workspace", "denyTools"], + policyDocName, + "agents/workspace/denyTools", + evidence, + () => true, + ), + ...agentScopedWorkspaceFindings(policy, policyPath, policyDocName, evidence), ]; } function agentWorkspaceAccessFindings( policy: unknown, + policyPath: readonly string[], policyDocName: string, + requirementPath: string, evidence: PolicyEvidence, + evidenceFilter: (entry: PolicyAgentWorkspaceEvidence) => boolean, ): readonly HealthFinding[] { - const allowed = new Set(readStringList(policy, ["agents", "workspace", "allowedAccess"])); + const allowed = new Set(readStringList(policy, policyPath)); if (allowed.size === 0) { return []; } return (evidence.agentWorkspace ?? []) + .filter(evidenceFilter) .filter( (entry) => entry.kind === "workspaceAccess" && @@ -2021,7 +2398,7 @@ function agentWorkspaceAccessFindings( path: "openclaw config", ocPath, target: ocPath, - requirement: `oc://${policyDocName}/agents/workspace/allowedAccess`, + requirement: `oc://${policyDocName}/${requirementPath}`, fixHint: "Enable sandbox mode with workspaceAccess none/ro or update policy after review.", }; }); @@ -2029,14 +2406,18 @@ function agentWorkspaceAccessFindings( function agentWorkspaceToolDenyFindings( policy: unknown, + policyPath: readonly string[], policyDocName: string, + requirementPath: string, evidence: PolicyEvidence, + evidenceFilter: (entry: PolicyAgentWorkspaceEvidence) => boolean, ): readonly HealthFinding[] { - const requiredDeniedTools = new Set(readStringList(policy, ["agents", "workspace", "denyTools"])); + const requiredDeniedTools = new Set(readStringList(policy, policyPath)); if (requiredDeniedTools.size === 0) { return []; } return (evidence.agentWorkspace ?? []) + .filter(evidenceFilter) .filter( (entry) => entry.kind === "toolDeny" && @@ -2054,110 +2435,287 @@ function agentWorkspaceToolDenyFindings( path: "openclaw config", ocPath: entry.source, target: entry.source, - requirement: `oc://${policyDocName}/agents/workspace/denyTools`, + requirement: `oc://${policyDocName}/${requirementPath}`, fixHint: "Add the tool to tools.deny or agents.list[].tools.deny, or update policy after review.", }; }); } +function agentScopedWorkspaceFindings( + policy: unknown, + policyPath: string, + policyDocName: string, + evidence: PolicyEvidence, +): readonly HealthFinding[] { + if (!hasValidScopedPolicy(policy, policyPath, policyDocName)) { + return []; + } + const findings: HealthFinding[] = []; + for (const target of agentScopedPolicyTargets(policy)) { + const scopedAgents = isRecord(target.overlay.agents) ? target.overlay.agents : {}; + const workspace = isRecord(scopedAgents.workspace) ? scopedAgents.workspace : {}; + const requirementBase = `scopes/${ocPathSegment(target.scopeName)}/agents/workspace`; + const evidenceFilter = (entry: PolicyAgentWorkspaceEvidence) => + scopedWorkspaceAgentMatches(entry, target.agentId, evidence.agentWorkspace ?? []); + findings.push( + ...agentWorkspaceAccessFindings( + { workspace }, + ["workspace", "allowedAccess"], + policyDocName, + `${requirementBase}/allowedAccess`, + evidence, + evidenceFilter, + ), + ...agentWorkspaceToolDenyFindings( + { workspace }, + ["workspace", "denyTools"], + policyDocName, + `${requirementBase}/denyTools`, + evidence, + evidenceFilter, + ), + ); + } + return findings; +} + function toolPostureFindings( policy: unknown, policyPath: string, policyDocName: string, evidence: PolicyEvidence, ): readonly HealthFinding[] { + const findings: HealthFinding[] = []; if ( - !isRecord(policy) || - !isRecord(policy.tools) || - toolPosturePolicyShapeFinding(policy.tools, { policyDocName, policyPath }) !== undefined + isRecord(policy) && + isRecord(policy.tools) && + toolPosturePolicyShapeFinding(policy.tools, { policyDocName, policyPath }) === undefined ) { - return []; + findings.push( + ...toolPostureFindingsForRule(policy.tools, policyDocName, "tools", evidence, () => true), + ); } + if (!hasValidScopedPolicy(policy, policyPath, policyDocName)) { + return findings; + } + for (const target of agentScopedPolicyTargets(policy)) { + if (!isRecord(target.overlay.tools)) { + continue; + } + const requirementBase = `scopes/${ocPathSegment(target.scopeName)}/tools`; + if ( + toolPosturePolicyShapeFinding(target.overlay.tools, { + policyDocName, + policyPath, + targetPrefix: requirementBase, + propertyPrefix: `scopes.${target.scopeName}.tools`, + }) !== undefined + ) { + continue; + } + findings.push( + ...toolPostureFindingsForRule( + target.overlay.tools, + policyDocName, + requirementBase, + evidence, + (entry) => scopedToolAgentMatches(entry, target.agentId, evidence.toolPosture ?? []), + ), + ); + } + return findings; +} +function hasValidScopedPolicy(policy: unknown, policyPath: string, policyDocName: string): boolean { + return ( + isRecord(policy) && + scopedPolicyShapeFinding(policy.scopes, { policyDocName, policyPath, policy }) === undefined + ); +} + +function scopedWorkspaceAgentMatches( + entry: PolicyAgentWorkspaceEvidence, + policyAgentId: string, + entries: readonly PolicyAgentWorkspaceEvidence[], +): boolean { + if (scopedAgentIdMatches(entry.agentId, policyAgentId)) { + return true; + } + return entry.scope === "defaults" && !hasScopedAgentEvidence(entries, entry.kind, policyAgentId); +} + +function scopedToolAgentMatches( + entry: PolicyToolPostureEvidence, + policyAgentId: string, + entries: readonly PolicyToolPostureEvidence[], +): boolean { + if (scopedAgentIdMatches(entry.agentId, policyAgentId)) { + return true; + } + return entry.scope === "global" && !hasScopedToolEvidence(entries, entry.kind, policyAgentId); +} + +function hasScopedAgentEvidence( + entries: readonly PolicyAgentWorkspaceEvidence[], + kind: PolicyAgentWorkspaceEvidence["kind"], + policyAgentId: string, +): boolean { + return entries.some( + (candidate) => + candidate.scope === "agent" && + candidate.kind === kind && + scopedAgentIdMatches(candidate.agentId, policyAgentId), + ); +} + +function hasScopedToolEvidence( + entries: readonly PolicyToolPostureEvidence[], + kind: PolicyToolPostureEvidence["kind"], + policyAgentId: string, +): boolean { + return entries.some( + (candidate) => + candidate.scope === "agent" && + candidate.kind === kind && + scopedAgentIdMatches(candidate.agentId, policyAgentId), + ); +} + +function scopedAgentIdMatches(evidenceAgentId: string | undefined, policyAgentId: string): boolean { + return ( + evidenceAgentId !== undefined && + normalizeAgentId(evidenceAgentId) === normalizeAgentId(policyAgentId) + ); +} + +function toolPostureFindingsForRule( + toolsPolicy: Record, + policyDocName: string, + requirementBase: string, + evidence: PolicyEvidence, + evidenceFilter: (entry: PolicyToolPostureEvidence) => boolean, +): readonly HealthFinding[] { return [ - ...toolProfileFindings(policy, policyDocName, evidence), - ...toolFsWorkspaceOnlyFindings(policy, policyDocName, evidence), - ...toolExecPostureFindings(policy, policyDocName, evidence), - ...toolElevatedFindings(policy, policyDocName, evidence), - ...toolRequiredDenyFindings(policy, policyDocName, evidence), + ...toolProfileFindings(toolsPolicy, policyDocName, requirementBase, evidence, evidenceFilter), + ...toolFsWorkspaceOnlyFindings( + toolsPolicy, + policyDocName, + requirementBase, + evidence, + evidenceFilter, + ), + ...toolExecPostureFindings( + toolsPolicy, + policyDocName, + requirementBase, + evidence, + evidenceFilter, + ), + ...toolElevatedFindings(toolsPolicy, policyDocName, requirementBase, evidence, evidenceFilter), + ...toolAlsoAllowExpectedFindings( + toolsPolicy, + policyDocName, + requirementBase, + evidence, + evidenceFilter, + ), + ...toolRequiredDenyFindings( + toolsPolicy, + policyDocName, + requirementBase, + evidence, + evidenceFilter, + ), ]; } function toolProfileFindings( - policy: unknown, + toolsPolicy: Record, policyDocName: string, + requirementBase: string, evidence: PolicyEvidence, + evidenceFilter: (entry: PolicyToolPostureEvidence) => boolean, ): readonly HealthFinding[] { - const allowed = new Set(readStringList(policy, ["tools", "profiles", "allow"])); + const allowed = new Set(readStringList(toolsPolicy, ["profiles", "allow"])); if (allowed.size === 0) { return []; } return toolPostureEntries(evidence, "profile") + .filter(evidenceFilter) .filter((entry) => typeof entry.value === "string" && !allowed.has(entry.value.toLowerCase())) .map((entry): HealthFinding => { return toolPostureFinding(entry, { checkId: CHECK_IDS.policyToolsProfileUnapproved, message: `${toolPostureLabel(entry)} uses unapproved tool profile '${entry.value ?? ""}'.`, - requirement: `oc://${policyDocName}/tools/profiles/allow`, + requirement: `oc://${policyDocName}/${requirementBase}/profiles/allow`, fixHint: "Use an approved tools.profile value or update policy after review.", }); }); } function toolFsWorkspaceOnlyFindings( - policy: unknown, + toolsPolicy: Record, policyDocName: string, + requirementBase: string, evidence: PolicyEvidence, + evidenceFilter: (entry: PolicyToolPostureEvidence) => boolean, ): readonly HealthFinding[] { - if (readPolicyBoolean(policy, ["tools", "fs", "requireWorkspaceOnly"]) !== true) { + if (readPolicyBoolean(toolsPolicy, ["fs", "requireWorkspaceOnly"]) !== true) { return []; } return toolPostureEntries(evidence, "fsWorkspaceOnly") + .filter(evidenceFilter) .filter((entry) => entry.value !== true) .map((entry): HealthFinding => { return toolPostureFinding(entry, { checkId: CHECK_IDS.policyToolsFsWorkspaceOnlyRequired, message: `${toolPostureLabel(entry)} does not require workspace-only filesystem tools.`, - requirement: `oc://${policyDocName}/tools/fs/requireWorkspaceOnly`, + requirement: `oc://${policyDocName}/${requirementBase}/fs/requireWorkspaceOnly`, fixHint: "Set tools.fs.workspaceOnly=true or update policy after review.", }); }); } function toolExecPostureFindings( - policy: unknown, + toolsPolicy: Record, policyDocName: string, + requirementBase: string, evidence: PolicyEvidence, + evidenceFilter: (entry: PolicyToolPostureEvidence) => boolean, ): readonly HealthFinding[] { return [ - ...toolStringPostureAllowFindings(policy, policyDocName, evidence, { + ...toolStringPostureAllowFindings(toolsPolicy, policyDocName, requirementBase, evidence, { checkId: CHECK_IDS.policyToolsExecSecurityUnapproved, kind: "execSecurity", - policyPath: ["tools", "exec", "allowSecurity"], - requirementPath: "tools/exec/allowSecurity", + policyPath: ["exec", "allowSecurity"], + requirementPath: "exec/allowSecurity", settingLabel: "exec security", + evidenceFilter, }), - ...toolStringPostureAllowFindings(policy, policyDocName, evidence, { + ...toolStringPostureAllowFindings(toolsPolicy, policyDocName, requirementBase, evidence, { checkId: CHECK_IDS.policyToolsExecAskUnapproved, kind: "execAsk", - policyPath: ["tools", "exec", "requireAsk"], - requirementPath: "tools/exec/requireAsk", + policyPath: ["exec", "requireAsk"], + requirementPath: "exec/requireAsk", settingLabel: "exec ask", + evidenceFilter, }), - ...toolStringPostureAllowFindings(policy, policyDocName, evidence, { + ...toolStringPostureAllowFindings(toolsPolicy, policyDocName, requirementBase, evidence, { checkId: CHECK_IDS.policyToolsExecHostUnapproved, kind: "execHost", - policyPath: ["tools", "exec", "allowHosts"], - requirementPath: "tools/exec/allowHosts", + policyPath: ["exec", "allowHosts"], + requirementPath: "exec/allowHosts", settingLabel: "exec host", + evidenceFilter, }), ]; } function toolStringPostureAllowFindings( - policy: unknown, + toolsPolicy: Record, policyDocName: string, + requirementBase: string, evidence: PolicyEvidence, params: { readonly checkId: (typeof POLICY_CHECK_IDS)[number]; @@ -2165,56 +2723,117 @@ function toolStringPostureAllowFindings( readonly policyPath: readonly string[]; readonly requirementPath: string; readonly settingLabel: string; + readonly evidenceFilter: (entry: PolicyToolPostureEvidence) => boolean; }, ): readonly HealthFinding[] { - const allowed = new Set(readStringList(policy, params.policyPath)); + const allowed = new Set(readStringList(toolsPolicy, params.policyPath)); if (allowed.size === 0) { return []; } return toolPostureEntries(evidence, params.kind) + .filter(params.evidenceFilter) .filter((entry) => typeof entry.value === "string" && !allowed.has(entry.value.toLowerCase())) .map((entry): HealthFinding => { return toolPostureFinding(entry, { checkId: params.checkId, message: `${toolPostureLabel(entry)} uses unapproved ${params.settingLabel} '${entry.value ?? ""}'.`, - requirement: `oc://${policyDocName}/${params.requirementPath}`, + requirement: `oc://${policyDocName}/${requirementBase}/${params.requirementPath}`, fixHint: "Adjust the configured tool posture or update policy after review.", }); }); } function toolElevatedFindings( - policy: unknown, + toolsPolicy: Record, policyDocName: string, + requirementBase: string, evidence: PolicyEvidence, + evidenceFilter: (entry: PolicyToolPostureEvidence) => boolean, ): readonly HealthFinding[] { - if (readPolicyBoolean(policy, ["tools", "elevated", "allow"]) !== false) { + if (readPolicyBoolean(toolsPolicy, ["elevated", "allow"]) !== false) { return []; } return toolPostureEntries(evidence, "elevatedEnabled") + .filter(evidenceFilter) .filter((entry) => entry.value !== false) .map((entry): HealthFinding => { return toolPostureFinding(entry, { checkId: CHECK_IDS.policyToolsElevatedEnabled, message: `${toolPostureLabel(entry)} permits elevated tool mode.`, - requirement: `oc://${policyDocName}/tools/elevated/allow`, + requirement: `oc://${policyDocName}/${requirementBase}/elevated/allow`, fixHint: "Set tools.elevated.enabled=false or update policy after review.", }); }); } -function toolRequiredDenyFindings( - policy: unknown, +function toolAlsoAllowExpectedFindings( + toolsPolicy: Record, policyDocName: string, + requirementBase: string, evidence: PolicyEvidence, + evidenceFilter: (entry: PolicyToolPostureEvidence) => boolean, ): readonly HealthFinding[] { - const required = readStringList(policy, ["tools", "denyTools"]); + const alsoAllowPolicy = isRecord(toolsPolicy.alsoAllow) ? toolsPolicy.alsoAllow : {}; + if (alsoAllowPolicy.expected === undefined) { + return []; + } + const expected = normalizedStringSet(readStringList(toolsPolicy, ["alsoAllow", "expected"])); + const findings: HealthFinding[] = []; + for (const entry of toolPostureEntries(evidence, "alsoAllow").filter(evidenceFilter)) { + const actual = normalizedStringSet(entry.entries ?? []); + for (const expectedTool of expected) { + if (actual.has(expectedTool)) { + continue; + } + findings.push( + toolPostureFinding(entry, { + checkId: CHECK_IDS.policyToolsAlsoAllowMissing, + message: `${toolPostureLabel(entry)} is missing expected tools.alsoAllow entry '${expectedTool}'.`, + requirement: `oc://${policyDocName}/${requirementBase}/alsoAllow/expected`, + fixHint: "Add the expected tools.alsoAllow entry or update policy after review.", + }), + ); + } + for (const actualTool of actual) { + if (expected.has(actualTool)) { + continue; + } + findings.push( + toolPostureFinding(entry, { + checkId: CHECK_IDS.policyToolsAlsoAllowUnexpected, + message: `${toolPostureLabel(entry)} has unexpected tools.alsoAllow entry '${actualTool}'.`, + requirement: `oc://${policyDocName}/${requirementBase}/alsoAllow/expected`, + fixHint: "Remove the unexpected tools.alsoAllow entry or update policy after review.", + }), + ); + } + } + return findings; +} + +function normalizedStringSet(entries: readonly string[]): ReadonlySet { + return new Set( + entries + .map((entry) => entry.trim().toLowerCase()) + .filter(Boolean) + .toSorted(), + ); +} + +function toolRequiredDenyFindings( + toolsPolicy: Record, + policyDocName: string, + requirementBase: string, + evidence: PolicyEvidence, + evidenceFilter: (entry: PolicyToolPostureEvidence) => boolean, +): readonly HealthFinding[] { + const required = readStringList(toolsPolicy, ["denyTools"]); if (required.length === 0) { return []; } const requiredTools = [...new Set(required.flatMap(expandPolicyToolRequirement))]; const findings: HealthFinding[] = []; - for (const entry of toolPostureEntries(evidence, "deny")) { + for (const entry of toolPostureEntries(evidence, "deny").filter(evidenceFilter)) { for (const tool of requiredTools) { if (toolListCoversTool(entry.entries ?? [], tool)) { continue; @@ -2223,7 +2842,7 @@ function toolRequiredDenyFindings( toolPostureFinding(entry, { checkId: CHECK_IDS.policyToolsRequiredDenyMissing, message: `${toolPostureLabel(entry)} does not deny required tool '${tool}'.`, - requirement: `oc://${policyDocName}/tools/denyTools`, + requirement: `oc://${policyDocName}/${requirementBase}/denyTools`, fixHint: "Add the tool or group to tools.deny/agents.list[].tools.deny, or update policy after review.", }), @@ -2332,20 +2951,39 @@ function policyHasGatewayRules(policy: unknown): boolean { } function policyHasAgentWorkspaceRules(policy: unknown): boolean { - return ( - isRecord(policy) && - isRecord(policy.agents) && - isRecord(policy.agents.workspace) && - (policy.agents.workspace.allowedAccess !== undefined || - policy.agents.workspace.denyTools !== undefined) - ); + if (!isRecord(policy)) { + return false; + } + if (isRecord(policy.agents) && workspacePolicyHasRules(policy.agents.workspace)) { + return true; + } + return agentScopedPolicyOverlays(policy).some(([, overlay]) => { + const scopedAgents = isRecord(overlay.agents) ? overlay.agents : {}; + return workspacePolicyHasRules(scopedAgents.workspace); + }); } function policyHasToolPostureRules(policy: unknown): boolean { - if (!isRecord(policy) || !isRecord(policy.tools)) { + if (!isRecord(policy)) { return false; } - const tools = policy.tools; + if (toolPosturePolicyHasRules(policy.tools)) { + return true; + } + return agentScopedPolicyOverlays(policy).some(([, overlay]) => + toolPosturePolicyHasRules(overlay.tools), + ); +} + +function workspacePolicyHasRules(value: unknown): boolean { + return isRecord(value) && (value.allowedAccess !== undefined || value.denyTools !== undefined); +} + +function toolPosturePolicyHasRules(value: unknown): boolean { + if (!isRecord(value)) { + return false; + } + const tools = value; return ( (isRecord(tools.profiles) && tools.profiles.allow !== undefined) || (isRecord(tools.fs) && tools.fs.requireWorkspaceOnly !== undefined) || @@ -2354,10 +2992,246 @@ function policyHasToolPostureRules(policy: unknown): boolean { tools.exec.requireAsk !== undefined || tools.exec.allowHosts !== undefined)) || (isRecord(tools.elevated) && tools.elevated.allow !== undefined) || + (isRecord(tools.alsoAllow) && tools.alsoAllow.expected !== undefined) || tools.denyTools !== undefined ); } +type AgentScopedPolicyTarget = { + readonly scopeName: string; + readonly agentId: string; + readonly overlay: Record; +}; + +function agentScopedPolicyOverlays( + policy: unknown, +): readonly (readonly [string, Record])[] { + if (!isRecord(policy) || !isRecord(policy.scopes)) { + return []; + } + return Object.entries(policy.scopes).filter((entry): entry is [string, Record] => + isRecord(entry[1]), + ); +} + +function agentScopedPolicyTargets(policy: unknown): readonly AgentScopedPolicyTarget[] { + const targets: AgentScopedPolicyTarget[] = []; + for (const [scopeName, overlay] of agentScopedPolicyOverlays(policy)) { + if (!Array.isArray(overlay.agentIds)) { + continue; + } + for (const rawAgentId of overlay.agentIds) { + if (typeof rawAgentId !== "string" || rawAgentId.trim() === "") { + continue; + } + targets.push({ scopeName, agentId: normalizeAgentId(rawAgentId), overlay }); + } + } + return targets; +} + +type ScopedAgentPolicyField = { + readonly fieldPath: string; + readonly propertyPath: string; + readonly targetPath: string; + readonly metadata: PolicyRuleMetadata; + readonly value: unknown; +}; + +function duplicateScopedAgentFieldFinding( + scopedAgents: Record, + params: { + readonly policyDocName: string; + readonly policyPath: string; + readonly policy: Record; + }, +): HealthFinding | undefined { + const seen = new Map< + string, + { + readonly scopeName: string; + readonly propertyPath: string; + readonly field: ScopedAgentPolicyField; + } + >(); + for (const [scopeName, overlay] of Object.entries(scopedAgents)) { + if (!isRecord(overlay) || !Array.isArray(overlay.agentIds)) { + continue; + } + const fields = scopedAgentPolicyFields(scopeName, overlay); + for (const rawAgentId of overlay.agentIds) { + if (typeof rawAgentId !== "string" || rawAgentId.trim() === "") { + continue; + } + const agentId = normalizeAgentId(rawAgentId); + for (const field of fields) { + const topLevelValue = getPolicyPath(params.policy, field.metadata.policyPath); + if ( + topLevelValue !== undefined && + !isPolicyValueAtLeastAsStrict(field.metadata, field.value, topLevelValue) + ) { + return policyShapeFinding( + params.policyPath, + `oc://${params.policyDocName}/${field.targetPath}`, + `${params.policyPath} scopes.${scopeName}.${field.propertyPath} is weaker than the top-level ${field.propertyPath} policy.`, + `Use an equally or more restrictive scoped value, or remove the scoped override.`, + ); + } + const key = `${agentId}\0${field.fieldPath}`; + const previous = seen.get(key); + if (previous !== undefined) { + if (isPolicyValueAtLeastAsStrict(field.metadata, field.value, previous.field.value)) { + seen.set(key, { + scopeName, + propertyPath: `scopes.${scopeName}.${field.propertyPath}`, + field, + }); + continue; + } + return policyShapeFinding( + params.policyPath, + `oc://${params.policyDocName}/${field.targetPath}`, + `${params.policyPath} scopes.${scopeName}.${field.propertyPath} is not an equally or more restrictive override of ${previous.propertyPath} for agent '${agentId}'.`, + `Use one effective scoped value per agent, or make later scoped values stricter according to policy metadata.`, + ); + } + seen.set(key, { + scopeName, + propertyPath: `scopes.${scopeName}.${field.propertyPath}`, + field, + }); + } + } + } + return undefined; +} + +function scopedAgentPolicyFields( + scopeName: string, + overlay: Record, +): readonly ScopedAgentPolicyField[] { + const prefix = `scopes/${ocPathSegment(scopeName)}`; + return POLICY_RULE_METADATA.filter((rule) => rule.scopeSelectors?.includes("agentIds")) + .map((rule) => ({ rule, value: scopedPolicyValue(overlay, rule.policyPath) })) + .filter((entry) => entry.value !== undefined) + .map(({ rule, value }) => ({ + fieldPath: rule.policyPath.join("."), + propertyPath: rule.policyPath.join("."), + targetPath: `${prefix}/${rule.policyPath.map(ocPathSegment).join("/")}`, + metadata: rule, + value, + })); +} + +export function isPolicyValueAtLeastAsStrict( + metadata: PolicyRuleMetadata, + candidate: unknown, + baseline: unknown, +): boolean { + switch (metadata.strictness) { + case "allowlist-subset": + return isPolicyAllowlistSubset(metadata, candidate, baseline); + case "denylist-superset": + return isPolicyDenylistSuperset(metadata, candidate, baseline); + case "requires-true": + return baseline !== true || candidate === true; + case "requires-false": + return baseline !== false || candidate === false; + case "exact-list": + return samePolicyStringList(candidate, baseline, metadata); + } + return false; +} + +function isPolicyAllowlistSubset( + metadata: PolicyRuleMetadata, + candidate: unknown, + baseline: unknown, +): boolean { + const candidateList = policyStringList(candidate, metadata); + const baselineList = policyStringList(baseline, metadata); + if (candidateList === undefined || baselineList === undefined) { + return false; + } + if (metadata.emptyList === "disabled" && baselineList.length === 0) { + return true; + } + if (metadata.emptyList === "disabled" && baselineList.length > 0 && candidateList.length === 0) { + return false; + } + const allowed = new Set(baselineList); + return candidateList.every((entry) => allowed.has(entry)); +} + +function isPolicyDenylistSuperset( + metadata: PolicyRuleMetadata, + candidate: unknown, + baseline: unknown, +): boolean { + const candidateList = policyStringList(candidate, metadata); + const baselineList = policyStringList(baseline, metadata); + if (candidateList === undefined || baselineList === undefined) { + return false; + } + if (metadata.policyPath.join(".") === "tools.denyTools") { + return baselineList + .flatMap(expandPolicyToolRequirement) + .every((tool) => toolListCoversTool(candidateList, tool)); + } + const denied = new Set(candidateList); + return baselineList.every((entry) => denied.has(entry)); +} + +function samePolicyStringList( + candidate: unknown, + baseline: unknown, + metadata: PolicyRuleMetadata, +): boolean { + const candidateList = policyStringList(candidate, metadata); + const baselineList = policyStringList(baseline, metadata); + if (candidateList === undefined || baselineList === undefined) { + return false; + } + const candidateSorted = candidateList.toSorted(); + const baselineSorted = baselineList.toSorted(); + return ( + candidateSorted.length === baselineSorted.length && + candidateSorted.every((entry, index) => entry === baselineSorted[index]) + ); +} + +function policyStringList( + value: unknown, + metadata: PolicyRuleMetadata, +): readonly string[] | undefined { + 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())); +} + +function scopedPolicyValue(overlay: Record, path: readonly string[]): unknown { + const scopedRoot = path[0] === "agents" ? overlay.agents : overlay[path[0]]; + if (path[0] === "agents") { + return getPolicyPath(scopedRoot, path.slice(1)); + } + 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; +} + function secretPolicyShapeFindings( policy: unknown, policyPath: string, @@ -2984,6 +3858,13 @@ function readStringList( return readPolicyStringArray(policy, path, options) ?? []; } +function ocPathSegment(value: string): string { + if (/^(?:[A-Za-z0-9_-]+|#\d+)$/.test(value)) { + return value; + } + return JSON.stringify(value); +} + function readPolicyBoolean(policy: unknown, path: readonly string[]): boolean | undefined { let current: unknown = policy; for (const part of path) {