Skip to content

Commit 5b06eba

Browse files
authored
policy: repair required deny tool findings (#99700)
1 parent bfb89d3 commit 5b06eba

6 files changed

Lines changed: 267 additions & 2 deletions

File tree

docs/cli/policy.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -943,6 +943,8 @@ only after the policy file has been reviewed, because a valid rule can change
943943
workspace config:
944944

945945
- set `tools.elevated.enabled=false` when a global policy forbids elevated tools
946+
- add missing required-deny tool ids to `tools.deny` or
947+
`agents.list[].tools.deny` when policy requires those tools to be denied
946948
- set insecure `gateway.controlUi.*` toggles to `false`
947949
- set `gateway.mode=local` when policy denies remote gateway mode
948950
- set `logging.redactSensitive=tools` when policy requires sensitive logging
@@ -956,6 +958,11 @@ also skipped when the finding reports shared logging or telemetry config,
956958
because changing the shared setting would affect more than the scoped policy
957959
target.
958960

961+
Scoped required-deny repairs are skipped when the finding reports inherited
962+
root `tools.deny`, because adding the required tool to root config would affect
963+
more than the scoped policy target. Agent-local required-deny repairs can update
964+
the reported `agents.list[].tools.deny` path.
965+
959966
```jsonc
960967
{
961968
"plugins": {

extensions/policy/src/doctor/automatic-repairs.ts

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,9 @@ type RepairPatch = {
1717
};
1818

1919
const AUTOMATIC_REPAIR_CHECK_IDS = new Set<PolicyCheckId>([
20+
CHECK_IDS.policyAgentsToolNotDenied,
2021
CHECK_IDS.policyToolsElevatedEnabled,
22+
CHECK_IDS.policyToolsRequiredDenyMissing,
2123
CHECK_IDS.policyGatewayControlUiInsecure,
2224
CHECK_IDS.policyGatewayRemoteEnabled,
2325
CHECK_IDS.policyDataHandlingRedactionDisabled,
@@ -77,6 +79,8 @@ function applyAutomaticPatch(
7779
checkId: PolicyCheckId,
7880
): RepairPatch {
7981
switch (checkId) {
82+
case CHECK_IDS.policyAgentsToolNotDenied:
83+
return mergeRequiredDenyTools(cfg, findings);
8084
case CHECK_IDS.policyToolsElevatedEnabled:
8185
if (hasScopedPolicyRequirement(findings)) {
8286
return skippedUnsafeScopedRepair(
@@ -85,6 +89,8 @@ function applyAutomaticPatch(
8589
);
8690
}
8791
return disableElevatedTools(cfg, findings);
92+
case CHECK_IDS.policyToolsRequiredDenyMissing:
93+
return mergeRequiredDenyTools(cfg, findings);
8894
case CHECK_IDS.policyGatewayControlUiInsecure:
8995
return disableInsecureControlUi(cfg, findings);
9096
case CHECK_IDS.policyGatewayRemoteEnabled:
@@ -110,6 +116,36 @@ function applyAutomaticPatch(
110116
}
111117
}
112118

119+
function mergeRequiredDenyTools(
120+
cfg: OpenClawConfig,
121+
findings: readonly HealthFinding[],
122+
): RepairPatch {
123+
const next = cloneConfig(cfg);
124+
const changes: string[] = [];
125+
const warnings: string[] = [];
126+
for (const finding of findings) {
127+
const tool = missingRequiredTool(finding);
128+
if (tool === undefined || finding.ocPath === undefined) {
129+
continue;
130+
}
131+
if (
132+
hasScopedPolicyRequirement([finding]) &&
133+
finding.ocPath === "oc://openclaw.config/tools/deny"
134+
) {
135+
warnings.push(
136+
`Skipped scoped deny repair for ${tool}. The finding reports inherited root tools.deny, so changing it would affect more than the scoped policy target.`,
137+
);
138+
continue;
139+
}
140+
if (mergeStringArrayAtOcPath(next, finding.ocPath, tool)) {
141+
changes.push(`Added ${tool} to ${configPathLabel(finding.ocPath)} for policy conformance.`);
142+
}
143+
}
144+
return changes.length > 0
145+
? { config: next as OpenClawConfig, changes: uniqueStrings(changes), warnings }
146+
: { config: cfg, changes, warnings: uniqueStrings(warnings) };
147+
}
148+
113149
function disableElevatedTools(
114150
cfg: OpenClawConfig,
115151
findings: readonly HealthFinding[],
@@ -213,6 +249,74 @@ function cloneConfig(cfg: OpenClawConfig): ConfigRecord {
213249
return structuredClone(cfg) as ConfigRecord;
214250
}
215251

252+
function mergeStringArrayAtOcPath(cfg: ConfigRecord, ocPath: string, entry: string): boolean {
253+
const segments = configPathSegments(ocPath);
254+
if (segments.length === 0 || segments.at(-1) !== "deny") {
255+
return false;
256+
}
257+
let current: unknown = cfg;
258+
for (let index = 0; index < segments.length - 1; index += 1) {
259+
const segment = segments[index];
260+
if (segment === undefined) {
261+
return false;
262+
}
263+
if (segment.startsWith("#")) {
264+
const arrayIndex = Number.parseInt(segment.slice(1), 10);
265+
if (!Array.isArray(current) || !Number.isInteger(arrayIndex) || arrayIndex < 0) {
266+
return false;
267+
}
268+
current = current[arrayIndex];
269+
continue;
270+
}
271+
if (!isRecord(current)) {
272+
return false;
273+
}
274+
const nextSegment = segments[index + 1];
275+
const existing = current[segment];
276+
if (existing === undefined) {
277+
current[segment] = nextSegment?.startsWith("#") ? [] : {};
278+
}
279+
current = current[segment];
280+
}
281+
if (!isRecord(current)) {
282+
return false;
283+
}
284+
const existing = current.deny;
285+
if (existing !== undefined && !Array.isArray(existing)) {
286+
return false;
287+
}
288+
const deny = existing ?? [];
289+
if (deny.some((value) => typeof value === "string" && value === entry)) {
290+
return false;
291+
}
292+
current.deny = [...deny, entry];
293+
return true;
294+
}
295+
296+
function configPathSegments(ocPath: string): readonly string[] {
297+
const prefix = "oc://openclaw.config/";
298+
if (!ocPath.startsWith(prefix)) {
299+
return [];
300+
}
301+
return ocPath.slice(prefix.length).split("/").filter(Boolean);
302+
}
303+
304+
function configPathLabel(ocPath: string): string {
305+
let label = "";
306+
for (const segment of configPathSegments(ocPath)) {
307+
if (segment.startsWith("#")) {
308+
label += `[${segment.slice(1)}]`;
309+
} else {
310+
label += label === "" ? segment : `.${segment}`;
311+
}
312+
}
313+
return label;
314+
}
315+
316+
function missingRequiredTool(finding: HealthFinding): string | undefined {
317+
return finding.message.match(/required tool '([^']+)'/)?.[1]?.trim();
318+
}
319+
216320
function workspaceRepairsEnabled(ctx: HealthRepairContext): boolean {
217321
const plugins = isRecord(ctx.cfg.plugins) ? ctx.cfg.plugins : {};
218322
const entries = isRecord(plugins.entries) ? plugins.entries : {};
@@ -255,3 +359,7 @@ function ensureRecord(parent: ConfigRecord, key: string): ConfigRecord {
255359
function isRecord(value: unknown): value is ConfigRecord {
256360
return typeof value === "object" && value !== null && !Array.isArray(value);
257361
}
362+
363+
function uniqueStrings(values: readonly string[]): readonly string[] {
364+
return [...new Set(values)];
365+
}

extensions/policy/src/doctor/fix-metadata.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -229,7 +229,10 @@ export const POLICY_FIX_METADATA = [
229229
CHECK_IDS.policyToolsRequiredDenyMissing,
230230
"automatic",
231231
"Merge required built-in deny tool classes.",
232-
{ policyPath: ["tools", "denyTools"], configTargets: ["tools.denyTools"] },
232+
{
233+
policyPath: ["tools", "denyTools"],
234+
configTargets: ["tools.deny", "agents.list[].tools.deny"],
235+
},
233236
),
234237
m(
235238
CHECK_IDS.policySandboxModeUnapproved,

extensions/policy/src/doctor/metadata.test.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,12 @@ import {
55
POLICY_FIX_METADATA_BY_CHECK_ID,
66
type PolicyFixMetadata,
77
} from "./fix-metadata.js";
8-
import { POLICY_CHECK_IDS, POLICY_RULE_METADATA, type PolicyRuleMetadata } from "./metadata.js";
8+
import {
9+
CHECK_IDS,
10+
POLICY_CHECK_IDS,
11+
POLICY_RULE_METADATA,
12+
type PolicyRuleMetadata,
13+
} from "./metadata.js";
914

1015
describe("policy doctor metadata", () => {
1116
it("describes strictness for agent-scoped policy fields", () => {
@@ -173,6 +178,12 @@ describe("policy doctor metadata", () => {
173178
);
174179
});
175180

181+
it("points required-deny repair metadata at OpenClaw deny config paths", () => {
182+
expect(
183+
POLICY_FIX_METADATA_BY_CHECK_ID.get(CHECK_IDS.policyToolsRequiredDenyMissing)?.configTargets,
184+
).toEqual(["tools.deny", "agents.list[].tools.deny"]);
185+
});
186+
176187
it("keeps policy fix class assignments explicit", () => {
177188
const grouped = new Map<PolicyFixMetadata["fixClass"], PolicyFixMetadata[]>();
178189
for (const rule of POLICY_FIX_METADATA) {

extensions/policy/src/doctor/register.test.ts

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1739,6 +1739,136 @@ describe("registerPolicyDoctorChecks", () => {
17391739
});
17401740
});
17411741

1742+
it("dry-runs required tool deny repairs without mutating config", async () => {
1743+
const configPath = join(workspaceDir, "openclaw.jsonc");
1744+
const cfg = {
1745+
...cfgWithPolicy({ workspaceRepairs: true }),
1746+
tools: { deny: ["read"] },
1747+
} as unknown as OpenClawConfig;
1748+
await fs.writeFile(configPath, "{}", "utf-8");
1749+
await fs.writeFile(
1750+
join(workspaceDir, "policy.jsonc"),
1751+
JSON.stringify({ tools: { denyTools: ["exec", "write"] } }),
1752+
"utf-8",
1753+
);
1754+
1755+
const result = await runPolicyRepairCheck("policy/tools-required-deny-missing", {
1756+
...repairCtx(configPath, cfg),
1757+
dryRun: true,
1758+
});
1759+
1760+
expect(result.findings).toEqual(
1761+
expect.arrayContaining([
1762+
expect.objectContaining({
1763+
checkId: "policy/tools-required-deny-missing",
1764+
message: "global tools config does not deny required tool 'exec'.",
1765+
ocPath: "oc://openclaw.config/tools/deny",
1766+
}),
1767+
expect.objectContaining({
1768+
checkId: "policy/tools-required-deny-missing",
1769+
message: "global tools config does not deny required tool 'write'.",
1770+
ocPath: "oc://openclaw.config/tools/deny",
1771+
}),
1772+
]),
1773+
);
1774+
expect(result.status).toBe("repaired");
1775+
expect(result.changes).toEqual([
1776+
"Added exec to tools.deny for policy conformance.",
1777+
"Added write to tools.deny for policy conformance.",
1778+
]);
1779+
expect(result.config.tools?.deny).toEqual(["read", "exec", "write"]);
1780+
expect(cfg.tools?.deny).toEqual(["read"]);
1781+
});
1782+
1783+
it("repairs required agent workspace deny tool findings", async () => {
1784+
const configPath = join(workspaceDir, "openclaw.jsonc");
1785+
const cfg = {
1786+
...cfgWithPolicy({ workspaceRepairs: true }),
1787+
agents: {
1788+
list: [
1789+
{
1790+
id: "reviewer",
1791+
tools: { deny: ["exec"] },
1792+
},
1793+
],
1794+
},
1795+
} as unknown as OpenClawConfig;
1796+
await fs.writeFile(configPath, "{}", "utf-8");
1797+
await fs.writeFile(
1798+
join(workspaceDir, "policy.jsonc"),
1799+
JSON.stringify({
1800+
scopes: {
1801+
reviewer: {
1802+
agentIds: ["reviewer"],
1803+
agents: { workspace: { denyTools: ["exec", "write", "edit"] } },
1804+
},
1805+
},
1806+
}),
1807+
"utf-8",
1808+
);
1809+
1810+
const result = await runPolicyRepairCheck(
1811+
"policy/agents-tool-not-denied",
1812+
repairCtx(configPath, cfg),
1813+
);
1814+
1815+
expect(result.status).toBe("repaired");
1816+
expect(result.changes).toEqual([
1817+
"Added edit to agents.list[0].tools.deny for policy conformance.",
1818+
"Added write to agents.list[0].tools.deny for policy conformance.",
1819+
]);
1820+
expect(result.remainingFindings).toEqual([]);
1821+
expect(result.config.agents?.list?.[0]).toMatchObject({
1822+
id: "reviewer",
1823+
tools: { deny: ["exec", "edit", "write"] },
1824+
});
1825+
});
1826+
1827+
it("skips scoped required deny repairs that would mutate root tools deny", async () => {
1828+
const configPath = join(workspaceDir, "openclaw.jsonc");
1829+
const cfg = {
1830+
...cfgWithPolicy({ workspaceRepairs: true }),
1831+
tools: { deny: ["exec"] },
1832+
agents: {
1833+
list: [{ id: "reviewer" }],
1834+
},
1835+
} as unknown as OpenClawConfig;
1836+
await fs.writeFile(configPath, "{}", "utf-8");
1837+
await fs.writeFile(
1838+
join(workspaceDir, "policy.jsonc"),
1839+
JSON.stringify({
1840+
scopes: {
1841+
reviewer: {
1842+
agentIds: ["reviewer"],
1843+
tools: { denyTools: ["exec", "write"] },
1844+
},
1845+
},
1846+
}),
1847+
"utf-8",
1848+
);
1849+
1850+
const result = await runPolicyRepairCheck(
1851+
"policy/tools-required-deny-missing",
1852+
repairCtx(configPath, cfg),
1853+
);
1854+
1855+
expect(result.status).toBe("skipped");
1856+
expect(result.reason).toBe("policy automatic repair had no config changes to apply");
1857+
expect(result.changes).toEqual([]);
1858+
expect(result.warnings).toEqual([
1859+
"Skipped scoped deny repair for write. The finding reports inherited root tools.deny, so changing it would affect more than the scoped policy target.",
1860+
]);
1861+
expect(result.config.tools?.deny).toEqual(["exec"]);
1862+
expect(result.config.agents?.list?.[0]).toEqual({ id: "reviewer" });
1863+
expect(result.remainingFindings).toEqual([
1864+
expect.objectContaining({
1865+
checkId: "policy/tools-required-deny-missing",
1866+
ocPath: "oc://openclaw.config/tools/deny",
1867+
requirement: "oc://policy.jsonc/scopes/reviewer/tools/denyTools",
1868+
}),
1869+
]);
1870+
});
1871+
17421872
it("skips scoped data-handling repairs that would mutate shared config", async () => {
17431873
const configPath = join(workspaceDir, "openclaw.jsonc");
17441874
const cfg = {

extensions/policy/src/doctor/scopes/tools.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,9 @@ export function createPolicyAgentToolChecks(deps: PolicyDoctorCheckDeps): readon
2727
async detect(ctx) {
2828
return findingsForCheck(await evaluatePolicy(ctx), CHECK_IDS.policyAgentsToolNotDenied);
2929
},
30+
repair(ctx, findings) {
31+
return repairPolicyAutomaticNarrower(ctx, findings, CHECK_IDS.policyAgentsToolNotDenied);
32+
},
3033
};
3134
const policyToolsProfileUnapprovedCheck: HealthCheck = {
3235
id: CHECK_IDS.policyToolsProfileUnapproved,
@@ -117,6 +120,9 @@ export function createPolicyAgentToolChecks(deps: PolicyDoctorCheckDeps): readon
117120
async detect(ctx) {
118121
return findingsForCheck(await evaluatePolicy(ctx), CHECK_IDS.policyToolsRequiredDenyMissing);
119122
},
123+
repair(ctx, findings) {
124+
return repairPolicyAutomaticNarrower(ctx, findings, CHECK_IDS.policyToolsRequiredDenyMissing);
125+
},
120126
};
121127

122128
return [

0 commit comments

Comments
 (0)