Repository navigation
security(plugin-audit): an activity row composed at write time may carry a changed field's values to a reader who can read the parent record but not that field (unmeasured; measure first) #21081
Description
Filing gate: ① a product defect, filed under the possible-data-disclosure exception, whose first point is measure reach first. No reach: is measured yet.
Source: #20833's dev report (5924706600, out_of_scope_findings, the carrier: entry, "noted, not filed"). The at-tier contract review of PR #21069 (5924857517, ③) escalated it: "a data-exposure class with no carrier yet — the seat should give it a card or a row in the close-out rather than acceptance notes alone". It is not the same mechanism as the close-out family card filed beside it, so it gets its own card. Filed by the domain:services execution seat (#6021, session_01XY5uCwTjZj7884yYtyur4H). ⛔ Not a claim.
The suspicion (by class; ⛔ not measured)
- The caller: a non-system reader who can read a parent record but not every field of it (a field masked for this reader, or one its sets do not grant).
- The rows: activity rows about that record. After PR fix(plugin-audit)!: an engine read of sys_activity returns only the rows whose parent record the caller can read #21069, the activity read gate keeps exactly the rows about parents the caller can read, so this reader keeps them.
- The position:
packages/plugins/plugin-audit/src/audit-writers.tscomposes the activity row at write time, as the system. Its human-readable summary and its recorded details can carry the changed fields' values. The writer masks credential fields (secret, andpasswordoff better-auth objects) and drops virtual ones. It does not apply the reading caller's field-level answer, which it cannot know at write time. - So the question is whether a field this reader may not read reaches it through an activity row's text. This covers a masked field, a capability-gated field and a field no set grants. security(approvals): the approval snapshot redaction narrows by
getReadableFields, which counts a field the caller is served masked as readable, so such a field may reach an approver as stored rather than masked (source-read, not yet measured) #20964 (PR fix(approvals): a snapshot field the reader is served masked is no longer served as stored (#20964) #20993) closed the same class for approval payload snapshots, at read time and through the security service's own answer.
Direction (⛔ not a ruling)
- Measure first, privately: on a real boot, with a reader of each field class above and an unmasking reader as the control, does the activity row about a change to that field serve the field's value? Measure through the generic data doors and the activity feed. If nothing is served, close this card with the reading.
- If served, the safe side: the activity row about a field serves that field's values only to a reader the security service answers can read it. Either the summary is composed so that it carries no field value the platform masks, or the read path redacts with the security service's answer, as security(approvals): the approval snapshot redaction narrows by
getReadableFields, which counts a field the caller is served masked as readable, so such a field may reach an approver as stored rather than masked (source-read, not yet measured) #20964's redaction does.- ⛔ No second derivation of the masking rule in
plugin-audit. - Which of the two applies is the owner's call after the measurement.
- ⛔ No second derivation of the masking rule in
- Pins: one per field class, with the unmasking reader as the control. No pin title states a value.
Reader who acts
Triage (grade and route; plugin-audit is domain:services), then this lane's seat for the measurement.
Dedupe
mcp__github__search_issues, repo-scoped, open and closed, in the act that filed this card:
- 「activity summary row carries masked field value before after field level permission reader」: 13 hits.
- security(approvals): the approval snapshot redaction narrows by
getReadableFields, which counts a field the caller is served masked as readable, so such a field may reach an approver as stored rather than masked (source-read, not yet measured) #20964 (closed) is the same class for approval snapshots: the precedent. - security(spec): a capability-gated field (requiredPermissions, no maskingRule) is served stored to a caller who resolves no permission set, while explain reports it hidden; narrowing it moves getReadableFields' contract answer #21063, security(forms): two public-form doors meet a field masking rule wrongly — the submit read-back serves masked fields stored to an anonymous submitter, and a picker whose first display field is masked answers 403 to every caller it applies to #21062 and security(plugin-security): for a caller who resolves no permission set, the field-projection answers say no masking rule reaches it, while
maskingRule's describe and the result masker mask it — which one a public door serves is not measured #20995 (open) are the zero-set caller's field answers at other doors. - security(analytics): a field the caller may only see masked is answered unmasked as a grouped or filtered member on the native-SQL strategy; the published field reader has no masked-for-this-caller answer #20935, security explain: the field-mask layer does not report partial masking (maskingRule) — gated fields read as fully hidden, gate-less rule fields as fully readable #9127, finding(spec): the protocol declares no masked field-type set — objectql's
collectMaskedReadFieldsand objectui'sMASKED_FIELD_TYPESeach own a copy of one fact #20141, A field denied by FLS (readable:false) is exactly recoverable from a readable formula that references it — measured on showcase_project.budget #9562, spec/security: field masking is all-or-nothing — no partial masking (phone last-4, ID middle-8), andmaskingRulewas pruned as dead in 2026-06 #8993, finding: after ADR-0106, a restricted caller's GET → edit → PUT of an object schema DELETES the fields that were masked out of their read #6603, A permission set accepts a hierarchyreadScopebesideviewAllRecords: true, never reads it, and emits no diagnostic — the declaration materialises and a capability census counts it as coverage #16870, [permissions] 行级读可见范围无法按业务字段收窄:viewAllRecords 全有/全无两档之间缺共享规则 #4376 and finding: the #7929 read-scope disclosure is NOT analytics-only — an RLS$fieldfilter injected by the security middleware discloses the same policy column on the ordinary CRUD path, and predates #7598 #7988 (closed) are other masking or FLS defects.
- security(approvals): the approval snapshot redaction narrows by
- None is a field value carried by an activity row.
Dedupe words: activity summary masked field value · activity row field level · write-time summary field permission · audit activity before after values
Generated by Claude Code
Activity
objectstack-fleet commentedon Oct 1, 2026
Triage: first grade —
bug·security·priority:p2·domain:services·area:access·pm:queue. Measure first; a served value raises it to p1Triage seat (objectstack-wide, seat post #6015) ·
session_01AavokzJ5DndAwitDXvKy4U· 2026-10-01T06:01Z. ⛔ Not a claim, ⛔ not a dispatch. ⛔ Classes and positions only.Why p2, for now.
reach:measured yet.main, with the activity read gate in place.getReadableFields, which counts a field the caller is served masked as readable, so such a field may reach an approver as stored rather than masked (source-read, not yet measured) #20964, the same class for approval snapshots, p1).Routing.
plugin-auditisdomain:services.Direction. It is the card's own scope, confirmed:
getReadableFields, which counts a field the caller is served masked as readable, so such a field may reach an approver as stored rather than masked (source-read, not yet measured) #20964 did;plugin-audit.Generated by Claude Code
objectstack-fleet commentedon Oct 1, 2026
Claim: PM loop round 1 · 2026-10-01T08:11Z
Session:
session_01DiCSbmJrkzNhuEAier4VoJAccount:
os-bill(the seat's linked user asGET /useranswers it; the card's assignee)Branch:
claude/issue-21081-activity-field-valuesWorktree:
objectstack-issue-21081Domain:
domain:servicesSeat:
domain:services#2(seat post #21118)File surface: measure first, privately, then fix only if a value is served. If it is, the expected landing is
packages/plugins/plugin-audit/src/audit-writers.ts(composition) or the activity read path besideactivity-read-visibility.ts(read-time redaction through the security service's own answer), with tests underpackages/plugins/plugin-audit/src/and a changeset. ⛔ No second derivation of the masking rule inplugin-audit. ⛔ No edit topackages/plugins/plugin-security/src/security-plugin.ts(#21063 holds it). Stop on breach; explain in the report.Container & model:
M,mode:subagent,model: opus(dispatch-gates--tier: no path-derived mandate; default tier, because the composition-or-read choice is a judgment)Clause-②: no
Thread-read: 5925692878
Serial constraints cleared: I diffed the pushed branches of every open
pm:dispatchedcard (29 cards, 26 branches) againstorigin/main, and read the file lists of all open PRs. No branch and no open PR touchespackages/plugins/plugin-audit/src/**. The open dependabot PR #21024 changes onlyplugin-audit/package.json. Thearea:accesscards in flight (#21087, #21086, #21062, #21052, #20987, #21063) are on other files. Today6f578888(PR #21069, the activity read gate) landed onplugin-audit, and the measurement runs on top of it.Selection: P1 first, per the maintainer's order 「优先 p1 的卡片」. #21079 and #21080 are still serial behind other seats' in-flight work (seat post #21118). #21057 moved to
pm:blockedbehind #15193. This card is the nextbugby label order. #20965 is serial behind #21044 onanalytics-service.ts.Clause-②⚠️ Disclosure discipline: classes and positions only, in every GitHub text.
no: if a fix lands, it narrows what an activity row serves to a reader who may not read a field. No accept set widens and no public surface grows.Generated by Claude Code
objectstack-fleet commentedon Oct 1, 2026
os-dev-report
{
"issue": 21081,
"status": "done",
"branch": "claude/issue-21081-activity-field-values",
"pr": "#21152",
"session": "session_01DiCSbmJrkzNhuEAier4VoJ — the dispatching seat session (this run is its subagent)",
"premise_still_valid": true,
"summary": "MEASURED FIRST, AND SERVED: on a real boot (bootStack with the real SecurityPlugin, ObjectQL, SQL driver, REST, auth and AuditPlugin; rows written by the CRUD mirror from REST writes), each of the three reader classes — a field served masked, a capability-gated field with no mask, and a field a held permission set marks non-readable — was served that field's stored value by the activity rows about the parent, through the generic list and by-id doors. This held at every position: the tracked-change summary, the milestone summary, the record label and the recorded change. The data plane meanwhile served the same reader the field replaced or without the key. The unmasking control read stored values everywhere. Under the triage's raise rule this is the p1 trigger; that write is the seat's. THE CHANGE (draft PR #21152), read time and through the security service's own answer, mirroring the approval snapshot precedent. Composition time cannot know the reader and cannot see the permission-set class at all. A new sys_activity read middleware narrows the recorded change key by key. It serves the summary and the record label whole or drops them whole, by a declaration of their source parent fields that the writer now records in the row. The served-unmasked set is getReadableFields intersected with getQueryableFields, failing closed when the query-side answer is missing. Nothing in plugin-audit derives masking. Rows written before the declaration existed serve their text only to a reader served every field of the parent. App-written rows are served as written. After the fix, the same probe reads every class's values gone for its reader, the other classes still served, and the control unchanged.",
"tests": "All at final head eec3115, after merging current main twice; the second merge brought no plugin-audit source overlap. (1) pnpm --filter @objectstack/plugin-audit test: 29 files / 411 tests passed. (2) pnpm --filter @objectstack/plugin-audit typecheck: exit 0, check:test-typecheck OK. (3) Dogfood vitest --project isolated on the new activity-field-values pin plus the sibling activity-parent-read-gate pin: 2 files / 16 tests passed. Dogfood typecheck: exit 0. (4) New unit pin activity-field-redaction.test.ts, 19 cases: real engine, real SQLite driver, the real AuditPlugin mount, and a security-service double answering both contract members per reader. (5) New dogfood pin activity-field-values.dogfood.test.ts, 10 cases: one reader per class plus the control, armed in beforeAll by assertArmed (rows at rest carry every class; the data plane serves each reader its class as declared). ABLATIONS, every negative pin, all via scripts/ablation-replace.mjs (anchor hit once, landed by count and blob, restore proven blob==HEAD and git diff HEAD empty), unit legs re-run at eec3115 with identical counts: mount removed 10/19 red; query-side intersection removed 7 red (masked class only); text check off 8 red; key narrowing off 7 red; earlier-shape recognition off 1; projection augmentation off 1; milestone provenance off 4; tracked provenance off 6; label provenance off 1; fail-closed branch opened 2; declaration strip off 1. Each leg's other cases stayed green. Dogfood legs (dist-resolved): plugin-audit rebuilt, and scripts/ablation-dist-preflight.mjs proved the marker present in dist; the restore leg rebuilt and proved it absent with --absent on a clean whole tree. Mount removed: 6/10 red (all six per-class negatives; control and preservation green). Query-side intersection removed: 2/10 red (masked class only). One first dogfood mount leg was VOID: the declaration build refused the mutation (an import left unread), nothing ran, and it is not counted; it was re-run with a buildable mutation. LINT, proven narrowing: eslint --no-inline-config --format json over the 6 changed TS files reported 6 files, none ignored, 0 errors, 0 warnings. eslint.config.mjs enables no type-aware linting (0 parserOptions.project / projectService), so no untouched file's verdict can move. The repo-wide pnpm lint is CI's. CI on eec3115, read once: 14 completed (12 success, 2 skipped), 18 in_progress — in_progress, not awaited.",
"gates": "dispatch-gates --repo objectstack-ai/objectstack --commands at eec3115 (no paths; change set from merge base c6954d6): 95 families. All 95 were run with exit codes captured before any pipe, and all exited 0. The --ran reconciliation reads 95 derived, 95 run, 0 NOT-MEASURED, 0 UNRUN, with every exit code recorded. Two were red on the way and fixed, not waived: check-system-context-census (the new middleware's system-context read wanted a row; added beside the sibling read gate's row, counts regenerated by gen:system-context-census) and check:query-options-erasure (the new test had added 4 cast sites; options now typed, test surface back at its 236 ceiling). check:dual-build-cjs-loads first answered PREREQUISITE NOT MET (8 unrelated packages had no dist); built, then exit 0.",
"line_budget": "1193 changed lines (+1156 / -37) across 8 files at eec3115 vs merge base c6954d6, under the 5000 human-merge threshold. No skills/** and no governed surface in the diff.",
"files_changed": [
".changeset/21081-activity-field-values.md",
"content/docs/permissions/system-context.mdx",
"packages/plugins/plugin-audit/src/activity-field-redaction.test.ts",
"packages/plugins/plugin-audit/src/activity-field-redaction.ts",
"packages/plugins/plugin-audit/src/activity-read-visibility.ts",
"packages/plugins/plugin-audit/src/audit-plugin.ts",
"packages/plugins/plugin-audit/src/audit-writers.ts",
"packages/qa/dogfood/test/activity-field-values.dogfood.test.ts"
],
"deviations": [
"File surface widened by two paths. packages/qa/dogfood/test/activity-field-values.dogfood.test.ts: only the real security plugin tells a capability-gated field from a field no set grants, so the per-class pin needs a real boot; this is the precedent's dogfood shape. content/docs/permissions/system-context.mdx: the check-system-context-census gate requires a row for the new system-context read. No edit to plugin-security/src/security-plugin.ts.",
"Dispatch-prose conflict: the order said to pass changed paths to dispatch-gates.mjs; os-dev.md says to derive with no paths (the script reads the change set from the merge base). I followed os-dev.md. With no paths, the change set it derived was exactly the 8 committed paths.",
"Measurement probe: a temporary, never-committed probe file under packages/qa/dogfood/test/ produced the readings into the scratch dir and was deleted before the first commit that touched dogfood.",
"A read-only, blob-filtered clone of objectstack-ai/objectui at the pinned .objectui-sha went into the scratch dir, to check how the timeline consumes the activity columns (summary null-tolerant; recorded change and record label not read). No write.",
"A turbo-backed gate in the final union appended a managed block to AGENTS.md in this worktree (turbo 2.11.5 agent guidance). It was restored with git checkout HEAD, proven by disk blob == HEAD blob and an empty diff, and not committed. Reported as a finding below.",
"Merged origin/main into the branch twice (merge commits 93c2e68 and eec3115), with no source overlap, so that gates and suites read a current tree."
],
"mcp_calls": "0 — no MCP GitHub tool called.",
"api_writes": "3 — each one POST /repos/objectstack-ai/objectstack/dispatches through the fleet-write relay, executed as objectstack-fleet[bot]: (1) pr_create → POST /repos/objectstack-ai/objectstack/pulls (draft #21152; read-back: 10699 bytes sent and 10699 stored, identical); (2) label-write assign → POST /repos//issues/21152/assignees (os-bill; read-back matches); (3) this os-dev-report → POST /repos//issues/21081/comments. Not REST: 6 git pushes of the feature branch.",
"open_questions": [],
"out_of_scope_findings": [
"class: a · reach: public door — the generic list door on the activity stream, as a reader of each of the three classes, with a predicate over the stored activity text: row presence answered a value probe (rows on a hit, none on a miss), both before and after this fix. The predicate is evaluated at rest, before any redaction. A group or aggregate over the same columns is the same class and was not measured. The approval snapshot column on its generic door is plausibly the same family and was not measured. · evidence: probe readings in this dispatch's scratch; the precedent closed only its service door's free-text arm. · dedupe words: activity text predicate oracle · filter over redacted column row presence · generic door predicate snapshot column · sys_activity summary contains",
"class: a · reach: public door — the generic list door on the compliance ledger, as a reader whose sets grant that object read (the platform's viewer_readonly wildcard set) and also withhold a field of the parent: the ledger's create and update rows served the withheld field's stored values in their before/after snapshots, while the data plane served the same reader without the key. Measured on the same boot. Whether ledger readers are meant to be field-unrestricted is a policy question for the seat. · evidence: probe readings in this dispatch's scratch · dedupe words: audit log old_value new_value field level security · sys_audit_log snapshot withheld field reader · compliance ledger field values viewer_readonly",
"class: a · reach: named producer — turbo 2.11.5 (the dev-dependency group bump now on main, 840ec9d), run by a turbo-backed check:* gate in an agent session, appended a 'turborepo-agent-rules' managed block to AGENTS.md (a Tier H governed surface). turbo.json carries no agentGuidance opt-out. An agent's git add -A would carry it into a PR and turn that PR governed. · evidence: this worktree at eec3115 showed AGENTS.md +11 lines (mtime 10:00:16Z) right after the gate union; restored. · dedupe words: turbo agentGuidance AGENTS.md · turborepo-agent-rules block · turbo 2.11 writes AGENTS.md · governed surface modified by tool",
"carrier: 承接者:无 · noted, not filed. A tracked-change summary renders a referenced record's title, resolved at write time. This fix judges the parent's own lookup field, not the referenced record's readability or the readability of its title field. Unmeasured. It is not in PR #21152's acceptance notes (the body is written once); the seat may add the line there.",
"carrier: 承接者:无 · noted, not filed (in PR #21152's acceptance notes). The served-unmasked composition (read projection intersected with the query-side answer, failing closed) now exists in two consumers: the approval snapshot and the activity redaction. Hoisting it to one shared helper would remove a drift seam."
]
}
objectstack-fleet commentedon Oct 1, 2026
Raise to
priority:p1(triage's raise rule), and PM review — PR #21152: ACCEPT. Landing: ordinary queue once every check on headeec3115fis greendomain:servicesseat 2 (#21118) ·session_01DiCSbmJrkzNhuEAier4VoJ· 2026-10-01T10:19Z. Reviewed-by: this seat. Implemented-by: os-dev subagent, same session (report on this card, PR body).Raised
priority:p2→priority:p1, in this act. Triage's rule (5925692878) reads: "if the measurement shows a field value served to a reader who may not read that field, the claim raises the card to p1 and says so here." The dev measured it served on a real boot (bootStack with the real security plugin, SQL driver, REST and audit plugin). All three reader classes (masked, capability-gated without a mask, not granted) were served the field's stored value at every position: the tracked-change summary, the milestone summary, the record label and the recorded change, through the list and by-id doors. The unmasking control was served stored values. The readings are private to the dispatch.Checklist, read on GitHub rather than from the report:
main, first lineFixes #21081,Clause-②: no(matches the claim). The body carries classes only, so disclosure discipline holds.plugin-audit/src/, apatchchangeset, and two declared widenings beyond the claim.packages/qa/dogfood/test/activity-field-values.dogfood.test.ts: only the real security plugin distinguishes a capability-gated field from an ungranted one. This is the precedent's dogfood shape.content/docs/permissions/system-context.mdx: census counts 109 → 110, whichcheck-system-context-censusrequires for the new system-context read.check-governed-merges.mjs --pr 21152: not governed, 1193 changed lines (≤ 5000). No edit tosecurity-plugin.ts.sys_activityread middleware (find/findOne, non-system context) runs after the parent-record read gate.getReadableFields∩getQueryableFields, which fails closed when the query-side answer is missing.plugin-audit, which holds triage's direction.resolveServedFieldsis line-for-line the approval snapshot'sresolveReadableSnapshotFields(plugin-approvals/src/payload-redaction.ts). That includes its pass-through whengetReadableFieldsitself throws, which both consumers inherit. The dev's hoisting note below would give that shared shape a single home.assertArmed;distthat was proven present and then absent. One void leg was declared and re-run;eec3115fat this write: 22 success, 2 skipped, 9 in progress, 0 failed.Out-of-scope findings:
turbowriting a managed block intoAGENTS.mdis already tooling: turbo 2.11.5 writes a managed block into AGENTS.md in every agent worktree; opt out with "agentGuidance": false in turbo.json #21146 (open).Generated by Claude Code
objectstack-fleet commentedon Oct 1, 2026
Filed from this card's out-of-scope findings ·
domain:servicesseat 2 (#21118) ·session_01DiCSbmJrkzNhuEAier4VoJ· 2026-10-01T10:22Z · ⛔ Not a claim.Both are bare, for triage.
Generated by Claude Code
fix(plugin-audit): an activity row serves a parent field's value only…
fix(plugin-audit): the compliance ledger's before/after snapshots ser…
fix(plugin-audit,plugin-approvals)!: refuse a query over activity tex…
fix(plugin-audit): a read of the compliance ledger returns only the r…