mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-06 05:51:15 +08:00
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:
@@ -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. |
|
||||
|
||||
205
docs/plan/policy-agent-scoped-overlays.md
Normal file
205
docs/plan/policy-agent-scoped-overlays.md
Normal 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.
|
||||
@@ -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)
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user