From 55263b3dfa416425f4fc4fdc60cd08d6e8bbb345 Mon Sep 17 00:00:00 2001 From: Gio Della-Libera Date: Mon, 15 Jun 2026 17:30:48 -0700 Subject: [PATCH] feat(policy): cover exec approvals artifact (#90003) Add exec approvals artifact evidence to Policy. - add the execApprovals policy namespace and check IDs for required artifact presence, default/per-agent security posture, autoAllowSkills, and allowlist drift - read the active exec-approvals.json artifact only when execApprovals policy rules are configured, honoring OPENCLAW_STATE_DIR before the default ~/.openclaw path - emit redacted posture evidence and stable oc:// references without socket tokens, command text, resolved paths, timestamps, or approval-session details - document the public policy surface and add focused scanner, doctor, conformance, and CLI coverage Validation: - GitHub Actions for head b82eefe49214a256ace4536f33a70aa97d97e121 are green, including Real behavior proof. - ClawSweeper re-review completed for the same head with proof: sufficient and status: ready for maintainer look. - Maintainer artifact-boundary acceptance is recorded in the PR discussion and body. Co-authored-by: Gio Della-Libera <235387111+giodl73-repo@users.noreply.github.com> --- docs/cli/policy.md | 102 +- extensions/policy/src/cli.test.ts | 114 ++ extensions/policy/src/doctor/register.test.ts | 815 +++++++++++++ extensions/policy/src/doctor/register.ts | 1014 ++++++++++++++++- extensions/policy/src/policy-conformance.ts | 66 +- extensions/policy/src/policy-state.test.ts | 122 +- extensions/policy/src/policy-state.ts | 303 +++++ 7 files changed, 2514 insertions(+), 22 deletions(-) diff --git a/docs/cli/policy.md b/docs/cli/policy.md index 228197c6ccc5..98dd9123453a 100644 --- a/docs/cli/policy.md +++ b/docs/cli/policy.md @@ -54,7 +54,8 @@ doctor can report the missing artifact. Policy is authored, not generated from the user's current settings. A minimal policy for channels, MCP servers, model providers, network posture, ingress/channel access, Gateway exposure, agent workspace posture, configured sandbox runtime posture, OpenClaw -data-handling posture, config secret provider/auth profile posture, and tool metadata looks like this: +data-handling posture, config secret provider/auth profile posture, exec approval +file posture, and tool metadata looks like this: ```jsonc { @@ -145,6 +146,15 @@ data-handling posture, config secret provider/auth profile posture, and tool met "allowModes": ["api_key", "token"], }, }, + "execApprovals": { + "requireFile": true, + "defaults": { "allowSecurity": ["deny"] }, + "agents": { + "allowSecurity": ["deny", "allowlist"], + "allowAutoAllowSkills": false, + "allowlist": { "expected": ["deploy", "status"] }, + }, + }, "tools": { "requireMetadata": ["risk", "sensitivity", "owner"], "profiles": { @@ -187,9 +197,11 @@ and `group:runtime` covers shell/process tools. Tool posture policy observes `tools.profile`, `tools.allow`, `tools.alsoAllow`, `tools.deny`, `tools.fs.workspaceOnly`, `tools.exec.security`, `tools.exec.ask`, `tools.exec.host`, `tools.elevated.enabled`, and the same per-agent -`agents.list[].tools.*` overrides. It does not read runtime/operator approval -state such as exec-approvals.json, and it does not enforce tool calls at -runtime. Secret evidence records +`agents.list[].tools.*` overrides. Exec approval policy reads the named +`exec-approvals.json` product artifact only when an `execApprovals` rule is +present; evidence records defaults, per-agent posture, and allowlist patterns +without socket tokens or last-used command text. Policy does not enforce tool +calls at runtime. Secret evidence records provider/source posture and SecretRef metadata, never raw secret values. Policy does not read or attest per-agent credential stores such as `auth-profiles.json`; those stores remain owned by the existing auth and credential flows. @@ -218,8 +230,8 @@ its own finding against the same observed config. Use `scopes.` when one set of agents or channels needs stricter policy than the top-level baseline. Agent-scoped sections use `agentIds`, which -supports `tools.*`, `agents.workspace.*`, `sandbox.*`, and -`dataHandling.memory.*`. Channel-scoped +supports `tools.*`, `agents.workspace.*`, `sandbox.*`, `dataHandling.memory.*`, +and `execApprovals.*`. Channel-scoped ingress uses `channelIds`, which supports `ingress.channels.*`. 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 @@ -304,10 +316,10 @@ groups where those fields cannot be observed. Top-level `ingress.session.requireDmScope` remains global because `session.dmScope` is not channel-attributable evidence. -| Selector | Supported sections | Use when | -| ------------ | ----------------------------------------------------------------- | ------------------------------------------------- | -| `agentIds` | `tools`, `agents.workspace`, `sandbox`, and `dataHandling.memory` | One or more runtime agents need stricter rules. | -| `channelIds` | `ingress.channels` | One or more channels need stricter ingress rules. | +| Selector | Supported sections | Use when | +| ------------ | ---------------------------------------------------------------------------------- | ------------------------------------------------- | +| `agentIds` | `tools`, `agents.workspace`, `sandbox`, `dataHandling.memory`, and `execApprovals` | One or more runtime agents need stricter rules. | +| `channelIds` | `ingress.channels` | One or more channels need stricter ingress rules. | Every scope present in `policy.jsonc` must be valid and enforceable. @@ -401,6 +413,69 @@ allowlist such as `["all"]`. | `secrets.denySources` | Secret provider sources and SecretRef sources | Deny sources such as `exec`, `file`, or another configured source name. | | `secrets.allowInsecureProviders` | Insecure secret-provider posture flags | Set to `false` to reject providers that opt into insecure posture. | +#### Exec approvals + +Exec approvals policy observes the active runtime `exec-approvals.json` +artifact. By default this is `~/.openclaw/exec-approvals.json`; when +`OPENCLAW_STATE_DIR` is set, Policy reads +`$OPENCLAW_STATE_DIR/exec-approvals.json`. Actual posture rules such as +`execApprovals.defaults.*` or `execApprovals.agents.*` require readable artifact +evidence; a missing or invalid artifact is reported as unobservable evidence +instead of becoming a best-effort pass against synthetic runtime defaults. Once +the artifact is readable, omitted approval fields inherit runtime defaults: missing +`defaults.security` is `full`, and missing agent security inherits that +default. Evidence includes `defaults`, `agents.*`, and +`agents.*.allowlist[].pattern` plus optional `argPattern`, effective +`autoAllowSkills` posture, and entry source. It does not include socket +path/token, `commandText`, `lastUsedCommand`, resolved paths, or timestamps. + +| Policy field | Observed state | Use when | +| ------------------------------------------- | -------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------- | +| `execApprovals.requireFile` | Active runtime `exec-approvals.json` path | Set to `true` to require the approvals artifact to exist and parse. | +| `execApprovals.defaults.allowSecurity` | `defaults.security`, defaulting to `full` | Allow only approved default approval security modes. | +| `execApprovals.agents.allowSecurity` | `agents.*.security`, inheriting defaults | Allow only approved per-agent effective approval security modes. | +| `execApprovals.agents.allowAutoAllowSkills` | `defaults.autoAllowSkills` and `agents.*.autoAllowSkills`, inheriting runtime defaults | Set to `false` to require strict manual allowlists without implicit skill CLI approval. | +| `execApprovals.agents.allowlist.expected` | Aggregate `agents.*.allowlist[]` pattern and optional argPattern entries | Require the approvals allowlist to match the reviewed pattern set. | + +For example, require the approvals artifact, deny permissive defaults, and +allow only reviewed exec approval posture for selected agents: + +```jsonc +{ + "execApprovals": { + "requireFile": true, + "defaults": { + // Security modes: "deny", "allowlist", or "full". + // This default permits only the locked-down deny posture. + "allowSecurity": ["deny"], + }, + }, + "scopes": { + "restricted-shell": { + "agentIds": ["family-agent", "groups-agent"], + "execApprovals": { + "agents": { + // Selected agents may use reviewed allowlist posture, but not "full". + "allowSecurity": ["allowlist"], + // false means skill CLIs must appear in the reviewed allowlist instead of + // being implicitly approved by autoAllowSkills. + "allowAutoAllowSkills": false, + "allowlist": { + "expected": [ + // Simple entry: exact reviewed executable pattern with no argPattern. + "travel-hub", + // Constrained entry: pattern plus reviewed argument regex. + { "pattern": "calendar-cli", "argPattern": "^sync\\b" }, + "/bin/date", + ], + }, + }, + }, + }, + }, +} +``` + #### Auth profiles | Policy field | Observed state | Use when | @@ -769,6 +844,13 @@ Policy currently verifies: | `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/exec-approvals-missing` | Policy requires `exec-approvals.json`, but the artifact is missing. | +| `policy/exec-approvals-invalid` | The configured exec approvals artifact cannot be parsed. | +| `policy/exec-approvals-default-security-unapproved` | Exec approval defaults use a security mode outside the policy allowlist. | +| `policy/exec-approvals-agent-security-unapproved` | A per-agent effective exec approval security mode is outside the allowlist. | +| `policy/exec-approvals-auto-allow-skills-enabled` | An exec approval agent implicitly auto-allows skill CLIs when policy denies it. | +| `policy/exec-approvals-allowlist-missing` | The approvals allowlist is missing a pattern required by policy. | +| `policy/exec-approvals-allowlist-unexpected` | The approvals allowlist includes a pattern not expected by policy. | | `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. | diff --git a/extensions/policy/src/cli.test.ts b/extensions/policy/src/cli.test.ts index 45b69663519a..324ca500843c 100644 --- a/extensions/policy/src/cli.test.ts +++ b/extensions/policy/src/cli.test.ts @@ -514,6 +514,82 @@ describe("policy commands", () => { expect(parsed.rulesChecked).toBeGreaterThan(10); }); + it("accepts exec approval allowlist conformance entries with argPattern", async () => { + const policy = { + execApprovals: { + agents: { + allowAutoAllowSkills: false, + allowlist: { + expected: ["status", { pattern: "calendar-cli", argPattern: "^sync\\b" }], + }, + }, + }, + }; + await fs.writeFile( + join(workspaceDir, "baseline.policy.jsonc"), + JSON.stringify(policy), + "utf-8", + ); + await fs.writeFile(join(workspaceDir, "policy.jsonc"), JSON.stringify(policy), "utf-8"); + + const { exitCode, parsed } = await runPolicyCompareJson({ + baseline: "baseline.policy.jsonc", + }); + + expect(exitCode).toBe(0); + expect(parsed).toMatchObject({ + ok: true, + findings: [], + }); + }); + + it("rejects unsupported exec approval allowlist requirement keys in policy compare", async () => { + await fs.writeFile( + join(workspaceDir, "baseline.policy.jsonc"), + JSON.stringify({ + execApprovals: { + agents: { + allowlist: { + expected: [{ pattern: "deploy", argpattern: "^--prod$" }], + }, + }, + }, + }), + "utf-8", + ); + await fs.writeFile( + join(workspaceDir, "policy.jsonc"), + JSON.stringify({ + execApprovals: { + agents: { + allowlist: { + expected: [{ pattern: "deploy", argPattern: "^--prod$" }], + }, + }, + }, + }), + "utf-8", + ); + + const { exitCode, parsed } = await runPolicyCompareJson({ + baseline: "baseline.policy.jsonc", + }); + + expect(exitCode).toBe(1); + expect(parsed).toMatchObject({ + ok: false, + rulesChecked: 0, + }); + expect(parsed.findings).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + checkId: "policy/policy-conformance-invalid", + target: "oc://baseline.policy.jsonc/execApprovals/agents/allowlist/expected/#0", + }), + ]), + ); + }); + it("reports missing and weaker policy file conformance rules", async () => { await fs.writeFile( join(workspaceDir, "baseline.policy.jsonc"), @@ -940,6 +1016,44 @@ describe("policy commands", () => { ]); }); + it("accepts stricter later scoped candidate overlays during policy compare", async () => { + await fs.writeFile( + join(workspaceDir, "baseline.policy.jsonc"), + JSON.stringify({ + scopes: { + release: { + agentIds: ["main"], + tools: { exec: { allowHosts: ["sandbox"] } }, + }, + }, + }), + "utf-8", + ); + await fs.writeFile( + join(workspaceDir, "policy.jsonc"), + JSON.stringify({ + scopes: { + team: { + agentIds: ["main"], + tools: { exec: { allowHosts: ["sandbox", "node"] } }, + }, + lockdown: { + agentIds: ["main"], + tools: { exec: { allowHosts: ["sandbox"] } }, + }, + }, + }), + "utf-8", + ); + + const { exitCode, parsed } = await runPolicyCompareJson({ + baseline: "baseline.policy.jsonc", + }); + + expect(exitCode).toBe(0); + expect(parsed.findings).toEqual([]); + }); + it("rejects duplicate scoped candidates when any matching scoped value is weaker", async () => { await fs.writeFile( join(workspaceDir, "baseline.policy.jsonc"), diff --git a/extensions/policy/src/doctor/register.test.ts b/extensions/policy/src/doctor/register.test.ts index ed70ad7a9eda..2f37696cec8c 100644 --- a/extensions/policy/src/doctor/register.test.ts +++ b/extensions/policy/src/doctor/register.test.ts @@ -28,6 +28,8 @@ import { } from "./register.js"; let workspaceDir: string; +let originalOpenClawHome: string | undefined; +let originalOpenClawStateDir: string | undefined; function cfgWithPolicy(settings: Record = {}): OpenClawConfig { return { @@ -104,10 +106,37 @@ describe("registerPolicyDoctorChecks", () => { beforeEach(async () => { clearHealthChecksForTest(); resetPolicyDoctorChecksForTest(); + originalOpenClawHome = process.env.OPENCLAW_HOME; + originalOpenClawStateDir = process.env.OPENCLAW_STATE_DIR; workspaceDir = await fs.mkdtemp(join(tmpdir(), "policy-doctor-")); + process.env.OPENCLAW_HOME = workspaceDir; + delete process.env.OPENCLAW_STATE_DIR; + await fs.mkdir(join(workspaceDir, ".openclaw"), { recursive: true }); + try { + await fs.symlink( + "../exec-approvals.json", + join(workspaceDir, ".openclaw", "exec-approvals.json"), + ); + } catch (err) { + if (typeof err !== "object" || err === null || !("code" in err) || err.code !== "EPERM") { + throw err; + } + await fs.rm(join(workspaceDir, ".openclaw"), { recursive: true, force: true }); + await fs.symlink(workspaceDir, join(workspaceDir, ".openclaw"), "junction"); + } }); afterEach(async () => { + if (originalOpenClawHome === undefined) { + delete process.env.OPENCLAW_HOME; + } else { + process.env.OPENCLAW_HOME = originalOpenClawHome; + } + if (originalOpenClawStateDir === undefined) { + delete process.env.OPENCLAW_STATE_DIR; + } else { + process.env.OPENCLAW_STATE_DIR = originalOpenClawStateDir; + } await fs.rm(workspaceDir, { recursive: true, force: true }); clearHealthChecksForTest(); resetPolicyDoctorChecksForTest(); @@ -249,6 +278,23 @@ describe("registerPolicyDoctorChecks", () => { strictness: "requires-true", selectors: ["agentIds"], }, + { + path: "execApprovals.agents.allowSecurity", + strictness: "allowlist-subset", + emptyList: "disabled", + selectors: ["agentIds"], + }, + { + path: "execApprovals.agents.allowAutoAllowSkills", + strictness: "requires-false", + selectors: ["agentIds"], + }, + { + path: "execApprovals.agents.allowlist.expected", + strictness: "exact-list", + emptyList: "meaningful", + selectors: ["agentIds"], + }, ]); }); @@ -564,6 +610,13 @@ describe("registerPolicyDoctorChecks", () => { "policy/secrets-insecure-provider", "policy/auth-profile-invalid-metadata", "policy/auth-profile-unapproved-mode", + "policy/exec-approvals-missing", + "policy/exec-approvals-invalid", + "policy/exec-approvals-default-security-unapproved", + "policy/exec-approvals-agent-security-unapproved", + "policy/exec-approvals-auto-allow-skills-enabled", + "policy/exec-approvals-allowlist-missing", + "policy/exec-approvals-allowlist-unexpected", "policy/tools-missing-risk-level", "policy/tools-unknown-risk-level", "policy/tools-missing-sensitivity-token", @@ -7805,6 +7858,768 @@ describe("registerPolicyDoctorChecks", () => { ]); }); + it("reports exec approvals file conformance findings", async () => { + const configPath = join(workspaceDir, "openclaw.jsonc"); + await fs.writeFile(configPath, "{}", "utf-8"); + await fs.writeFile( + join(workspaceDir, "policy.jsonc"), + JSON.stringify({ + execApprovals: { + requireFile: true, + defaults: { allowSecurity: ["deny"] }, + agents: { + allowSecurity: ["allowlist"], + allowlist: { expected: ["deploy", "doctor"] }, + }, + }, + }), + "utf-8", + ); + await fs.writeFile( + join(workspaceDir, "exec-approvals.json"), + JSON.stringify({ + version: 1, + socket: { path: "/tmp/openclaw.sock", token: "secret-token" }, + defaults: { security: "full" }, + agents: { + sebby: { + security: "full", + allowlist: [{ pattern: "deploy", commandText: "deploy --prod" }], + }, + buddy: { + security: "allowlist", + allowlist: [{ pattern: "status" }], + }, + }, + }), + "utf-8", + ); + + registerPolicyDoctorChecks(); + const result = await runDoctorLintChecks(ctx(configPath, cfgWithPolicy())); + + expect(result.findings).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + checkId: "policy/exec-approvals-default-security-unapproved", + ocPath: "oc://exec-approvals.json/defaults", + requirement: "oc://policy.jsonc/execApprovals/defaults/allowSecurity", + }), + expect.objectContaining({ + checkId: "policy/exec-approvals-agent-security-unapproved", + ocPath: "oc://exec-approvals.json/agents/sebby", + requirement: "oc://policy.jsonc/execApprovals/agents/allowSecurity", + }), + expect.objectContaining({ + checkId: "policy/exec-approvals-allowlist-missing", + target: "oc://exec-approvals.json", + requirement: "oc://policy.jsonc/execApprovals/agents/allowlist/expected", + }), + expect.objectContaining({ + checkId: "policy/exec-approvals-allowlist-unexpected", + ocPath: "oc://exec-approvals.json/agents/buddy/allowlist/#0", + requirement: "oc://policy.jsonc/execApprovals/agents/allowlist/expected", + }), + ]), + ); + expect(JSON.stringify(result.findings)).not.toContain("secret-token"); + expect(JSON.stringify(result.findings)).not.toContain("deploy --prod"); + }); + + it("compares exec approval allowlist entries with argPattern", async () => { + const configPath = join(workspaceDir, "openclaw.jsonc"); + await fs.writeFile(configPath, "{}", "utf-8"); + await fs.writeFile( + join(workspaceDir, "policy.jsonc"), + JSON.stringify({ + execApprovals: { + agents: { + allowlist: { expected: [{ pattern: "deploy", argPattern: "^--prod$" }] }, + }, + }, + }), + "utf-8", + ); + await fs.writeFile( + join(workspaceDir, "exec-approvals.json"), + JSON.stringify({ + version: 1, + agents: { main: { allowlist: [{ pattern: "deploy" }] } }, + }), + "utf-8", + ); + + registerPolicyDoctorChecks(); + const result = await runDoctorLintChecks(ctx(configPath, cfgWithPolicy())); + + expect(result.findings).toEqual([ + expect.objectContaining({ + checkId: "policy/exec-approvals-allowlist-missing", + message: + "exec approvals allowlist is missing expected pattern 'deploy argPattern=^--prod$'.", + target: "oc://exec-approvals.json", + }), + expect.objectContaining({ + checkId: "policy/exec-approvals-allowlist-unexpected", + message: "exec approvals allowlist has unexpected pattern 'deploy'.", + ocPath: "oc://exec-approvals.json/agents/main/allowlist/#0", + }), + ]); + }); + + it("checks inherited default security for global exec approval agent rules", async () => { + const configPath = join(workspaceDir, "openclaw.jsonc"); + await fs.writeFile(configPath, "{}", "utf-8"); + await fs.writeFile( + join(workspaceDir, "policy.jsonc"), + JSON.stringify({ execApprovals: { agents: { allowSecurity: ["allowlist"] } } }), + "utf-8", + ); + await fs.writeFile( + join(workspaceDir, "exec-approvals.json"), + JSON.stringify({ version: 1, defaults: { security: "full" } }), + "utf-8", + ); + + registerPolicyDoctorChecks(); + const result = await runDoctorLintChecks(ctx(configPath, cfgWithPolicy())); + + expect(result.findings).toEqual([ + expect.objectContaining({ + checkId: "policy/exec-approvals-agent-security-unapproved", + ocPath: "oc://exec-approvals.json/defaults", + requirement: "oc://policy.jsonc/execApprovals/agents/allowSecurity", + }), + ]); + }); + + it("reports inherited autoAllowSkills when policy requires manual exec allowlists", async () => { + const configPath = join(workspaceDir, "openclaw.jsonc"); + await fs.writeFile(configPath, "{}", "utf-8"); + await fs.writeFile( + join(workspaceDir, "policy.jsonc"), + JSON.stringify({ execApprovals: { agents: { allowAutoAllowSkills: false } } }), + "utf-8", + ); + await fs.writeFile( + join(workspaceDir, "exec-approvals.json"), + JSON.stringify({ version: 1, defaults: { autoAllowSkills: true } }), + "utf-8", + ); + + registerPolicyDoctorChecks(); + const result = await runDoctorLintChecks(ctx(configPath, cfgWithPolicy())); + + expect(result.findings).toEqual([ + expect.objectContaining({ + checkId: "policy/exec-approvals-auto-allow-skills-enabled", + ocPath: "oc://exec-approvals.json/defaults", + requirement: "oc://policy.jsonc/execApprovals/agents/allowAutoAllowSkills", + }), + ]); + }); + + it("uses wildcard security for global exec approval agents that only add allowlist entries", async () => { + const configPath = join(workspaceDir, "openclaw.jsonc"); + await fs.writeFile(configPath, "{}", "utf-8"); + await fs.writeFile( + join(workspaceDir, "policy.jsonc"), + JSON.stringify({ execApprovals: { agents: { allowSecurity: ["deny"] } } }), + "utf-8", + ); + await fs.writeFile( + join(workspaceDir, "exec-approvals.json"), + JSON.stringify({ + version: 1, + defaults: { security: "full" }, + agents: { + "*": { security: "deny" }, + main: { allowlist: [{ pattern: "status" }] }, + }, + }), + "utf-8", + ); + + registerPolicyDoctorChecks(); + const result = await runDoctorLintChecks(ctx(configPath, cfgWithPolicy())); + + expect(result.findings).toEqual([]); + }); + + it("checks default-inherited global exec approval agents when explicit agents exist", async () => { + const configPath = join(workspaceDir, "openclaw.jsonc"); + await fs.writeFile(configPath, "{}", "utf-8"); + await fs.writeFile( + join(workspaceDir, "policy.jsonc"), + JSON.stringify({ execApprovals: { agents: { allowSecurity: ["allowlist"] } } }), + "utf-8", + ); + await fs.writeFile( + join(workspaceDir, "exec-approvals.json"), + JSON.stringify({ + version: 1, + defaults: { security: "full" }, + agents: { main: { security: "allowlist" } }, + }), + "utf-8", + ); + + registerPolicyDoctorChecks(); + const result = await runDoctorLintChecks(ctx(configPath, cfgWithPolicy())); + + expect(result.findings).toEqual([ + expect.objectContaining({ + checkId: "policy/exec-approvals-agent-security-unapproved", + ocPath: "oc://exec-approvals.json/defaults", + requirement: "oc://policy.jsonc/execApprovals/agents/allowSecurity", + }), + ]); + }); + + it("applies scoped exec approvals only to selected agents", async () => { + const configPath = join(workspaceDir, "openclaw.jsonc"); + await fs.writeFile(configPath, "{}", "utf-8"); + await fs.writeFile( + join(workspaceDir, "policy.jsonc"), + JSON.stringify({ + scopes: { + restricted: { + agentIds: ["sebby"], + execApprovals: { + agents: { + allowSecurity: ["allowlist"], + allowlist: { expected: ["deploy", "doctor"] }, + }, + }, + }, + }, + }), + "utf-8", + ); + await fs.writeFile( + join(workspaceDir, "exec-approvals.json"), + JSON.stringify({ + version: 1, + defaults: { security: "deny" }, + agents: { + sebby: { + security: "full", + allowlist: [{ pattern: "deploy" }, { pattern: "status" }], + }, + buddy: { + security: "full", + allowlist: [{ pattern: "unrelated" }], + }, + }, + }), + "utf-8", + ); + + registerPolicyDoctorChecks(); + const result = await runDoctorLintChecks(ctx(configPath, cfgWithPolicy())); + + expect(result.findings).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + checkId: "policy/exec-approvals-agent-security-unapproved", + ocPath: "oc://exec-approvals.json/agents/sebby", + requirement: "oc://policy.jsonc/scopes/restricted/execApprovals/agents/allowSecurity", + }), + expect.objectContaining({ + checkId: "policy/exec-approvals-allowlist-missing", + requirement: + "oc://policy.jsonc/scopes/restricted/execApprovals/agents/allowlist/expected", + }), + expect.objectContaining({ + checkId: "policy/exec-approvals-allowlist-unexpected", + ocPath: "oc://exec-approvals.json/agents/sebby/allowlist/#1", + requirement: + "oc://policy.jsonc/scopes/restricted/execApprovals/agents/allowlist/expected", + }), + ]), + ); + expect(result.findings).not.toEqual( + expect.arrayContaining([ + expect.objectContaining({ ocPath: expect.stringContaining("agents/buddy") }), + ]), + ); + }); + + it("does not inherit wildcard security when exact agent security is malformed", async () => { + const configPath = join(workspaceDir, "openclaw.jsonc"); + await fs.writeFile(configPath, "{}", "utf-8"); + await fs.writeFile( + join(workspaceDir, "policy.jsonc"), + JSON.stringify({ + scopes: { + restricted: { + agentIds: ["sebby"], + execApprovals: { agents: { allowSecurity: ["deny"] } }, + }, + }, + }), + "utf-8", + ); + await fs.writeFile( + join(workspaceDir, "exec-approvals.json"), + JSON.stringify({ + version: 1, + defaults: { security: "deny" }, + agents: { + "*": { security: "full" }, + sebby: { security: "bogus" }, + }, + }), + "utf-8", + ); + + registerPolicyDoctorChecks(); + const result = await runDoctorLintChecks(ctx(configPath, cfgWithPolicy())); + + expect(result.findings).toEqual([]); + }); + + it("uses runtime defaults for malformed exec approval mode fields", async () => { + const configPath = join(workspaceDir, "openclaw.jsonc"); + await fs.writeFile(configPath, "{}", "utf-8"); + await fs.writeFile( + join(workspaceDir, "policy.jsonc"), + JSON.stringify({ execApprovals: { defaults: { allowSecurity: ["full"] } } }), + "utf-8", + ); + await fs.writeFile( + join(workspaceDir, "exec-approvals.json"), + JSON.stringify({ version: 1, defaults: { security: "bogus" } }), + "utf-8", + ); + + registerPolicyDoctorChecks(); + const result = await runDoctorLintChecks(ctx(configPath, cfgWithPolicy())); + + expect(result.findings).toEqual([]); + }); + + it("requires exec approvals artifacts for scoped exec approval rules", async () => { + const configPath = join(workspaceDir, "openclaw.jsonc"); + await fs.writeFile(configPath, "{}", "utf-8"); + await fs.writeFile( + join(workspaceDir, "policy.jsonc"), + JSON.stringify({ + scopes: { + restricted: { + agentIds: ["sebby", "buddy"], + execApprovals: { + agents: { allowSecurity: ["allowlist"] }, + }, + }, + }, + }), + "utf-8", + ); + + registerPolicyDoctorChecks(); + const result = await runDoctorLintChecks(ctx(configPath, cfgWithPolicy())); + + expect(result.findings).toEqual([ + expect.objectContaining({ + checkId: "policy/exec-approvals-missing", + target: "oc://exec-approvals.json", + requirement: "oc://policy.jsonc/scopes/restricted/execApprovals", + }), + ]); + }); + + it("rejects invalid exec approvals artifacts for scoped exec approval rules", async () => { + const configPath = join(workspaceDir, "openclaw.jsonc"); + await fs.writeFile(configPath, "{}", "utf-8"); + await fs.writeFile( + join(workspaceDir, "policy.jsonc"), + JSON.stringify({ + scopes: { + restricted: { + agentIds: ["sebby", "buddy"], + execApprovals: { + agents: { allowSecurity: ["allowlist"] }, + }, + }, + }, + }), + "utf-8", + ); + await fs.writeFile(join(workspaceDir, "exec-approvals.json"), "{", "utf-8"); + + registerPolicyDoctorChecks(); + const result = await runDoctorLintChecks(ctx(configPath, cfgWithPolicy())); + + expect(result.findings).toEqual([ + expect.objectContaining({ + checkId: "policy/exec-approvals-invalid", + target: "oc://exec-approvals.json", + requirement: "oc://policy.jsonc/scopes/restricted/execApprovals", + }), + ]); + }); + + it("does not require exec approvals artifacts for requireFile false alone", async () => { + const configPath = join(workspaceDir, "openclaw.jsonc"); + await fs.writeFile(configPath, "{}", "utf-8"); + await fs.writeFile( + join(workspaceDir, "policy.jsonc"), + JSON.stringify({ execApprovals: { requireFile: false } }), + "utf-8", + ); + + registerPolicyDoctorChecks(); + const result = await runDoctorLintChecks(ctx(configPath, cfgWithPolicy())); + + expect(result.findings).toEqual([]); + }); + + it("applies wildcard exec approvals to scoped agents", async () => { + const configPath = join(workspaceDir, "openclaw.jsonc"); + await fs.writeFile(configPath, "{}", "utf-8"); + await fs.writeFile( + join(workspaceDir, "policy.jsonc"), + JSON.stringify({ + scopes: { + restricted: { + agentIds: ["sebby"], + execApprovals: { + agents: { + allowSecurity: ["allowlist"], + allowlist: { expected: ["deploy"] }, + }, + }, + }, + }, + }), + "utf-8", + ); + await fs.writeFile( + join(workspaceDir, "exec-approvals.json"), + JSON.stringify({ + version: 1, + defaults: { security: "deny" }, + agents: { + "*": { + security: "full", + allowlist: [{ pattern: "status" }], + }, + sebby: { + allowlist: [{ pattern: "deploy" }], + }, + }, + }), + "utf-8", + ); + + registerPolicyDoctorChecks(); + const result = await runDoctorLintChecks(ctx(configPath, cfgWithPolicy())); + + expect(result.findings).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + checkId: "policy/exec-approvals-agent-security-unapproved", + ocPath: 'oc://exec-approvals.json/agents/"*"', + requirement: "oc://policy.jsonc/scopes/restricted/execApprovals/agents/allowSecurity", + }), + expect.objectContaining({ + checkId: "policy/exec-approvals-allowlist-unexpected", + ocPath: 'oc://exec-approvals.json/agents/"*"/allowlist/#0', + requirement: + "oc://policy.jsonc/scopes/restricted/execApprovals/agents/allowlist/expected", + }), + ]), + ); + }); + + it("applies wildcard autoAllowSkills posture to scoped exec approvals", async () => { + const configPath = join(workspaceDir, "openclaw.jsonc"); + await fs.writeFile(configPath, "{}", "utf-8"); + await fs.writeFile( + join(workspaceDir, "policy.jsonc"), + JSON.stringify({ + scopes: { + restricted: { + agentIds: ["sebby"], + execApprovals: { + agents: { allowAutoAllowSkills: false }, + }, + }, + }, + }), + "utf-8", + ); + await fs.writeFile( + join(workspaceDir, "exec-approvals.json"), + JSON.stringify({ + version: 1, + agents: { + "*": { autoAllowSkills: true }, + buddy: { autoAllowSkills: true }, + }, + }), + "utf-8", + ); + + registerPolicyDoctorChecks(); + const result = await runDoctorLintChecks(ctx(configPath, cfgWithPolicy())); + + expect(result.findings).toEqual([ + expect.objectContaining({ + checkId: "policy/exec-approvals-auto-allow-skills-enabled", + ocPath: 'oc://exec-approvals.json/agents/"*"', + requirement: + "oc://policy.jsonc/scopes/restricted/execApprovals/agents/allowAutoAllowSkills", + }), + ]); + expect(result.findings).not.toEqual( + expect.arrayContaining([ + expect.objectContaining({ ocPath: expect.stringContaining("agents/buddy") }), + ]), + ); + }); + + it("applies inherited default autoAllowSkills posture to scoped exec approvals", async () => { + const configPath = join(workspaceDir, "openclaw.jsonc"); + await fs.writeFile(configPath, "{}", "utf-8"); + await fs.writeFile( + join(workspaceDir, "policy.jsonc"), + JSON.stringify({ + scopes: { + restricted: { + agentIds: ["sebby"], + execApprovals: { + agents: { allowAutoAllowSkills: false }, + }, + }, + }, + }), + "utf-8", + ); + await fs.writeFile( + join(workspaceDir, "exec-approvals.json"), + JSON.stringify({ + version: 1, + defaults: { autoAllowSkills: true }, + agents: { + sebby: { allowlist: [{ pattern: "deploy" }] }, + }, + }), + "utf-8", + ); + + registerPolicyDoctorChecks(); + const result = await runDoctorLintChecks(ctx(configPath, cfgWithPolicy())); + + expect(result.findings).toEqual([ + expect.objectContaining({ + checkId: "policy/exec-approvals-auto-allow-skills-enabled", + ocPath: "oc://exec-approvals.json/defaults", + requirement: + "oc://policy.jsonc/scopes/restricted/execApprovals/agents/allowAutoAllowSkills", + }), + ]); + }); + + it("evaluates legacy default exec approvals for scoped main policies", async () => { + const configPath = join(workspaceDir, "openclaw.jsonc"); + await fs.writeFile(configPath, "{}", "utf-8"); + await fs.writeFile( + join(workspaceDir, "policy.jsonc"), + JSON.stringify({ + scopes: { + restricted: { + agentIds: ["main"], + execApprovals: { + agents: { + allowSecurity: ["deny"], + allowlist: { expected: ["legacy", "doctor"] }, + }, + }, + }, + }, + }), + "utf-8", + ); + await fs.writeFile( + join(workspaceDir, "exec-approvals.json"), + JSON.stringify({ + version: 1, + defaults: { security: "deny" }, + agents: { + default: { + security: "allowlist", + allowlist: ["legacy", { pattern: "doctor" }], + }, + }, + }), + "utf-8", + ); + + registerPolicyDoctorChecks(); + const result = await runDoctorLintChecks(ctx(configPath, cfgWithPolicy())); + + expect(result.findings).toEqual([ + expect.objectContaining({ + checkId: "policy/exec-approvals-agent-security-unapproved", + ocPath: "oc://exec-approvals.json/agents/default", + target: "oc://exec-approvals.json/agents/default", + requirement: "oc://policy.jsonc/scopes/restricted/execApprovals/agents/allowSecurity", + }), + ]); + }); + + it("uses OPENCLAW_HOME for the default exec approvals artifact path", async () => { + const configPath = join(workspaceDir, "openclaw.jsonc"); + const openclawHome = join(workspaceDir, "home"); + const approvalsDir = join(openclawHome, ".openclaw"); + const previousOpenClawHome = process.env.OPENCLAW_HOME; + await fs.mkdir(approvalsDir, { recursive: true }); + await fs.writeFile(configPath, "{}", "utf-8"); + await fs.writeFile( + join(workspaceDir, "policy.jsonc"), + JSON.stringify({ execApprovals: { defaults: { allowSecurity: ["deny"] } } }), + "utf-8", + ); + await fs.writeFile( + join(approvalsDir, "exec-approvals.json"), + JSON.stringify({ version: 1, defaults: { security: "full" } }), + "utf-8", + ); + + process.env.OPENCLAW_HOME = openclawHome; + try { + registerPolicyDoctorChecks(); + const result = await runDoctorLintChecks(ctx(configPath, cfgWithPolicy())); + + expect(result.findings).toEqual([ + expect.objectContaining({ + checkId: "policy/exec-approvals-default-security-unapproved", + ocPath: "oc://exec-approvals.json/defaults", + }), + ]); + } finally { + if (previousOpenClawHome === undefined) { + delete process.env.OPENCLAW_HOME; + } else { + process.env.OPENCLAW_HOME = previousOpenClawHome; + } + } + }); + + it("uses OPENCLAW_STATE_DIR for the exec approvals artifact path", async () => { + const configPath = join(workspaceDir, "openclaw.jsonc"); + const stateDir = join(workspaceDir, "state"); + await fs.mkdir(stateDir, { recursive: true }); + await fs.writeFile(configPath, "{}", "utf-8"); + await fs.writeFile( + join(workspaceDir, "policy.jsonc"), + JSON.stringify({ execApprovals: { defaults: { allowSecurity: ["deny"] } } }), + "utf-8", + ); + await fs.writeFile( + join(workspaceDir, "exec-approvals.json"), + JSON.stringify({ version: 1, defaults: { security: "deny" } }), + "utf-8", + ); + await fs.writeFile( + join(stateDir, "exec-approvals.json"), + JSON.stringify({ version: 1, defaults: { security: "full" } }), + "utf-8", + ); + + process.env.OPENCLAW_STATE_DIR = stateDir; + + registerPolicyDoctorChecks(); + const result = await runDoctorLintChecks(ctx(configPath, cfgWithPolicy())); + + expect(result.findings).toEqual([ + expect.objectContaining({ + checkId: "policy/exec-approvals-default-security-unapproved", + ocPath: "oc://exec-approvals.json/defaults", + }), + ]); + }); + + it("rejects unsupported exec approval allowlist requirement keys", async () => { + const configPath = join(workspaceDir, "openclaw.jsonc"); + await fs.writeFile(configPath, "{}", "utf-8"); + await fs.writeFile( + join(workspaceDir, "policy.jsonc"), + JSON.stringify({ + execApprovals: { + agents: { + allowlist: { + expected: [{ pattern: "deploy", argpattern: "^--prod$" }], + }, + }, + }, + }), + "utf-8", + ); + + registerPolicyDoctorChecks(); + const result = await runDoctorLintChecks(ctx(configPath, cfgWithPolicy())); + + expect(result.findings).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + checkId: "policy/policy-jsonc-invalid", + target: "oc://policy.jsonc/execApprovals/agents/allowlist/expected/#0", + }), + ]), + ); + }); + + it("targets the missing exec approvals artifact when required", async () => { + const configPath = join(workspaceDir, "openclaw.jsonc"); + await fs.writeFile(configPath, "{}", "utf-8"); + await fs.writeFile( + join(workspaceDir, "policy.jsonc"), + JSON.stringify({ execApprovals: { requireFile: true } }), + "utf-8", + ); + + registerPolicyDoctorChecks(); + const result = await runDoctorLintChecks(ctx(configPath, cfgWithPolicy())); + + expect(result.findings).toEqual([ + expect.objectContaining({ + checkId: "policy/exec-approvals-missing", + target: "oc://exec-approvals.json", + requirement: "oc://policy.jsonc/execApprovals/requireFile", + }), + ]); + }); + + it("rejects required versionless exec approvals artifacts", async () => { + const configPath = join(workspaceDir, "openclaw.jsonc"); + await fs.writeFile(configPath, "{}", "utf-8"); + await fs.writeFile( + join(workspaceDir, "policy.jsonc"), + JSON.stringify({ + execApprovals: { requireFile: true, defaults: { allowSecurity: ["deny"] } }, + }), + "utf-8", + ); + await fs.writeFile( + join(workspaceDir, "exec-approvals.json"), + JSON.stringify({ defaults: { security: "deny" } }), + "utf-8", + ); + + registerPolicyDoctorChecks(); + const result = await runDoctorLintChecks(ctx(configPath, cfgWithPolicy())); + + expect(result.findings).toEqual([ + expect.objectContaining({ + checkId: "policy/exec-approvals-invalid", + requirement: "oc://policy.jsonc/execApprovals", + }), + ]); + }); + it("reports malformed secrets policy values before applying secrets checks", async () => { const configPath = join(workspaceDir, "openclaw.jsonc"); await fs.writeFile(configPath, "{}", "utf-8"); diff --git a/extensions/policy/src/doctor/register.ts b/extensions/policy/src/doctor/register.ts index 08d37c33b795..14ef33a28ed2 100644 --- a/extensions/policy/src/doctor/register.ts +++ b/extensions/policy/src/doctor/register.ts @@ -1,4 +1,5 @@ // Policy plugin module implements register behavior. +import os from "node:os"; import { basename, isAbsolute, resolve } from "node:path"; import JSON5 from "json5"; import { @@ -18,6 +19,7 @@ import { type PolicyAgentWorkspaceEvidence, type PolicyDataHandlingEvidence, type PolicyEvidence, + type PolicyExecApprovalEvidence, type PolicyIngressEvidence, type PolicySandboxPostureEvidence, type PolicyToolPostureEvidence, @@ -85,6 +87,13 @@ const CHECK_IDS = { policySecretsInsecureProvider: "policy/secrets-insecure-provider", policyAuthProfileInvalidMetadata: "policy/auth-profile-invalid-metadata", policyAuthProfileUnapprovedMode: "policy/auth-profile-unapproved-mode", + policyExecApprovalsMissing: "policy/exec-approvals-missing", + policyExecApprovalsInvalid: "policy/exec-approvals-invalid", + policyExecApprovalsDefaultSecurityUnapproved: "policy/exec-approvals-default-security-unapproved", + policyExecApprovalsAgentSecurityUnapproved: "policy/exec-approvals-agent-security-unapproved", + policyExecApprovalsAutoAllowSkillsEnabled: "policy/exec-approvals-auto-allow-skills-enabled", + policyExecApprovalsAllowlistMissing: "policy/exec-approvals-allowlist-missing", + policyExecApprovalsAllowlistUnexpected: "policy/exec-approvals-allowlist-unexpected", policyMissingToolOwner: "policy/tools-missing-owner", policyMissingToolRisk: "policy/tools-missing-risk-level", policyMissingToolSensitivity: "policy/tools-missing-sensitivity-token", @@ -144,6 +153,13 @@ export const POLICY_CHECK_IDS = [ CHECK_IDS.policySecretsInsecureProvider, CHECK_IDS.policyAuthProfileInvalidMetadata, CHECK_IDS.policyAuthProfileUnapprovedMode, + CHECK_IDS.policyExecApprovalsMissing, + CHECK_IDS.policyExecApprovalsInvalid, + CHECK_IDS.policyExecApprovalsDefaultSecurityUnapproved, + CHECK_IDS.policyExecApprovalsAgentSecurityUnapproved, + CHECK_IDS.policyExecApprovalsAutoAllowSkillsEnabled, + CHECK_IDS.policyExecApprovalsAllowlistMissing, + CHECK_IDS.policyExecApprovalsAllowlistUnexpected, CHECK_IDS.policyMissingToolRisk, CHECK_IDS.policyUnknownToolRisk, CHECK_IDS.policyMissingToolSensitivity, @@ -500,6 +516,49 @@ export const POLICY_RULE_METADATA = [ valueType: "boolean", checkIds: [CHECK_IDS.policySecretsInsecureProvider], }, + + { + policyPath: ["execApprovals", "requireFile"], + strictness: "requires-true", + valueType: "boolean", + checkIds: [CHECK_IDS.policyExecApprovalsMissing], + }, + { + policyPath: ["execApprovals", "defaults", "allowSecurity"], + strictness: "allowlist-subset", + valueType: "string-list", + checkIds: [CHECK_IDS.policyExecApprovalsDefaultSecurityUnapproved], + emptyList: "disabled", + allowedValues: ["deny", "allowlist", "full"], + }, + { + policyPath: ["execApprovals", "agents", "allowSecurity"], + strictness: "allowlist-subset", + valueType: "string-list", + checkIds: [CHECK_IDS.policyExecApprovalsAgentSecurityUnapproved], + emptyList: "disabled", + allowedValues: ["deny", "allowlist", "full"], + scopeSelectors: ["agentIds"], + }, + { + policyPath: ["execApprovals", "agents", "allowAutoAllowSkills"], + strictness: "requires-false", + valueType: "boolean", + checkIds: [CHECK_IDS.policyExecApprovalsAutoAllowSkillsEnabled], + scopeSelectors: ["agentIds"], + }, + { + policyPath: ["execApprovals", "agents", "allowlist", "expected"], + strictness: "exact-list", + valueType: "string-list", + checkIds: [ + CHECK_IDS.policyExecApprovalsAllowlistMissing, + CHECK_IDS.policyExecApprovalsAllowlistUnexpected, + ], + emptyList: "meaningful", + caseSensitive: true, + scopeSelectors: ["agentIds"], + }, { policyPath: ["auth", "profiles", "requireMetadata"], strictness: "denylist-superset", @@ -529,6 +588,7 @@ const SUPPORTED_POLICY_SECTIONS = [ "agents", "channels", "dataHandling", + "execApprovals", "gateway", "ingress", "mcp", @@ -565,6 +625,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_EXEC_APPROVAL_SECURITY = ["deny", "allowlist", "full"] as const; const SUPPORTED_SANDBOX_MODES = ["off", "non-main", "all"] as const; let registered = false; const policyEvaluationCache = new WeakMap>(); @@ -641,6 +702,13 @@ export function registerPolicyDoctorChecks(host?: PolicyDoctorRegistrationHost): registerHealthCheck(policySecretsInsecureProviderCheck); registerHealthCheck(policyAuthProfileInvalidMetadataCheck); registerHealthCheck(policyAuthProfileUnapprovedModeCheck); + registerHealthCheck(policyExecApprovalsMissingCheck); + registerHealthCheck(policyExecApprovalsInvalidCheck); + registerHealthCheck(policyExecApprovalsDefaultSecurityUnapprovedCheck); + registerHealthCheck(policyExecApprovalsAgentSecurityUnapprovedCheck); + registerHealthCheck(policyExecApprovalsAutoAllowSkillsEnabledCheck); + registerHealthCheck(policyExecApprovalsAllowlistMissingCheck); + registerHealthCheck(policyExecApprovalsAllowlistUnexpectedCheck); registerHealthCheck(policyToolsMissingRiskCheck); registerHealthCheck(policyToolsUnknownRiskCheck); registerHealthCheck(policyToolsMissingSensitivityCheck); @@ -1240,6 +1308,92 @@ const policyAuthProfileUnapprovedModeCheck: HealthCheck = { }, }; +const policyExecApprovalsMissingCheck: HealthCheck = { + id: CHECK_IDS.policyExecApprovalsMissing, + kind: "plugin", + description: "Required exec approvals artifact is present for policy conformance.", + source: "policy", + async detect(ctx) { + return findingsForCheck(await evaluatePolicy(ctx), CHECK_IDS.policyExecApprovalsMissing); + }, +}; + +const policyExecApprovalsInvalidCheck: HealthCheck = { + id: CHECK_IDS.policyExecApprovalsInvalid, + kind: "plugin", + description: "Exec approvals artifact parses before policy checks run.", + source: "policy", + async detect(ctx) { + return findingsForCheck(await evaluatePolicy(ctx), CHECK_IDS.policyExecApprovalsInvalid); + }, +}; + +const policyExecApprovalsDefaultSecurityUnapprovedCheck: HealthCheck = { + id: CHECK_IDS.policyExecApprovalsDefaultSecurityUnapproved, + kind: "plugin", + description: "Exec approval defaults use a policy-approved security mode.", + source: "policy", + async detect(ctx) { + return findingsForCheck( + await evaluatePolicy(ctx), + CHECK_IDS.policyExecApprovalsDefaultSecurityUnapproved, + ); + }, +}; + +const policyExecApprovalsAgentSecurityUnapprovedCheck: HealthCheck = { + id: CHECK_IDS.policyExecApprovalsAgentSecurityUnapproved, + kind: "plugin", + description: "Per-agent exec approval settings use policy-approved security modes.", + source: "policy", + async detect(ctx) { + return findingsForCheck( + await evaluatePolicy(ctx), + CHECK_IDS.policyExecApprovalsAgentSecurityUnapproved, + ); + }, +}; + +const policyExecApprovalsAutoAllowSkillsEnabledCheck: HealthCheck = { + id: CHECK_IDS.policyExecApprovalsAutoAllowSkillsEnabled, + kind: "plugin", + description: + "Exec approval agents do not implicitly auto-allow skill CLIs unless policy allows it.", + source: "policy", + async detect(ctx) { + return findingsForCheck( + await evaluatePolicy(ctx), + CHECK_IDS.policyExecApprovalsAutoAllowSkillsEnabled, + ); + }, +}; + +const policyExecApprovalsAllowlistMissingCheck: HealthCheck = { + id: CHECK_IDS.policyExecApprovalsAllowlistMissing, + kind: "plugin", + description: "Exec approval allowlists include every pattern required by policy.", + source: "policy", + async detect(ctx) { + return findingsForCheck( + await evaluatePolicy(ctx), + CHECK_IDS.policyExecApprovalsAllowlistMissing, + ); + }, +}; + +const policyExecApprovalsAllowlistUnexpectedCheck: HealthCheck = { + id: CHECK_IDS.policyExecApprovalsAllowlistUnexpected, + kind: "plugin", + description: "Exec approval allowlists do not contain patterns outside policy.", + source: "policy", + async detect(ctx) { + return findingsForCheck( + await evaluatePolicy(ctx), + CHECK_IDS.policyExecApprovalsAllowlistUnexpected, + ); + }, +}; + const policyToolsMissingRiskCheck: HealthCheck = { id: CHECK_IDS.policyMissingToolRisk, kind: "plugin", @@ -1301,6 +1455,7 @@ async function evaluatePolicyUncached(ctx: HealthCheckContext): Promise 0) { const toolsFile = await readWorkspaceFile(ctx, "TOOLS.md"); evidence = await collectPolicyEvidence(ctx.cfg as Record, { @@ -1404,6 +1561,8 @@ async function evaluatePolicyUncached(ctx: HealthCheckContext): Promise, { @@ -1415,6 +1574,8 @@ async function evaluatePolicyUncached(ctx: HealthCheckContext): Promise execApprovalAllowlistRequirement(entry) === undefined, + ); + if (invalidIndex < 0) { + return undefined; + } + return policyShapeFinding( + params.policyPath, + `oc://${params.policyDocName}/${params.target}/#${invalidIndex}`, + `${params.policyPath} ${params.property}[${invalidIndex}] must be a non-empty string or an object with pattern and optional argPattern strings.`, + `Use entries such as "deploy" or { "pattern": "deploy", "argPattern": "^--prod$" }.`, + ); +} + function policyStringArrayPropertyShapeFinding( value: unknown, params: { @@ -3991,6 +4355,502 @@ function agentScopedWorkspaceFindings( return findings; } +function execApprovalsFindings( + policy: unknown, + policyPath: string, + policyDocName: string, + evidence: PolicyEvidence, + file: + | { readonly raw: string; readonly displayName: string; readonly ocDocName: string } + | null + | undefined, + displayName: string, +): readonly HealthFinding[] { + if (!isRecord(policy)) { + return []; + } + const findings: HealthFinding[] = []; + const entries = evidence.execApprovals ?? []; + const defaults = entries.find((entry) => entry.kind === "defaults"); + const defaultSecurity = defaults?.security ?? "full"; + + if (isRecord(policy.execApprovals)) { + const shapeFinding = execApprovalsPolicyShapeFinding(policy.execApprovals, { + policyDocName, + policyPath, + }); + if (shapeFinding !== undefined) { + return [shapeFinding]; + } + const fileFindings = execApprovalsFileFindings(policy.execApprovals, { + policyDocName, + file, + displayName, + requirementBase: "execApprovals", + }); + findings.push(...fileFindings); + if (fileFindings.length > 0) { + return findings; + } + findings.push( + ...execApprovalsRuleFindings(policy.execApprovals, { + entries, + defaultSecurity, + defaults, + displayName, + fileDisplayName: file?.displayName, + policyDocName, + requirementBase: "execApprovals", + }), + ); + } + + if (!hasValidScopedPolicy(policy, policyPath, policyDocName)) { + return findings; + } + const scopedFileFindingScopes = new Set(); + for (const target of agentScopedPolicyTargets(policy)) { + if (!isRecord(target.overlay.execApprovals)) { + continue; + } + const requirementBase = `scopes/${ocPathSegment(target.scopeName)}/execApprovals`; + const shapeFinding = execApprovalsPolicyShapeFinding(target.overlay.execApprovals, { + policyDocName, + policyPath, + targetPrefix: requirementBase, + propertyPrefix: `scopes.${target.scopeName}.execApprovals`, + allowDefaults: false, + }); + if (shapeFinding !== undefined) { + findings.push(shapeFinding); + continue; + } + const fileFindings = execApprovalsFileFindings(target.overlay.execApprovals, { + policyDocName, + file, + displayName, + requirementBase, + }); + if (fileFindings.length > 0) { + if (!scopedFileFindingScopes.has(target.scopeName)) { + findings.push(...fileFindings); + scopedFileFindingScopes.add(target.scopeName); + } + continue; + } + findings.push( + ...execApprovalsRuleFindings(target.overlay.execApprovals, { + entries, + defaultSecurity, + defaults, + displayName, + fileDisplayName: file?.displayName, + policyDocName, + requirementBase, + targetAgentId: target.agentId, + }), + ); + } + return findings; +} + +function execApprovalsFileFindings( + execApprovalsPolicy: Record, + params: { + readonly policyDocName: string; + readonly file: + | { readonly raw: string; readonly displayName: string; readonly ocDocName: string } + | null + | undefined; + readonly displayName: string; + readonly requirementBase: string; + }, +): readonly HealthFinding[] { + const requireFile = readPolicyBoolean(execApprovalsPolicy, ["requireFile"]) === true; + const needsArtifactEvidence = + requireFile || execApprovalsPolicyNeedsArtifactEvidence(execApprovalsPolicy); + if (needsArtifactEvidence && params.file === null) { + return [ + { + checkId: CHECK_IDS.policyExecApprovalsMissing, + severity: "error", + message: "exec-approvals.json evidence is required by policy but was not found.", + source: "policy", + path: params.displayName, + target: "oc://exec-approvals.json", + requirement: `oc://${params.policyDocName}/${ + requireFile ? `${params.requirementBase}/requireFile` : params.requirementBase + }`, + fixHint: "Restore the approved exec approvals artifact or update policy after review.", + }, + ]; + } + if (params.file === null || params.file === undefined) { + return []; + } + const parsed = parseExecApprovalsFile(params.file.raw); + if (parsed.ok || !needsArtifactEvidence) { + return []; + } + return [ + { + checkId: CHECK_IDS.policyExecApprovalsInvalid, + severity: "error", + message: `${params.file.displayName} could not be parsed: ${parsed.message}`, + source: "policy", + path: params.file.displayName, + target: `oc://${params.file.ocDocName}`, + requirement: `oc://${params.policyDocName}/${params.requirementBase}`, + fixHint: "Fix exec-approvals.json so it is valid JSON.", + }, + ]; +} + +function execApprovalsPolicyNeedsArtifactEvidence( + execApprovalsPolicy: Record, +): boolean { + return isRecord(execApprovalsPolicy.defaults) || isRecord(execApprovalsPolicy.agents); +} + +function execApprovalsRuleFindings( + execApprovalsPolicy: Record, + params: { + readonly entries: readonly PolicyExecApprovalEvidence[]; + readonly defaultSecurity: string; + readonly defaults?: PolicyExecApprovalEvidence; + readonly displayName: string; + readonly fileDisplayName?: string; + readonly policyDocName: string; + readonly requirementBase: string; + readonly targetAgentId?: string; + }, +): readonly HealthFinding[] { + const findings: HealthFinding[] = []; + const allowedDefaults = new Set( + readStringList(execApprovalsPolicy, ["defaults", "allowSecurity"]), + ); + if ( + params.targetAgentId === undefined && + allowedDefaults.size > 0 && + !allowedDefaults.has(params.defaultSecurity.toLowerCase()) + ) { + findings.push( + execApprovalFinding(params.defaults, { + checkId: CHECK_IDS.policyExecApprovalsDefaultSecurityUnapproved, + message: `exec approvals defaults use unapproved security mode '${params.defaultSecurity}'.`, + requirement: `oc://${params.policyDocName}/${params.requirementBase}/defaults/allowSecurity`, + fixHint: "Set defaults.security to an approved mode or update policy after review.", + }), + ); + } + + const allowedAgents = new Set(readStringList(execApprovalsPolicy, ["agents", "allowSecurity"])); + if (allowedAgents.size > 0) { + const agentEntries = + params.targetAgentId === undefined + ? globalExecApprovalAgentSecurityEntries(params.entries, params.defaults) + : [ + effectiveExecApprovalAgentSecurityEntry(params.entries, params.targetAgentId) ?? + params.defaults ?? + syntheticExecApprovalAgentEntry(params.targetAgentId), + ]; + for (const entry of agentEntries) { + const security = entry.security ?? params.defaultSecurity; + if (allowedAgents.has(security.toLowerCase())) { + continue; + } + findings.push( + execApprovalFinding(entry, { + checkId: CHECK_IDS.policyExecApprovalsAgentSecurityUnapproved, + message: `exec approvals agent '${entry.agentId ?? params.targetAgentId ?? "inherited defaults"}' uses unapproved security mode '${security}'.`, + requirement: `oc://${params.policyDocName}/${params.requirementBase}/agents/allowSecurity`, + fixHint: + "Set the agent approval security mode to an approved value or update policy after review.", + }), + ); + } + } + + const allowAutoAllowSkills = readPolicyBoolean(execApprovalsPolicy, [ + "agents", + "allowAutoAllowSkills", + ]); + if (allowAutoAllowSkills === false) { + const autoAllowEntries = + params.targetAgentId === undefined + ? globalExecApprovalAgentAutoAllowSkillsEntries(params.entries, params.defaults) + : [ + effectiveExecApprovalAgentAutoAllowSkillsEntry(params.entries, params.targetAgentId) ?? + params.defaults ?? + syntheticExecApprovalAgentEntry(params.targetAgentId), + ]; + for (const entry of autoAllowEntries) { + if (entry.autoAllowSkills !== true) { + continue; + } + findings.push( + execApprovalFinding(entry, { + checkId: CHECK_IDS.policyExecApprovalsAutoAllowSkillsEnabled, + message: `exec approvals agent '${entry.agentId ?? params.targetAgentId ?? "inherited defaults"}' enables autoAllowSkills outside policy.`, + requirement: `oc://${params.policyDocName}/${params.requirementBase}/agents/allowAutoAllowSkills`, + fixHint: + "Set autoAllowSkills to false or update policy after reviewing implicit skill CLI trust.", + }), + ); + } + } + + const expected = readExecApprovalAllowlistRequirements(execApprovalsPolicy, [ + "agents", + "allowlist", + "expected", + ]); + if (expected !== undefined) { + const expectedSet = new Set(expected.map((entry) => entry.key)); + const actualEntries = execApprovalAllowlistEntries(params.entries, params.targetAgentId).filter( + (entry) => entry.pattern !== undefined, + ); + const actual = actualEntries + .map((entry) => + execApprovalAllowlistRequirementKey(entry.pattern as string, entry.argPattern), + ) + .toSorted(); + const actualSet = new Set(actual); + for (const entry of expected.toSorted((a, b) => a.key.localeCompare(b.key))) { + if (!actualSet.has(entry.key)) { + const requirement = `oc://${params.policyDocName}/${params.requirementBase}/agents/allowlist/expected`; + const target = execApprovalAllowlistMissingTarget(params.targetAgentId); + findings.push({ + checkId: CHECK_IDS.policyExecApprovalsAllowlistMissing, + severity: "error", + message: `exec approvals allowlist is missing expected pattern '${formatExecApprovalAllowlistRequirement(entry)}'.`, + source: "policy", + path: params.fileDisplayName ?? params.displayName, + target, + requirement, + fixHint: "Add the expected approval pattern or update policy after review.", + }); + } + } + for (const key of actualSet) { + if (expectedSet.has(key)) { + continue; + } + const entry = actualEntries.find( + (candidate) => + candidate.pattern !== undefined && + execApprovalAllowlistRequirementKey(candidate.pattern, candidate.argPattern) === key, + ); + findings.push( + execApprovalFinding(entry, { + checkId: CHECK_IDS.policyExecApprovalsAllowlistUnexpected, + message: `exec approvals allowlist has unexpected pattern '${formatExecApprovalAllowlistEntry(entry)}'.`, + requirement: `oc://${params.policyDocName}/${params.requirementBase}/agents/allowlist/expected`, + fixHint: "Remove the unexpected approval pattern or update policy after review.", + }), + ); + } + } + return findings; +} + +function globalExecApprovalAgentSecurityEntries( + entries: readonly PolicyExecApprovalEvidence[], + defaults: PolicyExecApprovalEvidence | undefined, +): readonly PolicyExecApprovalEvidence[] { + const agentEntries = entries.filter((candidate) => candidate.kind === "agent"); + const wildcard = agentEntries.find((entry) => entry.agentId === "*"); + const securityEntries = agentEntries.filter( + (entry) => + entry.agentId === "*" || entry.security !== undefined || entry.securityConfigured === true, + ); + return wildcard === undefined + ? [...securityEntries, defaults ?? syntheticExecApprovalAgentEntry("*")] + : securityEntries; +} + +function globalExecApprovalAgentAutoAllowSkillsEntries( + entries: readonly PolicyExecApprovalEvidence[], + defaults: PolicyExecApprovalEvidence | undefined, +): readonly PolicyExecApprovalEvidence[] { + const agentEntries = entries.filter((candidate) => candidate.kind === "agent"); + const wildcard = agentEntries.find((entry) => entry.agentId === "*"); + const explicitEntries = agentEntries.filter((entry) => entry.autoAllowSkills !== undefined); + return wildcard?.autoAllowSkills === undefined + ? [...explicitEntries, defaults ?? syntheticExecApprovalAgentEntry("*")] + : explicitEntries; +} + +type ExecApprovalAllowlistRequirement = { + readonly key: string; + readonly pattern: string; + readonly argPattern?: string; +}; + +function readExecApprovalAllowlistRequirements( + policy: unknown, + path: readonly string[], +): readonly ExecApprovalAllowlistRequirement[] | undefined { + let current: unknown = policy; + for (const part of path) { + if (!isRecord(current)) { + return undefined; + } + current = current[part]; + } + if (!Array.isArray(current)) { + return undefined; + } + const entries = current.map(execApprovalAllowlistRequirement); + return entries.every((entry): entry is ExecApprovalAllowlistRequirement => entry !== undefined) + ? entries + : undefined; +} + +function execApprovalAllowlistRequirement( + value: unknown, +): ExecApprovalAllowlistRequirement | undefined { + if (typeof value === "string") { + const pattern = value.trim(); + return pattern === "" ? undefined : execApprovalAllowlistRequirementFromParts(pattern); + } + if (!isRecord(value)) { + return undefined; + } + if (unsupportedPolicyKey(value, ["argPattern", "pattern"]) !== undefined) { + return undefined; + } + const pattern = typeof value.pattern === "string" ? value.pattern.trim() : ""; + if (pattern === "") { + return undefined; + } + const argPattern = typeof value.argPattern === "string" ? value.argPattern.trim() : undefined; + if (value.argPattern !== undefined && argPattern === undefined) { + return undefined; + } + return execApprovalAllowlistRequirementFromParts( + pattern, + argPattern === "" ? undefined : argPattern, + ); +} + +function execApprovalAllowlistRequirementFromParts( + pattern: string, + argPattern?: string, +): ExecApprovalAllowlistRequirement { + return { + key: execApprovalAllowlistRequirementKey(pattern, argPattern), + pattern, + ...(argPattern === undefined ? {} : { argPattern }), + }; +} + +function execApprovalAllowlistRequirementKey( + pattern: string, + argPattern: string | undefined, +): string { + return `${pattern}\0${argPattern ?? ""}`; +} + +function execApprovalAllowlistMissingTarget(agentId: string | undefined): string { + return agentId === undefined + ? "oc://exec-approvals.json" + : `oc://exec-approvals.json/agents/${ocPathSegment(agentId)}/allowlist`; +} + +function formatExecApprovalAllowlistRequirement(entry: ExecApprovalAllowlistRequirement): string { + return formatExecApprovalAllowlistParts(entry.pattern, entry.argPattern); +} + +function formatExecApprovalAllowlistEntry(entry: PolicyExecApprovalEvidence | undefined): string { + return formatExecApprovalAllowlistParts(entry?.pattern ?? "", entry?.argPattern); +} + +function formatExecApprovalAllowlistParts(pattern: string, argPattern: string | undefined): string { + return argPattern === undefined ? pattern : `${pattern} argPattern=${argPattern}`; +} + +function effectiveExecApprovalAgentSecurityEntry( + entries: readonly PolicyExecApprovalEvidence[], + agentId: string, +): PolicyExecApprovalEvidence | undefined { + const exact = entries.find( + (entry) => + entry.kind === "agent" && + entry.agentId !== undefined && + normalizeAgentId(entry.agentId) === normalizeAgentId(agentId), + ); + const wildcard = entries.find((entry) => entry.kind === "agent" && entry.agentId === "*"); + if (exact?.security !== undefined || exact?.securityConfigured === true) { + return exact; + } + return wildcard?.security === undefined ? (exact ?? wildcard) : wildcard; +} + +function effectiveExecApprovalAgentAutoAllowSkillsEntry( + entries: readonly PolicyExecApprovalEvidence[], + agentId: string, +): PolicyExecApprovalEvidence | undefined { + const exact = entries.find( + (entry) => + entry.kind === "agent" && + entry.agentId !== undefined && + normalizeAgentId(entry.agentId) === normalizeAgentId(agentId), + ); + if (exact?.autoAllowSkills !== undefined) { + return exact; + } + const wildcard = entries.find((entry) => entry.kind === "agent" && entry.agentId === "*"); + return wildcard?.autoAllowSkills === undefined ? undefined : wildcard; +} + +function syntheticExecApprovalAgentEntry(agentId: string): PolicyExecApprovalEvidence { + return { + id: `agent:${agentId}:runtime-defaults`, + kind: "agent", + source: "oc://exec-approvals.json", + agentId, + }; +} + +function execApprovalAllowlistEntries( + entries: readonly PolicyExecApprovalEvidence[], + agentId: string | undefined, +): readonly PolicyExecApprovalEvidence[] { + if (agentId === undefined) { + return entries.filter((entry) => entry.kind === "allowlist"); + } + return entries.filter( + (entry) => + entry.kind === "allowlist" && + entry.agentId !== undefined && + (normalizeAgentId(entry.agentId) === normalizeAgentId(agentId) || entry.agentId === "*"), + ); +} + +function execApprovalFinding( + entry: PolicyExecApprovalEvidence | undefined, + params: { + readonly checkId: (typeof POLICY_CHECK_IDS)[number]; + readonly message: string; + readonly requirement: string; + readonly fixHint: string; + }, +): HealthFinding { + const target = entry?.source ?? "oc://exec-approvals.json"; + return { + checkId: params.checkId, + severity: "error", + message: params.message, + source: "policy", + path: "exec-approvals.json", + ocPath: target, + target, + requirement: params.requirement, + fixHint: params.fixHint, + }; +} + function toolPostureFindings( policy: unknown, policyPath: string, @@ -5238,6 +6098,25 @@ function dataHandlingLabel(entry: PolicyDataHandlingEvidence): string { return entry.agentId === undefined ? "Global data handling config" : `agent '${entry.agentId}'`; } +function policyHasExecApprovalsRules(policy: unknown): boolean { + if (!isRecord(policy)) { + return false; + } + if (execApprovalsPolicyHasRules(policy.execApprovals)) { + return true; + } + return agentScopedPolicyOverlays(policy).some(([, overlay]) => + execApprovalsPolicyHasRules(overlay.execApprovals), + ); +} + +function execApprovalsPolicyHasRules(value: unknown): boolean { + return ( + isRecord(value) && + (value.requireFile !== undefined || isRecord(value.defaults) || isRecord(value.agents)) + ); +} + function policyHasSecretRules(policy: unknown): boolean { if (!isRecord(policy) || !isRecord(policy.secrets)) { return false; @@ -5695,7 +6574,17 @@ function policyStringList( if (metadata.valueType === "channel-provider-deny-rules") { return channelProviderDenyRuleList(value, metadata); } - if (!Array.isArray(value) || !value.every((entry) => typeof entry === "string")) { + if (!Array.isArray(value)) { + return undefined; + } + if (metadata.policyPath.join(".") === "execApprovals.agents.allowlist.expected") { + const entries = value.map(execApprovalAllowlistRequirement); + if (!entries.every((entry): entry is ExecApprovalAllowlistRequirement => entry !== undefined)) { + return undefined; + } + return entries.map((entry) => entry.key); + } + if (!value.every((entry) => typeof entry === "string")) { return undefined; } return value @@ -6138,6 +7027,26 @@ async function readPolicyFile( } } +async function readExecApprovalsFile( + ctx: HealthCheckContext, +): Promise<{ raw: string; path: string; displayName: string; ocDocName: string } | null> { + const artifact = execApprovalsArtifactLocation(ctx); + try { + const fs = await loadFsPromisesModule(); + return { + raw: await fs.readFile(artifact.path, "utf-8"), + path: artifact.path, + displayName: artifact.displayName, + ocDocName: "exec-approvals.json", + }; + } catch (err) { + if (isNotFound(err)) { + return null; + } + throw err; + } +} + async function readWorkspaceFile( ctx: HealthCheckContext, fileName: string, @@ -6154,6 +7063,57 @@ async function readWorkspaceFile( } } +function resolvePolicyArtifactPath(ctx: HealthCheckContext, fileName: string): string { + if (fileName.startsWith("~/") || fileName.startsWith("~\\")) { + const home = resolvePolicyArtifactHomeDir(); + if (home !== undefined) { + return resolve(home, fileName.slice(2)); + } + } + return resolveWorkspacePath(ctx, fileName); +} + +function resolvePolicyArtifactHomeDir(): string | undefined { + const explicitHome = normalizedEnvValue(process.env.OPENCLAW_HOME); + if (explicitHome !== undefined) { + if (explicitHome === "~" || explicitHome.startsWith("~/") || explicitHome.startsWith("~\\")) { + return resolvePolicyHomeRelativePath(explicitHome); + } + return resolve(explicitHome); + } + return resolveOsPolicyHomeDir(); +} + +function resolvePolicyHomeRelativePath(value: string): string { + const fallbackHome = resolveOsPolicyHomeDir(); + return fallbackHome === undefined + ? resolve(value) + : resolve(value.replace(/^~(?=$|[\\/])/, fallbackHome)); +} + +function resolveOsPolicyHomeDir(): string | undefined { + return ( + normalizedEnvValue(process.env.HOME) ?? + normalizedEnvValue(process.env.USERPROFILE) ?? + safeOsHomeDir() + ); +} + +function safeOsHomeDir(): string | undefined { + try { + return normalizedEnvValue(os.homedir()); + } catch { + return undefined; + } +} + +function normalizedEnvValue(value: string | undefined): string | undefined { + const trimmed = value?.trim(); + return trimmed === undefined || trimmed === "" || trimmed === "undefined" || trimmed === "null" + ? undefined + : trimmed; +} + function resolveWorkspacePath(ctx: HealthCheckContext, fileName: string): string { if (isAbsolute(fileName)) { return fileName; @@ -6165,6 +7125,25 @@ function isNotFound(err: unknown): boolean { return typeof err === "object" && err !== null && "code" in err && err.code === "ENOENT"; } +function parseExecApprovalsFile( + raw: string, +): + | { readonly ok: true; readonly value: unknown } + | { readonly ok: false; readonly message: string } { + try { + const value = JSON.parse(raw); + if (!isRecord(value) || value.version !== 1) { + return { ok: false, message: "unsupported exec approvals version" }; + } + return { ok: true, value }; + } catch (err) { + return { + ok: false, + message: err instanceof Error ? err.message : String(err), + }; + } +} + function parsePolicyFile( raw: string, ): @@ -6454,6 +7433,37 @@ function normalizePolicyChannelId(value: string): string { return value.trim().toLowerCase(); } +function canonicalExecApprovalsPath(): string { + return "~/.openclaw/exec-approvals.json"; +} + +function execApprovalsArtifactLocation(ctx: HealthCheckContext): { + readonly path: string; + readonly displayName: string; +} { + const stateDir = normalizedEnvValue(process.env.OPENCLAW_STATE_DIR); + if (stateDir !== undefined) { + const path = resolve(resolvePolicyStateDir(stateDir), "exec-approvals.json"); + return { path, displayName: path }; + } + return { + path: resolvePolicyArtifactPath(ctx, canonicalExecApprovalsPath()), + displayName: canonicalExecApprovalsPath(), + }; +} + +function execApprovalsDisplayName(): string { + const stateDir = normalizedEnvValue(process.env.OPENCLAW_STATE_DIR); + if (stateDir === undefined) { + return canonicalExecApprovalsPath(); + } + return resolve(resolvePolicyStateDir(stateDir), "exec-approvals.json"); +} + +function resolvePolicyStateDir(stateDir: string): string { + return stateDir.startsWith("~") ? resolvePolicyHomeRelativePath(stateDir) : resolve(stateDir); +} + function policyPathSetting(ctx: HealthCheckContext): string { const configured = policySettings(ctx).path; return typeof configured === "string" && configured.trim() !== "" diff --git a/extensions/policy/src/policy-conformance.ts b/extensions/policy/src/policy-conformance.ts index 0ed2116b125a..8e4d996c81f7 100644 --- a/extensions/policy/src/policy-conformance.ts +++ b/extensions/policy/src/policy-conformance.ts @@ -355,19 +355,49 @@ function policyRuleValueIsValid(metadata: PolicyRuleMetadata, value: unknown): b case "string": return typeof value === "string" && policyStringIsAllowed(metadata, value); case "string-list": - return ( - Array.isArray(value) && - value.every( - (entry) => - typeof entry === "string" && - entry.trim() !== "" && - policyStringIsAllowed(metadata, entry), - ) + if (!Array.isArray(value)) { + return false; + } + if (isExecApprovalAllowlistExpectedRule(metadata)) { + return value.every(isExecApprovalAllowlistRequirement); + } + return value.every( + (entry) => + typeof entry === "string" && + entry.trim() !== "" && + policyStringIsAllowed(metadata, entry), ); } return false; } +function isExecApprovalAllowlistExpectedRule(metadata: PolicyRuleMetadata): boolean { + return metadata.policyPath.join(".") === "execApprovals.agents.allowlist.expected"; +} + +function unsupportedPolicyKey( + value: Record, + supported: readonly string[], +): string | undefined { + return Object.keys(value).find((key) => !supported.includes(key)); +} + +function isExecApprovalAllowlistRequirement(value: unknown): boolean { + if (typeof value === "string") { + return value.trim() !== ""; + } + if (!isRecord(value)) { + return false; + } + if (unsupportedPolicyKey(value, ["argPattern", "pattern"]) !== undefined) { + return false; + } + if (typeof value.pattern !== "string" || value.pattern.trim() === "") { + return false; + } + return value.argPattern === undefined || typeof value.argPattern === "string"; +} + function policyStringIsAllowed(metadata: PolicyRuleMetadata, value: string): boolean { const normalized = metadata.caseSensitive === true ? value.trim() : value.trim().toLowerCase(); if (normalized === "") { @@ -506,7 +536,25 @@ function collectScopedPolicyRuleClaims(document: PolicyDocument): readonly Polic } } } - return claims; + return coalesceScopedPolicyRuleClaims(claims); +} + +function coalesceScopedPolicyRuleClaims( + claims: readonly PolicyRuleClaim[], +): readonly PolicyRuleClaim[] { + const byKey = new Map(); + for (const claim of claims) { + const previous = byKey.get(claim.key); + if ( + previous !== undefined && + isPolicyValueAtLeastAsStrict(previous.metadata, claim.value, previous.value) + ) { + byKey.set(claim.key, claim); + continue; + } + byKey.set(claim.key, previous ?? claim); + } + return [...byKey.values()]; } function normalizeSelectorValues( diff --git a/extensions/policy/src/policy-state.test.ts b/extensions/policy/src/policy-state.test.ts index 59bffad8964a..12558d9d0587 100644 --- a/extensions/policy/src/policy-state.test.ts +++ b/extensions/policy/src/policy-state.test.ts @@ -1,6 +1,6 @@ // Policy tests cover policy state plugin behavior. import { describe, expect, it } from "vitest"; -import { scanPolicyChannels, scanPolicyTools } from "./policy-state.js"; +import { scanPolicyChannels, scanPolicyExecApprovals, scanPolicyTools } from "./policy-state.js"; describe("scanPolicyChannels", () => { it("ignores reserved channel config namespaces", () => { @@ -84,3 +84,123 @@ describe("scanPolicyTools", () => { ]); }); }); + +describe("scanPolicyExecApprovals", () => { + it("scans redacted exec approvals posture and allowlist metadata", () => { + const evidence = scanPolicyExecApprovals( + JSON.stringify({ + version: 1, + socket: { path: "/tmp/openclaw.sock", token: "secret-token" }, + defaults: { security: "full", ask: "off", askFallback: "full", autoAllowSkills: true }, + agents: { + sebby: { + security: "allowlist", + ask: "on-miss", + allowlist: [ + { + pattern: "deploy", + argPattern: "^--prod$", + source: "allow-always", + commandText: "deploy --prod", + lastUsedCommand: "deploy --prod", + }, + { + pattern: "inspect", + source: "free-form text that must not leak", + }, + ], + }, + }, + }), + ); + + expect(evidence).toEqual([ + expect.objectContaining({ + id: "defaults", + kind: "defaults", + security: "full", + autoAllowSkills: true, + }), + expect.objectContaining({ + id: "agent:sebby", + kind: "agent", + agentId: "sebby", + security: "allowlist", + ask: "on-miss", + }), + expect.objectContaining({ + id: "agent:sebby:allowlist:0", + kind: "allowlist", + agentId: "sebby", + pattern: "deploy", + argPattern: "^--prod$", + entrySource: "allow-always", + }), + expect.not.objectContaining({ + entrySource: "free-form text that must not leak", + }), + ]); + expect(JSON.stringify(evidence)).not.toContain("secret-token"); + expect(JSON.stringify(evidence)).not.toContain("deploy --prod"); + expect(JSON.stringify(evidence)).not.toContain("free-form text that must not leak"); + }); + + it("omits malformed exec approval mode fields", () => { + expect( + scanPolicyExecApprovals( + JSON.stringify({ + version: 1, + defaults: { security: "bogus", ask: "bad", askFallback: "nope" }, + agents: { + sebby: { security: "bogus", ask: "bad", askFallback: "nope" }, + }, + }), + ), + ).toEqual([ + expect.not.objectContaining({ security: expect.any(String) }), + expect.not.objectContaining({ security: expect.any(String) }), + ]); + }); + + it("normalizes legacy default agents and string allowlist entries", () => { + expect( + scanPolicyExecApprovals( + JSON.stringify({ + version: 1, + agents: { + default: { + security: "allowlist", + allowlist: ["legacy", { pattern: "doctor" }], + }, + }, + }), + ), + ).toEqual([ + expect.objectContaining({ + id: "defaults", + kind: "defaults", + }), + expect.objectContaining({ + id: "agent:main", + kind: "agent", + agentId: "main", + security: "allowlist", + source: "oc://exec-approvals.json/agents/default", + }), + expect.objectContaining({ + id: "agent:main:allowlist:0", + kind: "allowlist", + agentId: "main", + pattern: "legacy", + source: "oc://exec-approvals.json/agents/default/allowlist/#0", + }), + expect.objectContaining({ + id: "agent:main:allowlist:1", + kind: "allowlist", + agentId: "main", + pattern: "doctor", + source: "oc://exec-approvals.json/agents/default/allowlist/#1", + }), + ]); + }); +}); diff --git a/extensions/policy/src/policy-state.ts b/extensions/policy/src/policy-state.ts index 527a6649d51d..9bc8798f7943 100644 --- a/extensions/policy/src/policy-state.ts +++ b/extensions/policy/src/policy-state.ts @@ -12,6 +12,7 @@ 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"; +const DEFAULT_EXEC_APPROVAL_AGENT_ID = "main"; const ALLOWLIST_DEFAULT_INGRESS_GROUP_POLICY_CHANNELS = new Set([ "googlechat", "irc", @@ -53,6 +54,7 @@ export type PolicyEvidence = { readonly dataHandling?: readonly PolicyDataHandlingEvidence[]; readonly secrets?: readonly PolicySecretEvidence[]; readonly authProfiles?: readonly PolicyAuthProfileEvidence[]; + readonly execApprovals?: readonly PolicyExecApprovalEvidence[]; }; export type PolicyChannelEvidence = { @@ -209,6 +211,21 @@ export type PolicyAuthProfileEvidence = { readonly mode?: string; }; +export type PolicyExecApprovalEvidence = { + readonly id: string; + readonly kind: "agent" | "allowlist" | "defaults"; + readonly source: string; + readonly agentId?: string; + readonly security?: string; + readonly securityConfigured?: boolean; + readonly ask?: string; + readonly askFallback?: string; + readonly autoAllowSkills?: boolean; + readonly pattern?: string; + readonly argPattern?: string; + readonly entrySource?: string; +}; + export type PolicyDataHandlingEvidence = { readonly id: string; readonly kind: @@ -302,6 +319,8 @@ export function collectPolicyEvidence( readonly includeSandboxPosture?: boolean; readonly includeSecrets?: boolean; readonly includeAuthProfiles?: boolean; + readonly execApprovalsRaw?: string | null; + readonly includeExecApprovals?: boolean; }, ): PolicyEvidence; export function collectPolicyEvidence( @@ -316,6 +335,8 @@ export function collectPolicyEvidence( readonly includeSandboxPosture?: boolean; readonly includeSecrets?: boolean; readonly includeAuthProfiles?: boolean; + readonly execApprovalsRaw?: string | null; + readonly includeExecApprovals?: boolean; }, ): Promise; export function collectPolicyEvidence( @@ -330,6 +351,8 @@ export function collectPolicyEvidence( readonly includeSandboxPosture?: boolean; readonly includeSecrets?: boolean; readonly includeAuthProfiles?: boolean; + readonly execApprovalsRaw?: string | null; + readonly includeExecApprovals?: boolean; } = {}, ): PolicyEvidence | Promise { const evidence = { @@ -352,6 +375,14 @@ export function collectPolicyEvidence( : { sandboxPosture: scanPolicySandboxPosture(cfg) }), ...(options.includeSecrets === false ? {} : { secrets: scanPolicySecrets(cfg) }), ...(options.includeAuthProfiles === false ? {} : { authProfiles: scanPolicyAuthProfiles(cfg) }), + ...(options.includeExecApprovals === false || options.execApprovalsRaw === undefined + ? {} + : { + execApprovals: + options.execApprovalsRaw === null + ? [] + : scanPolicyExecApprovals(options.execApprovalsRaw), + }), }; if (options.toolsRaw === undefined) { return evidence; @@ -359,6 +390,278 @@ export function collectPolicyEvidence( return scanPolicyTools(options.toolsRaw).then((tools) => ({ ...evidence, tools })); } +export function scanPolicyExecApprovals(raw: string): readonly PolicyExecApprovalEvidence[] { + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + return []; + } + if (!isRecord(parsed) || parsed.version !== 1) { + return []; + } + const evidence: PolicyExecApprovalEvidence[] = []; + const defaults = isRecord(parsed.defaults) ? parsed.defaults : {}; + evidence.push( + execApprovalPostureEvidence( + "defaults", + "defaults", + defaults, + "oc://exec-approvals.json/defaults", + ), + ); + + for (const agent of normalizedExecApprovalAgents(parsed.agents)) { + const agentSource = `oc://exec-approvals.json/agents/${ocPathSegment(agent.sourceAgentId)}`; + evidence.push( + execApprovalPostureEvidence( + `agent:${agent.agentId}`, + "agent", + agent.value, + agentSource, + agent.agentId, + ), + ); + for (const [index, entry] of agent.allowlistEntries.entries()) { + const allowlistSource = `oc://exec-approvals.json/agents/${ocPathSegment( + entry.sourceAgentId, + )}/allowlist/#${entry.index}`; + evidence.push({ + id: `agent:${agent.agentId}:allowlist:${index}`, + kind: "allowlist", + source: allowlistSource, + agentId: agent.agentId, + pattern: entry.pattern, + ...(entry.argPattern === undefined ? {} : { argPattern: entry.argPattern }), + ...(entry.entrySource === undefined ? {} : { entrySource: entry.entrySource }), + }); + } + } + return evidence; +} + +function execApprovalPostureEvidence( + id: string, + kind: "agent" | "defaults", + value: Record, + source: string, + agentId?: string, +): PolicyExecApprovalEvidence { + const security = readExecApprovalSecurity(value.security); + const ask = readExecApprovalAsk(value.ask); + const askFallback = readExecApprovalSecurity(value.askFallback); + const autoAllowSkills = readBoolean(value.autoAllowSkills); + return { + id, + kind, + source, + ...(agentId === undefined ? {} : { agentId }), + ...(value.security == null ? {} : { securityConfigured: true }), + ...(security === undefined ? {} : { security }), + ...(ask === undefined ? {} : { ask }), + ...(askFallback === undefined ? {} : { askFallback }), + ...(autoAllowSkills === undefined ? {} : { autoAllowSkills }), + }; +} + +function readExecApprovalSecurity(value: unknown): string | undefined { + const normalized = readString(value); + return normalized === "deny" || normalized === "allowlist" || normalized === "full" + ? normalized + : undefined; +} + +function readExecApprovalAsk(value: unknown): string | undefined { + const normalized = readString(value); + return normalized === "off" || normalized === "on-miss" || normalized === "always" + ? normalized + : undefined; +} + +type NormalizedExecApprovalAllowlistEntry = ReturnType< + typeof execApprovalAllowlistEntries +>[number] & { + readonly sourceAgentId: string; +}; + +type NormalizedExecApprovalAgent = { + readonly agentId: string; + readonly sourceAgentId: string; + readonly value: Record; + readonly allowlistEntries: readonly NormalizedExecApprovalAllowlistEntry[]; +}; + +function normalizedExecApprovalAgents(rawAgents: unknown): readonly NormalizedExecApprovalAgent[] { + if (!isRecord(rawAgents)) { + return []; + } + const agents = Object.entries(rawAgents).filter( + (entry): entry is [string, Record] => isRecord(entry[1]), + ); + const legacyDefault = agents.find(([agentId]) => agentId === "default")?.[1]; + const normalized = agents + .filter(([agentId]) => agentId !== "default") + .map(([agentId, value]): NormalizedExecApprovalAgent => { + if (agentId === DEFAULT_EXEC_APPROVAL_AGENT_ID && legacyDefault !== undefined) { + return { + agentId, + sourceAgentId: agentId, + value: mergeLegacyExecApprovalAgent(value, legacyDefault), + allowlistEntries: mergedExecApprovalAllowlistEntries( + value.allowlist, + legacyDefault.allowlist, + ), + }; + } + return execApprovalAgentFromParts(agentId, agentId, value); + }); + if ( + legacyDefault !== undefined && + !agents.some(([agentId]) => agentId === DEFAULT_EXEC_APPROVAL_AGENT_ID) + ) { + normalized.push( + execApprovalAgentFromParts(DEFAULT_EXEC_APPROVAL_AGENT_ID, "default", legacyDefault), + ); + } + return normalized.toSorted((a, b) => a.agentId.localeCompare(b.agentId)); +} + +function execApprovalAgentFromParts( + agentId: string, + sourceAgentId: string, + value: Record, +): NormalizedExecApprovalAgent { + const allowlistEntries = execApprovalAllowlistEntries(value.allowlist).map( + (entry): NormalizedExecApprovalAllowlistEntry => ({ + index: entry.index, + pattern: entry.pattern, + argPattern: entry.argPattern, + entrySource: entry.entrySource, + sourceAgentId, + }), + ); + return { + agentId, + sourceAgentId, + value, + allowlistEntries, + }; +} + +function mergeLegacyExecApprovalAgent( + current: Record, + legacy: Record, +): Record { + return { + ...legacy, + ...current, + security: current.security ?? legacy.security, + ask: current.ask ?? legacy.ask, + askFallback: current.askFallback ?? legacy.askFallback, + autoAllowSkills: current.autoAllowSkills ?? legacy.autoAllowSkills, + allowlist: mergedExecApprovalAllowlist(current.allowlist, legacy.allowlist), + }; +} + +function mergedExecApprovalAllowlist( + current: unknown, + legacy: unknown, +): readonly unknown[] | undefined { + const entries = mergedExecApprovalAllowlistEntries(current, legacy).map((entry) => { + const allowlistEntry: Record = { pattern: entry.pattern }; + if (entry.argPattern !== undefined) { + allowlistEntry.argPattern = entry.argPattern; + } + if (entry.entrySource !== undefined) { + allowlistEntry.source = entry.entrySource; + } + return allowlistEntry; + }); + return entries.length === 0 ? undefined : entries; +} + +function mergedExecApprovalAllowlistEntries( + current: unknown, + legacy: unknown, +): readonly NormalizedExecApprovalAllowlistEntry[] { + const entries: NormalizedExecApprovalAllowlistEntry[] = []; + const seen = new Set(); + const appendEntries = (sourceEntries: readonly NormalizedExecApprovalAllowlistEntry[]) => { + for (const sourceEntry of sourceEntries) { + const key = `${sourceEntry.pattern.toLowerCase()}\x00${sourceEntry.argPattern ?? ""}`; + if (seen.has(key)) { + continue; + } + seen.add(key); + entries.push(sourceEntry); + } + }; + appendEntries(withExecApprovalAllowlistSource(current, DEFAULT_EXEC_APPROVAL_AGENT_ID)); + appendEntries(withExecApprovalAllowlistSource(legacy, "default")); + return entries; +} + +function withExecApprovalAllowlistSource( + value: unknown, + sourceAgentId: string, +): readonly NormalizedExecApprovalAllowlistEntry[] { + return execApprovalAllowlistEntries(value).map( + (entry): NormalizedExecApprovalAllowlistEntry => ({ + index: entry.index, + pattern: entry.pattern, + argPattern: entry.argPattern, + entrySource: entry.entrySource, + sourceAgentId, + }), + ); +} + +function readExecApprovalAllowlistEntrySource(value: unknown): "allow-always" | undefined { + return readString(value) === "allow-always" ? "allow-always" : undefined; +} + +function execApprovalAllowlistEntries(value: unknown): readonly { + readonly index: number; + readonly pattern: string; + readonly argPattern?: string; + readonly entrySource?: string; +}[] { + if (!Array.isArray(value)) { + return []; + } + const entries: { + readonly index: number; + readonly pattern: string; + readonly argPattern?: string; + readonly entrySource?: string; + }[] = []; + for (const [index, entry] of value.entries()) { + if (typeof entry === "string") { + const pattern = entry.trim(); + if (pattern !== "") { + entries.push({ index, pattern }); + } + continue; + } + if (!isRecord(entry)) { + continue; + } + const pattern = readString(entry.pattern); + if (pattern === undefined) { + continue; + } + const argPattern = readString(entry.argPattern); + const entrySource = readExecApprovalAllowlistEntrySource(entry.source); + entries.push({ + index, + pattern, + ...(argPattern === undefined ? {} : { argPattern }), + ...(entrySource === undefined ? {} : { entrySource }), + }); + } + return entries; +} + export function scanPolicyChannels(cfg: Record): readonly PolicyChannelEvidence[] { return Object.entries(configuredChannels(cfg)) .filter(([id]) => !RESERVED_CHANNEL_CONFIG_KEYS.has(id))