Skip to content

MCP run_action on a flow action answers ok: true and starts the run on a row the caller cannot read (or that does not exist) — the flow door still turns a denied load into an implicit grant #16370

Description

@yinlianghui

Summary

MCP run_action on a flow-typed action answers ok: true and starts the run for a recordId the caller cannot read — and for a recordId that names nothing at all. The caller-scope load fails, the door stamps record = { id: recordId }, and the flow starts on that stub. Nothing in the MCP response tells the agent the row was not delivered.

This is the flow-door sibling of the rule #15079 already wrote into the declarative-update door ("a swallowed load must never become an implicit grant" — executeDeclarativeUpdateAction throws recordNotFoundError on subject.recordLoadDenied before any work). #14244 / #15168 carried the recordLoadDenied signal INTO the flow context, explicitly context-only: "whether the automation engine acts on it (a flow-level refusal, a step condition) is a separate reading". This card is that reading, as seen from the MCP caller's side.

Measured (hotcrm @ 5e08628f, @objectstack/{mcp,runtime,service-automation} 17.2.0, Claude Code desktop via x-api-key, principal human)

Caller: na.rep@objectos.ai — profile sales_rep, crm_lead is private OWD + readScope: 'own', she owns no leads.

  1. get_record(crm_lead, I1EsT9vv-FUP6jym)Record I1EsT9vv-FUP6jym not found in crm_lead (RLS, existence non-disclosure — correct).
  2. update_record(crm_lead, I1EsT9vv-FUP6jym, {next_followup_date})You do not have access to this record. (correct).
  3. run_action(schedule_followup, recordId: I1EsT9vv-FUP6jym, params: {subject, dueDate, activityType, priority})
    { "ok": true, "action": "schedule_followup", "objectName": "crm_lead", "recordId": "I1EsT9vv-FUP6jym",
      "result": { "success": true, "status": "paused", "runId": "run_94fcb26b-9fe6-4c4e-b347-3f10ef46ef7e", "durationMs": 1, "screen": { "nodeId": "screen_1", ... } } }
  4. Same call with recordId: "does-not-exist-0000" → identical shape, ok: true, status: "paused", runId: run_8827846a-….

So the same agent that is told "not found" by get_record and "no access" by update_record is told "ok" by run_action on the same id, and a persisted paused run now exists for a row the caller has never demonstrated read access to. (Nothing was written on 17.2.0 only because schedule_followup is a screen flow and the screen node parks the run before its get_record / create_record / update_record nodes — see #15705.)

Where it stands on main (read at packages/runtime/src/action-execution.ts)

Why this matters more after #15787

#15787 (landed 2026-09-05, Part of #15705) makes a screen node NOT park when the caller already answered its required fields. With that, the run in step 3 no longer stops at screen_1: it proceeds into get_lead (caller-scoped, returns nothing), create_task (owner $User.Id, related_to_lead = an id the caller cannot read) and stamp_lead (update_record on the hidden row). The flow's own data nodes run as the caller (runAs user), so the last step should be refused by the data plane — but by then a task referencing a row the caller cannot see may already exist, and the agent was told ok: true at the door. A flow declaring runAs: 'system' (a documented authoring option) would stamp the hidden row outright; the only guard is an author-written decision node on ctx.recordLoadDenied, which #15168's own docs describe as opt-in.

Expected (either, or both)

  1. Refuse at the door, mirroring runtime: execute the declarative row-level operation: 'update' action — the platform action route performs one data-plane update of the current record as the caller (runtime half of #14092) #15079: a row-scoped flow or script action invoked with a recordId whose caller-scope load was denied is refused with the shared not-found envelope (RECORD_NOT_FOUND, 404) before the run is created — for actions that are NOT record-less (requiresRecord: true / a non-object-less key). Record-less and new-record actions keep the stamp behaviour untouched.
  2. At minimum, surface the verdict to the caller: run_action's result carries recordLoadDenied: true (same true | undefined shape the context uses), so an agent can stop instead of reporting success.

Not claimed

Related: #14143, #14244, #15168, #15079, #15705, #15787. Companion observations from the same session are in #15705 (screen-flow dead end) and objectstack-ai/hotcrm#1578.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions