diff --git a/extensions/policy/src/doctor/fix-metadata.ts b/extensions/policy/src/doctor/fix-metadata.ts new file mode 100644 index 000000000000..3a82952e94cd --- /dev/null +++ b/extensions/policy/src/doctor/fix-metadata.ts @@ -0,0 +1,453 @@ +// Policy doctor fix metadata classifies findings before patch builders exist. +import { CHECK_IDS, POLICY_CHECK_IDS } from "./metadata.js"; + +export type PolicyFixClass = + | "automatic" + | "reviewRequired" + | "manual" + | "validateOnly" + | "unsupported"; + +export type PolicyFixMetadata = { + readonly checkId: (typeof POLICY_CHECK_IDS)[number]; + readonly fixClass: PolicyFixClass; + readonly policyPath?: readonly string[]; + readonly configTargets?: readonly string[]; + readonly summary: string; +}; + +const m = ( + checkId: (typeof POLICY_CHECK_IDS)[number], + fixClass: PolicyFixClass, + summary: string, + options: Omit = {}, +): PolicyFixMetadata => ({ + checkId, + fixClass, + summary, + ...options, +}); + +export const POLICY_FIX_METADATA = [ + m(CHECK_IDS.policyMissingFile, "manual", "Restore or author the approved policy artifact."), + m(CHECK_IDS.policyInvalidFile, "manual", "Repair the policy JSONC syntax or schema."), + m( + CHECK_IDS.policyHashMismatch, + "manual", + "Restore the approved artifact or update the expected hash after review.", + { configTargets: ["plugins.entries.policy.config.expectedHash"] }, + ), + m( + CHECK_IDS.policyAttestationMismatch, + "manual", + "Review the current attestation and update accepted hashes after approval.", + { configTargets: ["plugins.entries.policy.config.expectedAttestationHash"] }, + ), + m( + CHECK_IDS.policyDeniedChannelProvider, + "automatic", + "Disable product-managed channels matching the denied provider.", + { policyPath: ["channels", "denyRules"], configTargets: ["channels"] }, + ), + m(CHECK_IDS.policyDeniedMcpServer, "reviewRequired", "Remove or disable the denied MCP server.", { + policyPath: ["mcp", "servers", "deny"], + configTargets: ["mcp.servers"], + }), + m( + CHECK_IDS.policyUnapprovedMcpServer, + "reviewRequired", + "Remove the unapproved MCP server or select an approved replacement.", + { policyPath: ["mcp", "servers", "allow"], configTargets: ["mcp.servers"] }, + ), + m( + CHECK_IDS.policyDeniedModelProvider, + "reviewRequired", + "Remove the model provider or switch references to an approved provider.", + { policyPath: ["models", "providers", "deny"], configTargets: ["models"] }, + ), + m( + CHECK_IDS.policyUnapprovedModelProvider, + "reviewRequired", + "Select an approved model provider.", + { policyPath: ["models", "providers", "allow"], configTargets: ["models"] }, + ), + m( + CHECK_IDS.policyPrivateNetworkAccess, + "reviewRequired", + "Disable the concrete private-network access opt-in.", + { policyPath: ["network", "privateNetwork", "allow"], configTargets: ["network"] }, + ), + m( + CHECK_IDS.policyIngressDmPolicyUnapproved, + "reviewRequired", + "Set channel DM policy to an allowed value.", + { policyPath: ["ingress", "channels", "allowDmPolicies"], configTargets: ["channels"] }, + ), + m( + CHECK_IDS.policyIngressDmScopeUnapproved, + "reviewRequired", + "Move session DM scope to the required or stricter ordered value.", + { policyPath: ["ingress", "session", "requireDmScope"], configTargets: ["ingress"] }, + ), + m( + CHECK_IDS.policyIngressOpenGroupsDenied, + "automatic", + "Disable product-managed open group ingress.", + { policyPath: ["ingress", "channels", "denyOpenGroups"], configTargets: ["channels"] }, + ), + m( + CHECK_IDS.policyIngressGroupMentionRequired, + "automatic", + "Require mention in product-managed group channels.", + { policyPath: ["ingress", "channels", "requireMentionInGroups"], configTargets: ["channels"] }, + ), + m( + CHECK_IDS.policyGatewayNonLoopbackBind, + "reviewRequired", + "Set gateway bind address to loopback when remote exposure is not intended.", + { + policyPath: ["gateway", "exposure", "allowNonLoopbackBind"], + configTargets: ["gateway.bind"], + }, + ), + m( + CHECK_IDS.policyGatewayAuthDisabled, + "manual", + "Configure token, password, or trusted-proxy auth.", + { policyPath: ["gateway", "auth", "requireAuth"], configTargets: ["gateway.auth"] }, + ), + m( + CHECK_IDS.policyGatewayRateLimitMissing, + "reviewRequired", + "Add explicit gateway auth rate limits from product defaults.", + { + policyPath: ["gateway", "auth", "requireExplicitRateLimit"], + configTargets: ["gateway.auth.rateLimit"], + }, + ), + m( + CHECK_IDS.policyGatewayControlUiInsecure, + "automatic", + "Disable the insecure Control UI toggle.", + { policyPath: ["gateway", "controlUi", "allowInsecure"], configTargets: ["gateway.controlUi"] }, + ), + m( + CHECK_IDS.policyGatewayTailscaleFunnel, + "reviewRequired", + "Disable Tailscale funnel or serve exposure.", + { policyPath: ["gateway", "exposure", "allowTailscaleFunnel"], configTargets: ["tailscale"] }, + ), + m( + CHECK_IDS.policyGatewayRemoteEnabled, + "automatic", + "Disable product-managed remote gateway mode.", + { policyPath: ["gateway", "remote", "allow"], configTargets: ["gateway.remote"] }, + ), + m( + CHECK_IDS.policyGatewayHttpEndpointEnabled, + "reviewRequired", + "Disable denied Gateway HTTP endpoints.", + { policyPath: ["gateway", "http", "denyEndpoints"], configTargets: ["gateway.http"] }, + ), + m( + CHECK_IDS.policyGatewayHttpUrlFetchUnrestricted, + "manual", + "Add URL allowlists for each URL-fetch input.", + { policyPath: ["gateway", "http", "requireUrlAllowlists"], configTargets: ["gateway.http"] }, + ), + m( + CHECK_IDS.policyGatewayNodeCommandDenied, + "reviewRequired", + "Add the command to gateway node denyCommands or update policy after review.", + { + policyPath: ["gateway", "nodes", "denyCommands"], + configTargets: ["gateway.nodes.denyCommands"], + }, + ), + m( + CHECK_IDS.policyAgentsWorkspaceAccessDenied, + "reviewRequired", + "Set agent workspace access to an allowed mode.", + { policyPath: ["agents", "workspace", "allowedAccess"], configTargets: ["agents"] }, + ), + m( + CHECK_IDS.policyAgentsToolNotDenied, + "automatic", + "Merge required built-in workspace tool denies.", + { policyPath: ["agents", "workspace", "denyTools"], configTargets: ["agents"] }, + ), + m( + CHECK_IDS.policyToolsProfileUnapproved, + "reviewRequired", + "Set the tool profile to an allowed profile.", + { policyPath: ["tools", "profiles", "allow"], configTargets: ["tools.profile"] }, + ), + m( + CHECK_IDS.policyToolsFsWorkspaceOnlyRequired, + "reviewRequired", + "Set workspace-only filesystem posture when required assets remain readable.", + { + policyPath: ["tools", "fs", "requireWorkspaceOnly"], + configTargets: ["tools.fs.workspaceOnly"], + }, + ), + m( + CHECK_IDS.policyToolsExecSecurityUnapproved, + "reviewRequired", + "Set exec security to an allowed value.", + { policyPath: ["tools", "exec", "allowSecurity"], configTargets: ["tools.exec.security"] }, + ), + m( + CHECK_IDS.policyToolsExecAskUnapproved, + "reviewRequired", + "Set exec ask mode to an allowed value.", + { policyPath: ["tools", "exec", "requireAsk"], configTargets: ["tools.exec.ask"] }, + ), + m( + CHECK_IDS.policyToolsExecHostUnapproved, + "reviewRequired", + "Move exec host to an allowed host mode.", + { policyPath: ["tools", "exec", "allowHosts"], configTargets: ["tools.exec.host"] }, + ), + m(CHECK_IDS.policyToolsElevatedEnabled, "automatic", "Set tools elevated mode to disabled.", { + policyPath: ["tools", "elevated", "allow"], + configTargets: ["tools.elevated.enabled"], + }), + m( + CHECK_IDS.policyToolsAlsoAllowMissing, + "reviewRequired", + "Add expected alsoAllow entries only when policy intentionally grants them.", + { policyPath: ["tools", "alsoAllow", "expected"], configTargets: ["tools.alsoAllow"] }, + ), + m( + CHECK_IDS.policyToolsAlsoAllowUnexpected, + "reviewRequired", + "Remove unexpected alsoAllow entries.", + { policyPath: ["tools", "alsoAllow", "expected"], configTargets: ["tools.alsoAllow"] }, + ), + m( + CHECK_IDS.policyToolsRequiredDenyMissing, + "automatic", + "Merge required built-in deny tool classes.", + { policyPath: ["tools", "denyTools"], configTargets: ["tools.denyTools"] }, + ), + m( + CHECK_IDS.policySandboxModeUnapproved, + "reviewRequired", + "Set sandbox mode to an allowed value.", + { policyPath: ["sandbox", "requireMode"], configTargets: ["sandbox.mode"] }, + ), + m( + CHECK_IDS.policySandboxBackendUnapproved, + "reviewRequired", + "Choose an approved sandbox backend that is installed.", + { policyPath: ["sandbox", "allowBackends"], configTargets: ["sandbox.backend"] }, + ), + m( + CHECK_IDS.policySandboxContainerPostureUnobservable, + "unsupported", + "Add observable container posture evidence before patching.", + ), + m( + CHECK_IDS.policySandboxContainerHostNetworkDenied, + "reviewRequired", + "Disable container host networking.", + { + policyPath: ["sandbox", "containers", "denyHostNetwork"], + configTargets: ["sandbox.containers"], + }, + ), + m( + CHECK_IDS.policySandboxContainerNamespaceJoinDenied, + "reviewRequired", + "Disable joining container namespaces.", + { + policyPath: ["sandbox", "containers", "denyContainerNamespaceJoin"], + configTargets: ["sandbox.containers"], + }, + ), + m( + CHECK_IDS.policySandboxContainerMountModeRequired, + "reviewRequired", + "Change required mounts to read-only.", + { + policyPath: ["sandbox", "containers", "requireReadOnlyMounts"], + configTargets: ["sandbox.containers"], + }, + ), + m( + CHECK_IDS.policySandboxContainerRuntimeSocketMount, + "reviewRequired", + "Remove container runtime socket binds.", + { + policyPath: ["sandbox", "containers", "denyContainerRuntimeSocketMounts"], + configTargets: ["sandbox.containers"], + }, + ), + m( + CHECK_IDS.policySandboxContainerUnconfinedProfile, + "reviewRequired", + "Remove unconfined container profiles.", + { + policyPath: ["sandbox", "containers", "denyUnconfinedProfiles"], + configTargets: ["sandbox.containers"], + }, + ), + m( + CHECK_IDS.policySandboxBrowserCdpSourceRangeMissing, + "manual", + "Add an explicit browser CDP source range.", + { + policyPath: ["sandbox", "browser", "requireCdpSourceRange"], + configTargets: ["agents.sandbox.browser"], + }, + ), + m( + CHECK_IDS.policyDataHandlingRedactionDisabled, + "automatic", + "Set sensitive logging to a redacting mode.", + { + policyPath: ["dataHandling", "sensitiveLogging", "requireRedaction"], + configTargets: ["logging.redactSensitive"], + }, + ), + m( + CHECK_IDS.policyDataHandlingTelemetryContentCapture, + "automatic", + "Disable telemetry content capture.", + { + policyPath: ["dataHandling", "telemetry", "denyContentCapture"], + configTargets: ["diagnostics.otel.captureContent"], + }, + ), + m( + CHECK_IDS.policyDataHandlingSessionRetentionNotEnforced, + "reviewRequired", + "Set session maintenance to enforced mode.", + { + policyPath: ["dataHandling", "retention", "requireSessionMaintenance"], + configTargets: ["session.maintenance.mode"], + }, + ), + m( + CHECK_IDS.policyDataHandlingSessionTranscriptMemory, + "reviewRequired", + "Disable transcript indexing for the affected agent scope.", + { + policyPath: ["dataHandling", "memory", "denySessionTranscriptIndexing"], + configTargets: ["memory"], + }, + ), + m( + CHECK_IDS.policySecretsUnmanagedProvider, + "manual", + "Migrate the secret to a managed provider.", + { policyPath: ["secrets", "requireManagedProviders"], configTargets: ["secrets"] }, + ), + m( + CHECK_IDS.policySecretsDeniedProviderSource, + "reviewRequired", + "Move the secret out of the denied source.", + { policyPath: ["secrets", "denySources"], configTargets: ["secrets"] }, + ), + m( + CHECK_IDS.policySecretsInsecureProvider, + "reviewRequired", + "Remove insecure provider overrides.", + { policyPath: ["secrets", "allowInsecureProviders"], configTargets: ["secrets"] }, + ), + m( + CHECK_IDS.policyAuthProfileInvalidMetadata, + "manual", + "Add required provider and mode metadata to auth profiles.", + { policyPath: ["auth", "profiles", "requireMetadata"], configTargets: ["auth.profiles"] }, + ), + m( + CHECK_IDS.policyAuthProfileUnapprovedMode, + "manual", + "Change auth mode and credentials through the auth owner flow.", + { policyPath: ["auth", "profiles", "allowModes"], configTargets: ["auth.profiles"] }, + ), + m( + CHECK_IDS.policyExecApprovalsMissing, + "manual", + "Restore an attributable exec-approvals evidence file.", + { policyPath: ["execApprovals", "requireFile"], configTargets: ["exec-approvals.json"] }, + ), + m(CHECK_IDS.policyExecApprovalsInvalid, "manual", "Repair the exec approvals evidence artifact."), + m( + CHECK_IDS.policyExecApprovalsDefaultSecurityUnapproved, + "manual", + "Update reviewed default approval evidence or policy.", + { + policyPath: ["execApprovals", "defaults", "allowSecurity"], + configTargets: ["exec-approvals.json"], + }, + ), + m( + CHECK_IDS.policyExecApprovalsAgentSecurityUnapproved, + "manual", + "Update reviewed agent approval evidence or policy.", + { + policyPath: ["execApprovals", "agents", "allowSecurity"], + configTargets: ["exec-approvals.json"], + }, + ), + m( + CHECK_IDS.policyExecApprovalsAutoAllowSkillsEnabled, + "reviewRequired", + "Disable auto-allow skills in the approval owner surface.", + { + policyPath: ["execApprovals", "agents", "allowAutoAllowSkills"], + configTargets: ["exec-approvals.json"], + }, + ), + m( + CHECK_IDS.policyExecApprovalsAllowlistMissing, + "manual", + "Add expected approval patterns through approval review.", + { + policyPath: ["execApprovals", "agents", "allowlist", "expected"], + configTargets: ["exec-approvals.json"], + }, + ), + m( + CHECK_IDS.policyExecApprovalsAllowlistUnexpected, + "manual", + "Remove unexpected approval patterns through approval review.", + { + policyPath: ["execApprovals", "agents", "allowlist", "expected"], + configTargets: ["exec-approvals.json"], + }, + ), + m( + CHECK_IDS.policyMissingToolRisk, + "manual", + "Add tool risk metadata in the owning tool declaration.", + { policyPath: ["tools", "requireMetadata"], configTargets: ["tools"] }, + ), + m(CHECK_IDS.policyUnknownToolRisk, "manual", "Use a supported tool risk level.", { + policyPath: ["tools", "requireMetadata"], + configTargets: ["tools"], + }), + m( + CHECK_IDS.policyMissingToolSensitivity, + "manual", + "Add tool sensitivity metadata in the owning tool declaration.", + { policyPath: ["tools", "requireMetadata"], configTargets: ["tools"] }, + ), + m( + CHECK_IDS.policyMissingToolOwner, + "manual", + "Add owner metadata in the owning tool declaration.", + { policyPath: ["tools", "requireMetadata"], configTargets: ["tools"] }, + ), + m(CHECK_IDS.policyUnknownToolSensitivity, "manual", "Use a supported tool sensitivity token.", { + policyPath: ["tools", "requireMetadata"], + configTargets: ["tools"], + }), +] as const satisfies readonly PolicyFixMetadata[]; + +export const POLICY_FIX_METADATA_BY_CHECK_ID = new Map( + POLICY_FIX_METADATA.map((rule) => [rule.checkId, rule] as const), +); diff --git a/extensions/policy/src/doctor/metadata.test.ts b/extensions/policy/src/doctor/metadata.test.ts index 72a564109c14..eda293d42910 100644 --- a/extensions/policy/src/doctor/metadata.test.ts +++ b/extensions/policy/src/doctor/metadata.test.ts @@ -1,6 +1,7 @@ // Policy doctor metadata tests cover rule metadata. import { describe, expect, it } from "vitest"; -import { POLICY_RULE_METADATA, type PolicyRuleMetadata } from "./metadata.js"; +import { POLICY_FIX_METADATA, POLICY_FIX_METADATA_BY_CHECK_ID } from "./fix-metadata.js"; +import { POLICY_CHECK_IDS, POLICY_RULE_METADATA, type PolicyRuleMetadata } from "./metadata.js"; describe("policy doctor metadata", () => { it("describes strictness for agent-scoped policy fields", () => { @@ -158,4 +159,97 @@ describe("policy doctor metadata", () => { }, ]); }); + + it("classifies every policy finding for fix recommendation coverage", () => { + expect(POLICY_FIX_METADATA.map((rule) => rule.checkId)).toHaveLength( + new Set(POLICY_FIX_METADATA.map((rule) => rule.checkId)).size, + ); + expect([...POLICY_FIX_METADATA_BY_CHECK_ID.keys()].toSorted()).toEqual( + [...POLICY_CHECK_IDS].toSorted(), + ); + }); + + it("keeps policy fix class assignments explicit", () => { + const grouped = Object.groupBy(POLICY_FIX_METADATA, (rule) => rule.fixClass); + + expect({ + automatic: grouped.automatic?.map((rule) => rule.checkId).toSorted(), + manual: grouped.manual?.map((rule) => rule.checkId).toSorted(), + reviewRequired: grouped.reviewRequired?.map((rule) => rule.checkId).toSorted(), + unsupported: grouped.unsupported?.map((rule) => rule.checkId).toSorted(), + validateOnly: grouped.validateOnly?.map((rule) => rule.checkId).toSorted() ?? [], + }).toEqual({ + automatic: [ + "policy/agents-tool-not-denied", + "policy/channels-denied-provider", + "policy/data-handling-redaction-disabled", + "policy/data-handling-telemetry-content-capture", + "policy/gateway-control-ui-insecure", + "policy/gateway-remote-enabled", + "policy/ingress-group-mention-required", + "policy/ingress-open-groups-denied", + "policy/tools-elevated-enabled", + "policy/tools-required-deny-missing", + ], + manual: [ + "policy/attestation-hash-mismatch", + "policy/auth-profile-invalid-metadata", + "policy/auth-profile-unapproved-mode", + "policy/exec-approvals-agent-security-unapproved", + "policy/exec-approvals-allowlist-missing", + "policy/exec-approvals-allowlist-unexpected", + "policy/exec-approvals-default-security-unapproved", + "policy/exec-approvals-invalid", + "policy/exec-approvals-missing", + "policy/gateway-auth-disabled", + "policy/gateway-http-url-fetch-unrestricted", + "policy/policy-hash-mismatch", + "policy/policy-jsonc-invalid", + "policy/policy-jsonc-missing", + "policy/sandbox-browser-cdp-source-range-missing", + "policy/secrets-unmanaged-provider", + "policy/tools-missing-owner", + "policy/tools-missing-risk-level", + "policy/tools-missing-sensitivity-token", + "policy/tools-unknown-risk-level", + "policy/tools-unknown-sensitivity-token", + ], + reviewRequired: [ + "policy/agents-workspace-access-denied", + "policy/data-handling-session-retention-not-enforced", + "policy/data-handling-session-transcript-memory-enabled", + "policy/exec-approvals-auto-allow-skills-enabled", + "policy/gateway-http-endpoint-enabled", + "policy/gateway-node-command-denied", + "policy/gateway-non-loopback-bind", + "policy/gateway-rate-limit-missing", + "policy/gateway-tailscale-funnel", + "policy/ingress-dm-policy-unapproved", + "policy/ingress-dm-scope-unapproved", + "policy/mcp-denied-server", + "policy/mcp-unapproved-server", + "policy/models-denied-provider", + "policy/models-unapproved-provider", + "policy/network-private-access-enabled", + "policy/sandbox-backend-unapproved", + "policy/sandbox-container-host-network-denied", + "policy/sandbox-container-mount-mode-required", + "policy/sandbox-container-namespace-join-denied", + "policy/sandbox-container-runtime-socket-mount", + "policy/sandbox-container-unconfined-profile", + "policy/sandbox-mode-unapproved", + "policy/secrets-denied-provider-source", + "policy/secrets-insecure-provider", + "policy/tools-also-allow-missing", + "policy/tools-also-allow-unexpected", + "policy/tools-exec-ask-unapproved", + "policy/tools-exec-host-unapproved", + "policy/tools-exec-security-unapproved", + "policy/tools-fs-workspace-only-required", + "policy/tools-profile-unapproved", + ], + unsupported: ["policy/sandbox-container-posture-unobservable"], + validateOnly: [], + }); + }); }); diff --git a/extensions/policy/src/doctor/register.test.ts b/extensions/policy/src/doctor/register.test.ts index 85db51c034fb..0f2ace2b969e 100644 --- a/extensions/policy/src/doctor/register.test.ts +++ b/extensions/policy/src/doctor/register.test.ts @@ -19,7 +19,11 @@ import { scanPolicyIngress, scanPolicyMcpServers, } from "../policy-state.js"; -import { registerPolicyDoctorChecks, resetPolicyDoctorChecksForTest } from "./register.js"; +import { + evaluatePolicy, + registerPolicyDoctorChecks, + resetPolicyDoctorChecksForTest, +} from "./register.js"; let workspaceDir: string; let originalOpenClawHome: string | undefined; @@ -1406,8 +1410,18 @@ describe("registerPolicyDoctorChecks", () => { target: "oc://openclaw.config/channels/telegram", requirement: "oc://policy.jsonc/channels/denyRules/#0", fixHint: "Telegram is not approved for this workspace.", + fixRecommendation: { + fixClass: "automatic", + policyPath: ["channels", "denyRules"], + configTargets: ["channels"], + summary: "Disable product-managed channels matching the denied provider.", + }, }), ]); + + const evaluation = await evaluatePolicy(ctx(configPath, cfg)); + expect(evaluation.findings[0]).toHaveProperty("fixRecommendation"); + expect(evaluation.attestedFindings[0]).not.toHaveProperty("fixRecommendation"); }); it("repairs denied enabled channels by disabling them when workspace repairs are enabled", async () => { diff --git a/extensions/policy/src/doctor/register.ts b/extensions/policy/src/doctor/register.ts index 48c518a46fbd..7d994558cf1d 100644 --- a/extensions/policy/src/doctor/register.ts +++ b/extensions/policy/src/doctor/register.ts @@ -29,6 +29,7 @@ import { POLICY_TOOL_GROUPS } from "../tool-policy-conformance.js"; const loadFsPromisesModule = createLazyRuntimeModule(() => import("node:fs/promises")); import { createPolicyDoctorChecks } from "./checks.js"; +import { POLICY_FIX_METADATA_BY_CHECK_ID } from "./fix-metadata.js"; import { CHECK_IDS, POLICY_CHECK_IDS, @@ -175,7 +176,7 @@ async function evaluatePolicyUncached(ctx: HealthCheckContext): Promise { + const metadata = POLICY_FIX_METADATA_BY_CHECK_ID.get( + finding.checkId as (typeof POLICY_CHECK_IDS)[number], + ); + if (metadata === undefined) { + return finding; + } + return { + ...finding, + fixRecommendation: { + fixClass: metadata.fixClass, + ...(metadata.policyPath !== undefined ? { policyPath: metadata.policyPath } : {}), + ...(metadata.configTargets !== undefined ? { configTargets: metadata.configTargets } : {}), + summary: metadata.summary, + }, + }; + }); +} + function policyParseFinding( policyPath: string, policyDocName: string, diff --git a/src/flows/health-checks.ts b/src/flows/health-checks.ts index ebba145c3776..c71011520411 100644 --- a/src/flows/health-checks.ts +++ b/src/flows/health-checks.ts @@ -44,6 +44,12 @@ export interface HealthFinding { readonly target?: string; readonly requirement?: string; readonly fixHint?: string; + readonly fixRecommendation?: { + readonly fixClass: "automatic" | "reviewRequired" | "manual" | "validateOnly" | "unsupported"; + readonly policyPath?: readonly string[]; + readonly configTargets?: readonly string[]; + readonly summary: string; + }; } export type HealthCheckMode = "doctor" | "lint" | "fix";