diff --git a/docs/cli/policy.md b/docs/cli/policy.md index 9152591d81a..b6e788ec068 100644 --- a/docs/cli/policy.md +++ b/docs/cli/policy.md @@ -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.`. 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.` 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 diff --git a/docs/plan/policy-agent-scoped-overlays.md b/docs/plan/policy-agent-scoped-overlays.md deleted file mode 100644 index a2d6ef70e1b..00000000000 --- a/docs/plan/policy-agent-scoped-overlays.md +++ /dev/null @@ -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.` for purpose-named agent policy scopes. Each -scope lists the runtime `agentIds` it applies to, then reuses the normal -top-level policy section grammar where the section evidence can be attributed to -those agents. The initial shipped scoped sections are `tools` and -`agents.workspace`; sandbox and ingress stay out of this PR and can join the -same container once those policy PRs land and their evidence carries agent -identity. The scoped field inventory is backed by policy rule metadata that -records each field's strictness semantics for later policy-file conformance. - -```jsonc -{ - "tools": { - "denyTools": ["process"], - }, - "agents": { - "workspace": { - "allowedAccess": ["none", "ro"], - }, - }, - "scopes": { - "release-agent-lockdown": { - "agentIds": ["release-agent"], - "agents": { - "workspace": { - "allowedAccess": ["none", "ro"], - }, - }, - "tools": { - "profiles": { "allow": ["minimal", "messaging"] }, - "fs": { "requireWorkspaceOnly": true }, - "exec": { - "allowSecurity": ["deny", "allowlist"], - "requireAsk": ["always"], - "allowHosts": ["sandbox"], - }, - "elevated": { "allow": false }, - "alsoAllow": { "expected": ["message", "read"] }, - "denyTools": ["exec", "process", "write", "edit", "apply_patch"], - }, - }, - }, -} -``` - -`agents.workspace` remains the existing all-agent workspace baseline. -`scopes.` is a scoped overlay, not a replacement for global -policy. The scope name is descriptive only; matching uses `agentIds`, not -display names. It deliberately contains normal section names instead of a -bespoke per-agent mini-grammar. -Every scope present in `policy.jsonc` must be valid and enforceable. In this -PR, the only supported selector is `agentIds`, and it supports only `tools.*` -and `agents.workspace.*`. - -## Layering semantics - -Policy evaluation is additive: - -1. Top-level policy applies to all matching evidence. -2. Existing `agents.workspace` applies to defaults and every listed agent. -3. `scopes.` applies to evidence for each normalized runtime - id in `agentIds`. -4. Multiple scope blocks may target the same agent when they govern - different fields, or when a later value for the same field is equally or - more restrictive according to policy metadata. -5. A named-agent overlay can tighten policy, but it cannot make a global - violation acceptable. - -If both global and agent-scoped rules fail, findings should point at the rule -that was violated: - -```text -oc://policy.jsonc/tools/denyTools -oc://policy.jsonc/scopes/release-agent-lockdown/tools/denyTools -oc://policy.jsonc/scopes/release-agent-lockdown/agents/workspace/allowedAccess -``` - -That keeps broad tool posture, named-agent tool posture, and workspace posture -auditable as separate requirements even when they observe the same config -fields. - -Exact-list claims such as `tools.alsoAllow.expected` compare the configured list -to the expected list and report both missing expected entries and unexpected -extra entries. This is intended for additive posture such as `alsoAllow`, where -one extra entry can widen an agent beyond its reviewed role. - -## Policy and config layering - -The overlay model separates where policy is authored from where OpenClaw config -is observed: - -| Policy scope | Observed config | Applies to | Example result | -| --------------------------------------- | ---------------------------------------------------- | --------------------------------- | ----------------------------------------------------------------------------- | -| Top-level `tools.*` | Global `tools.*` and inherited agent tool posture | All agents using matching posture | Deny `gateway` exec host for every agent unless the global policy allows it. | -| Top-level `tools.*` | `agents.list[].tools.*` overrides | Any agent with an override | Flag one agent that overrides `tools.exec.host` to an unapproved value. | -| `scopes..tools.*` | Matching `agents.list[]` entry and inherited posture | Only that named agent | Let most agents use `node` exec host while one agent must use only `sandbox`. | -| `agents.workspace` | Defaults and every listed agent workspace posture | Defaults and all listed agents | Require every agent workspace access to be `none` or `ro`. | -| `scopes..agents.workspace.*` | Matching `agents.list[]` workspace posture | Only that named agent | Require one agent to be read-only without requiring the same for `main`. | - -Per-agent overlays are additive. A named-agent rule can be stricter than the -top-level rule, but it cannot make a global violation acceptable. For allow-list -rules, the effective allowed set is the intersection of the global rule and the -named-agent overlay when both are present. - -For example, if top-level `tools.exec.allowHosts` permits `["sandbox", "node"]` -and `scopes.release-agent-lockdown.tools.exec.allowHosts` permits only -`["sandbox"]`, `release-agent` fails when its effective exec host is `node`; -another agent can still pass -with `node`. - -## Tool posture versus workspace posture - -Tool posture belongs under `tools` because it describes what tool behavior a -configuration may expose. The existing `tools.*` policy observes both global -`tools.*` config and per-agent `agents.list[].tools.*` overrides. - -Workspace posture belongs under `workspace` because it describes sandbox mode -and workspace access. The workspace section should not grow into a general tool -policy namespace. If one agent needs stricter tool restrictions to make its -workspace posture meaningful, put those restrictions in the same agent overlay -under `scopes..tools`. - -For a restricted release agent, the intended split is: - -```jsonc -{ - "scopes": { - "release-agent-lockdown": { - "agentIds": ["release-agent"], - "agents": { - "workspace": { "allowedAccess": ["none", "ro"] }, - }, - "tools": { - "denyTools": ["exec", "process", "write", "edit", "apply_patch"], - }, - }, - }, -} -``` - -## Section eligibility - -An agent-scoped section should be added only when policy evidence carries an -agent id or can be attributed to one without guessing. - -| Section | Initial agent-scoped status | Reason | -| ----------- | --------------------------- | ------------------------------------------------------------------------ | -| `workspace` | Include | Agent sandbox/workspace evidence already has agent identity. | -| `tools` | Include | Tool posture evidence includes global and per-agent tool config. | -| `sandbox` | Pipeline follow-up | Keep out until the sandbox posture PR lands and evidence can be scoped. | -| `ingress` | Pipeline follow-up | Keep out until ingress/channel posture lands with agent attribution. | -| `models` | Include when mapped | Selected model refs can be agent-specific. | -| `mcp` | Include when mapped | Use only when MCP server evidence is attributable to an agent. | -| `auth` | Defer | Auth profile metadata is a config catalog unless agent binding is clear. | -| `channels` | Defer | Channel provider posture is deployment-level until routing is scoped. | -| `gateway` | Keep global | Gateway exposure/auth/http posture is process-level. | -| `network` | Keep global | Private-network SSRF posture is runtime-level. | -| `secrets` | Keep global first | Secret provider posture is shared unless refs are agent-attributed. | - -## Compatibility - -The implementation is additive: - -- keep all existing top-level policy fields valid; -- keep `agents.workspace` semantics unchanged; -- validate `scopes` before evaluating scoped rules; -- reject unsupported scoped sections clearly until their evidence and policy - contracts are implemented; -- do not reinterpret top-level `tools.requireMetadata` as agent-scoped, because - tool metadata describes the declared workspace tool catalog; -- include agent-scoped evidence in the attestation hash when any scoped rule is - present. - -This lets broad tool posture remain a top-level policy contract while named -agents add stricter observable claims without weakening the global baseline. diff --git a/docs/plugins/reference/policy.md b/docs/plugins/reference/policy.md index ab9285c1a12..425d4dfdaa3 100644 --- a/docs/plugins/reference/policy.md +++ b/docs/plugins/reference/policy.md @@ -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.` 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) diff --git a/extensions/policy/src/doctor/register.test.ts b/extensions/policy/src/doctor/register.test.ts index ff291241996..d8f90586e8a 100644 --- a/extensions/policy/src/doctor/register.test.ts +++ b/extensions/policy/src/doctor/register.test.ts @@ -114,11 +114,20 @@ describe("registerPolicyDoctorChecks", () => { 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, - }), + (rule: PolicyRuleMetadata) => { + const description: { + path: string; + strictness: PolicyRuleMetadata["strictness"]; + emptyList?: PolicyRuleMetadata["emptyList"]; + } = { + path: rule.policyPath.join("."), + strictness: rule.strictness, + }; + if (rule.emptyList !== undefined) { + description.emptyList = rule.emptyList; + } + return description; + }, ), ).toEqual([ { @@ -135,6 +144,14 @@ describe("registerPolicyDoctorChecks", () => { { path: "tools.elevated.allow", strictness: "requires-false" }, { path: "tools.alsoAllow.expected", strictness: "exact-list", emptyList: "meaningful" }, { path: "tools.denyTools", strictness: "denylist-superset" }, + { path: "sandbox.requireMode", strictness: "allowlist-subset", emptyList: "disabled" }, + { path: "sandbox.allowBackends", strictness: "allowlist-subset", emptyList: "disabled" }, + { path: "sandbox.containers.denyHostNetwork", strictness: "requires-true" }, + { path: "sandbox.containers.denyContainerNamespaceJoin", strictness: "requires-true" }, + { path: "sandbox.containers.requireReadOnlyMounts", strictness: "requires-true" }, + { path: "sandbox.containers.denyContainerRuntimeSocketMounts", strictness: "requires-true" }, + { path: "sandbox.containers.denyUnconfinedProfiles", strictness: "requires-true" }, + { path: "sandbox.browser.requireCdpSourceRange", strictness: "requires-true" }, ]); }); @@ -148,6 +165,9 @@ describe("registerPolicyDoctorChecks", () => { const fsWorkspaceOnly = POLICY_RULE_METADATA.find( (rule) => rule.policyPath.join(".") === "tools.fs.requireWorkspaceOnly", ); + const denyHostNetwork = POLICY_RULE_METADATA.find( + (rule) => rule.policyPath.join(".") === "sandbox.containers.denyHostNetwork", + ); const alsoAllow = POLICY_RULE_METADATA.find( (rule) => rule.policyPath.join(".") === "tools.alsoAllow.expected", ); @@ -155,6 +175,7 @@ describe("registerPolicyDoctorChecks", () => { expect(allowHosts).toBeDefined(); expect(denyTools).toBeDefined(); expect(fsWorkspaceOnly).toBeDefined(); + expect(denyHostNetwork).toBeDefined(); expect(alsoAllow).toBeDefined(); expect(isPolicyValueAtLeastAsStrict(allowHosts!, ["sandbox"], ["sandbox", "node"])).toBe(true); expect(isPolicyValueAtLeastAsStrict(allowHosts!, ["sandbox", "node"], ["sandbox"])).toBe(false); @@ -164,6 +185,8 @@ describe("registerPolicyDoctorChecks", () => { 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(denyHostNetwork!, true, true)).toBe(true); + expect(isPolicyValueAtLeastAsStrict(denyHostNetwork!, false, true)).toBe(false); expect(isPolicyValueAtLeastAsStrict(fsWorkspaceOnly!, true, true)).toBe(true); expect(isPolicyValueAtLeastAsStrict(fsWorkspaceOnly!, false, true)).toBe(false); expect(isPolicyValueAtLeastAsStrict(alsoAllow!, ["read"], ["read"])).toBe(true); @@ -242,6 +265,59 @@ describe("registerPolicyDoctorChecks", () => { ); }); + it("allows scoped sandbox container requirements that match 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({ + sandbox: { containers: { denyHostNetwork: true } }, + scopes: { + sebby: { + agentIds: ["sebby"], + sandbox: { containers: { denyHostNetwork: true } }, + }, + }, + }), + "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 sandbox container policies weaker than top-level requirements", async () => { + const configPath = join(workspaceDir, "openclaw.jsonc"); + await fs.writeFile(configPath, "{}", "utf-8"); + await fs.writeFile( + join(workspaceDir, "policy.jsonc"), + JSON.stringify({ + sandbox: { containers: { denyHostNetwork: true } }, + scopes: { + sebby: { + agentIds: ["sebby"], + sandbox: { containers: { denyHostNetwork: false } }, + }, + }, + }), + "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/sandbox/containers/denyHostNetwork", + }), + ]), + ); + }); + it("rejects scoped overrides that are weaker than top-level policy", async () => { const configPath = join(workspaceDir, "openclaw.jsonc"); await fs.writeFile(configPath, "{}", "utf-8"); @@ -369,6 +445,15 @@ describe("registerPolicyDoctorChecks", () => { "policy/tools-also-allow-missing", "policy/tools-also-allow-unexpected", "policy/tools-required-deny-missing", + "policy/sandbox-mode-unapproved", + "policy/sandbox-backend-unapproved", + "policy/sandbox-container-posture-unobservable", + "policy/sandbox-container-host-network-denied", + "policy/sandbox-container-namespace-join-denied", + "policy/sandbox-container-mount-mode-required", + "policy/sandbox-container-runtime-socket-mount", + "policy/sandbox-container-unconfined-profile", + "policy/sandbox-browser-cdp-source-range-missing", "policy/secrets-unmanaged-provider", "policy/secrets-denied-provider-source", "policy/secrets-insecure-provider", @@ -608,14 +693,26 @@ describe("registerPolicyDoctorChecks", () => { }, 'oc://policy.jsonc/scopes/"team/sebby"/tools/exec/allowHosts/#0', ], + [ + "scopes agent sandbox unsupported container key", + { + scopes: { + sebby: { + agentIds: ["sebby"], + sandbox: { containers: { denyNetwork: true } }, + }, + }, + }, + "oc://policy.jsonc/scopes/sebby/sandbox/containers/denyNetwork", + ], [ "scopes agent unsupported section", { scopes: { - sebby: { agentIds: ["sebby"], sandbox: { allow: true } }, + sebby: { agentIds: ["sebby"], ingress: { allow: true } }, }, }, - "oc://policy.jsonc/scopes/sebby/sandbox", + "oc://policy.jsonc/scopes/sebby/ingress", ], ["channels array", { channels: [] }, "oc://policy.jsonc/channels"], ["mcp array", { mcp: [] }, "oc://policy.jsonc/mcp"], @@ -707,6 +804,21 @@ describe("registerPolicyDoctorChecks", () => { { agents: { workspace: { denyTools: ["exec", "browser"] } } }, "oc://policy.jsonc/agents/workspace/denyTools/#1", ], + [ + "sandbox unsupported key", + { sandbox: { requireModes: ["all"] } }, + "oc://policy.jsonc/sandbox/requireModes", + ], + [ + "sandbox containers unsupported key", + { sandbox: { containers: { denyNetwork: true } } }, + "oc://policy.jsonc/sandbox/containers/denyNetwork", + ], + [ + "sandbox browser unsupported key", + { sandbox: { browser: { cdpSourceRange: true } } }, + "oc://policy.jsonc/sandbox/browser/cdpSourceRange", + ], ["secrets array", { secrets: [] }, "oc://policy.jsonc/secrets"], ["auth array", { auth: [] }, "oc://policy.jsonc/auth"], ["auth profiles array", { auth: { profiles: [] } }, "oc://policy.jsonc/auth/profiles"], @@ -868,6 +980,7 @@ describe("registerPolicyDoctorChecks", () => { includeGatewayExposure: false, includeAgentWorkspace: false, includeToolPosture: false, + includeSandboxPosture: false, includeSecrets: false, includeAuthProfiles: false, }, @@ -899,6 +1012,7 @@ describe("registerPolicyDoctorChecks", () => { includeGatewayExposure: false, includeAgentWorkspace: false, includeToolPosture: false, + includeSandboxPosture: false, includeSecrets: false, includeAuthProfiles: false, }, @@ -942,6 +1056,7 @@ describe("registerPolicyDoctorChecks", () => { includeGatewayExposure: false, includeAgentWorkspace: false, includeToolPosture: false, + includeSandboxPosture: false, includeSecrets: false, includeAuthProfiles: false, }, @@ -971,11 +1086,13 @@ describe("registerPolicyDoctorChecks", () => { includeGatewayExposure: false, includeAgentWorkspace: false, includeToolPosture: false, + includeSandboxPosture: false, includeSecrets: false, includeAuthProfiles: false, }); expect(evidence).not.toHaveProperty("gatewayExposure"); expect(evidence).not.toHaveProperty("agentWorkspace"); + expect(evidence).not.toHaveProperty("sandboxPosture"); expect(evidence).not.toHaveProperty("secrets"); expect(evidence).not.toHaveProperty("authProfiles"); }); @@ -3256,6 +3373,966 @@ describe("registerPolicyDoctorChecks", () => { ); }); + it("reports sandbox posture denied by policy", async () => { + const configPath = join(workspaceDir, "openclaw.jsonc"); + const cfg = { + ...cfgWithPolicy(), + agents: { + defaults: { + sandbox: { + mode: "off", + backend: "docker", + docker: { + network: "host", + binds: [ + "/var/run/docker.sock:/var/run/docker.sock:rw", + "/data:/data:rw", + "/run/containerd/containerd.sock:/containerd.sock:ro", + "/var/run/podman/podman.sock:/podman.sock:ro", + ], + seccompProfile: "unconfined", + }, + browser: { enabled: true }, + }, + }, + }, + } as OpenClawConfig; + await fs.writeFile(configPath, "{}", "utf-8"); + await fs.writeFile( + join(workspaceDir, "policy.jsonc"), + JSON.stringify({ + sandbox: { + requireMode: ["all", "non-main"], + allowBackends: ["ssh"], + containers: { + denyHostNetwork: true, + denyContainerNamespaceJoin: true, + requireReadOnlyMounts: true, + denyContainerRuntimeSocketMounts: true, + denyUnconfinedProfiles: true, + }, + browser: { requireCdpSourceRange: true }, + }, + }), + "utf-8", + ); + + const result = await runPolicyChecks(ctx(configPath, cfg)); + + expect(result.findings.map((finding) => finding.checkId)).toEqual([ + "policy/sandbox-mode-unapproved", + "policy/sandbox-backend-unapproved", + "policy/sandbox-container-host-network-denied", + "policy/sandbox-container-mount-mode-required", + "policy/sandbox-container-mount-mode-required", + "policy/sandbox-container-runtime-socket-mount", + "policy/sandbox-container-runtime-socket-mount", + "policy/sandbox-container-runtime-socket-mount", + "policy/sandbox-container-unconfined-profile", + "policy/sandbox-browser-cdp-source-range-missing", + ]); + expect(result.findings).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + checkId: "policy/sandbox-mode-unapproved", + ocPath: "oc://openclaw.config/agents/defaults/sandbox/mode", + requirement: "oc://policy.jsonc/sandbox/requireMode", + }), + expect.objectContaining({ + checkId: "policy/sandbox-container-runtime-socket-mount", + ocPath: "oc://openclaw.config/agents/defaults/sandbox/docker/binds/#0", + requirement: "oc://policy.jsonc/sandbox/containers/denyContainerRuntimeSocketMounts", + }), + expect.objectContaining({ + checkId: "policy/sandbox-container-runtime-socket-mount", + ocPath: "oc://openclaw.config/agents/defaults/sandbox/docker/binds/#2", + requirement: "oc://policy.jsonc/sandbox/containers/denyContainerRuntimeSocketMounts", + }), + expect.objectContaining({ + checkId: "policy/sandbox-container-runtime-socket-mount", + ocPath: "oc://openclaw.config/agents/defaults/sandbox/docker/binds/#3", + requirement: "oc://policy.jsonc/sandbox/containers/denyContainerRuntimeSocketMounts", + }), + ]), + ); + }); + + it("keeps read-only Windows binds with drive-letter destinations compliant", async () => { + const configPath = join(workspaceDir, "openclaw.jsonc"); + const cfg = { + ...cfgWithPolicy(), + agents: { + defaults: { + sandbox: { + mode: "all", + backend: "docker", + docker: { + binds: ["C:\\Users\\foo:C:\\container:ro"], + network: "none", + }, + }, + }, + }, + } as OpenClawConfig; + await fs.writeFile(configPath, "{}", "utf-8"); + await fs.writeFile( + join(workspaceDir, "policy.jsonc"), + JSON.stringify({ + sandbox: { + containers: { + requireReadOnlyMounts: true, + }, + }, + }), + "utf-8", + ); + + const result = await runPolicyChecks(ctx(configPath, cfg)); + + expect(result.findings).not.toEqual( + expect.arrayContaining([ + expect.objectContaining({ + checkId: "policy/sandbox-container-mount-mode-required", + }), + ]), + ); + }); + + it("applies sandbox bind policy to browser-specific binds", async () => { + const configPath = join(workspaceDir, "openclaw.jsonc"); + const cfg = { + ...cfgWithPolicy(), + agents: { + defaults: { + sandbox: { + mode: "all", + backend: "docker", + docker: { + network: "none", + binds: ["/safe:/safe:ro"], + }, + browser: { + enabled: true, + cdpSourceRange: "172.21.0.1/32", + network: "host", + binds: ["/var/run/docker.sock:/var/run/docker.sock:rw"], + }, + }, + }, + }, + } as OpenClawConfig; + await fs.writeFile(configPath, "{}", "utf-8"); + await fs.writeFile( + join(workspaceDir, "policy.jsonc"), + JSON.stringify({ + sandbox: { + requireMode: ["all"], + allowBackends: ["docker"], + containers: { + denyHostNetwork: true, + requireReadOnlyMounts: true, + denyContainerRuntimeSocketMounts: true, + }, + browser: { requireCdpSourceRange: true }, + }, + }), + "utf-8", + ); + + const result = await runPolicyChecks(ctx(configPath, cfg)); + const evidence = collectPolicyEvidence(cfg as unknown as Record); + + expect(evidence.sandboxPosture).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + kind: "containerMount", + bindSurface: "browser", + source: "oc://openclaw.config/agents/defaults/sandbox/browser/binds/#0", + }), + expect.objectContaining({ + kind: "containerNetwork", + value: "host", + source: "oc://openclaw.config/agents/defaults/sandbox/browser/network", + }), + ]), + ); + expect(result.findings).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + checkId: "policy/sandbox-container-host-network-denied", + ocPath: "oc://openclaw.config/agents/defaults/sandbox/browser/network", + }), + expect.objectContaining({ + checkId: "policy/sandbox-container-mount-mode-required", + ocPath: "oc://openclaw.config/agents/defaults/sandbox/browser/binds/#0", + }), + expect.objectContaining({ + checkId: "policy/sandbox-container-runtime-socket-mount", + ocPath: "oc://openclaw.config/agents/defaults/sandbox/browser/binds/#0", + }), + ]), + ); + }); + + it("does not require read-only mounts when the policy disables the rule", async () => { + const configPath = join(workspaceDir, "openclaw.jsonc"); + const cfg = { + ...cfgWithPolicy(), + agents: { + defaults: { + sandbox: { + mode: "all", + backend: "docker", + docker: { + binds: ["/safe:/safe:ro"], + }, + }, + }, + }, + } as OpenClawConfig; + await fs.writeFile(configPath, "{}", "utf-8"); + await fs.writeFile( + join(workspaceDir, "policy.jsonc"), + JSON.stringify({ + sandbox: { + containers: { + requireReadOnlyMounts: false, + }, + }, + }), + "utf-8", + ); + + const result = await runPolicyChecks(ctx(configPath, cfg)); + + expect(result.findings).not.toEqual( + expect.arrayContaining([ + expect.objectContaining({ checkId: "policy/sandbox-container-mount-mode-required" }), + ]), + ); + }); + + it("ignores agent-local Docker and browser posture under shared sandbox scope", async () => { + const cfg = { + agents: { + defaults: { + sandbox: { + mode: "all", + backend: "docker", + scope: "shared", + docker: { + network: "none", + binds: ["/shared:/shared:ro"], + }, + browser: { + enabled: true, + cdpSourceRange: "172.21.0.1/32", + binds: ["/browser-shared:/browser-shared:ro"], + }, + }, + }, + list: [ + { + id: "runner", + sandbox: { + docker: { + network: "host", + binds: ["/var/run/docker.sock:/var/run/docker.sock:rw"], + }, + browser: { + cdpSourceRange: "", + binds: ["/unsafe-browser:/unsafe-browser:rw"], + }, + }, + }, + ], + }, + }; + + const evidence = collectPolicyEvidence(cfg as unknown as Record); + const runnerEvidence = (evidence.sandboxPosture ?? []).filter( + (entry) => entry.agentId === "runner", + ); + + expect(runnerEvidence).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + kind: "containerNetwork", + value: "none", + source: "oc://openclaw.config/agents/defaults/sandbox/docker/network", + }), + expect.objectContaining({ + kind: "browserCdpSourceRange", + value: "172.21.0.1/32", + source: "oc://openclaw.config/agents/defaults/sandbox/browser/cdpSourceRange", + }), + expect.objectContaining({ + kind: "containerMount", + bind: "/shared:/shared:ro", + source: "oc://openclaw.config/agents/defaults/sandbox/docker/binds/#0", + }), + expect.objectContaining({ + kind: "containerMount", + bind: "/browser-shared:/browser-shared:ro", + source: "oc://openclaw.config/agents/defaults/sandbox/browser/binds/#0", + }), + ]), + ); + expect(runnerEvidence).not.toEqual( + expect.arrayContaining([ + expect.objectContaining({ bind: "/var/run/docker.sock:/var/run/docker.sock:rw" }), + expect.objectContaining({ bind: "/unsafe-browser:/unsafe-browser:rw" }), + expect.objectContaining({ + kind: "containerNetwork", + value: "host", + }), + ]), + ); + }); + + it("treats blank agent browser CDP source range as an explicit clear", async () => { + const configPath = join(workspaceDir, "openclaw.jsonc"); + const cfg = { + ...cfgWithPolicy(), + agents: { + defaults: { + sandbox: { + mode: "all", + backend: "docker", + browser: { enabled: true, cdpSourceRange: "172.21.0.1/32" }, + }, + }, + list: [ + { + id: "runner", + sandbox: { + browser: { cdpSourceRange: "" }, + }, + }, + ], + }, + } as OpenClawConfig; + await fs.writeFile(configPath, "{}", "utf-8"); + await fs.writeFile( + join(workspaceDir, "policy.jsonc"), + JSON.stringify({ + sandbox: { + browser: { requireCdpSourceRange: true }, + }, + }), + "utf-8", + ); + + const result = await runPolicyChecks(ctx(configPath, cfg)); + + expect(result.findings).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + checkId: "policy/sandbox-browser-cdp-source-range-missing", + ocPath: "oc://openclaw.config/agents/list/#0/sandbox/browser/cdpSourceRange", + }), + ]), + ); + }); + + it("reports enabled container posture rules that the backend cannot observe", async () => { + const configPath = join(workspaceDir, "openclaw.jsonc"); + const cfg = { + ...cfgWithPolicy(), + agents: { + defaults: { + sandbox: { + mode: "all", + backend: "openshell", + docker: { + network: "host", + binds: ["/var/run/docker.sock:/var/run/docker.sock:rw"], + seccompProfile: "unconfined", + }, + }, + }, + }, + } as OpenClawConfig; + await fs.writeFile(configPath, "{}", "utf-8"); + await fs.writeFile( + join(workspaceDir, "policy.jsonc"), + JSON.stringify({ + sandbox: { + allowBackends: ["openshell"], + containers: { + denyHostNetwork: true, + denyContainerRuntimeSocketMounts: true, + denyUnconfinedProfiles: true, + }, + }, + }), + "utf-8", + ); + + const result = await runPolicyChecks(ctx(configPath, cfg)); + const evidence = collectPolicyEvidence(cfg as unknown as Record); + + expect(evidence.sandboxPosture).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + kind: "backend", + value: "openshell", + }), + ]), + ); + expect(evidence.sandboxPosture).not.toEqual( + expect.arrayContaining([ + expect.objectContaining({ kind: "containerNetwork" }), + expect.objectContaining({ kind: "containerMount" }), + expect.objectContaining({ kind: "containerSecurityProfile" }), + ]), + ); + expect(result.findings).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + checkId: "policy/sandbox-container-posture-unobservable", + ocPath: "oc://openclaw.config/agents/defaults/sandbox/backend", + requirement: "oc://policy.jsonc/sandbox/containers/denyHostNetwork", + }), + expect.objectContaining({ + checkId: "policy/sandbox-container-posture-unobservable", + ocPath: "oc://openclaw.config/agents/defaults/sandbox/backend", + requirement: "oc://policy.jsonc/sandbox/containers/denyContainerRuntimeSocketMounts", + }), + expect.objectContaining({ + checkId: "policy/sandbox-container-posture-unobservable", + ocPath: "oc://openclaw.config/agents/defaults/sandbox/backend", + requirement: "oc://policy.jsonc/sandbox/containers/denyUnconfinedProfiles", + }), + ]), + ); + expect(result.findings).not.toEqual( + expect.arrayContaining([ + expect.objectContaining({ checkId: "policy/sandbox-container-host-network-denied" }), + expect.objectContaining({ checkId: "policy/sandbox-container-runtime-socket-mount" }), + expect.objectContaining({ checkId: "policy/sandbox-container-unconfined-profile" }), + ]), + ); + }); + + it("evaluates inherited container mounts for browser containers on non-Docker backends", async () => { + const configPath = join(workspaceDir, "openclaw.jsonc"); + const cfg = { + ...cfgWithPolicy(), + agents: { + defaults: { + sandbox: { + mode: "all", + backend: "openshell", + docker: { + binds: ["/var/run/docker.sock:/var/run/docker.sock:rw"], + }, + browser: { + enabled: true, + cdpSourceRange: "172.21.0.1/32", + }, + }, + }, + }, + } as OpenClawConfig; + await fs.writeFile(configPath, "{}", "utf-8"); + await fs.writeFile( + join(workspaceDir, "policy.jsonc"), + JSON.stringify({ + sandbox: { + allowBackends: ["openshell"], + containers: { + requireReadOnlyMounts: true, + denyContainerRuntimeSocketMounts: true, + }, + }, + }), + "utf-8", + ); + + const result = await runPolicyChecks(ctx(configPath, cfg)); + const evidence = collectPolicyEvidence(cfg as unknown as Record); + + expect(evidence.sandboxPosture).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + kind: "containerMount", + bindSurface: "browser", + bind: "/var/run/docker.sock:/var/run/docker.sock:rw", + source: "oc://openclaw.config/agents/defaults/sandbox/docker/binds/#0", + }), + ]), + ); + expect(result.findings).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + checkId: "policy/sandbox-container-mount-mode-required", + ocPath: "oc://openclaw.config/agents/defaults/sandbox/docker/binds/#0", + }), + expect.objectContaining({ + checkId: "policy/sandbox-container-runtime-socket-mount", + ocPath: "oc://openclaw.config/agents/defaults/sandbox/docker/binds/#0", + }), + ]), + ); + }); + + it("normalizes mixed-case Docker backend before collecting container posture", async () => { + const configPath = join(workspaceDir, "openclaw.jsonc"); + const cfg = { + ...cfgWithPolicy(), + agents: { + defaults: { + sandbox: { + mode: "all", + backend: "Docker", + docker: { + network: "host", + binds: ["/var/run/docker.sock:/var/run/docker.sock:rw"], + seccompProfile: "unconfined", + }, + }, + }, + }, + } as OpenClawConfig; + await fs.writeFile(configPath, "{}", "utf-8"); + await fs.writeFile( + join(workspaceDir, "policy.jsonc"), + JSON.stringify({ + sandbox: { + allowBackends: ["docker"], + containers: { + denyHostNetwork: true, + denyContainerRuntimeSocketMounts: true, + denyUnconfinedProfiles: true, + }, + }, + }), + "utf-8", + ); + + const result = await runPolicyChecks(ctx(configPath, cfg)); + const evidence = collectPolicyEvidence(cfg as unknown as Record); + + expect(evidence.sandboxPosture).toEqual( + expect.arrayContaining([ + expect.objectContaining({ kind: "backend", value: "docker" }), + expect.objectContaining({ kind: "containerNetwork", value: "host" }), + expect.objectContaining({ kind: "containerMount" }), + expect.objectContaining({ + kind: "containerSecurityProfile", + profile: "seccomp", + value: "unconfined", + }), + ]), + ); + expect(result.findings).toEqual( + expect.arrayContaining([ + expect.objectContaining({ checkId: "policy/sandbox-container-host-network-denied" }), + expect.objectContaining({ checkId: "policy/sandbox-container-runtime-socket-mount" }), + expect.objectContaining({ checkId: "policy/sandbox-container-unconfined-profile" }), + ]), + ); + }); + + it("uses explicit agent sandbox scope before inherited legacy perSession", async () => { + const cfg = { + agents: { + defaults: { + sandbox: { + mode: "all", + backend: "docker", + perSession: false, + docker: { + network: "none", + }, + }, + }, + list: [ + { + id: "runner", + sandbox: { + scope: "agent", + docker: { + network: "host", + binds: ["/var/run/docker.sock:/var/run/docker.sock:rw"], + }, + browser: { + enabled: true, + cdpSourceRange: "172.21.0.1/32", + binds: ["/browser:/browser:rw"], + }, + }, + }, + ], + }, + }; + + const evidence = collectPolicyEvidence(cfg as unknown as Record); + const runnerEvidence = (evidence.sandboxPosture ?? []).filter( + (entry) => entry.agentId === "runner", + ); + + expect(runnerEvidence).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + kind: "containerNetwork", + value: "host", + source: "oc://openclaw.config/agents/list/#0/sandbox/docker/network", + }), + expect.objectContaining({ + kind: "containerMount", + bind: "/var/run/docker.sock:/var/run/docker.sock:rw", + source: "oc://openclaw.config/agents/list/#0/sandbox/docker/binds/#0", + }), + expect.objectContaining({ + kind: "containerMount", + bind: "/browser:/browser:rw", + source: "oc://openclaw.config/agents/list/#0/sandbox/browser/binds/#0", + }), + ]), + ); + }); + + it("accepts configured sandbox posture that matches policy", async () => { + const configPath = join(workspaceDir, "openclaw.jsonc"); + const cfg = { + ...cfgWithPolicy(), + agents: { + defaults: { + sandbox: { + mode: "all", + backend: "docker", + docker: { + network: "none", + binds: ["/data:/data:ro"], + seccompProfile: "runtime/default", + }, + browser: { enabled: true, cdpSourceRange: "172.21.0.1/32" }, + }, + }, + }, + } as OpenClawConfig; + await fs.writeFile(configPath, "{}", "utf-8"); + await fs.writeFile( + join(workspaceDir, "policy.jsonc"), + JSON.stringify({ + sandbox: { + requireMode: ["all", "non-main"], + allowBackends: ["docker"], + containers: { + denyHostNetwork: true, + denyContainerNamespaceJoin: true, + requireReadOnlyMounts: true, + denyContainerRuntimeSocketMounts: true, + denyUnconfinedProfiles: true, + }, + browser: { requireCdpSourceRange: true }, + }, + }), + "utf-8", + ); + + const result = await runPolicyChecks(ctx(configPath, cfg)); + + expect(result.findings).toEqual([]); + }); + + it("applies agent-scoped sandbox claims only to matching agents", async () => { + const configPath = join(workspaceDir, "openclaw.jsonc"); + const cfg = { + ...cfgWithPolicy(), + agents: { + list: [ + { id: "Sebby", sandbox: { mode: "off", backend: "ssh" } }, + { id: "buddy", sandbox: { mode: "all", backend: "docker" } }, + ], + }, + } as OpenClawConfig; + await fs.writeFile(configPath, "{}", "utf-8"); + await fs.writeFile( + join(workspaceDir, "policy.jsonc"), + JSON.stringify({ + sandbox: { + requireMode: ["all"], + }, + scopes: { + sebby: { + agentIds: ["sebby"], + sandbox: { + allowBackends: ["docker"], + }, + }, + }, + }), + "utf-8", + ); + + const result = await runPolicyChecks(ctx(configPath, cfg)); + + expect(result.findings).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + checkId: "policy/sandbox-mode-unapproved", + ocPath: "oc://openclaw.config/agents/list/#0/sandbox/mode", + requirement: "oc://policy.jsonc/sandbox/requireMode", + }), + expect.objectContaining({ + checkId: "policy/sandbox-backend-unapproved", + ocPath: "oc://openclaw.config/agents/list/#0/sandbox/backend", + requirement: "oc://policy.jsonc/scopes/sebby/sandbox/allowBackends", + }), + ]), + ); + expect(result.findings).not.toEqual( + expect.arrayContaining([ + expect.objectContaining({ + ocPath: "oc://openclaw.config/agents/list/#1/sandbox/backend", + requirement: "oc://policy.jsonc/scopes/sebby/sandbox/allowBackends", + }), + ]), + ); + }); + + it("does not apply sandbox overlays from invalid scoped policy", async () => { + const configPath = join(workspaceDir, "openclaw.jsonc"); + const cfg = { + ...cfgWithPolicy(), + agents: { + list: [{ id: "sebby", sandbox: { mode: "off" } }], + }, + } as OpenClawConfig; + await fs.writeFile(configPath, "{}", "utf-8"); + await fs.writeFile( + join(workspaceDir, "policy.jsonc"), + JSON.stringify({ + scopes: { + sebby: { + agentIds: ["sebby"], + channels: { allow: ["discord"] }, + sandbox: { + requireMode: ["all"], + }, + }, + }, + }), + "utf-8", + ); + + const result = await runPolicyChecks(ctx(configPath, cfg)); + + expect(result.findings).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + checkId: "policy/policy-jsonc-invalid", + target: "oc://policy.jsonc/scopes/sebby/channels", + }), + ]), + ); + expect(result.findings).not.toEqual( + expect.arrayContaining([ + expect.objectContaining({ + checkId: "policy/sandbox-mode-unapproved", + requirement: "oc://policy.jsonc/scopes/sebby/sandbox/requireMode", + }), + ]), + ); + }); + + it("reports scoped container posture rules that a non-Docker agent group cannot observe", async () => { + const configPath = join(workspaceDir, "openclaw.jsonc"); + const cfg = { + ...cfgWithPolicy(), + agents: { + defaults: { + sandbox: { + mode: "all", + backend: "docker", + docker: { + network: "none", + binds: ["/workspace:/workspace:rw"], + }, + }, + }, + list: [ + { + id: "release-agent", + sandbox: { mode: "all", backend: "openshell" }, + }, + ], + }, + } as OpenClawConfig; + await fs.writeFile(configPath, "{}", "utf-8"); + await fs.writeFile( + join(workspaceDir, "policy.jsonc"), + JSON.stringify({ + scopes: { + release: { + agentIds: ["release-agent"], + sandbox: { + containers: { requireReadOnlyMounts: true }, + }, + }, + }, + }), + "utf-8", + ); + + const result = await runPolicyChecks(ctx(configPath, cfg)); + + expect(result.findings).toEqual([ + expect.objectContaining({ + checkId: "policy/sandbox-container-posture-unobservable", + ocPath: "oc://openclaw.config/agents/list/#0/sandbox/backend", + requirement: "oc://policy.jsonc/scopes/release/sandbox/containers/requireReadOnlyMounts", + }), + ]); + }); + + it("allows scoped non-Docker agent groups when container posture rules are off", async () => { + const configPath = join(workspaceDir, "openclaw.jsonc"); + const cfg = { + ...cfgWithPolicy(), + agents: { + defaults: { + sandbox: { + mode: "all", + backend: "docker", + docker: { + network: "none", + binds: ["/workspace:/workspace:rw"], + }, + }, + }, + list: [ + { + id: "release-agent", + sandbox: { mode: "all", backend: "openshell" }, + }, + ], + }, + } as OpenClawConfig; + await fs.writeFile(configPath, "{}", "utf-8"); + await fs.writeFile( + join(workspaceDir, "policy.jsonc"), + JSON.stringify({ + scopes: { + release: { + agentIds: ["release-agent"], + sandbox: { + containers: { requireReadOnlyMounts: false }, + }, + }, + }, + }), + "utf-8", + ); + + const result = await runPolicyChecks(ctx(configPath, cfg)); + + expect(result.findings).toEqual([]); + }); + + it("does not fall back to default browser posture for scoped browser-disabled agents", async () => { + const configPath = join(workspaceDir, "openclaw.jsonc"); + const cfg = { + ...cfgWithPolicy(), + agents: { + defaults: { + sandbox: { + mode: "all", + backend: "docker", + browser: { enabled: true, network: "host" }, + }, + }, + list: [ + { + id: "release-agent", + sandbox: { browser: { enabled: false } }, + }, + ], + }, + } as OpenClawConfig; + await fs.writeFile(configPath, "{}", "utf-8"); + await fs.writeFile( + join(workspaceDir, "policy.jsonc"), + JSON.stringify({ + scopes: { + release: { + agentIds: ["release-agent"], + sandbox: { + containers: { denyHostNetwork: true }, + browser: { requireCdpSourceRange: true }, + }, + }, + }, + }), + "utf-8", + ); + + const result = await runPolicyChecks(ctx(configPath, cfg)); + const evidence = collectPolicyEvidence(cfg as unknown as Record); + + expect(evidence.sandboxPosture).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + agentId: "release-agent", + kind: "browserCdpSourceRange", + value: false, + }), + expect.objectContaining({ + kind: "containerNetwork", + networkSurface: "browser", + value: "host", + }), + ]), + ); + expect(result.findings).toEqual([]); + }); + + it("applies main-scoped sandbox claims to defaults when unrelated agents exist", async () => { + const configPath = join(workspaceDir, "openclaw.jsonc"); + const cfg = { + ...cfgWithPolicy(), + agents: { + defaults: { + sandbox: { mode: "off" }, + }, + list: [ + { + id: "worker", + sandbox: { mode: "all" }, + }, + ], + }, + } as OpenClawConfig; + await fs.writeFile(configPath, "{}", "utf-8"); + await fs.writeFile( + join(workspaceDir, "policy.jsonc"), + JSON.stringify({ + scopes: { + mainSandbox: { + agentIds: ["main"], + sandbox: { requireMode: ["all"] }, + }, + }, + }), + "utf-8", + ); + + const result = await runPolicyChecks(ctx(configPath, cfg)); + + expect(result.findings).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + checkId: "policy/sandbox-mode-unapproved", + ocPath: "oc://openclaw.config/agents/defaults/sandbox/mode", + requirement: "oc://policy.jsonc/scopes/mainSandbox/sandbox/requireMode", + }), + ]), + ); + }); + it("reports tool posture denied by policy", async () => { const configPath = join(workspaceDir, "openclaw.jsonc"); const cfg = { diff --git a/extensions/policy/src/doctor/register.ts b/extensions/policy/src/doctor/register.ts index eae710fc8b7..cd1b906240c 100644 --- a/extensions/policy/src/doctor/register.ts +++ b/extensions/policy/src/doctor/register.ts @@ -16,6 +16,7 @@ import { type PolicyAuthProfileEvidence, type PolicyAgentWorkspaceEvidence, type PolicyEvidence, + type PolicySandboxPostureEvidence, type PolicyToolPostureEvidence, } from "../policy-state.js"; import { POLICY_TOOL_GROUPS } from "../tool-policy-conformance.js"; @@ -50,6 +51,15 @@ const CHECK_IDS = { policyToolsFsWorkspaceOnlyRequired: "policy/tools-fs-workspace-only-required", policyToolsProfileUnapproved: "policy/tools-profile-unapproved", policyToolsRequiredDenyMissing: "policy/tools-required-deny-missing", + policySandboxModeUnapproved: "policy/sandbox-mode-unapproved", + policySandboxBackendUnapproved: "policy/sandbox-backend-unapproved", + policySandboxContainerPostureUnobservable: "policy/sandbox-container-posture-unobservable", + policySandboxContainerHostNetworkDenied: "policy/sandbox-container-host-network-denied", + policySandboxContainerNamespaceJoinDenied: "policy/sandbox-container-namespace-join-denied", + policySandboxContainerMountModeRequired: "policy/sandbox-container-mount-mode-required", + policySandboxContainerRuntimeSocketMount: "policy/sandbox-container-runtime-socket-mount", + policySandboxContainerUnconfinedProfile: "policy/sandbox-container-unconfined-profile", + policySandboxBrowserCdpSourceRangeMissing: "policy/sandbox-browser-cdp-source-range-missing", policySecretsUnmanagedProvider: "policy/secrets-unmanaged-provider", policySecretsDeniedProviderSource: "policy/secrets-denied-provider-source", policySecretsInsecureProvider: "policy/secrets-insecure-provider", @@ -92,6 +102,15 @@ export const POLICY_CHECK_IDS = [ CHECK_IDS.policyToolsAlsoAllowMissing, CHECK_IDS.policyToolsAlsoAllowUnexpected, CHECK_IDS.policyToolsRequiredDenyMissing, + CHECK_IDS.policySandboxModeUnapproved, + CHECK_IDS.policySandboxBackendUnapproved, + CHECK_IDS.policySandboxContainerPostureUnobservable, + CHECK_IDS.policySandboxContainerHostNetworkDenied, + CHECK_IDS.policySandboxContainerNamespaceJoinDenied, + CHECK_IDS.policySandboxContainerMountModeRequired, + CHECK_IDS.policySandboxContainerRuntimeSocketMount, + CHECK_IDS.policySandboxContainerUnconfinedProfile, + CHECK_IDS.policySandboxBrowserCdpSourceRangeMissing, CHECK_IDS.policySecretsUnmanagedProvider, CHECK_IDS.policySecretsDeniedProviderSource, CHECK_IDS.policySecretsInsecureProvider, @@ -118,13 +137,74 @@ export type PolicyScopeSelectorKind = "agentIds"; export type PolicyRuleMetadata = { readonly policyPath: readonly string[]; readonly strictness: PolicyStrictnessKind; - readonly valueType: "boolean" | "string-list"; + readonly valueType: "boolean" | "string" | "string-list"; readonly checkIds: readonly (typeof POLICY_CHECK_IDS)[number][]; readonly emptyList?: PolicyEmptyListSemantics; readonly caseSensitive?: boolean; readonly scopeSelectors?: readonly PolicyScopeSelectorKind[]; }; +const SANDBOX_CONTAINER_POLICY_RULES = [ + { + key: "denyHostNetwork", + label: "host network posture", + checkIds: [CHECK_IDS.policySandboxContainerHostNetworkDenied], + }, + { + key: "denyContainerNamespaceJoin", + label: "container namespace posture", + checkIds: [CHECK_IDS.policySandboxContainerNamespaceJoinDenied], + }, + { + key: "requireReadOnlyMounts", + label: "container mount mode posture", + checkIds: [CHECK_IDS.policySandboxContainerMountModeRequired], + }, + { + key: "denyContainerRuntimeSocketMounts", + label: "container runtime socket mount posture", + checkIds: [CHECK_IDS.policySandboxContainerRuntimeSocketMount], + }, + { + key: "denyUnconfinedProfiles", + label: "container security profile posture", + checkIds: [CHECK_IDS.policySandboxContainerUnconfinedProfile], + }, +] as const; + +const SANDBOX_POLICY_RULE_METADATA = [ + { + policyPath: ["sandbox", "requireMode"], + strictness: "allowlist-subset", + valueType: "string-list", + checkIds: [CHECK_IDS.policySandboxModeUnapproved], + emptyList: "disabled", + scopeSelectors: ["agentIds"], + }, + { + policyPath: ["sandbox", "allowBackends"], + strictness: "allowlist-subset", + valueType: "string-list", + checkIds: [CHECK_IDS.policySandboxBackendUnapproved], + emptyList: "disabled", + scopeSelectors: ["agentIds"], + }, + ...SANDBOX_CONTAINER_POLICY_RULES.map((rule) => ({ + policyPath: ["sandbox", "containers", rule.key] as const, + strictness: "requires-true" as const, + valueType: "boolean" as const, + checkIds: rule.checkIds, + scopeSelectors: ["agentIds"] as const, + })), + { + policyPath: ["sandbox", "browser", "requireCdpSourceRange"], + strictness: "requires-true", + valueType: "boolean", + checkIds: [CHECK_IDS.policySandboxBrowserCdpSourceRangeMissing], + scopeSelectors: ["agentIds"], + }, +] as const satisfies readonly PolicyRuleMetadata[]; + export const POLICY_RULE_METADATA = [ { policyPath: ["agents", "workspace", "allowedAccess"], @@ -202,6 +282,7 @@ export const POLICY_RULE_METADATA = [ checkIds: [CHECK_IDS.policyToolsRequiredDenyMissing], scopeSelectors: ["agentIds"], }, + ...SANDBOX_POLICY_RULE_METADATA, ] as const satisfies readonly PolicyRuleMetadata[]; const KNOWN_RISK_LEVELS = ["low", "medium", "high", "critical"] as const; @@ -221,6 +302,7 @@ const SUPPORTED_TOOL_PROFILES = ["minimal", "coding", "messaging", "full"] as co const SUPPORTED_TOOL_EXEC_SECURITY = ["deny", "allowlist", "full"] as const; const SUPPORTED_TOOL_EXEC_ASK = ["off", "on-miss", "always"] as const; const SUPPORTED_TOOL_EXEC_HOST = ["auto", "sandbox", "gateway", "node"] as const; +const SUPPORTED_SANDBOX_MODES = ["off", "non-main", "all"] as const; let registered = false; const policyEvaluationCache = new WeakMap>(); @@ -274,6 +356,15 @@ export function registerPolicyDoctorChecks(host?: PolicyDoctorRegistrationHost): registerHealthCheck(policyToolsAlsoAllowMissingCheck); registerHealthCheck(policyToolsAlsoAllowUnexpectedCheck); registerHealthCheck(policyToolsRequiredDenyMissingCheck); + registerHealthCheck(policySandboxModeUnapprovedCheck); + registerHealthCheck(policySandboxBackendUnapprovedCheck); + registerHealthCheck(policySandboxContainerPostureUnobservableCheck); + registerHealthCheck(policySandboxContainerHostNetworkDeniedCheck); + registerHealthCheck(policySandboxContainerNamespaceJoinDeniedCheck); + registerHealthCheck(policySandboxContainerMountModeRequiredCheck); + registerHealthCheck(policySandboxContainerRuntimeSocketMountCheck); + registerHealthCheck(policySandboxContainerUnconfinedProfileCheck); + registerHealthCheck(policySandboxBrowserCdpSourceRangeMissingCheck); registerHealthCheck(policySecretsUnmanagedProviderCheck); registerHealthCheck(policySecretsDeniedProviderSourceCheck); registerHealthCheck(policySecretsInsecureProviderCheck); @@ -622,6 +713,117 @@ const policyToolsRequiredDenyMissingCheck: HealthCheck = { }, }; +const policySandboxModeUnapprovedCheck: HealthCheck = { + id: CHECK_IDS.policySandboxModeUnapproved, + kind: "plugin", + description: "Sandbox mode config satisfies policy requirements.", + source: "policy", + async detect(ctx) { + return findingsForCheck(await evaluatePolicy(ctx), CHECK_IDS.policySandboxModeUnapproved); + }, +}; + +const policySandboxBackendUnapprovedCheck: HealthCheck = { + id: CHECK_IDS.policySandboxBackendUnapproved, + kind: "plugin", + description: "Sandbox backend config satisfies policy requirements.", + source: "policy", + async detect(ctx) { + return findingsForCheck(await evaluatePolicy(ctx), CHECK_IDS.policySandboxBackendUnapproved); + }, +}; + +const policySandboxContainerPostureUnobservableCheck: HealthCheck = { + id: CHECK_IDS.policySandboxContainerPostureUnobservable, + kind: "plugin", + description: "Sandbox container posture policy only targets observable container backends.", + source: "policy", + async detect(ctx) { + return findingsForCheck( + await evaluatePolicy(ctx), + CHECK_IDS.policySandboxContainerPostureUnobservable, + ); + }, +}; + +const policySandboxContainerHostNetworkDeniedCheck: HealthCheck = { + id: CHECK_IDS.policySandboxContainerHostNetworkDenied, + kind: "plugin", + description: "Sandbox container config avoids host network mode.", + source: "policy", + async detect(ctx) { + return findingsForCheck( + await evaluatePolicy(ctx), + CHECK_IDS.policySandboxContainerHostNetworkDenied, + ); + }, +}; + +const policySandboxContainerNamespaceJoinDeniedCheck: HealthCheck = { + id: CHECK_IDS.policySandboxContainerNamespaceJoinDenied, + kind: "plugin", + description: "Sandbox container config avoids joining another container network namespace.", + source: "policy", + async detect(ctx) { + return findingsForCheck( + await evaluatePolicy(ctx), + CHECK_IDS.policySandboxContainerNamespaceJoinDenied, + ); + }, +}; + +const policySandboxContainerMountModeRequiredCheck: HealthCheck = { + id: CHECK_IDS.policySandboxContainerMountModeRequired, + kind: "plugin", + description: "Sandbox container mounts are read-only when policy requires it.", + source: "policy", + async detect(ctx) { + return findingsForCheck( + await evaluatePolicy(ctx), + CHECK_IDS.policySandboxContainerMountModeRequired, + ); + }, +}; + +const policySandboxContainerRuntimeSocketMountCheck: HealthCheck = { + id: CHECK_IDS.policySandboxContainerRuntimeSocketMount, + kind: "plugin", + description: "Sandbox container mounts avoid host container runtime sockets.", + source: "policy", + async detect(ctx) { + return findingsForCheck( + await evaluatePolicy(ctx), + CHECK_IDS.policySandboxContainerRuntimeSocketMount, + ); + }, +}; + +const policySandboxContainerUnconfinedProfileCheck: HealthCheck = { + id: CHECK_IDS.policySandboxContainerUnconfinedProfile, + kind: "plugin", + description: "Sandbox container profile config avoids unconfined profiles.", + source: "policy", + async detect(ctx) { + return findingsForCheck( + await evaluatePolicy(ctx), + CHECK_IDS.policySandboxContainerUnconfinedProfile, + ); + }, +}; + +const policySandboxBrowserCdpSourceRangeMissingCheck: HealthCheck = { + id: CHECK_IDS.policySandboxBrowserCdpSourceRangeMissing, + kind: "plugin", + description: "Sandbox browser CDP config includes a source range when policy requires it.", + source: "policy", + async detect(ctx) { + return findingsForCheck( + await evaluatePolicy(ctx), + CHECK_IDS.policySandboxBrowserCdpSourceRangeMissing, + ); + }, +}; + const policySecretsUnmanagedProviderCheck: HealthCheck = { id: CHECK_IDS.policySecretsUnmanagedProvider, kind: "plugin", @@ -732,6 +934,7 @@ async function evaluatePolicyUncached(ctx: HealthCheckContext): Promise 0) { const toolsFile = await readWorkspaceFile(ctx, "TOOLS.md"); evidence = await collectPolicyEvidence(ctx.cfg as Record, { @@ -829,6 +1033,7 @@ async function evaluatePolicyUncached(ctx: HealthCheckContext): Promise key !== "agentIds" && key !== "agents" && key !== "tools", + (key) => key !== "agentIds" && key !== "agents" && key !== "tools" && key !== "sandbox", ); if (unsupportedKey !== undefined) { return policyShapeFinding( params.policyPath, `oc://${params.policyDocName}/${targetPrefix}/${ocPathSegment(unsupportedKey)}`, `${params.policyPath} scopes.${scopeName}.${unsupportedKey} is not supported by the agentIds selector.`, - `Use only agentIds with agents.workspace or tools in this policy scope.`, + `Use only agentIds with agents.workspace, tools, or sandbox in this policy scope.`, ); } if (overlay.agents !== undefined && !isRecord(overlay.agents)) { @@ -1416,6 +1630,15 @@ function scopedPolicyShapeFinding( return toolsFinding; } } + const sandboxFinding = sandboxPolicyShapeFinding(overlay.sandbox, { + policyDocName: params.policyDocName, + policyPath: params.policyPath, + targetPrefix: `${targetPrefix}/sandbox`, + propertyPrefix: `scopes.${scopeName}.sandbox`, + }); + if (sandboxFinding !== undefined) { + return sandboxFinding; + } } return duplicateScopedAgentFieldFinding(value, { policyDocName: params.policyDocName, @@ -1636,6 +1859,128 @@ function toolPosturePolicyShapeFinding( return denyToolsFinding; } +function sandboxPolicyShapeFinding( + value: unknown, + params: { + readonly policyDocName: string; + readonly policyPath: string; + readonly targetPrefix?: string; + readonly propertyPrefix?: string; + }, +): HealthFinding | undefined { + const targetPrefix = params.targetPrefix ?? "sandbox"; + const propertyPrefix = params.propertyPrefix ?? "sandbox"; + if (value === undefined) { + return undefined; + } + if (!isRecord(value)) { + return policyShapeFinding( + params.policyPath, + `oc://${params.policyDocName}/${targetPrefix}`, + `${params.policyPath} ${propertyPrefix} must be an object.`, + `Fix ${params.policyPath} so ${propertyPrefix} is an object.`, + ); + } + const unsupportedTopLevel = unsupportedPolicyKey(value, [ + "requireMode", + "allowBackends", + "containers", + "browser", + ]); + if (unsupportedTopLevel !== undefined) { + return policyShapeFinding( + params.policyPath, + `oc://${params.policyDocName}/${targetPrefix}/${ocPathSegment(unsupportedTopLevel)}`, + `${params.policyPath} ${propertyPrefix}.${unsupportedTopLevel} is not supported in sandbox policy.`, + `Remove ${propertyPrefix}.${unsupportedTopLevel} or use a supported sandbox posture rule.`, + ); + } + const modeFinding = policyStringArrayPropertyShapeFinding(value.requireMode, { + allowed: SUPPORTED_SANDBOX_MODES, + policyDocName: params.policyDocName, + policyPath: params.policyPath, + property: `${propertyPrefix}.requireMode`, + target: `${targetPrefix}/requireMode`, + valueName: "sandbox mode", + }); + if (modeFinding !== undefined) { + return modeFinding; + } + const backendFinding = policyStringArrayPropertyShapeFinding(value.allowBackends, { + policyDocName: params.policyDocName, + policyPath: params.policyPath, + property: `${propertyPrefix}.allowBackends`, + target: `${targetPrefix}/allowBackends`, + valueName: "sandbox backend id", + }); + if (backendFinding !== undefined) { + return backendFinding; + } + for (const section of ["containers", "browser"] as const) { + if (value[section] !== undefined && !isRecord(value[section])) { + return policyShapeFinding( + params.policyPath, + `oc://${params.policyDocName}/${targetPrefix}/${section}`, + `${params.policyPath} ${propertyPrefix}.${section} must be an object.`, + `Fix ${params.policyPath} so ${propertyPrefix}.${section} is an object.`, + ); + } + } + const containers = isRecord(value.containers) ? value.containers : {}; + const unsupportedContainerKey = unsupportedPolicyKey( + containers, + SANDBOX_CONTAINER_POLICY_RULES.map((rule) => rule.key), + ); + if (unsupportedContainerKey !== undefined) { + return policyShapeFinding( + params.policyPath, + `oc://${params.policyDocName}/${targetPrefix}/containers/${ocPathSegment(unsupportedContainerKey)}`, + `${params.policyPath} ${propertyPrefix}.containers.${unsupportedContainerKey} is not supported in sandbox policy.`, + `Remove ${propertyPrefix}.containers.${unsupportedContainerKey} or use a supported sandbox container posture rule.`, + ); + } + for (const { key } of SANDBOX_CONTAINER_POLICY_RULES) { + if (containers[key] !== undefined && typeof containers[key] !== "boolean") { + return policyShapeFinding( + params.policyPath, + `oc://${params.policyDocName}/${targetPrefix}/containers/${key}`, + `${params.policyPath} ${propertyPrefix}.containers.${key} must be a boolean.`, + `Set ${propertyPrefix}.containers.${key} to true or false.`, + ); + } + } + const browser = isRecord(value.browser) ? value.browser : {}; + const unsupportedBrowserKey = unsupportedPolicyKey(browser, ["requireCdpSourceRange"]); + if (unsupportedBrowserKey !== undefined) { + return policyShapeFinding( + params.policyPath, + `oc://${params.policyDocName}/${targetPrefix}/browser/${ocPathSegment(unsupportedBrowserKey)}`, + `${params.policyPath} ${propertyPrefix}.browser.${unsupportedBrowserKey} is not supported in sandbox policy.`, + `Remove ${propertyPrefix}.browser.${unsupportedBrowserKey} or use a supported sandbox browser posture rule.`, + ); + } + if ( + browser.requireCdpSourceRange !== undefined && + typeof browser.requireCdpSourceRange !== "boolean" + ) { + return policyShapeFinding( + params.policyPath, + `oc://${params.policyDocName}/${targetPrefix}/browser/requireCdpSourceRange`, + `${params.policyPath} ${propertyPrefix}.browser.requireCdpSourceRange must be a boolean.`, + `Set ${propertyPrefix}.browser.requireCdpSourceRange to true or false.`, + ); + } + return undefined; +} + +function unsupportedPolicyKey( + value: Record, + allowedKeys: readonly string[], +): string | undefined { + const allowed = new Set(allowedKeys); + return Object.keys(value).find((key) => !allowed.has(key)); +} + function gatewayPolicyShapeFinding( value: unknown, params: { @@ -2886,6 +3231,495 @@ function toolPostureLabel(entry: PolicyToolPostureEvidence): string { return entry.agentId === undefined ? "global tools config" : `agent '${entry.agentId}'`; } +function sandboxPostureFindings( + policy: unknown, + policyPath: string, + policyDocName: string, + evidence: PolicyEvidence, +): readonly HealthFinding[] { + if (!isRecord(policy)) { + return []; + } + const findings: HealthFinding[] = []; + const sandboxPolicy = policy.sandbox; + if ( + isRecord(sandboxPolicy) && + sandboxPolicyShapeFinding(sandboxPolicy, { policyDocName, policyPath }) === undefined + ) { + findings.push( + ...sandboxPostureFindingsForRule( + sandboxPolicy, + policyDocName, + "sandbox", + evidence, + () => true, + ), + ); + } + if (!hasValidScopedPolicy(policy, policyPath, policyDocName)) { + return findings; + } + for (const target of agentScopedPolicyTargets(policy)) { + const scopedSandboxPolicy = target.overlay.sandbox; + if ( + sandboxPolicyShapeFinding(scopedSandboxPolicy, { + policyDocName, + policyPath, + targetPrefix: `scopes/${ocPathSegment(target.scopeName)}/sandbox`, + propertyPrefix: `scopes.${target.scopeName}.sandbox`, + }) !== undefined || + !isRecord(scopedSandboxPolicy) + ) { + continue; + } + findings.push( + ...sandboxPostureFindingsForRule( + scopedSandboxPolicy, + policyDocName, + `scopes/${ocPathSegment(target.scopeName)}/sandbox`, + evidence, + (entry) => scopedSandboxAgentMatches(entry, target.agentId, evidence.sandboxPosture ?? []), + ), + ); + } + return findings; +} + +function sandboxPostureFindingsForRule( + sandboxPolicy: Record | undefined, + policyDocName: string, + requirementBase: string, + evidence: PolicyEvidence, + evidenceFilter: (entry: PolicySandboxPostureEvidence) => boolean, +): readonly HealthFinding[] { + if (!isRecord(sandboxPolicy)) { + return []; + } + return [ + ...sandboxModeFindings(sandboxPolicy, policyDocName, requirementBase, evidence, evidenceFilter), + ...sandboxBackendFindings( + sandboxPolicy, + policyDocName, + requirementBase, + evidence, + evidenceFilter, + ), + ...sandboxContainerPostureUnobservableFindings( + sandboxPolicy, + policyDocName, + requirementBase, + evidence, + evidenceFilter, + ), + ...sandboxContainerHostNetworkFindings( + sandboxPolicy, + policyDocName, + requirementBase, + evidence, + evidenceFilter, + ), + ...sandboxContainerNamespaceJoinFindings( + sandboxPolicy, + policyDocName, + requirementBase, + evidence, + evidenceFilter, + ), + ...sandboxContainerMountModeFindings( + sandboxPolicy, + policyDocName, + requirementBase, + evidence, + evidenceFilter, + ), + ...sandboxContainerRuntimeSocketMountFindings( + sandboxPolicy, + policyDocName, + requirementBase, + evidence, + evidenceFilter, + ), + ...sandboxContainerUnconfinedProfileFindings( + sandboxPolicy, + policyDocName, + requirementBase, + evidence, + evidenceFilter, + ), + ...sandboxBrowserCdpSourceRangeFindings( + sandboxPolicy, + policyDocName, + requirementBase, + evidence, + evidenceFilter, + ), + ]; +} + +function scopedSandboxAgentMatches( + entry: PolicySandboxPostureEvidence, + policyAgentId: string, + entries: readonly PolicySandboxPostureEvidence[], +): boolean { + if (scopedAgentIdMatches(entry.agentId, policyAgentId)) { + return true; + } + return ( + entry.scope === "defaults" && + !scopedSandboxDefaultDisabledForAgent(entry, policyAgentId, entries) && + !entries.some( + (candidate) => + candidate.scope === "agent" && + sandboxPostureEntriesDescribeSameField(candidate, entry) && + scopedAgentIdMatches(candidate.agentId, policyAgentId), + ) + ); +} + +function scopedSandboxDefaultDisabledForAgent( + entry: PolicySandboxPostureEvidence, + policyAgentId: string, + entries: readonly PolicySandboxPostureEvidence[], +): boolean { + if (sandboxEntryRequiresContainerBackend(entry)) { + const backend = entries.find( + (candidate) => + candidate.scope === "agent" && + candidate.kind === "backend" && + scopedAgentIdMatches(candidate.agentId, policyAgentId), + ); + if (typeof backend?.value === "string" && backend.value.toLowerCase() !== "docker") { + return true; + } + } + + if (sandboxEntryRequiresBrowser(entry)) { + const browser = entries.find( + (candidate) => + candidate.scope === "agent" && + candidate.kind === "browserCdpSourceRange" && + scopedAgentIdMatches(candidate.agentId, policyAgentId), + ); + if (browser?.value === false) { + return true; + } + } + + return false; +} + +function sandboxEntryRequiresContainerBackend(entry: PolicySandboxPostureEvidence): boolean { + return ( + (entry.kind === "containerNetwork" && entry.networkSurface === "docker") || + entry.kind === "containerSecurityProfile" || + (entry.kind === "containerMount" && entry.bindSurface === "docker") + ); +} + +function sandboxEntryRequiresBrowser(entry: PolicySandboxPostureEvidence): boolean { + return ( + entry.kind === "browserCdpSourceRange" || + (entry.kind === "containerNetwork" && entry.networkSurface === "browser") || + (entry.kind === "containerMount" && entry.bindSurface === "browser") + ); +} + +function sandboxPostureEntriesDescribeSameField( + candidate: PolicySandboxPostureEvidence, + baseline: PolicySandboxPostureEvidence, +): boolean { + return ( + candidate.kind === baseline.kind && + candidate.bindSurface === baseline.bindSurface && + candidate.networkSurface === baseline.networkSurface && + candidate.profile === baseline.profile + ); +} + +function sandboxModeFindings( + sandboxPolicy: Record, + policyDocName: string, + requirementBase: string, + evidence: PolicyEvidence, + evidenceFilter: (entry: PolicySandboxPostureEvidence) => boolean, +): readonly HealthFinding[] { + const allowed = new Set(readStringList(sandboxPolicy, ["requireMode"])); + if (allowed.size === 0) { + return []; + } + return sandboxPostureEntries(evidence, "mode") + .filter(evidenceFilter) + .filter((entry) => typeof entry.value === "string" && !allowed.has(entry.value.toLowerCase())) + .map((entry) => + sandboxPostureFinding(entry, { + checkId: CHECK_IDS.policySandboxModeUnapproved, + message: `${sandboxPostureLabel(entry)} uses unapproved sandbox mode '${entry.value ?? ""}'.`, + requirement: `oc://${policyDocName}/${requirementBase}/requireMode`, + fixHint: + "Set agents.defaults.sandbox.mode or agents.list[].sandbox.mode to an approved value.", + }), + ); +} + +function sandboxBackendFindings( + sandboxPolicy: Record, + policyDocName: string, + requirementBase: string, + evidence: PolicyEvidence, + evidenceFilter: (entry: PolicySandboxPostureEvidence) => boolean, +): readonly HealthFinding[] { + const allowed = new Set(readStringList(sandboxPolicy, ["allowBackends"])); + if (allowed.size === 0) { + return []; + } + return sandboxPostureEntries(evidence, "backend") + .filter(evidenceFilter) + .filter((entry) => typeof entry.value === "string" && !allowed.has(entry.value.toLowerCase())) + .map((entry) => + sandboxPostureFinding(entry, { + checkId: CHECK_IDS.policySandboxBackendUnapproved, + message: `${sandboxPostureLabel(entry)} uses unapproved sandbox backend '${entry.value ?? ""}'.`, + requirement: `oc://${policyDocName}/${requirementBase}/allowBackends`, + fixHint: "Use an approved sandbox backend or update policy after review.", + }), + ); +} + +function sandboxContainerPostureUnobservableFindings( + sandboxPolicy: Record, + policyDocName: string, + requirementBase: string, + evidence: PolicyEvidence, + evidenceFilter: (entry: PolicySandboxPostureEvidence) => boolean, +): readonly HealthFinding[] { + const enabledRules = SANDBOX_CONTAINER_POLICY_RULES.filter( + (rule) => readPolicyBoolean(sandboxPolicy, ["containers", rule.key]) === true, + ); + if (enabledRules.length === 0) { + return []; + } + return sandboxPostureEntries(evidence, "backend") + .filter(evidenceFilter) + .filter((entry) => typeof entry.value === "string" && entry.value.toLowerCase() !== "docker") + .flatMap((entry) => + enabledRules.map((rule) => + sandboxPostureFinding(entry, { + checkId: CHECK_IDS.policySandboxContainerPostureUnobservable, + message: `${sandboxPostureLabel(entry)} uses sandbox backend '${entry.value ?? ""}', which cannot observe ${rule.label}.`, + requirement: `oc://${policyDocName}/${requirementBase}/containers/${rule.key}`, + fixHint: + "Use an observable container backend for this sandbox or remove the container posture rule.", + }), + ), + ); +} + +function sandboxContainerHostNetworkFindings( + sandboxPolicy: Record, + policyDocName: string, + requirementBase: string, + evidence: PolicyEvidence, + evidenceFilter: (entry: PolicySandboxPostureEvidence) => boolean, +): readonly HealthFinding[] { + if (readPolicyBoolean(sandboxPolicy, ["containers", "denyHostNetwork"]) !== true) { + return []; + } + return sandboxPostureEntries(evidence, "containerNetwork") + .filter(evidenceFilter) + .filter((entry) => typeof entry.value === "string" && entry.value.toLowerCase() === "host") + .map((entry) => + sandboxPostureFinding(entry, { + checkId: CHECK_IDS.policySandboxContainerHostNetworkDenied, + message: `${sandboxPostureLabel(entry)} uses host container network mode.`, + requirement: `oc://${policyDocName}/${requirementBase}/containers/denyHostNetwork`, + fixHint: "Change the container network mode or update policy after review.", + }), + ); +} + +function sandboxContainerNamespaceJoinFindings( + sandboxPolicy: Record, + policyDocName: string, + requirementBase: string, + evidence: PolicyEvidence, + evidenceFilter: (entry: PolicySandboxPostureEvidence) => boolean, +): readonly HealthFinding[] { + if (readPolicyBoolean(sandboxPolicy, ["containers", "denyContainerNamespaceJoin"]) !== true) { + return []; + } + const containerNamespacePrefix = "container:"; + return sandboxPostureEntries(evidence, "containerNetwork") + .filter(evidenceFilter) + .filter( + (entry) => + typeof entry.value === "string" && + entry.value.toLowerCase().startsWith(containerNamespacePrefix), + ) + .map((entry) => + sandboxPostureFinding(entry, { + checkId: CHECK_IDS.policySandboxContainerNamespaceJoinDenied, + message: `${sandboxPostureLabel(entry)} joins another container network namespace '${entry.value ?? ""}'.`, + requirement: `oc://${policyDocName}/${requirementBase}/containers/denyContainerNamespaceJoin`, + fixHint: "Change the container network mode or update policy after review.", + }), + ); +} + +function sandboxContainerMountModeFindings( + sandboxPolicy: Record, + policyDocName: string, + requirementBase: string, + evidence: PolicyEvidence, + evidenceFilter: (entry: PolicySandboxPostureEvidence) => boolean, +): readonly HealthFinding[] { + if (readPolicyBoolean(sandboxPolicy, ["containers", "requireReadOnlyMounts"]) !== true) { + return []; + } + return sandboxPostureEntries(evidence, "containerMount") + .filter(evidenceFilter) + .filter((entry) => entry.bindMode !== "ro") + .map((entry) => + sandboxPostureFinding(entry, { + checkId: CHECK_IDS.policySandboxContainerMountModeRequired, + message: `${sandboxPostureLabel(entry)} has container mount '${entry.bind ?? ""}' with mode '${entry.bindMode ?? "unknown"}'.`, + requirement: `oc://${policyDocName}/${requirementBase}/containers/requireReadOnlyMounts`, + fixHint: "Set the mount mode to read-only or update policy after review.", + }), + ); +} + +function sandboxContainerRuntimeSocketMountFindings( + sandboxPolicy: Record, + policyDocName: string, + requirementBase: string, + evidence: PolicyEvidence, + evidenceFilter: (entry: PolicySandboxPostureEvidence) => boolean, +): readonly HealthFinding[] { + if ( + readPolicyBoolean(sandboxPolicy, ["containers", "denyContainerRuntimeSocketMounts"]) !== true + ) { + return []; + } + return sandboxPostureEntries(evidence, "containerMount") + .filter(evidenceFilter) + .filter((entry) => bindHostLooksLikeContainerRuntimeSocket(entry.bindHost)) + .map((entry) => + sandboxPostureFinding(entry, { + checkId: CHECK_IDS.policySandboxContainerRuntimeSocketMount, + message: `${sandboxPostureLabel(entry)} binds host container runtime socket '${entry.bindHost ?? ""}'.`, + requirement: `oc://${policyDocName}/${requirementBase}/containers/denyContainerRuntimeSocketMounts`, + fixHint: "Remove the container runtime socket bind or update policy after review.", + }), + ); +} + +function sandboxContainerUnconfinedProfileFindings( + sandboxPolicy: Record, + policyDocName: string, + requirementBase: string, + evidence: PolicyEvidence, + evidenceFilter: (entry: PolicySandboxPostureEvidence) => boolean, +): readonly HealthFinding[] { + if (readPolicyBoolean(sandboxPolicy, ["containers", "denyUnconfinedProfiles"]) !== true) { + return []; + } + return sandboxPostureEntries(evidence, "containerSecurityProfile") + .filter(evidenceFilter) + .filter( + (entry) => typeof entry.value === "string" && entry.value.toLowerCase() === "unconfined", + ) + .map((entry) => + sandboxPostureFinding(entry, { + checkId: CHECK_IDS.policySandboxContainerUnconfinedProfile, + message: `${sandboxPostureLabel(entry)} sets container ${entry.profile ?? "security"} profile to unconfined.`, + requirement: `oc://${policyDocName}/${requirementBase}/containers/denyUnconfinedProfiles`, + fixHint: "Remove the unconfined container profile or update policy after review.", + }), + ); +} + +function sandboxBrowserCdpSourceRangeFindings( + sandboxPolicy: Record, + policyDocName: string, + requirementBase: string, + evidence: PolicyEvidence, + evidenceFilter: (entry: PolicySandboxPostureEvidence) => boolean, +): readonly HealthFinding[] { + if (readPolicyBoolean(sandboxPolicy, ["browser", "requireCdpSourceRange"]) !== true) { + return []; + } + return sandboxPostureEntries(evidence, "browserCdpSourceRange") + .filter(evidenceFilter) + .filter((entry) => entry.value === undefined) + .map((entry) => + sandboxPostureFinding(entry, { + checkId: CHECK_IDS.policySandboxBrowserCdpSourceRangeMissing, + message: `${sandboxPostureLabel(entry)} enables sandbox browser without cdpSourceRange.`, + requirement: `oc://${policyDocName}/${requirementBase}/browser/requireCdpSourceRange`, + fixHint: "Set agents.*.sandbox.browser.cdpSourceRange or update policy after review.", + }), + ); +} + +function sandboxPostureEntries( + evidence: PolicyEvidence, + kind: PolicySandboxPostureEvidence["kind"], +): readonly PolicySandboxPostureEvidence[] { + return (evidence.sandboxPosture ?? []).filter((entry) => entry.kind === kind); +} + +function sandboxPostureFinding( + entry: PolicySandboxPostureEvidence, + params: { + readonly checkId: (typeof POLICY_CHECK_IDS)[number]; + readonly message: string; + readonly requirement: string; + readonly fixHint: string; + }, +): HealthFinding { + return { + checkId: params.checkId, + severity: "error", + message: params.message, + source: "policy", + path: "openclaw config", + ocPath: entry.source, + target: entry.source, + requirement: params.requirement, + fixHint: params.fixHint, + }; +} + +function sandboxPostureLabel(entry: PolicySandboxPostureEvidence): string { + return entry.agentId === undefined ? "default sandbox config" : `agent '${entry.agentId}'`; +} + +const CONTAINER_RUNTIME_SOCKET_BASENAMES = new Set([ + "containerd.sock", + "docker.sock", + "podman.sock", +]); + +const CONTAINER_RUNTIME_SOCKET_PATHS = new Set([ + "/run/containerd/containerd.sock", + "/run/docker.sock", + "/run/podman/podman.sock", + "/var/run/docker.sock", + "/var/run/podman/podman.sock", +]); + +function bindHostLooksLikeContainerRuntimeSocket(value: string | undefined): boolean { + if (value === undefined) { + return false; + } + const normalized = value.replaceAll("\\", "/").toLowerCase(); + const basename = normalized.split("/").at(-1) ?? ""; + return ( + CONTAINER_RUNTIME_SOCKET_PATHS.has(normalized) || + CONTAINER_RUNTIME_SOCKET_BASENAMES.has(basename) + ); +} + function secretAuthProvenanceFindings( policy: unknown, policyPath: string, @@ -2964,6 +3798,34 @@ function policyHasAgentWorkspaceRules(policy: unknown): boolean { }); } +function policyHasSandboxPostureRules(policy: unknown): boolean { + if (!isRecord(policy)) { + return false; + } + if (sandboxPosturePolicyHasRules(policy.sandbox)) { + return true; + } + return agentScopedPolicyOverlays(policy).some(([, overlay]) => + sandboxPosturePolicyHasRules(overlay.sandbox), + ); +} + +function sandboxPosturePolicyHasRules(value: unknown): boolean { + if (!isRecord(value)) { + return false; + } + const sandbox = value; + const containers = isRecord(sandbox.containers) ? sandbox.containers : undefined; + const browser = isRecord(sandbox.browser) ? sandbox.browser : undefined; + return ( + sandbox.requireMode !== undefined || + sandbox.allowBackends !== undefined || + (containers !== undefined && + SANDBOX_CONTAINER_POLICY_RULES.some((rule) => containers[rule.key] !== undefined)) || + browser?.requireCdpSourceRange !== undefined + ); +} + function policyHasToolPostureRules(policy: unknown): boolean { if (!isRecord(policy)) { return false; @@ -3056,7 +3918,10 @@ function duplicateScopedAgentFieldFinding( } >(); for (const [scopeName, overlay] of Object.entries(scopedAgents)) { - if (!isRecord(overlay) || !Array.isArray(overlay.agentIds)) { + if (!isRecord(overlay)) { + continue; + } + if (!Array.isArray(overlay.agentIds)) { continue; } const fields = scopedAgentPolicyFields(scopeName, overlay); diff --git a/extensions/policy/src/policy-state.ts b/extensions/policy/src/policy-state.ts index a8e927f69c1..3944c0cb9b7 100644 --- a/extensions/policy/src/policy-state.ts +++ b/extensions/policy/src/policy-state.ts @@ -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, +): 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, ): 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; + readonly inheritedSandbox: Record; + 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, + inheritedDocker: Record, + 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, + inheritedSandbox: Record, +): 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";