policy: repair automatic narrowing findings (#99690)

This commit is contained in:
Gio Della-Libera
2026-07-05 08:11:38 -07:00
committed by GitHub
parent f8016c44a4
commit 14b2ca11ff
6 changed files with 587 additions and 4 deletions
+18 -4
View File
@@ -937,10 +937,24 @@ Example findings:
`workspaceRepairs` is explicitly enabled; otherwise checks report what they
would repair and leave settings unchanged.
Currently, repair can disable channels that are enabled in OpenClaw config but
denied by `channels.denyRules`. Enable `workspaceRepairs` only after the
policy file has been reviewed, since a valid deny rule can turn off a
configured channel:
In this version, repair can disable channels denied by `channels.denyRules` and
apply the automatic narrowing repairs listed below. Enable `workspaceRepairs`
only after the policy file has been reviewed, because a valid rule can change
workspace config:
- set `tools.elevated.enabled=false` when a global policy forbids elevated tools
- set insecure `gateway.controlUi.*` toggles to `false`
- set `gateway.mode=local` when policy denies remote gateway mode
- set `logging.redactSensitive=tools` when policy requires sensitive logging
redaction
- set `diagnostics.otel.captureContent=false`, or
`diagnostics.otel.captureContent.enabled=false` for object-form telemetry
capture settings, when policy denies telemetry content capture
Scoped elevated-tools repairs are detect-only. Scoped data-handling repairs are
also skipped when the finding reports shared logging or telemetry config,
because changing the shared setting would affect more than the scoped policy
target.
```jsonc
{
@@ -0,0 +1,257 @@
// Policy automatic repairs apply only deterministic narrowing config changes.
import type {
HealthFinding,
HealthRepairContext,
HealthRepairResult,
OpenClawConfig,
} from "openclaw/plugin-sdk/health";
import { POLICY_FIX_METADATA_BY_CHECK_ID } from "./fix-metadata.js";
import { CHECK_IDS, type POLICY_CHECK_IDS } from "./metadata.js";
type PolicyCheckId = (typeof POLICY_CHECK_IDS)[number];
type ConfigRecord = Record<string, unknown>;
type RepairPatch = {
readonly config: OpenClawConfig;
readonly changes: readonly string[];
readonly warnings?: readonly string[];
};
const AUTOMATIC_REPAIR_CHECK_IDS = new Set<PolicyCheckId>([
CHECK_IDS.policyToolsElevatedEnabled,
CHECK_IDS.policyGatewayControlUiInsecure,
CHECK_IDS.policyGatewayRemoteEnabled,
CHECK_IDS.policyDataHandlingRedactionDisabled,
CHECK_IDS.policyDataHandlingTelemetryContentCapture,
]);
export function repairPolicyAutomaticNarrower(
ctx: HealthRepairContext,
findings: readonly HealthFinding[],
checkId: PolicyCheckId,
): Promise<HealthRepairResult> {
if (!workspaceRepairsEnabled(ctx)) {
return Promise.resolve(workspaceRepairsDisabledResult());
}
if (!AUTOMATIC_REPAIR_CHECK_IDS.has(checkId)) {
return Promise.resolve({
status: "skipped",
reason: "policy finding is not an automatic narrowing repair",
changes: [],
});
}
if (
findings.length === 0 ||
findings.some(
(finding) =>
finding.checkId !== checkId ||
POLICY_FIX_METADATA_BY_CHECK_ID.get(finding.checkId)?.fixClass !== "automatic",
)
) {
return Promise.resolve({
status: "skipped",
reason: "policy finding is not classified as automatic",
changes: [],
});
}
const patch = applyAutomaticPatch(ctx.cfg, findings, checkId);
if (patch.changes.length === 0) {
return Promise.resolve({
status: "skipped",
reason: "policy automatic repair had no config changes to apply",
changes: [],
...(patch.warnings !== undefined ? { warnings: patch.warnings } : {}),
});
}
return Promise.resolve({
status: "repaired",
config: patch.config,
changes: patch.changes,
...(patch.warnings !== undefined ? { warnings: patch.warnings } : {}),
});
}
function applyAutomaticPatch(
cfg: OpenClawConfig,
findings: readonly HealthFinding[],
checkId: PolicyCheckId,
): RepairPatch {
switch (checkId) {
case CHECK_IDS.policyToolsElevatedEnabled:
if (hasScopedPolicyRequirement(findings)) {
return skippedUnsafeScopedRepair(
cfg,
"Skipped scoped tools repair. Scoped elevated-tools policy findings are detect-only because automatic repair cannot safely choose between shared and agent-local config targets.",
);
}
return disableElevatedTools(cfg, findings);
case CHECK_IDS.policyGatewayControlUiInsecure:
return disableInsecureControlUi(cfg, findings);
case CHECK_IDS.policyGatewayRemoteEnabled:
return disableRemoteGatewayMode(cfg, findings);
case CHECK_IDS.policyDataHandlingRedactionDisabled:
if (hasScopedPolicyRequirement(findings)) {
return skippedUnsafeScopedRepair(
cfg,
"Skipped scoped data-handling repair. The finding reports shared logging config, so changing it would affect more than the scoped policy target.",
);
}
return enableSensitiveLoggingRedaction(cfg);
case CHECK_IDS.policyDataHandlingTelemetryContentCapture:
if (hasScopedPolicyRequirement(findings)) {
return skippedUnsafeScopedRepair(
cfg,
"Skipped scoped data-handling repair. The finding reports shared telemetry config, so changing it would affect more than the scoped policy target.",
);
}
return disableTelemetryContentCapture(cfg);
default:
return { config: cfg, changes: [] };
}
}
function disableElevatedTools(
cfg: OpenClawConfig,
findings: readonly HealthFinding[],
): RepairPatch {
if (
!findings.some((finding) => finding.ocPath === "oc://openclaw.config/tools/elevated/enabled")
) {
return { config: cfg, changes: [] };
}
const next = cloneConfig(cfg);
const tools = ensureRecord(next, "tools");
const elevated = ensureRecord(tools, "elevated");
if (elevated.enabled === false) {
return { config: cfg, changes: [] };
}
elevated.enabled = false;
return {
config: next as OpenClawConfig,
changes: ["Set tools.elevated.enabled=false for policy conformance."],
};
}
function disableInsecureControlUi(
cfg: OpenClawConfig,
findings: readonly HealthFinding[],
): RepairPatch {
const next = cloneConfig(cfg);
const gateway = ensureRecord(next, "gateway");
const controlUi = ensureRecord(gateway, "controlUi");
const changes: string[] = [];
const fields = [
["allowInsecureAuth", "oc://openclaw.config/gateway/controlUi/allowInsecureAuth"],
[
"dangerouslyDisableDeviceAuth",
"oc://openclaw.config/gateway/controlUi/dangerouslyDisableDeviceAuth",
],
[
"dangerouslyAllowHostHeaderOriginFallback",
"oc://openclaw.config/gateway/controlUi/dangerouslyAllowHostHeaderOriginFallback",
],
] as const;
const findingPaths = new Set(findings.map((finding) => finding.ocPath));
for (const [field, ocPath] of fields) {
if (findingPaths.has(ocPath) && controlUi[field] !== false) {
controlUi[field] = false;
changes.push(`Set gateway.controlUi.${field}=false for policy conformance.`);
}
}
return changes.length > 0
? { config: next as OpenClawConfig, changes }
: { config: cfg, changes };
}
function disableRemoteGatewayMode(
cfg: OpenClawConfig,
findings: readonly HealthFinding[],
): RepairPatch {
if (!findings.some((finding) => finding.ocPath === "oc://openclaw.config/gateway/mode")) {
return { config: cfg, changes: [] };
}
const next = cloneConfig(cfg);
const gateway = ensureRecord(next, "gateway");
const changes: string[] = [];
if (gateway.mode === "remote") {
gateway.mode = "local";
changes.push("Set gateway.mode=local for policy conformance.");
}
return changes.length > 0
? { config: next as OpenClawConfig, changes }
: { config: cfg, changes };
}
function enableSensitiveLoggingRedaction(cfg: OpenClawConfig): RepairPatch {
const next = cloneConfig(cfg);
const logging = ensureRecord(next, "logging");
if (logging.redactSensitive !== "off") {
return { config: cfg, changes: [] };
}
logging.redactSensitive = "tools";
return {
config: next as OpenClawConfig,
changes: ["Set logging.redactSensitive=tools for policy conformance."],
};
}
function disableTelemetryContentCapture(cfg: OpenClawConfig): RepairPatch {
const next = cloneConfig(cfg);
const diagnostics = ensureRecord(next, "diagnostics");
const otel = ensureRecord(diagnostics, "otel");
if (otel.captureContent === false) {
return { config: cfg, changes: [] };
}
otel.captureContent = false;
return {
config: next as OpenClawConfig,
changes: ["Set diagnostics.otel.captureContent=false for policy conformance."],
};
}
function cloneConfig(cfg: OpenClawConfig): ConfigRecord {
return structuredClone(cfg) as ConfigRecord;
}
function workspaceRepairsEnabled(ctx: HealthRepairContext): boolean {
const plugins = isRecord(ctx.cfg.plugins) ? ctx.cfg.plugins : {};
const entries = isRecord(plugins.entries) ? plugins.entries : {};
const policy = isRecord(entries.policy) ? entries.policy : {};
const config = isRecord(policy.config) ? policy.config : {};
return config.workspaceRepairs === true;
}
function workspaceRepairsDisabledResult(): HealthRepairResult {
const warning =
"Skipped policy config repair. Enable plugins.entries.policy.config.workspaceRepairs to let doctor --fix edit workspace policy config.";
return {
status: "skipped",
reason: "workspace repairs are disabled",
changes: [],
warnings: [warning],
};
}
function hasScopedPolicyRequirement(findings: readonly HealthFinding[]): boolean {
return findings.some((finding) => finding.requirement?.includes("/scopes/") === true);
}
function skippedUnsafeScopedRepair(cfg: OpenClawConfig, warning: string): RepairPatch {
return { config: cfg, changes: [], warnings: [warning] };
}
function ensureRecord(parent: ConfigRecord, key: string): ConfigRecord {
const current = parent[key];
if (isRecord(current)) {
const copy = { ...current };
parent[key] = copy;
return copy;
}
const next: ConfigRecord = {};
parent[key] = next;
return next;
}
function isRecord(value: unknown): value is ConfigRecord {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
@@ -100,6 +100,20 @@ async function runDeniedChannelRepair(repairCheckCtx: HealthRepairContext) {
return { ...result, config, remainingFindings };
}
async function runPolicyRepairCheck(checkId: string, repairCheckCtx: HealthRepairContext) {
resetPolicyDoctorChecksForTest();
const check = registerChecks().find((entry) => entry.id === checkId);
if (check?.detect === undefined || check.repair === undefined) {
throw new Error(`${checkId} repair check was not registered`);
}
const findings = await check.detect(repairCheckCtx);
const result = await check.repair(repairCheckCtx, findings);
const config = result.config ?? repairCheckCtx.cfg;
const remainingFindings =
repairCheckCtx.dryRun === true ? [] : await check.detect({ ...repairCheckCtx, cfg: config });
return { ...result, findings, config, remainingFindings };
}
describe("registerPolicyDoctorChecks", () => {
beforeEach(async () => {
clearHealthChecksForTest();
@@ -1507,6 +1521,278 @@ describe("registerPolicyDoctorChecks", () => {
expect(result.config.channels?.telegram).toEqual({ enabled: true });
});
it("dry-runs automatic policy narrowing repairs without mutating config", async () => {
const configPath = join(workspaceDir, "openclaw.jsonc");
const cfg = {
...cfgWithPolicy({ workspaceRepairs: true }),
tools: { elevated: { enabled: true } },
} as OpenClawConfig;
await fs.writeFile(configPath, "{}", "utf-8");
await fs.writeFile(
join(workspaceDir, "policy.jsonc"),
JSON.stringify({ tools: { elevated: { allow: false } } }),
"utf-8",
);
const result = await runPolicyRepairCheck("policy/tools-elevated-enabled", {
...repairCtx(configPath, cfg),
dryRun: true,
});
expect(result.status).toBe("repaired");
expect(result.changes).toEqual(["Set tools.elevated.enabled=false for policy conformance."]);
expect(result.config.tools?.elevated?.enabled).toBe(false);
expect(cfg.tools?.elevated?.enabled).toBe(true);
});
it("does not repair automatic policy narrowing config without workspace repair opt-in", async () => {
const configPath = join(workspaceDir, "openclaw.jsonc");
const cfg = {
...cfgWithPolicy(),
tools: { elevated: { enabled: true } },
} as OpenClawConfig;
await fs.writeFile(configPath, "{}", "utf-8");
await fs.writeFile(
join(workspaceDir, "policy.jsonc"),
JSON.stringify({ tools: { elevated: { allow: false } } }),
"utf-8",
);
const result = await runPolicyRepairCheck(
"policy/tools-elevated-enabled",
repairCtx(configPath, cfg),
);
expect(result.status).toBe("skipped");
expect(result.reason).toBe("workspace repairs are disabled");
expect(result.changes).toEqual([]);
expect(result.warnings).toEqual([
"Skipped policy config repair. Enable plugins.entries.policy.config.workspaceRepairs to let doctor --fix edit workspace policy config.",
]);
expect(result.config.tools?.elevated?.enabled).toBe(true);
});
it("does not over-apply scoped elevated policy findings globally", async () => {
const configPath = join(workspaceDir, "openclaw.jsonc");
const cfg = {
...cfgWithPolicy({ workspaceRepairs: true }),
agents: {
list: [
{
id: "reviewer",
tools: { elevated: { enabled: true } },
},
],
},
} as unknown as OpenClawConfig;
await fs.writeFile(configPath, "{}", "utf-8");
await fs.writeFile(
join(workspaceDir, "policy.jsonc"),
JSON.stringify({
scopes: {
reviewer: {
agentIds: ["reviewer"],
tools: { elevated: { allow: false } },
},
},
}),
"utf-8",
);
const result = await runPolicyRepairCheck(
"policy/tools-elevated-enabled",
repairCtx(configPath, cfg),
);
expect(result.status).toBe("skipped");
expect(result.reason).toBe("policy automatic repair had no config changes to apply");
expect(result.config).not.toHaveProperty("tools.elevated.enabled");
expect(result.config.agents?.list?.[0]).toMatchObject({
id: "reviewer",
tools: { elevated: { enabled: true } },
});
});
it("skips scoped elevated repairs that inherit shared global tools config", async () => {
const configPath = join(workspaceDir, "openclaw.jsonc");
const cfg = {
...cfgWithPolicy({ workspaceRepairs: true }),
tools: { elevated: { enabled: true } },
agents: {
list: [{ id: "reviewer" }],
},
} as unknown as OpenClawConfig;
await fs.writeFile(configPath, "{}", "utf-8");
await fs.writeFile(
join(workspaceDir, "policy.jsonc"),
JSON.stringify({
scopes: {
reviewer: {
agentIds: ["reviewer"],
tools: { elevated: { allow: false } },
},
},
}),
"utf-8",
);
const result = await runPolicyRepairCheck(
"policy/tools-elevated-enabled",
repairCtx(configPath, cfg),
);
expect(result.status).toBe("skipped");
expect(result.reason).toBe("policy automatic repair had no config changes to apply");
expect(result.changes).toEqual([]);
expect(result.warnings).toEqual([
"Skipped scoped tools repair. Scoped elevated-tools policy findings are detect-only because automatic repair cannot safely choose between shared and agent-local config targets.",
]);
expect(result.config.tools?.elevated?.enabled).toBe(true);
});
it("repairs automatic policy narrowing config findings", async () => {
const configPath = join(workspaceDir, "openclaw.jsonc");
const cfg = {
...cfgWithPolicy({ workspaceRepairs: true }),
tools: { elevated: { enabled: true } },
gateway: {
mode: "remote",
remote: { enabled: true, url: "wss://remote.example.test:18789" },
controlUi: {
allowInsecureAuth: true,
dangerouslyDisableDeviceAuth: true,
dangerouslyAllowHostHeaderOriginFallback: true,
},
},
logging: { redactSensitive: "off" },
diagnostics: { otel: { enabled: true, captureContent: { enabled: true, toolInputs: true } } },
} as unknown as OpenClawConfig;
await fs.writeFile(configPath, "{}", "utf-8");
await fs.writeFile(
join(workspaceDir, "policy.jsonc"),
JSON.stringify({
tools: { elevated: { allow: false } },
gateway: {
controlUi: { allowInsecure: false },
remote: { allow: false },
},
dataHandling: {
sensitiveLogging: { requireRedaction: true },
telemetry: { denyContentCapture: true },
},
}),
"utf-8",
);
const elevated = await runPolicyRepairCheck(
"policy/tools-elevated-enabled",
repairCtx(configPath, cfg),
);
const controlUi = await runPolicyRepairCheck(
"policy/gateway-control-ui-insecure",
repairCtx(configPath, elevated.config),
);
const remote = await runPolicyRepairCheck(
"policy/gateway-remote-enabled",
repairCtx(configPath, controlUi.config),
);
const redaction = await runPolicyRepairCheck(
"policy/data-handling-redaction-disabled",
repairCtx(configPath, remote.config),
);
const telemetry = await runPolicyRepairCheck(
"policy/data-handling-telemetry-content-capture",
repairCtx(configPath, redaction.config),
);
expect([
...elevated.changes,
...controlUi.changes,
...remote.changes,
...redaction.changes,
...telemetry.changes,
]).toEqual([
"Set tools.elevated.enabled=false for policy conformance.",
"Set gateway.controlUi.allowInsecureAuth=false for policy conformance.",
"Set gateway.controlUi.dangerouslyDisableDeviceAuth=false for policy conformance.",
"Set gateway.controlUi.dangerouslyAllowHostHeaderOriginFallback=false for policy conformance.",
"Set gateway.mode=local for policy conformance.",
"Set logging.redactSensitive=tools for policy conformance.",
"Set diagnostics.otel.captureContent=false for policy conformance.",
]);
expect(telemetry.remainingFindings).toEqual([]);
expect(telemetry.config).toMatchObject({
tools: { elevated: { enabled: false } },
gateway: {
mode: "local",
remote: {
enabled: true,
},
controlUi: {
allowInsecureAuth: false,
dangerouslyDisableDeviceAuth: false,
dangerouslyAllowHostHeaderOriginFallback: false,
},
},
logging: { redactSensitive: "tools" },
diagnostics: { otel: { captureContent: false } },
});
});
it("skips scoped data-handling repairs that would mutate shared config", async () => {
const configPath = join(workspaceDir, "openclaw.jsonc");
const cfg = {
...cfgWithPolicy({ workspaceRepairs: true }),
logging: { redactSensitive: "off" },
agents: {
list: [{ id: "reviewer" }],
},
} as unknown as OpenClawConfig;
await fs.writeFile(configPath, "{}", "utf-8");
await fs.writeFile(
join(workspaceDir, "policy.jsonc"),
JSON.stringify({
scopes: {
reviewer: {
agentIds: ["reviewer"],
dataHandling: {
sensitiveLogging: { requireRedaction: true },
},
},
},
}),
"utf-8",
);
const result = await runPolicyRepairCheck(
"policy/data-handling-redaction-disabled",
repairCtx(configPath, cfg),
);
expect(result.status).toBe("skipped");
expect(result.reason).toBe("policy automatic repair had no config changes to apply");
expect(result.changes).toEqual([]);
expect(result.warnings).toEqual([
"Skipped scoped data-handling repair. The finding reports shared logging config, so changing it would affect more than the scoped policy target.",
]);
expect(result.config.logging?.redactSensitive).toBe("off");
expect(result.remainingFindings).toEqual([
expect.objectContaining({
checkId: "policy/data-handling-redaction-disabled",
requirement:
"oc://policy.jsonc/scopes/reviewer/dataHandling/sensitiveLogging/requireRedaction",
}),
]);
});
it("does not register repair for review-required policy findings", () => {
const check = registerChecks().find(
(entry) => entry.id === "policy/gateway-http-url-fetch-unrestricted",
);
expect("repair" in (check ?? {})).toBe(false);
});
it("does not report denied providers for disabled channels", async () => {
const configPath = join(workspaceDir, "openclaw.jsonc");
const cfg = {
@@ -1,5 +1,6 @@
// Policy doctor health-check factories for one policy scope.
import type { HealthCheck } from "openclaw/plugin-sdk/health";
import { repairPolicyAutomaticNarrower } from "../automatic-repairs.js";
import { CHECK_IDS } from "../metadata.js";
import type { PolicyDoctorCheckDeps } from "../types.js";
@@ -17,6 +18,13 @@ export function createPolicyDataAuthChecks(deps: PolicyDoctorCheckDeps): readonl
CHECK_IDS.policyDataHandlingRedactionDisabled,
);
},
repair(ctx, findings) {
return repairPolicyAutomaticNarrower(
ctx,
findings,
CHECK_IDS.policyDataHandlingRedactionDisabled,
);
},
};
const policyDataHandlingTelemetryContentCaptureCheck: HealthCheck = {
id: CHECK_IDS.policyDataHandlingTelemetryContentCapture,
@@ -29,6 +37,13 @@ export function createPolicyDataAuthChecks(deps: PolicyDoctorCheckDeps): readonl
CHECK_IDS.policyDataHandlingTelemetryContentCapture,
);
},
repair(ctx, findings) {
return repairPolicyAutomaticNarrower(
ctx,
findings,
CHECK_IDS.policyDataHandlingTelemetryContentCapture,
);
},
};
const policyDataHandlingSessionRetentionNotEnforcedCheck: HealthCheck = {
id: CHECK_IDS.policyDataHandlingSessionRetentionNotEnforced,
@@ -1,6 +1,7 @@
// Policy doctor checks and findings for gateway exposure policy.
import type { HealthCheck, HealthFinding } from "openclaw/plugin-sdk/health";
import type { PolicyEvidence } from "../../policy-state.js";
import { repairPolicyAutomaticNarrower } from "../automatic-repairs.js";
import { CHECK_IDS } from "../metadata.js";
import type { PolicyDoctorCheckDeps } from "../types.js";
import { readPolicyBoolean, readStringList } from "../utils.js";
@@ -43,6 +44,9 @@ export function createPolicyGatewayChecks(deps: PolicyDoctorCheckDeps): readonly
async detect(ctx) {
return findingsForCheck(await evaluatePolicy(ctx), CHECK_IDS.policyGatewayControlUiInsecure);
},
repair(ctx, findings) {
return repairPolicyAutomaticNarrower(ctx, findings, CHECK_IDS.policyGatewayControlUiInsecure);
},
};
const policyGatewayTailscaleFunnelCheck: HealthCheck = {
id: CHECK_IDS.policyGatewayTailscaleFunnel,
@@ -61,6 +65,9 @@ export function createPolicyGatewayChecks(deps: PolicyDoctorCheckDeps): readonly
async detect(ctx) {
return findingsForCheck(await evaluatePolicy(ctx), CHECK_IDS.policyGatewayRemoteEnabled);
},
repair(ctx, findings) {
return repairPolicyAutomaticNarrower(ctx, findings, CHECK_IDS.policyGatewayRemoteEnabled);
},
};
const policyGatewayHttpEndpointEnabledCheck: HealthCheck = {
id: CHECK_IDS.policyGatewayHttpEndpointEnabled,
@@ -1,5 +1,6 @@
// Policy doctor health-check factories for one policy scope.
import type { HealthCheck } from "openclaw/plugin-sdk/health";
import { repairPolicyAutomaticNarrower } from "../automatic-repairs.js";
import { CHECK_IDS } from "../metadata.js";
import type { PolicyDoctorCheckDeps } from "../types.js";
@@ -86,6 +87,9 @@ export function createPolicyAgentToolChecks(deps: PolicyDoctorCheckDeps): readon
async detect(ctx) {
return findingsForCheck(await evaluatePolicy(ctx), CHECK_IDS.policyToolsElevatedEnabled);
},
repair(ctx, findings) {
return repairPolicyAutomaticNarrower(ctx, findings, CHECK_IDS.policyToolsElevatedEnabled);
},
};
const policyToolsAlsoAllowMissingCheck: HealthCheck = {
id: CHECK_IDS.policyToolsAlsoAllowMissing,