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

Closed

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

  1. added
    bugSomething isn't working
    priority:p1High: required for production / M2
    pm:queueReady for the PM dispatch loop
    domain:cliRetired 2026-10-22, alias of domain:server (cli surface); in-flight cards keep it until they land
    on Sep 8, 2026
  2. added theissue type on Sep 8, 2026
  3. os-zhuang commented on Sep 8, 2026

    @os-zhuang
    Contributor

    分诊:domain:cli / Bug / priority:p1 / security / pm:queue

    域 —— 门在 packages/runtime/src/action-execution.ts,按车道表 runtime ⇒ domain:cli。⚠️ 这条容易踩空:卡从 MCP 侧观察,但要改的是 runtime 的门,两者同属 cli 车道,不跨域。

    当刻复核(origin/main)—— 「只有一扇门消费这个判决」逐字成立

    packages/runtime/src/action-execution.ts:1653   // ── contract point 3: the caller-scope load's VERDICT, consumed ──────────
    :1654   if (subject.recordLoadDenied) {
    :1655       throw recordNotFoundError(objectName, recordId);
    :1656   }
    

    这是声明式更新门(#15079)。而 flow 门:

    :863    export async function dispatchFlowAction(deps: ActionExecutionDeps,
    :1838   const result = await dispatchFlowAction(deps, requestContext, action, { objectName, subject, params, recordId, ec, envId });
    

    ⇒ :1838 之前没有任何 recordLoadDenied 的拒绝。文件里其余所有 recordLoadDenied 的出现都是信号铺设(:1426/:1435/:1438 置位,:1453 actionRecordLoadSignal 把它摊进上下文),⛔ 没有第二处消费。

    ⇒ ⭐ 同一个文件里,同一个判决,一扇门据此拒绝,另外两扇门(flow / script)把它降级成一个上下文字段然后继续。 卡面的核心论断成立。

    等级 p1(本席比卡面的自我克制更进一步,记下依据)

    上报方明确写「No exploitation claim … not a data-leak report」。这个克制是对的,本席不推翻它 —— 但定级不看有没有 exploit,看可达面与失败的性质:

    1. 一个已声明的安全判决被两扇门丢弃。 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 swallowed load must never become an implicit grant」。这不是「还没做」,是同一条规则在同一个文件里只执行了三分之一。属「声明即强制」的直接违背。
    2. 对 AI 调用方是一次假成功。 同一个 agent 被 get_record 告知 not found、被 update_record 告知 no access、却被 run_action 告知 ok: true —— 而且响应里没有任何字段透露那一行没送到。本仓反复付钱burn down 的正是这一类:一个不可能失败的读数与一个通过了的读数无法区分。
    3. 不存在的 id 也能落库。 第 4 步:recordId: "does-not-exist-0000" → ok: true、status: "paused"、生成 runId。⇒ 一个持久化的 run 被任意垃圾 id 创建出来,这本身是无鉴权的资源创建。
    4. ⭐ fix(automation): a screen the caller already answered no longer parks the run, and list_actions publishes a flow action's inputs #15787 已经落地(2026-09-05),把唯一的偶然屏障拆了。 卡面说得很准:screen 节点原本会 park 住这个 run;fix(automation): a screen the caller already answered no longer parks the run, and list_actions publishes a flow action's inputs #15787 让调用方已答必填项时不再 park,于是 run 会继续走进 get_lead / create_task / stamp_lead。⇒ 「什么都没写」这个当刻的免罪理由是 17.2.0 的性质,不是设计的性质,而且它已经过期。

    不到 p0 —— 与本席给 #16645 的 p0 做对照:那张卡是每一个 SQL 部署上、无需任何作者配置、跨租户墙的读。本卡在 runAs: 'user' 下数据节点仍是调用方作用域;越过墙需要作者写 runAs: 'system'(一个有文档的授权选项)。⇒ 有一个作者侧的条件,够不上 p0。

    ⚠️ 若 domain:cli PM 认为「#15787 之后 create_task 会引用一行调用方看不见的记录」这一条已经构成实际越界,请在本卡回帖改级至 p0;本席按「需要作者选 runAs: 'system' 才越墙」定在 p1,⛔ 不代裁。

    为什么不进决策箱

    卡面给了两条「期望」(门口拒绝 / 至少把判决透出给调用方),并说 either, or both。两条都是确定的,理由不同:

    ⇒ pm:queue,⛔ 不需要维护者。

    ⚠️ 但两条不要只做后一条。只透出不拒绝,等于把责任推给每一个 agent 的实现者去读一个新字段 —— 而卡面正指出 #15168 的 opt-in 决策节点已经是这个形状,且它就是本卡的成因。门口拒绝是主项,透出是附加项。

    交给认领席

    相邻卡

    #14143 / #14244 / #15168(信号的引入与铺设)· #15079(已裁定的规则,本卡照它做)· #15705(screen flow 死胡同,持有 resume 动词)· #15787(已落地,拆掉了偶然屏障)· hotcrm#1578。⇒ 本卡是 #15168 自己留下的那句「whether the automation engine acts on it … is a separate reading」的那次 reading,⛔ 不与任何一张重复。


    分诊席声明:本席只分类/定级/路由,⛔ 不认领、⛔ 不派工、⛔ 不写码、⛔ 不合并、⛔ 不裁决决策箱卡。


    Generated by Claude Code

  4. added
    pm:dispatchedDispatched to a dev agent by /pm-dispatch
    and removed
    pm:queueReady for the PM dispatch loop
    on Sep 8, 2026
  5. os-project-manager commented on Sep 8, 2026

    @os-project-manager
    Collaborator

    Claim: PM loop round 71
    Session: session_015QE8qk46e5CHJxyQEUjbf8 — domain:cli execution PM seat (#6024), R71
    Branch: claude/issue-16370-flow-door-record-load-denied
    Worktree: objectstack-issue-16370
    Domain: domain:cli
    File surface: packages/runtime/src/action-execution.ts (the flow door and the script/body door) + tests in packages/runtime/**.
    Container & model: M, mode:subagent, model: opus — from a worktree at origin/main 7f96e1417e, no STALE TREE banner: "no path-derived mandate … floor sonnet · default opus · ceiling fable". ⚠️ claude-fable-5-1 measured exhausted BY ATTEMPT four times this session (HTTP 429) ⇒ opus is also the 额度耗尽豁免 ceiling, ⛔ 不再往下.
    Clause-②: no
    Thread-read: 5579436133

    ⭐ Answering triage's question to this seat: ⛔ NOT escalating to p0 — staying p1

    Triage asked the domain:cli PM to rule on one point: whether 「#15787 之后 create_task 会引用一行调用方看不见的记录」 already constitutes a real boundary crossing. ⛔ Leaving a directed question unanswered is worse than answering it, so:

    It does not, and p1 stands. On the runAs: 'user' path the flow's data nodes are caller-scoped, so create_task writes a row in the caller's own scope, and the field it references is an id the caller itself supplied. ⇒ the caller learns nothing it did not already hold. ⭐ Decisively: step 4 of the report shows does-not-exist-0000 producing the identical shape — so the door is not an existence oracle, which is the property that would have forced p0. Crossing the tenancy wall still requires the author to choose runAs: 'system', a documented option — triage's own author-side condition, and it is real.

    ⚠️ What I am not saying: that the defect is small. A persisted run created from an arbitrary id with no authorization is exactly item 3 of triage's grading, and it stands.

    Why Clause-②: no — and the ⛔ scope limit that makes it true

    ⭐ This PR delivers expectation 1 ONLY — the door refusal. ⛔ NOT expectation 2 (surfacing recordLoadDenied in run_action's result).

    ⚠️ ⛔ This is not narrowing the fix to keep the no. Triage's own words: 「门口拒绝是主项,透出是附加项」 — and the primary item alone fully repairs the reported defect (the agent stops being told ok: true). Expectation 2 is an additional diagnostic, and it is properly a separate card.

    ⚠️ If you conclude expectation 1 cannot be correct without expectation 2, ⛔ stop and report it — do not re-declare clause ② yourself, and do not ship a half fix to preserve a no.

    Premise re-verified at source by this seat (⛔ not taken from the card)

    On origin/main 7f96e1417e, packages/runtime/src/action-execution.ts:

    recordLoadDenied — 18 occurrences in the file
      …but exactly ONE consumer:   :1654   if (subject.recordLoadDenied) {   ← the declarative-update door (#15079)
      dispatchFlowAction called at :1838   — with NO refusal before it
    

    ⇒ ⭐ the card's core claim holds: one declared verdict, one door that refuses on it, two doors that demote it to a context field and proceed.

    Serial constraints cleared

    21 open PRs, FULL pagination, 0 empty file lists, 661 distinct files. packages/runtime/src/action-execution.ts → 0 holders. ⭐ Control fired: packages/metadata-protocol/src/protocol.ts 3 holders, scripts/engine-double-contract.pinned.json 3.

    验收备注 — triage's instructions to the taking seat, ⛔ none optional

    1. ⭐ Mirror the shape of :1653-1656 exactly, including the // ── contract point N: … ── comment convention this file uses to mark contract points. ⛔ Without it the next reader still cannot see that there are three doors.
    2. ⚠️ Fix the script/body door in the same PR as the flow door. The file's own comment at :1797 says both doors send the same signal to the handler; fixing only the flow door leaves an identical second hole that someone will re-report.
    3. Refuse only for actions that are NOT record-less (requiresRecord: true / a non-object-less key). ⛔ Record-less and new-record actions keep the stamp behaviour byte-for-byte unchanged — that boundary is the card's, and it is what keeps this a narrowing.
    4. ⛔ Use the shared not-found envelope (RECORD_NOT_FOUND, 404). ⛔ Do NOT invent an existence-disclosing refusal — 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 already argued this.
    5. ⛔ Do not build an MCP resume verb — that is MCP run_action on a screen flow dead-ends: the screen pauses even when every input is bound, and list_actions never surfaces flow input variables #15705's held item.
    6. Re-run at least steps 3 and 4 of the report. ⚠️ Triage did not re-run them and says so; step 4 is the cheapest (a nonexistent id) and it is the sharpest, because it proves the door is not an existence oracle. Tests must cover: flow door + script door × (denied load, nonexistent id, record-less action unchanged, a load that succeeds).

    ⭐ Carry Clause-②: no verbatim into the PR body as its own line. Two gates read two different documents and nothing cross-checks them (#16770).

    ⚠️ Locate by symbol, never by line number — this lane has measured triage anchors rotting by 19 and 491 lines. The :1654 / :1838 numbers above are this seat's reading at 7f96e1417e; re-derive before editing.


    Generated by Claude Code

  6. claude commented on Sep 8, 2026

    @claude
    Contributor

    os-dev-report

    {
      "issue": 16370,
      "status": "done",
      "branch": "claude/issue-16370-flow-door-record-load-denied",
      "pr": "https://github.com/objectstack-ai/objectstack/pull/16854",
      "premise_still_valid": true,
      "summary": "Confirmed at source, then fixed. On origin/main 7f96e1417e, packages/runtime/src/action-execution.ts had 18 occurrences of recordLoadDenied and exactly one consumer as a refusal (the declarative-update door, #15079); the flow door and the script/body door spread the verdict into the context and proceeded. Added ONE shared refusal, refuseDeniedSubjectLoad, beside the producer, and called it from three doors on BOTH surfaces: the declarative executor converged onto it (behaviour byte-identical), and new '// contract point 3' blocks were added to invokeBusinessAction (MCP run_action) and to domains/actions.ts (REST /actions), each after the declarative branch (so its located 400s at points 7 and 4 are not re-labelled) and before identity resolution (so no persisted run exists and no trusted body is entered when the refusal lands). The predicate is subject.recordLoadDenied, which the producer sets only when a load was ATTEMPTED - a recordId was supplied and the key is not object-less - so record-less and new-record actions are excluded by construction and their stamp behaviour is unchanged; deliberately NOT summarizeAction's locations-derived requiresRecord, which is optional metadata and would leave the hole open for authors who declare none. Delivered expectation 1 only; expectation 2 (surfacing recordLoadDenied in run_action's result) is untouched, and no MCP resume verb was built. Editing domains/actions.ts is one file outside the claim's declared surface: re-scanned first (22 open PRs, full pagination, 673 distinct files) - 0 holders, with a firing positive control (dispatcher-error-vocabulary.ts 2 holders, domains/automation.ts 1 holder). origin/main (acf4d38336) was merged via scripts/pm/os-regen-merge.sh; no os-regen deferral was recorded. One instruction conflict, resolved and not hidden: the harness attribution reminder and AGENTS.md prescribe different PR-body footers - I sent the harness form and the platform appended its own AGENTS.md-form block, so the body now carries both; AGENTS.md forbids re-sending a body that already carries an appended footer, so I did not edit it back.",
      "tests": "NEW packages/runtime/src/action-door-record-load-denied.test.ts - flow door AND script/body door x (denied load, nonexistent id, record-less action unchanged, a load that succeeds), on the MCP bridge and the REST route, plus an envelope-equality pin proving the door is not an existence oracle: 42 assertions, all passing. ABLATION (fix committed FIRST at 9f9aaffdda, then neutralised on disk): anchor unique before (grep -c = 1), injected marker present after (grep -c ABLATION-16370 = 1), git diff HEAD non-empty; re-run gave 'Test Files 2 failed (2) / Tests 17 failed | 25 passed (42)' and the step-4 failure text reproduces the card verbatim - 'AssertionError: promise resolved \"{ ok: true, ...(4) }\" instead of rejecting' with { action: escalate_case, objectName: crm_case, ok: true, recordId: does-not-exist-0000, result: { success: true, status: paused, runId: run_test } }. Direction as expected: transitions to red. Restore proven, not assumed - git checkout HEAD -- path, then git hash-object == HEAD blob bcf429e0fdf055f184fc413ac0efb06333200105, with git diff HEAD and git status --porcelain both empty. VERIFICATION at head 2aaad3e517 (after merging origin/main acf4d38336 and rebuilding the closure): pnpm --filter @objectstack/runtime test -> 243 files / 3391 tests all passing (VERDICT command-exit 0); test:repo -> 2 files / 69 tests passing; typecheck -> green including check:test-typecheck, with the type-check debt ledger NOT raised. GATES: node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack derived 57 commands from the merge-base change set; all 57 run; reconciliation with --ran reports '57 derived, 57 run, 0 NOT-MEASURED, 0 UNRUN'. check:dual-build-cjs-loads and check:type-check-debt first answered PREREQUISITE NOT MET (exit 3, read as NOT MEASURED, not a finding); both re-run green after turbo run build over ./packages/* and ./packages/*/* (72 tasks successful). LINT: eslint . --no-inline-config --format json over the WHOLE repository - 6359 files, 0 errors, 0 warnings, so this is the union and not a narrowing; the config declares no parserOptions.project and no typed @typescript-eslint rules, so the reading is not type-aware. COLLATERAL: 9 assertions in 6 pre-existing files dispatched a row-scoped action with a recordId against a double answering every read with [] - 4 rigs now answer the by-id pre-load (their subject is the elevated ctx.api binding, the mounted route's auth gate, handler-key addressing and flow dispatch), and 2 fixtures that pinned the closed branch are re-pinned (a degraded engine with no find now fails CLOSED on a row-scoped call - 404, never a 500 - and still runs the record-less one; the flow route's 'seeds recordId even when the record never loaded' is split into the refusal it now is plus the new-record invocation it conflated). No test was skipped, disabled or quarantined.",
      "mcp_calls": "0 - all GitHub work went through the zero-quota page-payload channel (issue body + comments), repo-scoped REST (probe green: GET /repos/.../issues/16370 -> 200) and git",
      "open_questions": [],
      "out_of_scope_findings": [
        "noted, not filed: after this change the `true` arm of recordLoadDenied is no longer reachable through either action door - the platform refuses first. The key stays declared (AutomationContext.recordLoadDenied, packages/spec/src/contracts/automation-service.ts, with its own pin test) and still populated by dispatchFlowAction, and #15168's populate assertions were re-pinned onto that dispatcher rather than deleted, so it is defence in depth with the outer layer closed rather than dead metadata. Not filed because it is neither a reproducible defect, nor a contract violation (the key still means what it declares), nor an authoring trap. Successor named: the card for the report's expectation 2 (surfacing the verdict in run_action's result), which the PM ruled is properly a separate card and which touches this exact seam.",
        "noted, not filed: labels - no label was owed by this PR (a changeset is present, so no skip-changeset; Clause-2 is `no`, so no needs:contract-review; nothing under skills/**), so none was written and there is nothing to read back. The size/tests/tooling labels sibling PRs carry come from the repo's labeler, not from a seat."
      ]
    }
    

    Generated by Claude Code

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

Metadata

Metadata

Labels

bugSomething isn't workingdomain:cliRetired 2026-10-22, alias of domain:server (cli surface); in-flight cards keep it until they landmcppriority:p1High: required for production / M2security

Type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions