ruvnet/ruflo · error · Error

policy- : ; receipt=

Error message

policy-${decision.enforcedOutcome}:${decision.reason}; receipt=${decision.receiptId}

What it means

Per ADR-324 every MCP tool call passes through a single policy chokepoint: authorizeMcpTool(toolName, input, context, classification). If the decision's enforcedOutcome is anything other than 'allowed' (e.g. denied), callMCPTool throws `policy-<outcome>:<reason>` including a receiptId. This is the authorization engine working as designed — the tool handler never ran — and the receipt is the audit handle for what rule fired. Policy administration is not exempt: policy.* actions must be explicitly allowed in enforce mode.

Solutions

  1. Read the reason and receiptId from the thrown message — the reason names the rule/outcome that fired; keep the receiptId for audit
  2. Add an explicit allow rule for that action class in the policy configuration (e.g. allow the classified action for the calling principal), or for policy.* administration use the local CLI bootstrap path which calls the engine directly
  3. Temporarily run policy in observe mode to see which rule matches the call, then codify the intended allow before re-enabling enforce
  4. Verify the context you pass to callMCPTool carries attributes (user/project/env) that the policy rules match on

Example fix

// before — enforce mode, no allow for the class
await callMCPTool('policy_compile', { policy: p });
// throws: policy-denied:no allow rule for policy.compile; receipt=...

// after — administer via the local CLI bootstrap path
// (bypasses recursive MCP dispatch by calling the engine directly)
await exec('npx @claude-flow/cli@latest policy compile --file policy.kdl');
Defensive patterns

Strategy: try-catch

Try / catch

try {
  return await callMCPTool(toolName, input, context);
} catch (e) {
  const m = /^(\w+)-policy-(\w+):(.+); receipt=(.+)$/.exec((e as Error).message)
    ?? /^policy-([\w-]+):(.+); receipt=(.+)$/.exec((e as Error).message);
  if (m) {
    // Authorization denial — never retry; route to admin/audit flow
    return { denied: true, outcome: m[1], reason: m[2] ?? m[2], receiptId: m[3] };
  }
  throw e;
}

Prevention

When it happens

Trigger: Running in enforce mode without an explicit allow rule for the action class that classifyMcpTool() assigned to the call; invoking policy.* administration tools through the MCP path instead of the local CLI bootstrap; a policy file missing context attributes the rule requires (user, project, environment); switching the engine from observe to enforce without adding the allows that observe-mode logs suggested.

Common situations: Hardening a deployment by flipping enforce on and every previously-working call starts throwing; CI bots calling policy-admin tools with no administrator allow; upgrading the CLI where classification of a tool changed, moving it into a restricted class; multi-tenant setups where one tenant's rules leak-deny another.

Related errors


AI-assisted analysis of ruvnet/ruflo@2602b642d9 (2026-08-18). Data as JSON: /api/errors/31bb7474ee38d234. Report an issue: GitHub.

Appendix: source

Thrown at v3/@claude-flow/cli/src/mcp-client.ts:267

  // Look up tool in registry
  const tool = TOOL_REGISTRY.get(toolName);

  if (!tool) {
    throw new MCPClientError(
      `MCP tool not found: ${toolName}`,
      toolName
    );
  }

  try {
    // ADR-324: one policy chokepoint for every local CLI/MCP invocation.
    // Policy administration is not exempt: authorization calls the engine
    // directly, so there is no recursive MCP dispatch. In enforce mode an
    // administrator must explicitly allow policy.* actions or use the local
    // CLI bootstrap path.
    const decision = await authorizeMcpTool(toolName, input, context, classifyMcpTool(toolName));
    if (decision.enforcedOutcome !== 'allowed') {
      throw new Error(`policy-${decision.enforcedOutcome}:${decision.reason}; receipt=${decision.receiptId}`);
    }
    // Call the tool handler
    const result = await tool.handler(input, context);
    // ADR-146 P2: scan every tool result for indirect-injection before it
    // returns to the caller. The screen is opt-in via env (default off in
    // 3.10.34 — flip to default in v4) so existing pipelines keep their
    // exact behaviour while the call site is exercised by tests and
    // adopters. Telemetry from the screen lands in the shared
    // GuardrailEvent sink (P5).
    return applyContentBoundaryGuardrail(toolName, result) as T;
  } catch (error) {
    // Wrap and re-throw with context
    throw new MCPClientError(
      `Failed to execute MCP tool '${toolName}': ${error instanceof Error ? error.message : String(error)}`,
      toolName,
      error instanceof Error ? error : undefined
    );
  }

View on GitHub (pinned to 2602b642d9)