diff --git a/openclaw.plugin.json b/openclaw.plugin.json index e8c05d9..5c4f32d 100644 --- a/openclaw.plugin.json +++ b/openclaw.plugin.json @@ -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" ], @@ -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." } } } diff --git a/package.json b/package.json index d48aaa6..adef145 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/core/types.ts b/src/core/types.ts index 0869a7d..01a8acf 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -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 ----------------------------------------------------------- diff --git a/src/plugin/index.ts b/src/plugin/index.ts index 1a91443..93334e0 100644 --- a/src/plugin/index.ts +++ b/src/plugin/index.ts @@ -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 = { + 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))) { @@ -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) { diff --git a/tests/approval-gate.test.ts b/tests/approval-gate.test.ts new file mode 100644 index 0000000..7a8dd02 --- /dev/null +++ b/tests/approval-gate.test.ts @@ -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; + +// Register the plugin against a mock api and return the captured +// before_tool_call handler for the given clawflow config. +function captureHook(clawflowConfig: Record): 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); + }); +});