Skip to content

Commit d81f5ed

Browse files
committed
fix(policy): align agent workspace evidence with runtime
1 parent e0edcdf commit d81f5ed

3 files changed

Lines changed: 248 additions & 11 deletions

File tree

docs/cli/policy.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -342,7 +342,7 @@ Example JSON output:
342342
}
343343
]
344344
},
345-
"checksRun": 31,
345+
"checksRun": 30,
346346
"checksSkipped": 0,
347347
"findings": []
348348
}

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

Lines changed: 183 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2135,6 +2135,141 @@ describe("registerPolicyDoctorChecks", () => {
21352135
);
21362136
});
21372137

2138+
it("accepts sandbox-scoped tool denies for read-only agent workspace policy", async () => {
2139+
const configPath = join(workspaceDir, "openclaw.jsonc");
2140+
const cfg = {
2141+
...cfgWithPolicy(),
2142+
tools: {
2143+
sandbox: { tools: { deny: ["group:runtime", "group:fs"] } },
2144+
},
2145+
agents: {
2146+
defaults: {
2147+
sandbox: { mode: "all", workspaceAccess: "ro" },
2148+
},
2149+
list: [
2150+
{
2151+
id: "locked",
2152+
sandbox: { workspaceAccess: "none" },
2153+
tools: { sandbox: { tools: { deny: ["group:runtime", "group:fs"] } } },
2154+
},
2155+
],
2156+
},
2157+
} as unknown as OpenClawConfig;
2158+
await fs.writeFile(configPath, "{}", "utf-8");
2159+
await fs.writeFile(
2160+
join(workspaceDir, "policy.jsonc"),
2161+
JSON.stringify({
2162+
agents: {
2163+
workspace: {
2164+
allowedAccess: ["none", "ro"],
2165+
denyTools: ["exec", "process", "write", "edit", "apply_patch"],
2166+
},
2167+
},
2168+
}),
2169+
"utf-8",
2170+
);
2171+
2172+
registerPolicyDoctorChecks();
2173+
const result = await runDoctorLintChecks(ctx(configPath, cfg));
2174+
const evidence = collectPolicyEvidence(cfg as unknown as Record<string, unknown>);
2175+
2176+
expect(evidence.agentWorkspace).toEqual(
2177+
expect.arrayContaining([
2178+
expect.objectContaining({
2179+
id: "agents-defaults-tool-exec",
2180+
denied: true,
2181+
source: "oc://openclaw.config/tools/sandbox/tools/deny",
2182+
}),
2183+
expect.objectContaining({
2184+
id: "locked-tool-apply_patch",
2185+
denied: true,
2186+
source: "oc://openclaw.config/agents/list/#0/tools/sandbox/tools/deny",
2187+
}),
2188+
]),
2189+
);
2190+
expect(result.findings).toEqual([]);
2191+
});
2192+
2193+
it("accepts runtime tool deny globs for agent workspace policy", async () => {
2194+
const configPath = join(workspaceDir, "openclaw.jsonc");
2195+
const cfg = {
2196+
...cfgWithPolicy(),
2197+
tools: {
2198+
deny: ["e*"],
2199+
},
2200+
agents: {
2201+
defaults: {
2202+
sandbox: { mode: "all", workspaceAccess: "ro" },
2203+
},
2204+
},
2205+
} as unknown as OpenClawConfig;
2206+
await fs.writeFile(configPath, "{}", "utf-8");
2207+
await fs.writeFile(
2208+
join(workspaceDir, "policy.jsonc"),
2209+
JSON.stringify({
2210+
agents: {
2211+
workspace: {
2212+
allowedAccess: ["ro"],
2213+
denyTools: ["exec"],
2214+
},
2215+
},
2216+
}),
2217+
"utf-8",
2218+
);
2219+
2220+
registerPolicyDoctorChecks();
2221+
const result = await runDoctorLintChecks(ctx(configPath, cfg));
2222+
2223+
expect(result.findings).toEqual([]);
2224+
});
2225+
2226+
it("reports sandbox tool deny overrides outside policy", async () => {
2227+
const configPath = join(workspaceDir, "openclaw.jsonc");
2228+
const cfg = {
2229+
...cfgWithPolicy(),
2230+
tools: {
2231+
sandbox: { tools: { deny: ["exec"] } },
2232+
},
2233+
agents: {
2234+
defaults: {
2235+
sandbox: { mode: "all", workspaceAccess: "ro" },
2236+
},
2237+
list: [
2238+
{
2239+
id: "locked",
2240+
sandbox: { workspaceAccess: "none" },
2241+
tools: { sandbox: { tools: { deny: ["group:fs"] } } },
2242+
},
2243+
],
2244+
},
2245+
} as unknown as OpenClawConfig;
2246+
await fs.writeFile(configPath, "{}", "utf-8");
2247+
await fs.writeFile(
2248+
join(workspaceDir, "policy.jsonc"),
2249+
JSON.stringify({
2250+
agents: {
2251+
workspace: {
2252+
allowedAccess: ["none", "ro"],
2253+
denyTools: ["exec"],
2254+
},
2255+
},
2256+
}),
2257+
"utf-8",
2258+
);
2259+
2260+
registerPolicyDoctorChecks();
2261+
const result = await runDoctorLintChecks(ctx(configPath, cfg));
2262+
2263+
expect(result.findings).toEqual([
2264+
expect.objectContaining({
2265+
checkId: "policy/agents-tool-not-denied",
2266+
message: "agent 'locked' does not deny required tool 'exec'.",
2267+
ocPath: "oc://openclaw.config/agents/list/#0/tools/deny",
2268+
requirement: "oc://policy.jsonc/agents/workspace/denyTools",
2269+
}),
2270+
]);
2271+
});
2272+
21382273
it("accepts read-only agent workspace policy with group denies", async () => {
21392274
const configPath = join(workspaceDir, "openclaw.jsonc");
21402275
const cfg = {
@@ -2174,6 +2309,54 @@ describe("registerPolicyDoctorChecks", () => {
21742309
expect(result.findings).toEqual([]);
21752310
});
21762311

2312+
it("reports read-only workspace policy when sandbox mode skips the main session", async () => {
2313+
const configPath = join(workspaceDir, "openclaw.jsonc");
2314+
const cfg = {
2315+
...cfgWithPolicy(),
2316+
tools: {
2317+
sandbox: { tools: { deny: ["exec"] } },
2318+
},
2319+
agents: {
2320+
defaults: {
2321+
sandbox: { mode: "non-main", workspaceAccess: "ro" },
2322+
},
2323+
},
2324+
} as unknown as OpenClawConfig;
2325+
await fs.writeFile(configPath, "{}", "utf-8");
2326+
await fs.writeFile(
2327+
join(workspaceDir, "policy.jsonc"),
2328+
JSON.stringify({
2329+
agents: {
2330+
workspace: {
2331+
allowedAccess: ["ro"],
2332+
denyTools: ["exec"],
2333+
},
2334+
},
2335+
}),
2336+
"utf-8",
2337+
);
2338+
2339+
registerPolicyDoctorChecks();
2340+
const result = await runDoctorLintChecks(ctx(configPath, cfg));
2341+
2342+
expect(result.findings).toEqual(
2343+
expect.arrayContaining([
2344+
expect.objectContaining({
2345+
checkId: "policy/agents-workspace-access-denied",
2346+
message: "agents.defaults sandbox mode 'non-main' is not allowed by policy.",
2347+
ocPath: "oc://openclaw.config/agents/defaults/sandbox/mode",
2348+
requirement: "oc://policy.jsonc/agents/workspace/allowedAccess",
2349+
}),
2350+
expect.objectContaining({
2351+
checkId: "policy/agents-tool-not-denied",
2352+
message: "agents.defaults does not deny required tool 'exec'.",
2353+
ocPath: "oc://openclaw.config/tools/deny",
2354+
requirement: "oc://policy.jsonc/agents/workspace/denyTools",
2355+
}),
2356+
]),
2357+
);
2358+
});
2359+
21772360
it("reports read-only workspace policy when sandbox mode is disabled", async () => {
21782361
const configPath = join(workspaceDir, "openclaw.jsonc");
21792362
const cfg = {

extensions/policy/src/policy-state.ts

Lines changed: 64 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -697,6 +697,7 @@ function pushAgentWorkspaceEvidence(
697697
const explicitSandboxMode = readString(params.sandbox.mode);
698698
const inheritedSandboxMode = readString(params.inheritedSandbox.mode);
699699
const sandboxMode = explicitSandboxMode ?? inheritedSandboxMode ?? "off";
700+
const sandboxModeCoversAgentMain = sandboxMode === "all";
700701
const sandboxModeSource =
701702
explicitSandboxMode !== undefined
702703
? `${params.workspaceSourceBase}/sandbox/mode`
@@ -719,30 +720,75 @@ function pushAgentWorkspaceEvidence(
719720
value: explicitWorkspaceAccess ?? inheritedWorkspaceAccess ?? "none",
720721
sandboxMode,
721722
sandboxModeSource,
722-
sandboxEnabled: sandboxMode !== "off",
723+
sandboxEnabled: sandboxModeCoversAgentMain,
723724
explicit: explicitWorkspaceAccess !== undefined,
724725
});
725726

726727
for (const tool of AGENT_WORKSPACE_POLICY_TOOLS) {
727-
const inheritedDenied = toolListCoversTool(readStringArray(params.inheritedTools.deny), tool);
728-
const localDenied = toolListCoversTool(readStringArray(params.tools.deny), tool);
728+
const denyEvidence = agentWorkspaceToolDenyEvidence(params, tool, sandboxModeCoversAgentMain);
729729
entries.push({
730730
id: `${params.id}-tool-${tool}`,
731731
kind: "toolDeny",
732-
source: localDenied
733-
? `${params.toolsSourceBase}/deny`
734-
: inheritedDenied
735-
? `${params.inheritedToolsSourceBase}/deny`
736-
: `${params.toolsSourceBase}/deny`,
732+
source: denyEvidence.source,
737733
scope: params.scope,
738734
...(params.agentId === undefined ? {} : { agentId: params.agentId }),
739735
tool,
740-
denied: inheritedDenied || localDenied,
741-
explicit: localDenied || inheritedDenied,
736+
denied: denyEvidence.denied,
737+
explicit: denyEvidence.denied,
742738
});
743739
}
744740
}
745741

742+
function agentWorkspaceToolDenyEvidence(
743+
params: {
744+
readonly tools: Record<string, unknown>;
745+
readonly inheritedTools: Record<string, unknown>;
746+
readonly toolsSourceBase: string;
747+
readonly inheritedToolsSourceBase: string;
748+
},
749+
tool: string,
750+
sandboxModeCoversAgentMain: boolean,
751+
): { readonly denied: boolean; readonly source: string } {
752+
const localSandboxToolDeny = configuredSandboxToolDenyEntries(params.tools);
753+
const inheritedSandboxToolDeny = configuredSandboxToolDenyEntries(params.inheritedTools);
754+
const sources = [
755+
{
756+
entries: readStringArray(params.tools.deny),
757+
source: `${params.toolsSourceBase}/deny`,
758+
},
759+
{
760+
entries: readStringArray(params.inheritedTools.deny),
761+
source: `${params.inheritedToolsSourceBase}/deny`,
762+
},
763+
...(sandboxModeCoversAgentMain
764+
? [
765+
localSandboxToolDeny !== undefined
766+
? {
767+
entries: localSandboxToolDeny,
768+
source: `${params.toolsSourceBase}/sandbox/tools/deny`,
769+
}
770+
: {
771+
entries: inheritedSandboxToolDeny ?? [],
772+
source: `${params.inheritedToolsSourceBase}/sandbox/tools/deny`,
773+
},
774+
]
775+
: []),
776+
];
777+
const match = sources.find((entry) => toolListCoversTool(entry.entries, tool));
778+
if (match !== undefined) {
779+
return { denied: true, source: match.source };
780+
}
781+
return { denied: false, source: `${params.toolsSourceBase}/deny` };
782+
}
783+
784+
function configuredSandboxToolDenyEntries(
785+
tools: Record<string, unknown>,
786+
): readonly string[] | undefined {
787+
const sandbox = isRecord(tools.sandbox) ? tools.sandbox : {};
788+
const sandboxTools = isRecord(sandbox.tools) ? sandbox.tools : {};
789+
return Array.isArray(sandboxTools.deny) ? readStringArray(sandboxTools.deny) : undefined;
790+
}
791+
746792
const AGENT_WORKSPACE_POLICY_TOOLS = ["exec", "process", "write", "edit", "apply_patch"] as const;
747793

748794
const POLICY_TOOL_GROUPS: Record<string, readonly string[]> = {
@@ -772,6 +818,11 @@ function normalizePolicyToolName(value: string): string {
772818
return normalized;
773819
}
774820

821+
function policyToolGlobMatches(tool: string, pattern: string): boolean {
822+
const escaped = pattern.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
823+
return new RegExp(`^${escaped.replaceAll("\\*", ".*")}$`).test(tool);
824+
}
825+
775826
function toolListCoversTool(list: readonly string[], tool: string): boolean {
776827
for (const entry of list) {
777828
const normalized = normalizePolicyToolName(entry);
@@ -781,6 +832,9 @@ function toolListCoversTool(list: readonly string[], tool: string): boolean {
781832
if (POLICY_TOOL_GROUPS[normalized]?.includes(tool)) {
782833
return true;
783834
}
835+
if (normalized.includes("*") && policyToolGlobMatches(tool, normalized)) {
836+
return true;
837+
}
784838
}
785839
return false;
786840
}

0 commit comments

Comments
 (0)