Skip to content

Commit 49b18dd

Browse files
mmapseleqtrizit
authored andcommitted
fix(cron): preserve creator tool policy for scheduled turns
1 parent f6a3ac7 commit 49b18dd

6 files changed

Lines changed: 243 additions & 3 deletions

File tree

src/agents/agent-tools.create-openclaw-coding-tools.test.ts

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -739,6 +739,60 @@ describe("createOpenClawCodingTools", () => {
739739
expect(inheritedAllow?.includes("process")).toBe(false);
740740
});
741741

742+
it("passes group-restricted tool surface to cron-created agent turns", () => {
743+
const createOpenClawToolsMock = vi.mocked(createOpenClawTools);
744+
createOpenClawToolsMock.mockClear();
745+
746+
createOpenClawCodingTools({
747+
sessionKey: "agent:main:whatsapp:group:restricted-room",
748+
config: {
749+
tools: { allow: ["read", "exec", "process", "cron"] },
750+
channels: {
751+
whatsapp: {
752+
groups: {
753+
"restricted-room": {
754+
tools: { allow: ["read", "cron"] },
755+
},
756+
},
757+
},
758+
},
759+
},
760+
});
761+
762+
expect(createOpenClawToolsMock).toHaveBeenCalledTimes(1);
763+
const cronAllow = latestCreateOpenClawToolsOptions().cronCreatorToolAllowlist;
764+
expectListIncludes(cronAllow, ["read", "cron"]);
765+
expect(cronAllow?.includes("exec")).toBe(false);
766+
expect(cronAllow?.includes("process")).toBe(false);
767+
});
768+
769+
it("passes deny-restricted tool surface to cron-created agent turns", () => {
770+
const createOpenClawToolsMock = vi.mocked(createOpenClawTools);
771+
createOpenClawToolsMock.mockClear();
772+
773+
createOpenClawCodingTools({
774+
sessionKey: "agent:main:whatsapp:group:restricted-room",
775+
config: {
776+
tools: { allow: ["read", "exec", "process", "cron"] },
777+
channels: {
778+
whatsapp: {
779+
groups: {
780+
"restricted-room": {
781+
tools: { deny: ["exec", "process"] },
782+
},
783+
},
784+
},
785+
},
786+
},
787+
});
788+
789+
expect(createOpenClawToolsMock).toHaveBeenCalledTimes(1);
790+
const cronAllow = latestCreateOpenClawToolsOptions().cronCreatorToolAllowlist;
791+
expectListIncludes(cronAllow, ["read", "cron"]);
792+
expect(cronAllow?.includes("exec")).toBe(false);
793+
expect(cronAllow?.includes("process")).toBe(false);
794+
});
795+
742796
it("records core tool-prep stages for hot-path diagnostics", () => {
743797
const stages: string[] = [];
744798

src/agents/agent-tools.ts

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,10 @@ import { resolveWorkspaceRoot } from "./workspace-dir.js";
120120

121121
const MEMORY_FLUSH_ALLOWED_TOOL_NAMES = new Set(["read", "write"]);
122122

123+
function hasExplicitDenyPolicy(policy?: { deny?: string[] }): boolean {
124+
return Array.isArray(policy?.deny) && policy.deny.some((entry) => entry.trim());
125+
}
126+
123127
type GuardContainerMount = {
124128
containerRoot: string;
125129
hostRoot: string;
@@ -922,7 +926,7 @@ export function createOpenClawCodingTools(options?: {
922926
// Passed by reference to sessions_spawn and populated after the final policy
923927
// pass so child sessions inherit the actual parent tool surface.
924928
const inheritedToolAllowlist: string[] = [];
925-
const shouldInheritEffectiveToolAllowlist = [
929+
const toolPolicyInheritanceSources = [
926930
profilePolicy,
927931
providerProfilePolicy,
928932
globalPolicy,
@@ -935,7 +939,13 @@ export function createOpenClawCodingTools(options?: {
935939
subagentPolicy,
936940
inheritedToolPolicy,
937941
options?.runtimeToolAllowlist ? { allow: options.runtimeToolAllowlist } : undefined,
938-
].some(hasRestrictiveAllowPolicy);
942+
];
943+
const shouldInheritEffectiveToolAllowlist =
944+
toolPolicyInheritanceSources.some(hasRestrictiveAllowPolicy);
945+
const cronCreatorToolAllowlist: string[] = [];
946+
const shouldCaptureCronCreatorToolAllowlist = toolPolicyInheritanceSources.some(
947+
(policy) => hasRestrictiveAllowPolicy(policy) || hasExplicitDenyPolicy(policy),
948+
);
939949
const pluginToolsOnly =
940950
includeOpenClawTools || !includePluginTools
941951
? []
@@ -1045,6 +1055,9 @@ export function createOpenClawCodingTools(options?: {
10451055
config: options?.config,
10461056
pluginToolAllowlist,
10471057
pluginToolDenylist,
1058+
cronCreatorToolAllowlist: shouldCaptureCronCreatorToolAllowlist
1059+
? cronCreatorToolAllowlist
1060+
: undefined,
10481061
currentChannelId: options?.currentChannelId,
10491062
currentMessagingTarget: options?.currentMessagingTarget,
10501063
currentThreadTs: options?.currentThreadTs,
@@ -1165,6 +1178,9 @@ export function createOpenClawCodingTools(options?: {
11651178
if (shouldInheritEffectiveToolAllowlist) {
11661179
replaceWithEffectiveToolAllowlist(inheritedToolAllowlist, subagentFiltered);
11671180
}
1181+
if (shouldCaptureCronCreatorToolAllowlist) {
1182+
replaceWithEffectiveToolAllowlist(cronCreatorToolAllowlist, subagentFiltered);
1183+
}
11681184
options?.recordToolPrepStage?.("authorization-policy");
11691185
// Always normalize tool JSON Schemas before handing them to OpenClaw model runtime.
11701186
// Without this, some providers (notably OpenAI) will reject root-level union schemas.

src/agents/openclaw-tools.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,8 @@ export function createOpenClawTools(
109109
config?: OpenClawConfig;
110110
pluginToolAllowlist?: string[];
111111
pluginToolDenylist?: string[];
112+
/** Effective caller tool surface to persist on isolated cron agentTurn jobs. */
113+
cronCreatorToolAllowlist?: string[];
112114
/** Current channel ID for auto-threading. */
113115
currentChannelId?: string;
114116
/** Routable target for the current conversation when it differs from the native channel ID. */
@@ -426,6 +428,7 @@ export function createOpenClawTools(
426428
accountId: options?.agentAccountId,
427429
threadId: options?.currentThreadTs ?? options?.agentThreadId,
428430
},
431+
creatorToolAllowlist: options?.cronCreatorToolAllowlist,
429432
...(options?.cronSelfRemoveOnlyJobId
430433
? { selfRemoveOnlyJobId: options.cronSelfRemoveOnlyJobId }
431434
: {}),

src/agents/tools/cron-tool.test.ts

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -859,6 +859,47 @@ describe("cron tool", () => {
859859
expect(params?.failureAlert).toEqual({ after: 3, cooldownMs: 60_000 });
860860
});
861861

862+
it("caps agentTurn add toolsAllow to the creator tool surface", async () => {
863+
const tool = createTestCronTool({
864+
agentSessionKey: "agent:main:telegram:group:restricted-room",
865+
creatorToolAllowlist: ["read", "cron"],
866+
});
867+
868+
await tool.execute("call-capped-add-tools", {
869+
action: "add",
870+
job: {
871+
...buildReminderAgentTurnJob(),
872+
payload: {
873+
kind: "agentTurn",
874+
message: "hello",
875+
toolsAllow: ["exec", "read"],
876+
},
877+
},
878+
});
879+
880+
const params = expectSingleGatewayCallMethod("cron.add") as
881+
| { payload?: { toolsAllow?: string[] } }
882+
| undefined;
883+
expect(params?.payload?.toolsAllow).toEqual(["read"]);
884+
});
885+
886+
it("stores the creator tool surface on agentTurn adds without explicit toolsAllow", async () => {
887+
const tool = createTestCronTool({
888+
agentSessionKey: "agent:main:telegram:group:restricted-room",
889+
creatorToolAllowlist: ["read", "cron"],
890+
});
891+
892+
await tool.execute("call-default-capped-add-tools", {
893+
action: "add",
894+
job: buildReminderAgentTurnJob(),
895+
});
896+
897+
const params = expectSingleGatewayCallMethod("cron.add") as
898+
| { payload?: { toolsAllow?: string[] } }
899+
| undefined;
900+
expect(params?.payload?.toolsAllow).toEqual(["read", "cron"]);
901+
});
902+
862903
it("recovers concatenated cron add keys from local tool-call parsers", async () => {
863904
const tool = createTestCronTool();
864905
await tool.execute("call-concatenated-add", {
@@ -1912,6 +1953,73 @@ describe("cron tool", () => {
19121953
});
19131954
});
19141955

1956+
it("caps agentTurn update toolsAllow to the creator tool surface", async () => {
1957+
callGatewayMock.mockResolvedValueOnce({ ok: true });
1958+
1959+
const tool = createTestCronTool({
1960+
agentSessionKey: "agent:main:telegram:group:restricted-room",
1961+
creatorToolAllowlist: ["read", "cron"],
1962+
});
1963+
await tool.execute("call-update-capped-tools", {
1964+
action: "update",
1965+
id: "job-7",
1966+
patch: {
1967+
payload: {
1968+
toolsAllow: [" exec ", " read "],
1969+
},
1970+
},
1971+
});
1972+
1973+
const params = expectSingleGatewayCallMethod("cron.update") as
1974+
| {
1975+
id?: string;
1976+
patch?: {
1977+
payload?: {
1978+
kind?: string;
1979+
toolsAllow?: string[];
1980+
};
1981+
};
1982+
}
1983+
| undefined;
1984+
expect(params?.patch?.payload).toEqual({
1985+
kind: "agentTurn",
1986+
toolsAllow: ["read"],
1987+
});
1988+
});
1989+
1990+
it("keeps the creator tool surface when an agentTurn update clears toolsAllow", async () => {
1991+
callGatewayMock.mockResolvedValueOnce({ ok: true });
1992+
1993+
const tool = createTestCronTool({
1994+
agentSessionKey: "agent:main:telegram:group:restricted-room",
1995+
creatorToolAllowlist: ["read", "cron"],
1996+
});
1997+
await tool.execute("call-update-capped-tools-clear", {
1998+
action: "update",
1999+
id: "job-8",
2000+
patch: {
2001+
payload: {
2002+
toolsAllow: null,
2003+
},
2004+
},
2005+
});
2006+
2007+
const params = expectSingleGatewayCallMethod("cron.update") as
2008+
| {
2009+
patch?: {
2010+
payload?: {
2011+
kind?: string;
2012+
toolsAllow?: string[];
2013+
};
2014+
};
2015+
}
2016+
| undefined;
2017+
expect(params?.patch?.payload).toEqual({
2018+
kind: "agentTurn",
2019+
toolsAllow: ["read", "cron"],
2020+
});
2021+
});
2022+
19152023
it("preserves null model payload patches on update", async () => {
19162024
callGatewayMock.mockResolvedValueOnce({ ok: true });
19172025

src/agents/tools/cron-tool.ts

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import {
2424
stringEnum,
2525
} from "../schema/typebox.js";
2626
import { CRON_TOOL_DISPLAY_SUMMARY } from "../tool-description-presets.js";
27+
import { expandToolGroups, normalizeToolName } from "../tool-policy.js";
2728
import { setToolTerminalPresentation } from "../tool-terminal-presentation.js";
2829
import {
2930
type AnyAgentTool,
@@ -332,6 +333,12 @@ export const CronToolSchema = createCronToolSchema();
332333
type CronToolOptions = {
333334
agentSessionKey?: string;
334335
currentDeliveryContext?: DeliveryContext;
336+
/**
337+
* Effective tool names visible to the caller that created or edited a cron job.
338+
* Isolated cron runs use a fresh session, so agent-origin jobs need this cap
339+
* persisted on agentTurn payloads before the original session policy is lost.
340+
*/
341+
creatorToolAllowlist?: string[];
335342
selfRemoveOnlyJobId?: string;
336343
};
337344

@@ -366,6 +373,56 @@ function assertNoCronCommandPayload(value: unknown): void {
366373
}
367374
}
368375

376+
function normalizeCronToolsAllow(values: readonly string[]): string[] {
377+
const normalized: string[] = [];
378+
const seen = new Set<string>();
379+
for (const entry of expandToolGroups([...values])) {
380+
const toolName = normalizeToolName(entry);
381+
if (!toolName || seen.has(toolName)) {
382+
continue;
383+
}
384+
seen.add(toolName);
385+
normalized.push(toolName);
386+
}
387+
return normalized;
388+
}
389+
390+
function capCronAgentTurnToolsAllow(params: {
391+
payload: Record<string, unknown>;
392+
creatorToolAllowlist: string[];
393+
}): void {
394+
if (params.payload.kind !== "agentTurn") {
395+
return;
396+
}
397+
const creatorToolsAllow = normalizeCronToolsAllow(params.creatorToolAllowlist);
398+
const requestedRaw = params.payload.toolsAllow;
399+
if (!Array.isArray(requestedRaw)) {
400+
params.payload.toolsAllow = creatorToolsAllow;
401+
return;
402+
}
403+
const requestedToolsAllow = normalizeCronToolsAllow(
404+
requestedRaw.filter((entry): entry is string => typeof entry === "string"),
405+
);
406+
if (requestedToolsAllow.includes("*")) {
407+
params.payload.toolsAllow = creatorToolsAllow;
408+
return;
409+
}
410+
const creatorAllowSet = new Set(creatorToolsAllow);
411+
params.payload.toolsAllow = requestedToolsAllow.filter((toolName) =>
412+
creatorAllowSet.has(toolName),
413+
);
414+
}
415+
416+
function capCronAgentTurnJobToolsAllow(
417+
value: unknown,
418+
creatorToolAllowlist: string[] | undefined,
419+
): void {
420+
if (!creatorToolAllowlist || !isRecord(value) || !isRecord(value.payload)) {
421+
return;
422+
}
423+
capCronAgentTurnToolsAllow({ payload: value.payload, creatorToolAllowlist });
424+
}
425+
369426
function truncateText(input: string, maxLen: number) {
370427
if (input.length <= maxLen) {
371428
return input;
@@ -730,6 +787,7 @@ Use jobId canonical; id accepted compat. contextMessages (0-10) adds previous me
730787
normalizeCronJobCreate(canonicalJob, {
731788
sessionContext: { sessionKey: opts?.agentSessionKey },
732789
}) ?? canonicalJob;
790+
capCronAgentTurnJobToolsAllow(job, opts?.creatorToolAllowlist);
733791
const cfg = getRuntimeConfig();
734792
if (job && typeof job === "object") {
735793
const { mainKey, alias } = resolveMainSessionAlias(cfg);
@@ -845,6 +903,7 @@ Use jobId canonical; id accepted compat. contextMessages (0-10) adds previous me
845903
assertNoCronCommandPayload(canonicalPatch);
846904
assertCronDeliveryInputNonBlankFields(canonicalPatch.delivery);
847905
const patch = normalizeCronJobPatch(canonicalPatch) ?? canonicalPatch;
906+
capCronAgentTurnJobToolsAllow(patch, opts?.creatorToolAllowlist);
848907
if (recoveredFlatPatch && isEmptyRecoveredCronPatch(patch)) {
849908
throw new Error("patch required");
850909
}

src/mcp/openclaw-tools-serve.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import { formatErrorMessage } from "../infra/errors.js";
1212
import { connectToolsMcpServerToStdio, createToolsMcpServer } from "./tools-stdio-server.js";
1313

1414
export function resolveOpenClawToolsForMcp(): AnyAgentTool[] {
15-
return [createCronTool()];
15+
return [createCronTool({ creatorToolAllowlist: ["cron"] })];
1616
}
1717

1818
function createOpenClawToolsMcpServer(

0 commit comments

Comments
 (0)