mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-06 05:51:15 +08:00
fix #88773: [Bug]: Telegram DM exec requires approval despite allowlist + ask:off — works in webchat, not in Telegram (#89035)
* fix exec ask policy source * fix gateway test type fixtures * docs: update exec ask parameter docs to match runtime behavior * fix: preserve trusted per-call exec ask hardening while blocking model-supplied overrides for channel runs * docs: align exec ask contract with runtime * refactor(agents): simplify exec ask policy cleanup --------- Co-authored-by: Ayaan Zaidi <hi@obviy.us>
This commit is contained in:
@@ -144,9 +144,15 @@ when set at the narrower session or agent scope.
|
||||
### `exec.ask`
|
||||
|
||||
<ParamField path="ask" type='"off" | "on-miss" | "always"'>
|
||||
- `off` - never prompt.
|
||||
- `on-miss` - prompt only when the allowlist does not match.
|
||||
- `always` - prompt on every command. `allow-always` durable trust does **not** suppress prompts when effective ask mode is `always`.
|
||||
Configured ask policy for host exec. Controls the baseline approval
|
||||
prompt behavior from `tools.exec.ask` and host approvals defaults. The
|
||||
per-call `ask` tool parameter (see [Exec tool](/tools/exec#parameters))
|
||||
can only harden that baseline, and channel-origin model calls ignore it
|
||||
when the effective host ask is `off`.
|
||||
|
||||
- `off` - never prompt.
|
||||
- `on-miss` - prompt only when the allowlist does not match.
|
||||
- `always` - prompt on every command. `allow-always` durable trust does **not** suppress prompts when effective ask mode is `always`.
|
||||
|
||||
</ParamField>
|
||||
|
||||
|
||||
@@ -52,7 +52,11 @@ force `security=full` only when the operator explicitly grants elevated access.
|
||||
</ParamField>
|
||||
|
||||
<ParamField path="ask" type="'off' | 'on-miss' | 'always'">
|
||||
Approval prompt behavior for `gateway` / `node` execution.
|
||||
The baseline ask mode comes from `tools.exec.ask` and host approvals.
|
||||
For channel-origin model calls, per-call `ask` is ignored when the
|
||||
effective host ask is `off`; otherwise it can only harden to a stricter
|
||||
mode. Trusted internal/API callers that construct exec tools with an
|
||||
explicit `ask` value are unchanged.
|
||||
</ParamField>
|
||||
|
||||
<ParamField path="node" type="string">
|
||||
|
||||
@@ -13,6 +13,24 @@ vi.mock("./tools/gateway.js", () => ({
|
||||
readGatewayCallOptions: vi.fn(() => ({})),
|
||||
}));
|
||||
|
||||
function installAllowlistedGogFixture(root: string): string {
|
||||
const binDir = path.join(root, "bin");
|
||||
const openclawDir = path.join(root, ".openclaw");
|
||||
fs.mkdirSync(binDir, { recursive: true });
|
||||
fs.mkdirSync(openclawDir, { recursive: true });
|
||||
const gogPath = path.join(binDir, "gog");
|
||||
fs.writeFileSync(gogPath, "#!/bin/sh\nprintf 'gog-ok %s\\n' \"$*\"\n", { mode: 0o755 });
|
||||
fs.writeFileSync(
|
||||
path.join(openclawDir, "exec-approvals.json"),
|
||||
`${JSON.stringify({
|
||||
version: 1,
|
||||
defaults: { security: "allowlist", ask: "off", askFallback: "allowlist" },
|
||||
agents: { "*": { allowlist: [{ pattern: gogPath }] } },
|
||||
})}\n`,
|
||||
);
|
||||
return binDir;
|
||||
}
|
||||
|
||||
describe("exec security floor", () => {
|
||||
let envSnapshot: ReturnType<typeof captureEnv>;
|
||||
let tempRoot: string | undefined;
|
||||
@@ -88,6 +106,52 @@ describe("exec security floor", () => {
|
||||
).rejects.toThrow(/exec denied: allowlist miss/i);
|
||||
});
|
||||
|
||||
it("ignores model-supplied ask overrides when configured ask is off", async () => {
|
||||
const root = tempRoot ?? os.tmpdir();
|
||||
const binDir = installAllowlistedGogFixture(root);
|
||||
const tool = createExecTool({
|
||||
host: "gateway",
|
||||
security: "allowlist",
|
||||
ask: "off",
|
||||
safeBins: [],
|
||||
pathPrepend: [binDir],
|
||||
messageProvider: "telegram",
|
||||
currentChannelId: "telegram:12345",
|
||||
accountId: "default",
|
||||
});
|
||||
|
||||
const result = await tool.execute("call-model-ask-ignored", {
|
||||
command: "gog tasks add tasklist --title test",
|
||||
ask: "always",
|
||||
});
|
||||
|
||||
expect(result.details.status).toBe("completed");
|
||||
expect((result.content[0] as { text?: string }).text ?? "").toContain(
|
||||
"gog-ok tasks add tasklist --title test",
|
||||
);
|
||||
expect(callGatewayTool).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("honors per-call ask hardening for trusted callers without messageProvider", async () => {
|
||||
const root = tempRoot ?? os.tmpdir();
|
||||
const binDir = installAllowlistedGogFixture(root);
|
||||
const tool = createExecTool({
|
||||
host: "gateway",
|
||||
security: "allowlist",
|
||||
ask: "off",
|
||||
safeBins: [],
|
||||
pathPrepend: [binDir],
|
||||
});
|
||||
|
||||
const result = await tool.execute("call-trusted-ask-always", {
|
||||
command: "gog tasks add tasklist --title test",
|
||||
ask: "always",
|
||||
});
|
||||
|
||||
expect(callGatewayTool).toHaveBeenCalled();
|
||||
expect(result.details.status).toBe("approval-pending");
|
||||
});
|
||||
|
||||
it("ignores model-supplied deny security when configured security is allowlist", async () => {
|
||||
const tool = createExecTool({
|
||||
security: "allowlist",
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
loadExecApprovals,
|
||||
maxAsk,
|
||||
minSecurity,
|
||||
normalizeExecAsk,
|
||||
requireValidExecTarget,
|
||||
resolveExecApprovalsFromFile,
|
||||
resolveExecModePolicy,
|
||||
@@ -56,7 +57,6 @@ import {
|
||||
type ExecProcessOutcome,
|
||||
applyPathPrepend,
|
||||
applyShellPath,
|
||||
normalizeExecAsk,
|
||||
normalizePathPrepend,
|
||||
resolveExecTarget,
|
||||
resolveApprovalRunningNoticeMs,
|
||||
@@ -1613,7 +1613,8 @@ export function createExecTool(
|
||||
// Keep local exec defaults in sync with exec-approvals.json when tools.exec.* is unset.
|
||||
const requestedAsk = normalizeExecAsk(params.ask);
|
||||
const hostAsk = maxAsk(modePolicy.ask, approvalPolicy?.ask ?? modePolicy.ask);
|
||||
let ask = maxAsk(hostAsk, requestedAsk ?? hostAsk);
|
||||
const trustedAsk = defaults?.messageProvider && hostAsk === "off" ? undefined : requestedAsk;
|
||||
let ask = maxAsk(hostAsk, trustedAsk ?? hostAsk);
|
||||
const bypassApprovals =
|
||||
elevatedRequested &&
|
||||
elevatedMode === "full" &&
|
||||
|
||||
@@ -40,7 +40,8 @@ export const execSchema = Type.Object({
|
||||
),
|
||||
ask: Type.Optional(
|
||||
Type.String({
|
||||
description: "Exec ask mode (off|on-miss|always).",
|
||||
description:
|
||||
"Baseline ask comes from tools.exec.ask and host approvals; channel-origin calls ignore per-call ask when effective host ask is off.",
|
||||
}),
|
||||
),
|
||||
node: Type.Optional(
|
||||
|
||||
Reference in New Issue
Block a user