From b82eefe49214a256ace4536f33a70aa97d97e121 Mon Sep 17 00:00:00 2001 From: Gio Della-Libera Date: Mon, 15 Jun 2026 07:00:32 -0700 Subject: [PATCH] fix(policy): honor state dir for exec approvals evidence --- docs/cli/policy.md | 16 ++--- extensions/policy/src/doctor/register.test.ts | 58 +++++++++++++++++-- extensions/policy/src/doctor/register.ts | 46 +++++++++++---- 3 files changed, 99 insertions(+), 21 deletions(-) diff --git a/docs/cli/policy.md b/docs/cli/policy.md index 82fef5ef1a8a..98dd9123453a 100644 --- a/docs/cli/policy.md +++ b/docs/cli/policy.md @@ -415,12 +415,14 @@ allowlist such as `["all"]`. #### Exec approvals -Exec approvals policy observes the runtime `~/.openclaw/exec-approvals.json` -file artifact. 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 +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 @@ -429,7 +431,7 @@ path/token, `commandText`, `lastUsedCommand`, resolved paths, or timestamps. | Policy field | Observed state | Use when | | ------------------------------------------- | -------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------- | -| `execApprovals.requireFile` | Runtime `~/.openclaw/exec-approvals.json` path | Set to `true` to require the approvals artifact to exist and parse. | +| `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. | diff --git a/extensions/policy/src/doctor/register.test.ts b/extensions/policy/src/doctor/register.test.ts index 55838555c36a..2f37696cec8c 100644 --- a/extensions/policy/src/doctor/register.test.ts +++ b/extensions/policy/src/doctor/register.test.ts @@ -29,6 +29,7 @@ import { let workspaceDir: string; let originalOpenClawHome: string | undefined; +let originalOpenClawStateDir: string | undefined; function cfgWithPolicy(settings: Record = {}): OpenClawConfig { return { @@ -106,13 +107,23 @@ describe("registerPolicyDoctorChecks", () => { 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 }); - await fs.symlink( - "../exec-approvals.json", - join(workspaceDir, ".openclaw", "exec-approvals.json"), - ); + 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 () => { @@ -121,6 +132,11 @@ describe("registerPolicyDoctorChecks", () => { } 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(); @@ -8492,6 +8508,40 @@ describe("registerPolicyDoctorChecks", () => { } }); + 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"); diff --git a/extensions/policy/src/doctor/register.ts b/extensions/policy/src/doctor/register.ts index f7154e890bb1..14ef33a28ed2 100644 --- a/extensions/policy/src/doctor/register.ts +++ b/extensions/policy/src/doctor/register.ts @@ -7030,14 +7030,13 @@ async function readPolicyFile( async function readExecApprovalsFile( ctx: HealthCheckContext, ): Promise<{ raw: string; path: string; displayName: string; ocDocName: string } | null> { - const displayName = execApprovalsDisplayName(); - const path = resolvePolicyArtifactPath(ctx, canonicalExecApprovalsPath()); + const artifact = execApprovalsArtifactLocation(ctx); try { const fs = await loadFsPromisesModule(); return { - raw: await fs.readFile(path, "utf-8"), - path, - displayName, + raw: await fs.readFile(artifact.path, "utf-8"), + path: artifact.path, + displayName: artifact.displayName, ocDocName: "exec-approvals.json", }; } catch (err) { @@ -7078,16 +7077,20 @@ function resolvePolicyArtifactHomeDir(): string | undefined { const explicitHome = normalizedEnvValue(process.env.OPENCLAW_HOME); if (explicitHome !== undefined) { if (explicitHome === "~" || explicitHome.startsWith("~/") || explicitHome.startsWith("~\\")) { - const fallbackHome = resolveOsPolicyHomeDir(); - return fallbackHome === undefined - ? undefined - : resolve(explicitHome.replace(/^~(?=$|[\\/])/, fallbackHome)); + 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) ?? @@ -7434,8 +7437,31 @@ 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 { - return canonicalExecApprovalsPath(); + 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 {