diff --git a/openclaw.plugin.json b/openclaw.plugin.json index 331a95b..6ce145f 100644 --- a/openclaw.plugin.json +++ b/openclaw.plugin.json @@ -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." } } } diff --git a/package-lock.json b/package-lock.json index 2bbeb46..1ad16cc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@clawnify/clawflow", - "version": "1.5.1", + "version": "1.6.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@clawnify/clawflow", - "version": "1.5.1", + "version": "1.6.1", "license": "MIT", "dependencies": { "croner": "^10.0.1" diff --git a/package.json b/package.json index 798d09d..0d039f2 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/plugin/index.ts b/src/plugin/index.ts index 95ff337..eadb899 100644 --- a/src/plugin/index.ts +++ b/src/plugin/index.ts @@ -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 = { - create: "Schedule", - update: "Reschedule", - delete: "Unschedule", - pause: "Pause schedule for", - resume: "Resume schedule for", - run_now: "Run now", - }; - - const MUTATION_VERBS: Record = { - 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))) { @@ -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) { diff --git a/tests/approval-gate.test.ts b/tests/approval-gate.test.ts index 639306f..efadb90 100644 --- a/tests/approval-gate.test.ts +++ b/tests/approval-gate.test.ts @@ -36,41 +36,35 @@ function captureHook(clawflowConfig: Record): 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 () => { @@ -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); - }); });