CLI: compute node effective exec policy

This commit is contained in:
Gustavo Madeira Santana
2026-04-01 20:46:10 -04:00
parent 43ada2b743
commit 851cc3945e
3 changed files with 109 additions and 7 deletions

View File

@@ -24,7 +24,7 @@ openclaw approvals get --node <id|name|ip>
openclaw approvals get --gateway
```
`openclaw approvals get` now shows the effective exec policy for local and gateway targets:
`openclaw approvals get` now shows the effective exec policy for local, gateway, and node targets:
- requested `tools.exec` policy
- host approvals-file policy
@@ -34,7 +34,8 @@ Precedence is intentional:
- the host approvals file is the enforceable source of truth
- requested `tools.exec` policy can narrow or broaden intent, but the effective result is still derived from the host rules
- node output stays host-file-only because gateway `tools.exec` policy is applied later at runtime
- `--node` combines the node host approvals file with gateway `tools.exec` policy, because both still apply at runtime
- if gateway config is unavailable, the CLI falls back to the node approvals snapshot and notes that the final runtime policy could not be computed
## Replace approvals from a file

View File

@@ -153,6 +153,7 @@ describe("exec approvals CLI", () => {
expect(callGatewayFromCli).toHaveBeenCalledWith("exec.approvals.node.get", expect.anything(), {
nodeId: "node-1",
});
expect(callGatewayFromCli).toHaveBeenCalledWith("config.get", expect.anything(), {});
expect(runtimeErrors).toHaveLength(0);
});
@@ -251,6 +252,68 @@ describe("exec approvals CLI", () => {
);
});
it("adds combined node effective policy to json output", async () => {
callGatewayFromCli.mockImplementation(
async (method: string, _opts: unknown, params?: unknown) => {
if (method === "config.get") {
return {
config: {
tools: {
exec: {
security: "full",
ask: "off",
},
},
},
};
}
if (method === "exec.approvals.node.get") {
return {
path: "/tmp/node-exec-approvals.json",
exists: true,
hash: "hash-node-1",
file: {
version: 1,
defaults: { security: "allowlist", ask: "always", askFallback: "deny" },
agents: {},
},
};
}
return { method, params };
},
);
await runApprovalsCommand(["approvals", "get", "--node", "macbook", "--json"]);
expect(defaultRuntime.writeJson).toHaveBeenCalledWith(
expect.objectContaining({
effectivePolicy: {
note: "Effective exec policy is the node host approvals file intersected with gateway tools.exec policy.",
scopes: [
expect.objectContaining({
scopeLabel: "tools.exec",
security: expect.objectContaining({
requested: "full",
host: "allowlist",
effective: "allowlist",
}),
ask: expect.objectContaining({
requested: "off",
host: "always",
effective: "always",
}),
askFallback: expect.objectContaining({
effective: "deny",
source: "~/.openclaw/exec-approvals.json defaults.askFallback",
}),
}),
],
},
}),
0,
);
});
it("keeps gateway approvals output when config.get fails", async () => {
callGatewayFromCli.mockImplementation(
async (method: string, _opts: unknown, params?: unknown) => {
@@ -283,6 +346,38 @@ describe("exec approvals CLI", () => {
expect(runtimeErrors).toHaveLength(0);
});
it("keeps node approvals output when gateway config is unavailable", async () => {
callGatewayFromCli.mockImplementation(
async (method: string, _opts: unknown, params?: unknown) => {
if (method === "config.get") {
throw new Error("gateway config unavailable");
}
if (method === "exec.approvals.node.get") {
return {
path: "/tmp/node-exec-approvals.json",
exists: true,
hash: "hash-node-1",
file: { version: 1, agents: {} },
};
}
return { method, params };
},
);
await runApprovalsCommand(["approvals", "get", "--node", "macbook", "--json"]);
expect(defaultRuntime.writeJson).toHaveBeenCalledWith(
expect.objectContaining({
effectivePolicy: {
note: "Gateway config unavailable. Node output above shows host approvals state only, and final runtime policy still intersects with gateway tools.exec.",
scopes: [],
},
}),
0,
);
expect(runtimeErrors).toHaveLength(0);
});
it("keeps local approvals output when config load fails", async () => {
readBestEffortConfig.mockRejectedValue(new Error("duplicate agent directories"));

View File

@@ -162,9 +162,6 @@ async function loadConfigForApprovalsTarget(params: {
opts: ExecApprovalsCliOpts;
source: ApprovalsTargetSource;
}): Promise<OpenClawConfig | null> {
if (params.source === "node") {
return null;
}
try {
if (params.source === "local") {
return await readBestEffortConfig();
@@ -217,9 +214,18 @@ function buildEffectivePolicyReport(params: {
approvals: ExecApprovalsFile;
}): EffectivePolicyReport {
if (params.source === "node") {
if (!params.cfg) {
return {
scopes: [],
note: "Gateway config unavailable. Node output above shows host approvals state only, and final runtime policy still intersects with gateway tools.exec.",
};
}
return {
scopes: [],
note: "Node output shows host approvals state only. Gateway tools.exec policy still intersects at runtime.",
scopes: collectExecPolicySnapshots({
cfg: params.cfg,
approvals: params.approvals,
}),
note: "Effective exec policy is the node host approvals file intersected with gateway tools.exec policy.",
};
}
if (!params.cfg) {