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
7 changes: 6 additions & 1 deletion openclaw.plugin.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
"id": "clawflow",
"name": "ClawFlow",
"description": "The n8n for agents. Declarative, AI-native workflow engine — LLM-writable, Cloudflare-portable.",
"version": "1.3.1",
"version": "1.4.0",
"skills": [
"./skills/clawflow"
],
Expand Down Expand Up @@ -125,6 +125,11 @@
"allow",
"deny"
]
},
"gateMutations": {
"type": "boolean",
"default": true,
"description": "Gate flow authoring/publishing (flow_create/edit/publish/delete) behind approval on every call, independently of `enabled` (which governs flow_run). Set false to disable."
}
}
}
Expand Down
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.3.1",
"version": "1.4.0",
"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
8 changes: 8 additions & 0 deletions src/core/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -567,6 +567,14 @@ export interface ApprovalConfig {
timeoutMs?: number;
/** Action on prompt timeout: `"allow"` or `"deny"`. Default: `"deny"`. */
timeoutBehavior?: "allow" | "deny";
/**
* Gate flow-authoring tools (`flow_create` / `flow_edit` / `flow_publish` /
* `flow_delete`) behind approval, independently of `enabled` (which governs
* `flow_run`). Creating, editing, publishing, or deleting a flow definition
* is a write action that should never happen unattended, so this applies on
* every call and does NOT honor `skipSessionPatterns`. Default: `true`.
*/
gateMutations?: boolean;
}

// ---- Model Shorthands -----------------------------------------------------------
Expand Down
37 changes: 33 additions & 4 deletions src/plugin/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,13 +117,42 @@ 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;
const MUTATION_VERBS: Record<string, string> = {
flow_create: "Create",
flow_edit: "Edit",
flow_publish: "Publish",
flow_delete: "Delete",
};

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

// Flow-authoring tools — always gate (no skipSessionPatterns). No
// allow-always persist path, so every call re-prompts.
const mutationVerb = toolName ? MUTATION_VERBS[toolName] : undefined;
if (gateMutations && mutationVerb) {
const mp = (event.params ?? {}) as { file?: string; flow?: string };
const name = mp.flow ?? mp.file ?? "inline flow";
return {
requireApproval: {
title: `${mutationVerb} clawflow "${name}"?`.slice(0, 80),
description: "Creates, edits, publishes, or deletes a flow definition.",
severity: "warning",
timeoutMs: approvalTimeoutMs,
timeoutBehavior: approvalTimeoutBehavior,
},
};
}

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

const sessionKey = event.context?.sessionKey ?? "";
if (skipPatterns.some((pattern) => sessionKey.includes(pattern))) {
Expand All @@ -145,8 +174,8 @@ function register(api: PluginApi) {
};
},
{
name: "clawflow-flow-run-approval",
description: "Request user approval before executing flow_run (skippable via approval config).",
name: "clawflow-approval-gate",
description: "Gate flow_run (skippable) and flow authoring/publishing/deletion (always) behind user approval.",
},
);
} else if (!api.registerHook) {
Expand Down
89 changes: 89 additions & 0 deletions tests/approval-gate.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import * as os from "os";

import plugin from "../src/plugin/index.js";

type HookResult =
| { requireApproval?: { title: string; description: string }; block?: boolean }
| void;
type Hook = (event: {
toolName?: string;
tool?: string;
params?: unknown;
context?: { sessionKey?: string };
}) => HookResult | Promise<HookResult>;

// Register the plugin against a mock api and return the captured
// before_tool_call handler for the given clawflow config.
function captureHook(clawflowConfig: Record<string, unknown>): Hook {
let hook: Hook | undefined;
const api = {
registerTool: () => {},
registerHook: (events: string | string[], handler: Hook) => {
if (events === "before_tool_call" || (Array.isArray(events) && events.includes("before_tool_call"))) {
hook = handler;
}
},
config: {
workspace: os.tmpdir(),
plugins: { entries: { clawflow: { config: clawflowConfig } } },
},
logger: { info: () => {}, warn: () => {}, error: () => {} },
};
plugin.register(api as never);
assert.ok(hook, "before_tool_call hook was not registered");
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"`));
}
});

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("keeps flow_run behavior: gated when enabled, skipped by pattern", async () => {
const hook = captureHook({ approval: { skipSessionPatterns: ["email"] } });
const gated = await hook({ toolName: "flow_run", params: { file: "f" }, context: { sessionKey: "chat:1" } });
assert.ok(gated && gated.requireApproval, "flow_run should gate in an interactive session");
const skipped = await hook({ toolName: "flow_run", params: { file: "f" }, context: { sessionKey: "x:email:1" } });
assert.equal(skipped, undefined, "flow_run should skip a matching session");
});

it("does not gate read-only tools", async () => {
const hook = captureHook({});
assert.equal(await hook({ toolName: "flow_list", params: {} }), undefined);
assert.equal(await hook({ toolName: "flow_read", params: { file: "f" } }), undefined);
});
});
Loading