fix(policy): honor state dir for exec approvals evidence

This commit is contained in:
Gio Della-Libera
2026-06-15 07:00:32 -07:00
parent 59aa56cc83
commit b82eefe492
3 changed files with 99 additions and 21 deletions
+9 -7
View File
@@ -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. |
+54 -4
View File
@@ -29,6 +29,7 @@ import {
let workspaceDir: string;
let originalOpenClawHome: string | undefined;
let originalOpenClawStateDir: string | undefined;
function cfgWithPolicy(settings: Record<string, unknown> = {}): 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");
+36 -10
View File
@@ -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 {