Skip to content

security(automation): a paused flow run's stored state keeps record-change values that #21830's mask removes elsewhere — detail withheld pending maintainer #21867

Closed

Description

@objectstack-fleet

Ruled: 5995381726 · letter A · 2026-10-05T13:25Z

Found by the independent security review of PR #21866 (the fix for #21830). #21830 masks credential-class and internal fields in record-change payloads and their downstream stores; one more store of the same class remains.


Generated by Claude Code

Activity

  1. objectstack-fleet commented on Oct 5, 2026

    @objectstack-fleet
    ContributorAuthor

    Path: approvals and automation — flows run: errors, pauses and schedules | 缺项 (no item reads a paused run's stored state as a non-privileged holder) | P2

    Triage: first grade — bug · security · priority:p2 · domain:services · area:access · needs-user-decision. Masking run state would narrow ruling #7900's "no per-field filtering of the variables map", so the maintainer rules

    Triage seat (objectstack-wide, seat post #6015) · session_01AavokzJ5DndAwitDXvKy4U · 2026-10-05T11:52Z. ⛔ Not a claim, ⛔ not a dispatch. ⛔ Classes and positions only. The withheld detail stays withheld (RUNNER rule 2); this seat does not hold it.

    Triage: lands where a paused run's state is persisted (service-automation, by class) ⇒ domain:services; rationale: it is the last store of #21830's class. The card names its direction, and the one obstacle is an earlier ruling.

    维护者速读

    一个流程暂停时会把运行状态存下来,其中包含触发它的那条记录。这条记录里的密钥类字段,在别处(写操作返回结果、记录变更事件)已经按 #21787 和 #21830 遮掩了,唯独这里没有遮掩,持有"运行状态读权限"的人能看到原值。你早先在 #7900 裁过"运行变量表不做逐字段过滤"。

    推荐 A(只看①选 A;②③④ 是否翻转:否)。置信缺口:我看不到暂扣的细节,不知道"运行状态读权限"实际给了哪些人。

    四棱(os-decision-facets)

    • ① 长远:A 让同一类字段在每个存储里都遮掩,规则只有一条;B 留下一个例外。
    • ② 拉动:持有运行状态读权限、却没有写权限的人会看到,今天没有用户报告。
    • ③ 防 AI 犯错:A 复用同一个函数,新的存储点也照此处理;B 要求每个人记住这个例外。
    • ④ 不扩散:两者都不新增键;A 只多一次函数调用。

    Prior rulings read: #7900 (variables map not filtered per field) → 1; #21787 / #21830 (the mask's class) → 2; thread: the security review of PR #21866


    Generated by Claude Code

  2. objectstack-fleet commented on Oct 5, 2026

    @objectstack-fleet
    ContributorAuthor

    Ruling: batch #278 item 1 · letter A · maintainer 「21867 同意」 2026-10-05T13:24Z

    Director seat, summon #33, session_011SekRJwTRqXSmsP6xTSci4 (GitHub os-tesla; written as objectstack-fleet[bot] via the relay). Batch #278 was presented in chat with options A, A′ and B; the maintainer answered 「21867 同意」, which takes the recommendation, A. Freshness gate: the body (last edited 2026-10-05T11:53Z) and the one comment (triage 5993882228) were re-read before this record; nothing newer exists. ⛔ Classes, positions and functions only; the withheld detail (RUNNER rule 2) stays in the holding session and is not on this record.

    The ruling

    A — mask at the source: the record-change trigger builds the flow's trigger record already masked. RecordChangeTrigger.buildContext (packages/triggers/trigger-record-change/src/record-change-trigger.ts, the one place the flow's record and previous are built from the engine hook's ctx.result / ctx.previous) projects both through the one helper PR #21816 landed and PR #21866 reused — omitInternalFieldsFromWriteResponse (@objectstack/core, packages/core/src/utils/internal-write-response.ts:169-204), with the object definition of the trigger's object — so credential-class fields carry SECRET_MASK (or null when unset) and internal: true fields are omitted. Everything downstream inherits it without a second copy: the variables map (record, $record, previous, service-automation/src/engine.ts:12463-12469), SuspendedRun.context (record / previous, packages/spec/src/contracts/automation-service.ts:26-33), the persisted variables_json / context_json (suspended-run-store.ts:988-990), the /automation/:name/runs/:runId answer and the /data/sys_automation_run columns, and the run a flow resumes with, in-process and after a restart alike.

    Settled in the same stroke:

    • Clause-②: no. No Zod or ADR-0100 table change. A flow author's visible value changes (a credential-class field read off the trigger record is now the mask), so the changeset carries the ! banner, FROM → TO and the one-line handling, and an ADR-0087 semantic entry, sibling of 18.by-id-write-unreadable-row-not-found.ts, carries the changed answer to the upgrade guide: a flow that needs a credential uses a privileged binder, never the trigger record (ADR-0100 A, "privileged read").
    • Execution parameters. This card continues (no new card): needs-user-decision → pm:queue; domain:services, priority:p2, security, area:access stay. At-tier contract review before landing (security boundary). The PR text stays at classes and positions. The claiming seat obtains the withheld consumers and mechanism from the holding session session_018zT8d8NpiQ1ExhuNd5TxY6 on this word; ⛔ the detail goes on neither the card nor the PR.
    • Pins. On a flow paused after a record-change trigger on an object with a secret and a password field: the paused row's variables_json and context_json carry SECRET_MASK for both and omit internal: true fields; GET /automation/:name/runs/:runId shows the same; a node after the pause reading record.<credential field> sees the mask, in-process and after a restart; a node reading an ordinary field sees its value; a privileged resolveSecret path is unchanged; the QA path "approvals and automation — flows run: errors, pauses and schedules" gains the item triage named as missing.

    Premises the dev verifies before writing (third-tier ruling: a failed premise is reported as fork on this card — do not force A, do not fall back to A′ or B silently)

    1. The object definition is reachable in buildContext. The trigger already materialises the record over the object's declared fields (materializeDeclaredFields, since #4649 的「记录对已声明字段全量」只落在两个接缝上 —— 另外三处求值仍是稀疏绑定 #4953), so the definition is in hand or one registry read away. Re-check: git grep -n "materializeDeclaredFields\|hydrateComputedFields" origin/main -- packages/triggers/trigger-record-change/src/record-change-trigger.ts.
    2. The projection is the last overlay. buildContext layers ctx.result over the prior row and re-reads computed fields; the mask must be applied after the last overlay so no later layer re-introduces a clear value. Re-check: read buildContext from record-change-trigger.ts:333 to its return.
    3. No shipped flow reads a credential-class field off its trigger record. Re-check: git grep -n -E "record\.(.*)(password|secret|api_key|token)" origin/main -- 'examples/**/*flow*' 'examples/**/flows/**'; a hit is a fork, not a reason to leave that field clear.
    4. Only the trigger's own record enters here. Records a flow reads later through get_record / CRUD nodes come through the engine's generic read path and are masked by ADR-0100 already; nothing in this ruling touches those nodes. Re-check: packages/services/service-automation/src/builtin/crud-nodes.ts:438-497.

    Readings behind it (each taken this summon at origin/main 9f9510f25e, or marked)

    四棱(本裁决新记录)


    Generated by Claude Code

  3. objectstack-fleet commented on Oct 5, 2026

    @objectstack-fleet
    ContributorAuthor

    Ruled, recorded by the PM seat from the maintainer's answer in Claude Code session session_018zT8d8NpiQ1ExhuNd5TxY6, 2026-10-05: when a paused run's state is persisted, mask credential-class fields in the trigger record and its previous values with the same helper PR #21816 introduced. Do not filter the variables map per field, which keeps this consistent with #7900. Chosen option 「持久化时遮盖凭据字段」. The withheld detail stays withheld (RUNNER rule 2); the dispatching session holds it.


    Generated by Claude Code

  4. objectstack-fleet commented on Oct 5, 2026

    @objectstack-fleet
    ContributorAuthor

    Alignment note from the PM seat (session_018zT8d8NpiQ1ExhuNd5TxY6). My ruling note above (6005739672) and the director's ruling (5995381726) record the same answer from the maintainer: mask credential-class fields, with no per-field filtering of the variables map. Where the two differ on where the mask is applied, the director's ruling governs. The mask is applied at the source: RecordChangeTrigger.buildContext projects record and previous through omitInternalFieldsFromWriteResponse. Persisted run state inherits the mask, so a separate mask at persistence time (option A′) is not added. This card is dispatched on that ruling, including its premises and pins.


    Generated by Claude Code

  5. objectstack-fleet commented on Oct 5, 2026

    @objectstack-fleet
    ContributorAuthor

    Claim: PM loop round 1 (17.7 pre-release follow-up, dispatched on the maintainer's direct order)
    Session: session_018zT8d8NpiQ1ExhuNd5TxY6
    Account: hotlong (the seat's linked user as GET /user answers it; always the card's assignee)
    Branch: claude/issue-21867-run-state-trigger-record-mask
    Worktree: objectstack-issue-21867
    Domain: domain:services (card label, as triage set it)
    File surface: packages/triggers/trigger-record-change/src, packages/spec/src/migrations (ADR-0087 semantic entry), docs/qa/platform-checklist/ (the missing item), .changeset/
    Container & model: M, mode:subagent, model: opus (default tier; no path-derived mandate)
    Clause-②: no
    Thread-read: 6005739672
    Serial constraints cleared: none named

    Provenance: the maintainer, in Claude Code session session_018zT8d8NpiQ1ExhuNd5TxY6, 2026-10-05, verbatim: 「持久化时遮盖凭据字段 (推荐)」, which matches the director's ruling A (5995381726, 「21867 同意」); dispatch per 「只有不能公开的才需要你派发处理」. This card withholds detail; this session holds it, and the dispatch carries it privately.


    Generated by Claude Code

  6. objectstack-fleet commented on Oct 6, 2026

    @objectstack-fleet
    ContributorAuthor

    os-dev-report

    {
      "issue": 21867,
      "status": "done",
      "branch": "claude/issue-21867-run-state-trigger-record-mask",
      "pr": "https://github.com/objectstack-ai/objectstack/pull/21928",
      "session": "session_018zT8d8NpiQ1ExhuNd5TxY6",
      "premise_still_valid": true,
      "summary": "Ruling A, implemented at the source. RecordChangeTrigger.buildContext projects record and previous (params is the same object as record) through omitInternalFieldsFromWriteResponse from @objectstack/core, using the trigger object's definition. The projection runs last: after hydration, materialisation and the decoupling copy, in place on the decoupled copies only. The engine's own hook objects stay whole. A credential-class field (secret, and password outside the exempt managedBy buckets) reads SECRET_MASK, or null when unset, and an internal: true field is omitted. There is no second mask in service-automation or in the store, and no other variable is filtered (#7900 stands). All four premises held at dcb11c2ec9. P1: getObject is already read in buildContext. P2: the last layers are materialise then decouple, and the projection goes after them. P3: the card's grep returns zero hits (exit 1); control: record.FIELD reads exist in 4 flow files; the only credential-holding example object is showcase_field_zoo, whose single flow is draft and reads neither credential field. P4: get_record and the other CRUD nodes read through data.find and data.findOne. Shipped with: a changeset (Clause-② no, ! banner, FROM to TO, one-line handling); the ADR-0087 semantic entry 18.flow-trigger-record-credential-masked, registered via gen:migration-registry; and the QA item automation.paused-run-trigger-record-masked. The PR body and this comment stay at classes and positions; no repro or attack detail.",
      "tests": "All gate readings at head 67ce8a46a2. Unit pin src/trigger-record-credential-mask.test.ts: 7 cases. Dogfood pin test/flow-trigger-record-credential-mask.dogfood.test.ts: real bootStack with automation, a file DB, the crypto provider and RecordChangeTriggerPlugin. Its cases: paused-row variables_json and context_json masked, internal field omitted; the data door over the row; GET /automation/:name/runs/:runId; a post-pause node stores the mask while the ordinary field stores its value; resolveSecretField still returns the plaintext; a cold-boot suite resumes in a second kernel and the node again sees the mask. Dogfood 7/7 green at 48e0b1cc35 (trigger source unchanged since). trigger-record-change pnpm test: 11 files, 108 tests, green at 67ce8a46a2. core pnpm test: 77 files, 2177 tests, green. service-automation vitest: 173 files, 2112 tests, green. spec src/migrations: 3 files, 179 tests, green. Typecheck green for trigger-record-change (src and tsconfig.test.json; --listFiles counts the new test once), dogfood (covers the new file) and spec (src, scripts, test layer). spec check:generated: 15/15 up to date. check:adr-0087-registration: green ([BREAKING+bang] registered flow-trigger-record-credential-masked). check:platform-checklist: green. Lint narrowed to the 6 changed .ts files: eslint --no-inline-config --format json reads 6 files, 0 errors, 0 warnings; the .md and .json files answer 'File ignored because no matching configuration was supplied'; --print-config shows parserOptions without project, so type-aware linting is off and no untouched file's verdict can move. dispatch-gates --ran: 90 derived, 90 run, 0 NOT-MEASURED, 0 UNRUN. Two gates went red mid-pass when plugin-approvals and plugin-auth dist lost their .d.ts (neither package is in this diff); after a rebuild, check:dts-closure and check:dual-build-cjs-loads read green. Ablation: scripts/ablation-replace.mjs in wrap mode with an EXIT/INT/TERM restore replaced the two projection calls. On-disk proof: anchor 1 to 0, marker 0 to 1, blob d0702684cb19 to 27a41720a0ab. No dist hop: the dogfood isolated project aliases @objectstack/trigger-record-change to source, and the plugin comes from that import. Unit pin: 3 red, 4 green; the 4 that stay green hold without a mask too (ordinary value, params identity, unset reads null, hook objects whole). Dogfood: 5 red, 2 green (armed scene, privileged path). Restore: blob == HEAD d0702684cb19, git diff HEAD empty. packages/cli is not touched, so there is no integration tier owed.",
      "mcp_calls": "0",
      "api_writes": "4 — git push of claude/issue-21867-run-state-trigger-record-mask (not REST); fleet-write relay pr_create (POST /repos/objectstack-ai/objectstack/pulls, draft, PR #21928); label-write relay assign (POST /repos/objectstack-ai/objectstack/issues/21928/assignees hotlong); post-stamped os-dev-report comment (POST /repos/objectstack-ai/objectstack/issues/21867/comments). Zero label writes: the dispatch named no labels, and skip-changeset does not apply because the PR ships a changeset.",
      "open_questions": [],
      "out_of_scope_findings": [
        "carrier: 承接者:无 · noted, not filed — during the second full gate pass, packages/plugins/plugin-approvals/dist and packages/plugins/plugin-auth/dist were found rewritten without .d.ts. Which step wrote them was not established; a rebuild restored them. Neither package is in this diff. Recorded in the PR's Acceptance notes only.",
        "deviation (not a finding): the claim's declared file surface is packages/triggers/trigger-record-change/src, packages/spec/src/migrations, docs/qa/platform-checklist and .changeset. The PR also touches packages/triggers/trigger-record-change/vitest.config.ts (anchored array alias plus @objectstack/spec/data to source, required by check:test-source-alias for the new value import) and adds packages/qa/dogfood/test/flow-trigger-record-credential-mask.dogfood.test.ts (the dogfood pin the dispatch asked for)."
      ]
    }

    Generated by Claude Code

  7. objectstack-fleet commented on Oct 6, 2026

    @objectstack-fleet
    ContributorAuthor

    os-dev-report

    {
      "issue": 21867,
      "status": "done",
      "branch": "claude/issue-21867-run-state-trigger-record-mask",
      "pr": "https://github.com/objectstack-ai/objectstack/pull/21928",
      "session": "session_018zT8d8NpiQ1ExhuNd5TxY6",
      "premise_still_valid": true,
      "summary": "Round 2 on PR #21928, head 4f287e072f. Merged origin/main as a merge commit (baa4b2fe6c; the branch was 9 behind). (1) The changeset gains the record-vs-previous line in the handling paragraph (two equal masks whenever the credential field is set on both sides; use a privileged binder to detect a change) and a 'Runs stored before this release' paragraph: paused runs, and terminal runs that keep a restorable snapshot, created before the upgrade still hold clear values in variables_json / context_json / steps_json, so operators resume, cancel or purge them after upgrading. There is no migration and no scrub. (2) In record-change-trigger.ts, the comment that said an unknown object 'was refused upstream' is corrected: the bind-time probe only warns and still binds. readObjectDefinition now logs once per object per trigger, at error through the plugin logger (falling back to warn when the logger has no error, the same pattern as the file's execution-failure log), whenever the definition cannot be resolved (getObject absent, returns nothing, or throws). The log names the object, the flow, the consequence and the fix. Dispatch is unchanged. (3) trigger-record-credential-mask.test.ts gains afterDelete and beforeUpdate mask cases, a pin for each of the three unresolved shapes (one error naming the object, the flow still runs on both writes) and a no-error control. The package's vitest config now aliases @objectstack/core to source, and the KNOWN_UNALIASED_TEST_IMPORTS entry for this package in scripts/check-test-source-alias.mjs drops @objectstack/core (the gate reported the entry stale).",
      "tests": "All at head 4f287e072f. Closure build: pnpm --workspace-concurrency=2 --filter '@objectstack/trigger-record-change...' build, VERDICT command-exit 0. Package: pnpm --filter @objectstack/trigger-record-change test, 'Test Files 11 passed (11) / Tests 114 passed (114)'. Then typecheck, which ran 'tsc --noEmit && tsc --noEmit -p tsconfig.test.json'; VERDICT command-exit 0 for both. The mask file has 13 cases. Ablation 1, proving the core alias resolves to source: through scripts/ablation-replace.mjs, an early return was planted in packages/core/src/utils/internal-write-response.ts omitInternalFieldsFromWriteResponse, with core dist NOT rebuilt (the marker has 0 hits in packages/core/dist). Landed: anchor 1 -> 0, blob 2a6a48c04fdb -> d51283c8f5e6. Result: 5 failed / 8 passed (the masking cases, the new afterDelete and beforeUpdate included). Restored: blob == HEAD 2a6a48c04fdb, git diff HEAD empty. The first attempt was a no-op that the tool refused (the replacement contained the anchor, so the anchor count did not drop); it measured nothing and was redone with a non-overlapping replacement. Ablation 2, proving the log pin can fail: the error branch's condition was replaced with false. Landed: anchor 1 -> 0. Result: 3 failed (the absent, nothing and throws cases), 10 passed. Restored blob == HEAD 04e3ca86825f, git diff HEAD empty. Gates, each exit 0: check:adr-0087-registration (reads '[BREAKING+bang] registered flow-trigger-record-credential-masked'; --self-test 441 assertions), check-adr-0087-registration --base origin/main, check-changeset-no-major --base origin/main, check-empty-changeset --base origin/main, check:changeset-gate-self-tests, check:test-source-alias ('73 packages with tests scanned; 60 registered'), check:nul-bytes, check-scripts-symbol-anchors, check-published-list-mirrors, check:cross-package-test-inputs, check:doc-authoring, check:issue-citations, check:logger-receiver-detach, check-changeset-fixed, check:published-files. Spec check:generated after the main merge (spec moved on main): 'All 15 generated artifacts are up to date', against the spec dist built post-merge. NOT MEASURED: check:console-injection, reason: it skipped, since there is no packages/console/dist in this worktree. Lint, narrowed: eslint --no-inline-config --format json over the 4 files changed this round (record-change-trigger.ts, trigger-record-credential-mask.test.ts, vitest.config.ts, scripts/check-test-source-alias.mjs) gave 4 files, 0 errors, 0 warnings. Population: all 4 are in eslint's own config, per --print-config. Invariance: --print-config shows parserOptions.project and projectService undefined, so type-aware linting is off and this diff cannot move any untouched file's verdict. The rest of the dispatch-gates derivation (109 commands over the whole PR diff, mostly round-1 spec and dogfood families) was not re-run this round; CI owns it.",
      "mcp_calls": "0",
      "api_writes": "1 - this os-dev-report comment on #21867, via scripts/pm/post-stamped.mjs (fleet relay). git push x2 is not a REST write. Zero PR-body, label or assignee writes.",
      "open_questions": [],
      "out_of_scope_findings": [
        "carrier: none (承接者:无) - noted in Acceptance notes only, not filed. In buildContext, the materialisation read of getObject (gated on ground truth) is not wrapped in try/catch. A getObject that throws therefore fails the dispatch before the mask runs, and the handler logs 'execution failed'. So readObjectDefinition's throw branch is reachable only on an update/delete with no prior row. This behaviour predates the PR and was left untouched, as the dispatch said dispatch behaviour must not change. No public entry point is shown to throw from getObject."
      ],
      "pr_body_edits_needed": [
        "What this changes, bullet 'The definition is read regardless of ground truth': replace 'tolerates a missing accessor or a throw. With no definition there is nothing to mask.' with: 'When the definition cannot be resolved (accessor absent, no answer, or a throw), the flow still dispatches unmasked, and readObjectDefinition logs that once per object at error through the plugin logger, naming the object. The bind-time existence probe only warns and still binds; nothing upstream refuses an unknown object.'",
        "Pins, unit bullet: '7 cases' becomes '13 cases'. Add: afterDelete (record from the prior row) and beforeUpdate (payload over the prior row) are masked on both roots; each of the three unresolved-definition shapes logs one error naming the object while the flow runs on both writes; a resolved definition logs no error.",
        "Upgrade text: add that the changeset now names the record-vs-previous credential comparison (two equal masks when the field is set on both sides; use a privileged binder) and carries a 'Runs stored before this release' paragraph (resume, cancel or purge pre-upgrade paused runs and snapshot-keeping terminal runs; no migration or scrub). Replace the vitest.config bullet's tail with: 'and gains @objectstack/spec/data and @objectstack/core to source; the check-test-source-alias registry entry for this package drops @objectstack/core'.",
        "Verification: add a round-2 block at head 4f287e072f with the test/typecheck/gate readings and the two ablations from this report's tests field. Acceptance notes: add the carrier-none note from out_of_scope_findings, and the touched-file extension (scripts/check-test-source-alias.mjs, registry narrowing only)."
      ]
    }

    Generated by Claude Code

  8. added 2 commits that reference this issue on Oct 7, 2026
    1f04696
    2a22177
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

area:accessPermissions that actually hold — RLS/FLS, sharing model, write-path guardsbugSomething isn't workingdomain:servicespriority:p2Medium: important, M3security

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions