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:
zhang-guiping
2026-06-03 21:03:08 +08:00
committed by GitHub
parent b3b203bf67
commit 60dcaa3cf5
5 changed files with 83 additions and 7 deletions

View File

@@ -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>

View File

@@ -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">

View File

@@ -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",

View File

@@ -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" &&

View File

@@ -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(