Policy: add sandbox posture conformance checks (#85572)

Policy: add sandbox posture conformance checks (#85572)

Merged via squash.

Prepared head SHA: 1cf1953d8c
Co-authored-by: giodl73-repo <235387111+giodl73-repo@users.noreply.github.com>
Co-authored-by: giodl73-repo <235387111+giodl73-repo@users.noreply.github.com>
Reviewed-by: @giodl73-repo
This commit is contained in:
Gio Della-Libera
2026-05-28 21:00:24 -07:00
committed by GitHub
parent c037ab5c74
commit af64a824a1
6 changed files with 2498 additions and 273 deletions

View File

@@ -172,21 +172,87 @@ 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.
Policy overlays keep broad top-level rules global, then let named scope blocks
add stricter normal policy sections for explicit selectors. A scope name is a
descriptive bucket only; matching uses the selector values inside the scope.
The overlay is additive: global claims still run, and a scoped claim can emit
its own finding against the same observed config.
#### Scoped overlays
Use `scopes.<scopeName>` when one set of agents needs stricter policy than the
top-level baseline. Scopes require the `agentIds` selector, which supports
`tools.*`, `agents.workspace.*`, and `sandbox.*`. Unsupported sections are
rejected instead of being ignored. If an `agentIds` entry is not present in
`agents.list[]`, OpenClaw evaluates the scoped rule against inherited
global/default posture for that runtime agent id.
```jsonc
{
"tools": {
"exec": {
"allowHosts": ["sandbox", "node"],
},
},
"sandbox": {
"requireMode": ["all", "non-main"],
},
"scopes": {
"release-workspace": {
"agentIds": ["release-agent", "review-agent"],
"agents": {
"workspace": {
"allowedAccess": ["none", "ro"],
},
},
},
"release-lockdown": {
"agentIds": ["release-agent"],
"tools": {
"exec": {
"allowHosts": ["sandbox"],
"allowSecurity": ["deny", "allowlist"],
"requireAsk": ["always"],
},
"denyTools": ["exec", "process", "write", "edit", "apply_patch"],
},
"sandbox": {
"requireMode": ["all"],
"allowBackends": ["docker"],
},
},
"shell-sandbox": {
"agentIds": ["shell-agent"],
"sandbox": {
"allowBackends": ["openshell"],
"containers": {
"requireReadOnlyMounts": false,
},
},
},
},
}
```
The same agent can appear in multiple scopes when each scope governs different
fields, as shown above. A repeated scoped field for the same agent must be
equally or more restrictive according to policy metadata; weaker duplicate
claims are rejected. Strictness metadata treats allow-lists as subsets,
deny-lists as supersets, and required booleans as fixed requirements.
Container posture policy is evaluated only against evidence OpenClaw can
observe for the matched agent. If an enabled `sandbox.containers.*` rule applies
to an agent whose sandbox backend cannot expose that field, policy reports
`policy/sandbox-container-posture-unobservable` instead of treating the claim as
passing. Use separate `agentIds` scopes for agent groups that use different
sandbox backends, and leave unsupported container rules unset or false for the
groups where those fields cannot be observed.
| Selector | Supported sections | Use when |
| ---------- | ------------------------------------------ | ----------------------------------------------- |
| `agentIds` | `tools`, `agents.workspace`, and `sandbox` | One or more runtime agents need stricter rules. |
Every scope present in `policy.jsonc` must be valid and enforceable.
#### Channels
@@ -235,6 +301,23 @@ that runtime agent id instead of being skipped.
| `agents.workspace.allowedAccess` | `agents.defaults.sandbox.workspaceAccess` and `agents.list[].sandbox.workspaceAccess` | Allow only sandbox workspace access values such as `none` or `ro`. |
| `agents.workspace.denyTools` | Global and per-agent tool deny config | Require workspace/runtime mutation tools such as `exec`, `process`, `write`, `edit`, or `apply_patch` to be denied. |
#### Sandbox posture
| Policy field | Observed state | Use when |
| ----------------------------------------------------- | ------------------------------------------------------- | -------------------------------------------------------------- |
| `sandbox.requireMode` | `agents.defaults.sandbox.mode` and per-agent mode | Allow only reviewed sandbox modes such as `all` or `non-main`. |
| `sandbox.allowBackends` | `agents.defaults.sandbox.backend` and per-agent backend | Allow only reviewed sandbox backends such as `docker`. |
| `sandbox.containers.denyHostNetwork` | Container-backed sandbox/browser network mode | Deny host network mode. |
| `sandbox.containers.denyContainerNamespaceJoin` | Container-backed sandbox/browser network mode | Deny joining another container network namespace. |
| `sandbox.containers.requireReadOnlyMounts` | Container-backed sandbox/browser mount mode | Require mounts to be read-only. |
| `sandbox.containers.denyContainerRuntimeSocketMounts` | Container-backed sandbox/browser mount targets | Deny container runtime socket mounts. |
| `sandbox.containers.denyUnconfinedProfiles` | Container security profile posture | Deny unconfined container security profiles. |
| `sandbox.browser.requireCdpSourceRange` | Sandbox browser CDP source range | Require browser CDP exposure to declare a source range. |
Policy treats missing `sandbox.mode` as the implicit default `off`, so
`sandbox.requireMode` reports a fresh or unconfigured sandbox as outside an
allowlist such as `["all"]`.
#### Secrets
| Policy field | Observed state | Use when |
@@ -522,47 +605,56 @@ choose a different interval.
Policy currently verifies:
| Check id | Finding |
| -------------------------------------------- | -------------------------------------------------------------------------------- |
| `policy/policy-jsonc-missing` | Policy is enabled but `policy.jsonc` is missing. |
| `policy/policy-jsonc-invalid` | Policy cannot be parsed or contains malformed rule entries. |
| `policy/policy-hash-mismatch` | Policy does not match configured `expectedHash`. |
| `policy/attestation-hash-mismatch` | Current policy evidence no longer matches the accepted attestation. |
| `policy/channels-denied-provider` | An enabled channel matches a channel deny rule. |
| `policy/mcp-denied-server` | A configured MCP server is denied by policy. |
| `policy/mcp-unapproved-server` | A configured MCP server is outside the allowlist. |
| `policy/models-denied-provider` | A configured model provider or model ref uses a denied provider. |
| `policy/models-unapproved-provider` | A configured model provider or model ref is outside the allowlist. |
| `policy/network-private-access-enabled` | A private-network SSRF escape hatch is enabled when policy denies it. |
| `policy/gateway-non-loopback-bind` | Gateway bind posture permits non-loopback exposure when policy denies it. |
| `policy/gateway-auth-disabled` | Gateway authentication is disabled when policy requires auth. |
| `policy/gateway-rate-limit-missing` | Gateway auth rate-limit posture is not explicit when policy requires it. |
| `policy/gateway-control-ui-insecure` | Gateway Control UI insecure exposure toggles are enabled. |
| `policy/gateway-tailscale-funnel` | Gateway Tailscale Funnel exposure is enabled when policy denies it. |
| `policy/gateway-remote-enabled` | Gateway remote mode is active when policy denies it. |
| `policy/gateway-http-endpoint-enabled` | A Gateway HTTP API endpoint is enabled while denied by policy. |
| `policy/gateway-http-url-fetch-unrestricted` | Gateway HTTP URL-fetch input lacks a required URL allowlist. |
| `policy/agents-workspace-access-denied` | Agent sandbox mode or workspace access is outside the policy allowlist. |
| `policy/agents-tool-not-denied` | An agent or default config does not deny a tool required by policy. |
| `policy/tools-profile-unapproved` | A configured global or per-agent tool profile is outside the allowlist. |
| `policy/tools-fs-workspace-only-required` | Filesystem tools are not configured with workspace-only path posture. |
| `policy/tools-exec-security-unapproved` | Exec security mode is outside the policy allowlist. |
| `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. |
| `policy/secrets-insecure-provider` | A secret provider opts into insecure posture when policy denies it. |
| `policy/auth-profile-invalid-metadata` | A config auth profile is missing valid provider or mode metadata. |
| `policy/auth-profile-unapproved-mode` | A config auth profile mode is outside the policy allowlist. |
| `policy/tools-missing-risk-level` | A governed tool declaration is missing risk metadata. |
| `policy/tools-unknown-risk-level` | A governed tool declaration uses an unknown risk value. |
| `policy/tools-missing-sensitivity-token` | A governed tool declaration is missing sensitivity metadata. |
| `policy/tools-missing-owner` | A governed tool declaration is missing owner metadata. |
| `policy/tools-unknown-sensitivity-token` | A governed tool declaration uses an unknown sensitivity value. |
| Check id | Finding |
| ------------------------------------------------- | --------------------------------------------------------------------------------- |
| `policy/policy-jsonc-missing` | Policy is enabled but `policy.jsonc` is missing. |
| `policy/policy-jsonc-invalid` | Policy cannot be parsed or contains malformed rule entries. |
| `policy/policy-hash-mismatch` | Policy does not match configured `expectedHash`. |
| `policy/attestation-hash-mismatch` | Current policy evidence no longer matches the accepted attestation. |
| `policy/channels-denied-provider` | An enabled channel matches a channel deny rule. |
| `policy/mcp-denied-server` | A configured MCP server is denied by policy. |
| `policy/mcp-unapproved-server` | A configured MCP server is outside the allowlist. |
| `policy/models-denied-provider` | A configured model provider or model ref uses a denied provider. |
| `policy/models-unapproved-provider` | A configured model provider or model ref is outside the allowlist. |
| `policy/network-private-access-enabled` | A private-network SSRF escape hatch is enabled when policy denies it. |
| `policy/gateway-non-loopback-bind` | Gateway bind posture permits non-loopback exposure when policy denies it. |
| `policy/gateway-auth-disabled` | Gateway authentication is disabled when policy requires auth. |
| `policy/gateway-rate-limit-missing` | Gateway auth rate-limit posture is not explicit when policy requires it. |
| `policy/gateway-control-ui-insecure` | Gateway Control UI insecure exposure toggles are enabled. |
| `policy/gateway-tailscale-funnel` | Gateway Tailscale Funnel exposure is enabled when policy denies it. |
| `policy/gateway-remote-enabled` | Gateway remote mode is active when policy denies it. |
| `policy/gateway-http-endpoint-enabled` | A Gateway HTTP API endpoint is enabled while denied by policy. |
| `policy/gateway-http-url-fetch-unrestricted` | Gateway HTTP URL-fetch input lacks a required URL allowlist. |
| `policy/agents-workspace-access-denied` | Agent sandbox mode or workspace access is outside the policy allowlist. |
| `policy/agents-tool-not-denied` | An agent or default config does not deny a tool required by policy. |
| `policy/tools-profile-unapproved` | A configured global or per-agent tool profile is outside the allowlist. |
| `policy/tools-fs-workspace-only-required` | Filesystem tools are not configured with workspace-only path posture. |
| `policy/tools-exec-security-unapproved` | Exec security mode is outside the policy allowlist. |
| `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/sandbox-mode-unapproved` | Sandbox mode is outside the policy allowlist. |
| `policy/sandbox-backend-unapproved` | Sandbox backend is outside the policy allowlist. |
| `policy/sandbox-container-posture-unobservable` | A container posture rule is enabled for a backend that cannot observe it. |
| `policy/sandbox-container-host-network-denied` | A container-backed sandbox or browser uses host network mode. |
| `policy/sandbox-container-namespace-join-denied` | A container-backed sandbox or browser joins another container namespace. |
| `policy/sandbox-container-mount-mode-required` | A container-backed sandbox or browser mount is not read-only. |
| `policy/sandbox-container-runtime-socket-mount` | A container-backed sandbox or browser mount exposes the container runtime socket. |
| `policy/sandbox-container-unconfined-profile` | Container sandbox profile is unconfined when policy denies it. |
| `policy/sandbox-browser-cdp-source-range-missing` | Sandbox browser CDP source range is missing when policy requires one. |
| `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. |
| `policy/secrets-insecure-provider` | A secret provider opts into insecure posture when policy denies it. |
| `policy/auth-profile-invalid-metadata` | A config auth profile is missing valid provider or mode metadata. |
| `policy/auth-profile-unapproved-mode` | A config auth profile mode is outside the policy allowlist. |
| `policy/tools-missing-risk-level` | A governed tool declaration is missing risk metadata. |
| `policy/tools-unknown-risk-level` | A governed tool declaration uses an unknown risk value. |
| `policy/tools-missing-sensitivity-token` | A governed tool declaration is missing sensitivity metadata. |
| `policy/tools-missing-owner` | A governed tool declaration is missing owner metadata. |
| `policy/tools-unknown-sensitivity-token` | A governed tool declaration uses an unknown sensitivity value. |
Policy findings can include both `target` and `requirement`. `target` is the
observed workspace thing that does not conform. `requirement` is the authored

View File

@@ -1,205 +0,0 @@
---
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,45 @@ 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, configured sandbox runtime
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.
Sandbox posture rules can require approved sandbox modes/backends, deny host
container networking, deny container namespace joins, require read-only container
mounts, deny container runtime socket mounts and unconfined container profiles,
and require sandbox browser CDP source ranges.
These checks observe config conformance only; they do not read runtime approval
state, inspect live containers, 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
supported scoped sections are `tools`, `agents.workspace`, and `sandbox`.
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)

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -8,6 +8,9 @@ import {
} from "openclaw/plugin-sdk/string-coerce-runtime";
import { POLICY_TOOL_GROUPS } from "./tool-policy-conformance.js";
// Mirrors the sandbox browser config default without importing core internals into the policy plugin.
const DEFAULT_POLICY_SANDBOX_BROWSER_NETWORK = "openclaw-sandbox-browser";
export type PolicyAttestation = {
readonly checkedAt: string;
readonly policy?: {
@@ -26,6 +29,7 @@ export type PolicyEvidence = {
readonly channels: readonly PolicyChannelEvidence[];
readonly tools?: readonly PolicyToolEvidence[];
readonly toolPosture?: readonly PolicyToolPostureEvidence[];
readonly sandboxPosture?: readonly PolicySandboxPostureEvidence[];
readonly mcpServers: readonly PolicyMcpServerEvidence[];
readonly modelProviders: readonly PolicyModelProviderEvidence[];
readonly modelRefs: readonly PolicyModelRefEvidence[];
@@ -82,6 +86,28 @@ export type PolicyToolPostureEvidence = {
readonly explicit?: boolean;
};
export type PolicySandboxPostureEvidence = {
readonly id: string;
readonly kind:
| "backend"
| "browserCdpSourceRange"
| "containerMount"
| "containerNetwork"
| "containerSecurityProfile"
| "mode";
readonly source: string;
readonly scope: "defaults" | "agent";
readonly agentId?: string;
readonly value?: boolean | string;
readonly bind?: string;
readonly bindMode?: string;
readonly bindHost?: string;
readonly bindSurface?: "browser" | "docker";
readonly networkSurface?: "browser" | "docker";
readonly profile?: "apparmor" | "seccomp";
readonly explicit?: boolean;
};
export type PolicyModelProviderEvidence = {
readonly id: string;
readonly source: string;
@@ -227,6 +253,7 @@ export function collectPolicyEvidence(
readonly includeGatewayExposure?: boolean;
readonly includeAgentWorkspace?: boolean;
readonly includeToolPosture?: boolean;
readonly includeSandboxPosture?: boolean;
readonly includeSecrets?: boolean;
readonly includeAuthProfiles?: boolean;
},
@@ -238,6 +265,7 @@ export function collectPolicyEvidence(
readonly includeGatewayExposure?: boolean;
readonly includeAgentWorkspace?: boolean;
readonly includeToolPosture?: boolean;
readonly includeSandboxPosture?: boolean;
readonly includeSecrets?: boolean;
readonly includeAuthProfiles?: boolean;
},
@@ -249,6 +277,7 @@ export function collectPolicyEvidence(
readonly includeGatewayExposure?: boolean;
readonly includeAgentWorkspace?: boolean;
readonly includeToolPosture?: boolean;
readonly includeSandboxPosture?: boolean;
readonly includeSecrets?: boolean;
readonly includeAuthProfiles?: boolean;
} = {},
@@ -266,6 +295,9 @@ export function collectPolicyEvidence(
? {}
: { agentWorkspace: scanPolicyAgentWorkspace(cfg) }),
...(options.includeToolPosture === false ? {} : { toolPosture: scanPolicyToolPosture(cfg) }),
...(options.includeSandboxPosture === false
? {}
: { sandboxPosture: scanPolicySandboxPosture(cfg) }),
...(options.includeSecrets === false ? {} : { secrets: scanPolicySecrets(cfg) }),
...(options.includeAuthProfiles === false ? {} : { authProfiles: scanPolicyAuthProfiles(cfg) }),
};
@@ -560,6 +592,45 @@ export function scanPolicyAgentWorkspace(
return entries.toSorted((a, b) => a.source.localeCompare(b.source) || a.id.localeCompare(b.id));
}
export function scanPolicySandboxPosture(
cfg: Record<string, unknown>,
): readonly PolicySandboxPostureEvidence[] {
const agents = isRecord(cfg.agents) ? cfg.agents : {};
const defaults = isRecord(agents.defaults) ? agents.defaults : {};
const defaultSandbox = isRecord(defaults.sandbox) ? defaults.sandbox : {};
const entries: PolicySandboxPostureEvidence[] = [];
pushSandboxPostureEvidence(entries, {
id: "agents-defaults",
scope: "defaults",
sandbox: defaultSandbox,
inheritedSandbox: {},
sourceBase: "oc://openclaw.config/agents/defaults/sandbox",
inheritedSourceBase: "oc://openclaw.config/agents/defaults/sandbox",
});
const list = Array.isArray(agents.list) ? agents.list : [];
list.forEach((agent, index) => {
if (!isRecord(agent)) {
return;
}
const agentId =
typeof agent.id === "string" && agent.id.trim() !== "" ? agent.id.trim() : undefined;
const sandbox = isRecord(agent.sandbox) ? agent.sandbox : {};
pushSandboxPostureEvidence(entries, {
id: agentId ?? `agent-${index}`,
scope: "agent",
agentId,
sandbox,
inheritedSandbox: defaultSandbox,
sharedSandboxScope: sandboxScopeIsShared(sandbox, defaultSandbox),
sourceBase: `oc://openclaw.config/agents/list/#${index}/sandbox`,
inheritedSourceBase: "oc://openclaw.config/agents/defaults/sandbox",
});
});
return entries.toSorted((a, b) => a.source.localeCompare(b.source) || a.id.localeCompare(b.id));
}
export function scanPolicyToolPosture(
cfg: Record<string, unknown>,
): readonly PolicyToolPostureEvidence[] {
@@ -998,6 +1069,292 @@ function pushToolElevatedPosture(
}
}
type SandboxPostureParams = {
readonly id: string;
readonly scope: "defaults" | "agent";
readonly agentId?: string;
readonly effectiveBackend?: string;
readonly sandbox: Record<string, unknown>;
readonly inheritedSandbox: Record<string, unknown>;
readonly sharedSandboxScope?: boolean;
readonly sourceBase: string;
readonly inheritedSourceBase: string;
};
function pushSandboxPostureEvidence(
entries: PolicySandboxPostureEvidence[],
params: SandboxPostureParams,
): void {
const localMode = readString(params.sandbox.mode);
const inheritedMode = readString(params.inheritedSandbox.mode);
pushSandboxPostureValue(entries, params, {
suffix: "mode",
kind: "mode",
value: localMode ?? inheritedMode ?? "off",
explicit: localMode !== undefined || inheritedMode !== undefined,
inherited: localMode === undefined && inheritedMode !== undefined,
});
const localBackend = readString(params.sandbox.backend);
const inheritedBackend = readString(params.inheritedSandbox.backend);
const effectiveBackend = (localBackend ?? inheritedBackend ?? "docker").toLowerCase();
const effectiveParams = { ...params, effectiveBackend };
pushSandboxPostureValue(entries, params, {
suffix: "backend",
kind: "backend",
value: effectiveBackend,
explicit: localBackend !== undefined || inheritedBackend !== undefined,
inherited: localBackend === undefined && inheritedBackend !== undefined,
});
if (effectiveBackend === "docker") {
pushSandboxDockerPosture(entries, effectiveParams);
}
pushSandboxBrowserPosture(entries, effectiveParams);
}
function pushSandboxDockerPosture(
entries: PolicySandboxPostureEvidence[],
params: SandboxPostureParams,
): void {
const localDocker =
!params.sharedSandboxScope && isRecord(params.sandbox.docker) ? params.sandbox.docker : {};
const inheritedDocker = isRecord(params.inheritedSandbox.docker)
? params.inheritedSandbox.docker
: {};
const localNetwork = readString(localDocker.network);
const inheritedNetwork = readString(inheritedDocker.network);
pushSandboxPostureValue(entries, params, {
suffix: "docker/network",
kind: "containerNetwork",
value: localNetwork ?? inheritedNetwork ?? "none",
networkSurface: "docker",
explicit: localNetwork !== undefined || inheritedNetwork !== undefined,
inherited: localNetwork === undefined && inheritedNetwork !== undefined,
});
pushSandboxDockerProfilePosture(entries, params, localDocker, inheritedDocker, "seccomp");
pushSandboxDockerProfilePosture(entries, params, localDocker, inheritedDocker, "apparmor");
pushSandboxBindPosture(entries, params, {
inheritedBinds: readStringArray(inheritedDocker.binds),
localBinds: readStringArray(localDocker.binds),
sourceSuffix: "docker/binds",
surface: "docker",
});
}
function pushSandboxBindPosture(
entries: PolicySandboxPostureEvidence[],
params: SandboxPostureParams,
bindParams: {
readonly inheritedBinds: readonly string[];
readonly localBinds: readonly string[];
readonly sourceSuffix: string;
readonly surface: "browser" | "docker";
},
): void {
const { inheritedBinds, localBinds } = bindParams;
for (const [index, bind] of [...inheritedBinds, ...localBinds].entries()) {
const inherited = index < inheritedBinds.length;
const parsed = splitPolicyBindSpec(bind);
entries.push({
id: `${params.id}-${bindParams.surface}-bind-${index}`,
kind: "containerMount",
source: `${inherited ? params.inheritedSourceBase : params.sourceBase}/${bindParams.sourceSuffix}/#${
inherited ? index : index - inheritedBinds.length
}`,
scope: params.scope,
...(params.agentId === undefined ? {} : { agentId: params.agentId }),
bind,
bindHost: parsed?.host,
bindMode: parsed?.mode ?? "rw",
bindSurface: bindParams.surface,
explicit: true,
});
}
}
function pushSandboxDockerProfilePosture(
entries: PolicySandboxPostureEvidence[],
params: SandboxPostureParams,
localDocker: Record<string, unknown>,
inheritedDocker: Record<string, unknown>,
profile: "apparmor" | "seccomp",
): void {
const key = profile === "apparmor" ? "apparmorProfile" : "seccompProfile";
const localValue = readString(localDocker[key]);
const inheritedValue = readString(inheritedDocker[key]);
const inherited = localValue === undefined && inheritedValue !== undefined;
const value = localValue ?? inheritedValue;
entries.push({
id: `${params.id}-docker-${profile}-profile`,
kind: "containerSecurityProfile",
source: `${inherited ? params.inheritedSourceBase : params.sourceBase}/docker/${key}`,
scope: params.scope,
...(params.agentId === undefined ? {} : { agentId: params.agentId }),
profile,
...(value === undefined ? {} : { value }),
explicit: value !== undefined,
});
}
function pushSandboxBrowserPosture(
entries: PolicySandboxPostureEvidence[],
params: SandboxPostureParams,
): void {
const localBrowser =
!params.sharedSandboxScope && isRecord(params.sandbox.browser) ? params.sandbox.browser : {};
const inheritedBrowser = isRecord(params.inheritedSandbox.browser)
? params.inheritedSandbox.browser
: {};
const localEnabled = readBoolean(localBrowser.enabled);
const inheritedEnabled = readBoolean(inheritedBrowser.enabled);
const enabled = localEnabled ?? inheritedEnabled ?? false;
if (!enabled) {
const disabledInherited = localEnabled === undefined && inheritedEnabled !== undefined;
if (localEnabled !== undefined || inheritedEnabled !== undefined) {
entries.push({
id: `${params.id}-browser-cdp-source-range`,
kind: "browserCdpSourceRange",
source: `${disabledInherited ? params.inheritedSourceBase : params.sourceBase}/browser/enabled`,
scope: params.scope,
...(params.agentId === undefined ? {} : { agentId: params.agentId }),
value: false,
explicit: true,
});
}
return;
}
const hasLocalRange = Object.hasOwn(localBrowser, "cdpSourceRange");
const localRange = readString(localBrowser.cdpSourceRange);
const inheritedRange = readString(inheritedBrowser.cdpSourceRange);
const inherited = !hasLocalRange && inheritedRange !== undefined;
const value = hasLocalRange ? localRange : inheritedRange;
entries.push({
id: `${params.id}-browser-cdp-source-range`,
kind: "browserCdpSourceRange",
source: `${inherited ? params.inheritedSourceBase : params.sourceBase}/browser/cdpSourceRange`,
scope: params.scope,
...(params.agentId === undefined ? {} : { agentId: params.agentId }),
...(value === undefined ? {} : { value }),
explicit: value !== undefined,
});
const localNetwork = readString(localBrowser.network);
const inheritedNetwork = readString(inheritedBrowser.network);
pushSandboxPostureValue(entries, params, {
suffix: "browser/network",
kind: "containerNetwork",
value: localNetwork ?? inheritedNetwork ?? DEFAULT_POLICY_SANDBOX_BROWSER_NETWORK,
networkSurface: "browser",
explicit: localNetwork !== undefined || inheritedNetwork !== undefined,
inherited: localNetwork === undefined && inheritedNetwork !== undefined,
});
const browserBindsConfigured =
inheritedBrowser.binds !== undefined || localBrowser.binds !== undefined;
if (browserBindsConfigured) {
pushSandboxBindPosture(entries, params, {
inheritedBinds: readStringArray(inheritedBrowser.binds),
localBinds: readStringArray(localBrowser.binds),
sourceSuffix: "browser/binds",
surface: "browser",
});
} else if (params.effectiveBackend !== "docker") {
const localDocker =
!params.sharedSandboxScope && isRecord(params.sandbox.docker) ? params.sandbox.docker : {};
const inheritedDocker = isRecord(params.inheritedSandbox.docker)
? params.inheritedSandbox.docker
: {};
pushSandboxBindPosture(entries, params, {
inheritedBinds: readStringArray(inheritedDocker.binds),
localBinds: readStringArray(localDocker.binds),
sourceSuffix: "docker/binds",
surface: "browser",
});
}
}
function sandboxScopeIsShared(
sandbox: Record<string, unknown>,
inheritedSandbox: Record<string, unknown>,
): boolean {
const localScope = readString(sandbox.scope);
const inheritedScope = readString(inheritedSandbox.scope);
const configuredScope = localScope ?? inheritedScope;
if (configuredScope !== undefined) {
return configuredScope === "shared";
}
const localPerSession = readBoolean(sandbox.perSession);
const inheritedPerSession = readBoolean(inheritedSandbox.perSession);
return (localPerSession ?? inheritedPerSession) === false;
}
function pushSandboxPostureValue(
entries: PolicySandboxPostureEvidence[],
params: SandboxPostureParams,
entry: {
readonly suffix: string;
readonly kind: PolicySandboxPostureEvidence["kind"];
readonly value: string | undefined;
readonly networkSurface?: "browser" | "docker";
readonly explicit: boolean;
readonly inherited: boolean;
},
): void {
entries.push({
id: `${params.id}-${entry.suffix.replaceAll("/", "-")}`,
kind: entry.kind,
source: `${entry.inherited ? params.inheritedSourceBase : params.sourceBase}/${entry.suffix}`,
scope: params.scope,
...(params.agentId === undefined ? {} : { agentId: params.agentId }),
...(entry.value === undefined ? {} : { value: entry.value }),
...(entry.networkSurface === undefined ? {} : { networkSurface: entry.networkSurface }),
explicit: entry.explicit,
});
}
function splitPolicyBindSpec(
value: string,
): { readonly host: string; readonly mode: string } | undefined {
const separator = policyBindSeparatorIndex(value);
if (separator < 0) {
return undefined;
}
const host = value.slice(0, separator);
const rest = value.slice(separator + 1);
const optionsStart = policyBindOptionsSeparatorIndex(rest);
const options = optionsStart < 0 ? "" : rest.slice(optionsStart + 1);
const mode = options
.split(",")
.map((entry) => entry.trim().toLowerCase())
.includes("ro")
? "ro"
: "rw";
return { host, mode };
}
function policyBindSeparatorIndex(value: string): number {
const hasDriveLetterPrefix = /^[A-Za-z]:[\\/]/.test(value);
for (let index = hasDriveLetterPrefix ? 2 : 0; index < value.length; index += 1) {
if (value[index] === ":") {
return index;
}
}
return -1;
}
function policyBindOptionsSeparatorIndex(value: string): number {
const hasDriveLetterPrefix = /^[A-Za-z]:[\\/]/.test(value);
for (let index = hasDriveLetterPrefix ? 2 : 0; index < value.length; index += 1) {
if (value[index] === ":") {
return index;
}
}
return -1;
}
type ToolPostureParams = {
readonly id: string;
readonly scope: "global" | "agent";