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 theissue type on Sep 8, 2026
  2. 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

  3. 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

  4. 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

Type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions