Policy: add agent-scoped policy overlays (#85817)

* feat(policy): add agent-scoped policy overlays

* docs(policy): use generic agent-scoped examples

* fix(policy): generalize scoped policy overlays

* fix(policy): clean scoped overlay checks

* fix(policy): evaluate inherited scoped agent posture

* chore(policy): keep agent harness out of scoped policy pr
This commit is contained in:
Gio Della-Libera
2026-05-25 08:45:16 -07:00
committed by GitHub
parent abe99230df
commit fbb6340542
5 changed files with 2160 additions and 83 deletions

View File

@@ -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.<scopeName>`. 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. |

View File

@@ -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.<scopeName>` 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.<scopeName>` 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.<scopeName>` 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.<scopeName>.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.<scopeName>.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.<scopeName>.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.

View File

@@ -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.<scopeName>` 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)

View File

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

File diff suppressed because it is too large Load Diff