Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion openclaw.plugin.json
Original file line number Diff line number Diff line change
Expand Up @@ -130,7 +130,7 @@
"gateMutations": {
"type": "boolean",
"default": true,
"description": "Gate flow authoring/publishing (flow_create/edit/publish/delete) and schedule changes (flow_trigger create/delete/pause/resume/run_now) behind approval on every call, independently of `enabled` (which governs flow_run). Set false to disable."
"description": "Retired in 1.6.1 and ignored: flow authoring (flow_create/edit/publish/delete) and schedule changes (flow_trigger) are gated by @clawnify/agent-permissions, the box's permission authority. Kept so existing configs still validate."
}
}
}
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@clawnify/clawflow",
"version": "1.6.0",
"version": "1.6.1",
"description": "The n8n for agents. A declarative, AI-native workflow format that agents can read, write, and run.",
"type": "module",
"main": "./dist/index.js",
Expand Down
67 changes: 14 additions & 53 deletions src/plugin/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -145,62 +145,23 @@ function register(api: PluginApi) {
: 5 * 60_000;
const approvalTimeoutBehavior: "allow" | "deny" =
approvalCfg.timeoutBehavior === "allow" ? "allow" : "deny";
// Intrinsic mutation gate: authoring/publishing/deleting a flow is a write
// action that must never run without a human OK, so it is gated on every call
// independently of `enabled` (which governs flow_run) and does NOT honor
// skipSessionPatterns. Kill-switch: approval.gateMutations=false.
const gateMutations = approvalCfg.gateMutations !== false;
// Arming a schedule commits the box to running a flow unattended, on a timer,
// with tool access — at least as consequential as editing one, so it is gated
// the same way. Reads (list) are not gated.
const TRIGGER_MUTATION_VERBS: Record<string, string> = {
create: "Schedule",
update: "Reschedule",
delete: "Unschedule",
pause: "Pause schedule for",
resume: "Resume schedule for",
run_now: "Run now",
};

const MUTATION_VERBS: Record<string, string> = {
flow_create: "Create",
flow_edit: "Edit",
flow_publish: "Publish",
flow_delete: "Delete",
};

if (api.registerHook && (approvalEnabled || gateMutations)) {
// Flow authoring (flow_create / flow_edit / flow_publish / flow_delete) and
// schedule changes (flow_trigger create / update / delete / pause / resume /
// run_now) are gated by @clawnify/agent-permissions ≥ 0.6.0, the one
// permission authority on the box, not here. This plugin's own gate for them
// (1.3 – 1.6.0) was registered through registerHook, which OpenClaw 2026.9.1
// no longer dispatches for before_tool_call, so it prompted on 2026.7.1 and
// silently not on 2026.9.1; and with both gates live a 2026.7.1 box prompted
// twice per write. `approval.gateMutations` is accepted for old configs and
// ignored. Only the flow_run gate stays here: it is the operator's opt-in,
// skippable for unattended sessions.

if (api.registerHook && approvalEnabled) {
api.registerHook(
"before_tool_call",
(event) => {
const toolName = event.toolName ?? event.tool;

// Flow-authoring tools — always gate (no skipSessionPatterns). No
// allow-always persist path, so every call re-prompts.
// flow_trigger is action-shaped: gate the mutating actions, let reads through.
let mutationVerb = toolName ? MUTATION_VERBS[toolName] : undefined;
if (toolName === "flow_trigger") {
const action = (event.params as { action?: string } | undefined)?.action;
mutationVerb = TRIGGER_MUTATION_VERBS[action ?? ""] ?? undefined;
}
if (gateMutations && mutationVerb) {
const mp = (event.params ?? {}) as { file?: string; flow?: string; id?: string };
const name = mp.flow ?? mp.file ?? mp.id ?? "inline flow";
return {
requireApproval: {
title: `${mutationVerb} clawflow "${name}"?`.slice(0, 80),
description:
toolName === "flow_trigger"
? "Changes an unattended schedule that runs a flow on a timer."
: "Creates, edits, publishes, or deletes a flow definition.",
severity: "warning",
timeoutMs: approvalTimeoutMs,
timeoutBehavior: approvalTimeoutBehavior,
},
};
}

if (!approvalEnabled || toolName !== "flow_run") return;
if (toolName !== "flow_run") return;

const sessionKey = event.context?.sessionKey ?? "";
if (skipPatterns.some((pattern) => sessionKey.includes(pattern))) {
Expand All @@ -223,7 +184,7 @@ function register(api: PluginApi) {
},
{
name: "clawflow-approval-gate",
description: "Gate flow_run (skippable) and flow authoring/publishing/deletion (always) behind user approval.",
description: "Gate flow_run behind user approval (skippable for unattended sessions). Flow authoring and schedules are gated by agent-permissions.",
},
);
} else if (!api.registerHook) {
Expand Down
120 changes: 37 additions & 83 deletions tests/approval-gate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,41 +36,35 @@ function captureHook(clawflowConfig: Record<string, unknown>): Hook {
return hook!;
}

describe("clawflow approval gate — flow mutation tools", () => {
it("gates create/edit/publish/delete by default", async () => {
const hook = captureHook({});
for (const [tool, verb] of [
["flow_create", "Create"],
["flow_edit", "Edit"],
["flow_publish", "Publish"],
["flow_delete", "Delete"],
] as const) {
const res = await hook({ toolName: tool, params: { flow: "my-flow" } });
assert.ok(res && res.requireApproval, `${tool} should require approval`);
assert.match(res.requireApproval!.title, new RegExp(`^${verb} clawflow "my-flow"`));
describe("clawflow approval gate", () => {
// Since 1.5.2 flow_create/edit/publish/delete are gated by
// @clawnify/agent-permissions (always-ask, no rule can pre-approve). This
// plugin must not gate them too: OpenClaw 2026.9.1 stopped dispatching
// registerHook for before_tool_call, so a gate here prompts on 2026.7.1 and
// silently does not on 2026.9.1, and with both live a 2026.7.1 box prompts
// twice per write.
it("does not gate create/edit/publish/delete, whatever the config says", async () => {
for (const approval of [{ enabled: true }, { enabled: true, gateMutations: true }, { enabled: true, skipSessionPatterns: ["email"] }]) {
const hook = captureHook({ approval });
for (const tool of ["flow_create", "flow_edit", "flow_publish", "flow_delete"]) {
const res = await hook({ toolName: tool, params: { flow: "my-flow" } });
assert.equal(res, undefined, `${tool} must be left to agent-permissions`);
}
}
});

it("gates mutations even when the flow_run gate is disabled (independent of enabled)", async () => {
const hook = captureHook({ approval: { enabled: false } });
const res = await hook({ toolName: "flow_delete", params: { file: "x" } });
assert.ok(res && res.requireApproval, "mutation must still gate when enabled=false");
});

it("does NOT honor skipSessionPatterns for mutations (always require)", async () => {
const hook = captureHook({ approval: { skipSessionPatterns: ["email"] } });
const res = await hook({
toolName: "flow_publish",
params: { flow: "f" },
context: { sessionKey: "agent:main:main:email:123" },
});
assert.ok(res && res.requireApproval, "mutation must gate even in a skipped session");
});

it("kill-switch: gateMutations=false disables mutation gating", async () => {
const hook = captureHook({ approval: { gateMutations: false } });
const res = await hook({ toolName: "flow_create", params: { flow: "f" } });
assert.equal(res, undefined, "gateMutations=false should not gate");
it("registers no hook at all when the flow_run gate is off", () => {
let registered = false;
const api = {
registerTool: () => {},
registerHook: () => {
registered = true;
},
config: { workspace: os.tmpdir(), plugins: { entries: { clawflow: { config: { approval: { enabled: false } } } } } },
logger: { info: () => {}, warn: () => {}, error: () => {} },
};
plugin.register(api as never);
assert.equal(registered, false, "nothing to gate here when flow_run gating is off");
});

it("keeps flow_run behavior: gated when enabled, skipped by pattern", async () => {
Expand All @@ -88,57 +82,17 @@ describe("clawflow approval gate — flow mutation tools", () => {
});
});

describe("clawflow approval gate — flow_trigger", () => {
it("gates the mutating actions", async () => {
const hook = captureHook({});
for (const [action, verb] of [
["create", "Schedule"],
["update", "Reschedule"],
["delete", "Unschedule"],
["pause", "Pause schedule for"],
["resume", "Resume schedule for"],
["run_now", "Run now"],
] as const) {
const res = await hook({
toolName: "flow_trigger",
params: { action, flow: "my-flow" },
});
assert.ok(res && res.requireApproval, `flow_trigger ${action} should require approval`);
assert.match(res.requireApproval!.title, new RegExp(`^${verb} clawflow "my-flow"`));
assert.match(res.requireApproval!.description, /unattended schedule/);
describe("clawflow approval gate — flow_trigger is the authority's too", () => {
// Schedules commit the box to running a flow unattended; since 1.6.1 the
// authority (@clawnify/agent-permissions ≥ 0.6.0) asks for every mutating
// action and lets list through. Nothing is gated here any more.
it("does not gate any action, whatever the config says", async () => {
for (const approval of [{ enabled: true }, { enabled: true, gateMutations: true }]) {
const hook = captureHook({ approval });
for (const action of ["create", "update", "delete", "pause", "resume", "run_now", "list"]) {
const res = await hook({ toolName: "flow_trigger", params: { action, id: "trg_1" } });
assert.equal(res, undefined, `flow_trigger ${action} must be left to agent-permissions`);
}
}
});

it("does not gate list", async () => {
const hook = captureHook({});
const res = await hook({ toolName: "flow_trigger", params: { action: "list" } });
assert.ok(!res || !res.requireApproval, "listing triggers is a read, not a mutation");
});

it("names the trigger id when there is no flow name in params", async () => {
const hook = captureHook({});
const res = await hook({
toolName: "flow_trigger",
params: { action: "pause", id: "digest-ab12cd34" },
});
assert.match(res!.requireApproval!.title, /"digest-ab12cd34"/);
});

it("gates trigger mutations even when the flow_run gate is disabled", async () => {
const hook = captureHook({ approval: { enabled: false } });
const res = await hook({
toolName: "flow_trigger",
params: { action: "create", flow: "x" },
});
assert.ok(res && res.requireApproval, "arming a schedule must still gate");
});

it("respects the gateMutations kill-switch", async () => {
const hook = captureHook({ approval: { gateMutations: false } });
const res = await hook({
toolName: "flow_trigger",
params: { action: "create", flow: "x" },
});
assert.ok(!res || !res.requireApproval);
});
});
Loading