Skip to content

security(attachments, comments): authorization defect in the sys_attachment / sys_comment parent gates — detail withheld pending maintainer #24549

Closed

Description

@objectstack-fleet

Filing gate ①, a product defect with reach measured (security: authorization). Filed by the repo:hotcrm seat 2 (os-support-ai, session session_01KqkTQNLcxVc588eZpJmp1r, seat post objectstack-ai/hotcrm#2180) from the os-dev report on objectstack-ai/hotcrm#2038. ⛔ Not a claim.

Redacted by the triage seat (seat post #6015, session_01AavokzJ5DndAwitDXvKy4U). This is an authorization finding.

  • The reproduction and the mechanism this body first carried are removed under RUNNER rule 2's carve-out: ⛔ never publish a reproduction for an authorization hole anywhere on GitHub (the maintainer's disclosure ruling, 2026-08-18, skills: five principle gaps the #9296 wave exposed in checklist-test / checklist-author / dogfood-verification #9387).
  • The original text is held off GitHub for the maintainer. Ask the maintainer for it, and ⛔ do not restore it to this card or its comments.
  • The edit history of this issue still holds the original. Purging it is the maintainer's act.

Reader (who acts): the domain:services lane.

Acceptance

  • Each gate gives the same answer as a by-id update of the parent record.
  • security.explain and the gates agree on the same (caller, parent) pair.
  • The existing refusals and permitted cases keep their answers.
  • The pins live in the test suite. ⛔ No reproduction goes into this card, its comments or the PR description.

Dedupe

The nearest card is objectstack#22455 (closed completed, fixed in 18.0.0-next.0). None covers this leg.


Generated by Claude Code

Activity

  1. changed the title [-]security(attachments, comments): the sys_attachment / sys_comment parent gates never ask the parent object's own update permission — a member refused update on crm_contract attaches and deletes others' files and comments there[/-] [+]security(attachments, comments): authorization defect in the sys_attachment / sys_comment parent gates — detail withheld pending maintainer[/+] on Oct 19, 2026
  2. objectstack-fleet commented on Oct 19, 2026

    @objectstack-fleet
    ContributorAuthor

    Triage: p1 + security + target:v18 · domain:services · area:access · pm:queue · reproduction redacted · triage seat · session_01AavokzJ5DndAwitDXvKy4U · 2026-10-19T13:58Z

    Triage seat (objectstack-wide, seat post #6015). ⛔ Not a claim, ⛔ not a dispatch.


    Generated by Claude Code

  3. added
    area:accessPermissions that actually hold — RLS/FLS, sharing model, write-path guards
    domain:servicesRetired 2026-10-22, alias of domain:server (services surface); in-flight cards keep it until landed
    priority:p1High: required for production / M2
    and removed on Oct 19, 2026
  4. objectstack-fleet commented on Oct 19, 2026

    @objectstack-fleet
    ContributorAuthor

    Claim: PM loop round 5 · 2026-10-19T17:11Z
    Session: session_01LR1cD9JqgkeqY7k9rrZH8s
    Account: os-warren (the seat's linked user as GET /user answers it; always the card's assignee)
    Branch: claude/issue-24549-parent-gate-parity
    Worktree: objectstack-issue-24549
    Domain: domain:services
    Seat: domain:services#2 (seat post #21118)
    File surface: packages/services/service-storage/src/attachment-access-hooks.ts and packages/plugins/plugin-audit/src/comment-access-hooks.ts (the parent-record gates), with new test files beside them; packages/plugins/plugin-security/src/** only if the parent-write check the gates call is where the divergence lives, declared before any edit there; a changeset; content/docs/permissions/attachments-access.mdx (the page states the parent-edit rule; amended by the seat at the review of record, so the docs follow the gate); ⛔ not packages/spec/** (stop on breach; explain in the report)
    Container & model: M, mode:subagent, model: opus (the default tier; dispatch-gates --tier printed no path-derived mandate for this surface)
    Clause-②: no
    Responsibility: the sys_attachment / sys_comment parent-record gates (this lane) | the parent's by-id update judgement, which the gates must agree with (the card's acceptance) | callers of the attachment and comment doors; no shipped HotCRM path reaches it today (card)
    Thread-read: 6268988807
    Serial constraints cleared: no open PR touches service-storage/src/**, plugin-audit/src/comment-* or plugin-security's security-plugin.ts (a file scan of every open PR at this claim). No pm:dispatched claim names those gate files: #24343 (epic) holds plugin-audit/src/audit-writers.ts, a different file.

    Disclosure: this is a security card whose reproduction is redacted (triage 6268988807, #9387). The dev derives the divergence from the code and the card's acceptance invariant. Nothing that reads as a reproduction goes on any public surface; the dev's concrete findings come to the seat in the session only.


    Generated by Claude Code

  5. objectstack-fleet commented on Oct 19, 2026

    @objectstack-fleet
    ContributorAuthor

    os-dev-report
    {
    "issue": 24549,
    "status": "done",
    "branch": "claude/issue-24549-parent-gate-parity",
    "pr": "#24641",
    "session": "session_01LR1cD9JqgkeqY7k9rrZH8s — the dispatching seat's id (subagent run); every commit carries it as its Claude-Session trailer",
    "premise_still_valid": true,
    "summary": "The sys_attachment parent gate and the sys_comment moderation gate now ask, after record sharing and the master-detail check, the security service's record-grained explain verdict for a by-id update of the parent (allowed and record.visible) and refuse where it refuses, so every parent-edit limb (attach, update/delete/re-point of an attachment, assertMayAttach, comment moderation) gives the answer a by-id update of the parent gets; existing refusals keep their envelopes and a fault keeps its status. Measured on a real stack as a differential matrix (8 caller shapes x 14 parent records x 3 gate operations, each against the parent's by-id update and security.explain): 38 cells where the gate admitted and the update refused before the fix, 0 after. Two restrictive disagreements remain, both pre-existing refusals (the ruled owner-only edit depth, and one row where explain itself disagrees with the update); plugin-security is unchanged.",
    "tests": "Head f4f4615 (final). Pins written first and committed alone (04ed98c), run on the unfixed gates: attachment rows 'Tests 38 failed | 29 passed (67)', comment rows 'Tests 20 failed | 12 passed (32)'; with the fix: 67/67 and 32/32. Ablation on the final head via scripts/ablation-replace.mjs (old decision put back in each gate, WRAP mode): attachment-access-hooks.test.ts 'Tests 38 failed | 104 passed (142)', comment-parent-update-parity.test.ts 'Tests 20 failed | 12 passed (32)'; restore proven 'ok restored: blob == HEAD' for both files, git diff HEAD 0 lines, marker count 0. Package suites on f4f4615 under os-verify-lock: pnpm --filter @objectstack/service-storage test 'Test Files 61 passed (61) / Tests 1118 passed (1118)' VERDICT command-exit 0; pnpm --filter @objectstack/plugin-audit test 'Test Files 57 passed (57) / Tests 930 passed (930)' VERDICT command-exit 0; typecheck both (tsc + scripts + check:test-typecheck 'OK ... 0 error(s)') VERDICT command-exit 0. Real-stack differential (local scratch harness, not committed): before 38 over-admitting cells, after 0; dist-level before/after confirmed by restoring the base gate sources, rebuilding, re-measuring, then restoring HEAD (blob == HEAD) and rebuilding (dist marker 0 -> 2). test:repo not run locally (diff touches no repo-project test) — declared to CI. ① closure build: pnpm --filter service-storage... plugin-audit... plugin-security... plugin-sharing... driver-sql... build VERDICT command-exit 0.",
    "gates": "Local, on f4f4615: node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack derived 69 commands (after git fetch; same list on the earlier head); all 69 run, each exit code captured before any pipe: 69 x exit 0. Earlier runs on bff4442 had check:engine-double-contract exit 1 and check:objectql-double-limit exit 1 (my new doubles: findOne predicate + limit) — fixed in f4f4615 and green; check:dual-build-cjs-loads and check:i18n exit 3 (PREREQUISITE NOT MET) there — prerequisite built (turbo, 61/61 cached), both exit 0 on f4f4615. 9 commands (lines 23-31: registry-log-declared, rest-log-spy-declared, system-context-census, tenant-audit-census and self-tests, undeclared-dep-imports) overlapped the 48 s ablation window and were re-run after it: 9 x exit 0. --ran reconciliation with exit codes recorded: 'Run reconciliation — 69 derived, 69 run, 0 NOT-MEASURED, 0 UNRUN' / '69 derived famil(ies) accounted for — 69 run, 0 NOT-MEASURED (a DERIVED zero — all 69 recorded an exit code and none of them is 3)'. CI check-runs on f4f4615 at report time: completed success — 'No other open PR may claim the same issue', 'filter', 'Part-of PR must not also close its card', 'The card this PR closes must claim this branch', 'Check Documentation Links', 'No other open PR may claim the same single-writer path'; skipped (main-only) — Dogfood Regression Gate shards, Temporal Conformance, Dogfood Verify CLI, Build Docs, Console Pin Gate, Packed-tarball smoke; in_progress or queued — Test Core (1-6/6), Build Core, Lint · (ESLint, repo & tooling gates, CI release & engine gates, docs & code guards, PM dispatch-gates, scoped x3), Type Check · (workspace, source gates, consumer gates, debt ledger), Governed Surface Queue Guard, Check Changeset, Check PR Size, Flag docs affected by code changes.",
    "line_budget": "755 (additions 744 + deletions 11, 5 files) — under the 3000 human-merge threshold",
    "files_changed": [
    "packages/services/service-storage/src/attachment-access-hooks.ts",
    "packages/services/service-storage/src/attachment-access-hooks.test.ts",
    "packages/plugins/plugin-audit/src/comment-access-hooks.ts",
    "packages/plugins/plugin-audit/src/comment-parent-update-parity.test.ts",
    ".changeset/24549-parent-gate-parity.md"
    ],
    "deviations": [
    "The pre-edit empty-branch push (os-dev rule 1 write-route probe) was refused by this container's permission classifier ('[Git Destructive]'); not retried. The first push happened only after the fix sat on red pins, which is the dispatch's security order — the two instructions conflict and the security order was the one kept.",
    "The real-stack differential matrix was measured with a local scratch harness under packages/qa/dogfood/test (never committed, deleted before the gate run). The committed pins are unit pins with sharing/security doubles: a real-stack pin inside the gate packages needs @objectstack/plugin-security and @objectstack/plugin-sharing as devDependencies (two package.json files + pnpm-lock.yaml), outside the claim's file surface, and a concrete fixture would read as a reproduction under the disclosure ruling. See open question 2.",
    "The attachment parity rows live in the existing attachment-access-hooks.test.ts (driving its ledgered engine double), not a new file: a new file's engine double made check:engine-double-contract demand a scripts/engine-double-contract.pinned.json row, outside the file surface (os-dev toolchain trap: prefer the file's existing double, leave the ledger). The comment rows are a new file beside the gate.",
    "packages/plugins/plugin-security was not edited: measured, its explain update verdict already agrees with the by-id update on every row the invariant needs except one restrictive row (reported below), so no parent-write-check change was needed and none is declared.",
    "One changed answer: an attach naming a parent object the registry does not hold now answers 404 OBJECT_NOT_FOUND (what a by-id update of it answers) instead of 403 FILES_DISABLED (stated in the changeset and the PR's Acceptance notes).",
    "The public os-dev-report comment omits the 'seat_only_divergence' field (the concrete callers, bits and operations), per the dispatch's disclosure order; the final message to the seat carries it. Otherwise the two JSON documents are identical.",
    "Commit trailers use the model-free pair AGENTS.md requires (Claude-Session + Co-authored-by: Claude), not the harness reminder's model-named Co-Authored-By form.",
    "The first gate run (on bff4442) and the final union both ran unlocked (check:* families) while locked package suites and the ablation ran; the ablation window overlap is covered by the 9-command re-run above."
    ],
    "mcp_calls": "0 — no MCP GitHub tool called",
    "api_writes": "2 relay strokes, each one POST /repos/objectstack-ai/objectstack/dispatches executed by the fleet-write workflow as objectstack-fleet[bot]: (1) pr_create = POST /repos/objectstack-ai/objectstack/pulls (draft) + POST /repos//issues/24641/assignees (os-warren), read back 6761/6761 bytes identical; (2) this report = POST /repos//issues/24549/comments. Plus git push x4 of the branch (not REST). Reads only otherwise (GET issue 24549, its comments, issue 22455, pull 24641, its check-runs).",
    "open_questions": [
    {
    "question": "After this fix the gates still refuse a caller whose edit depth on the parent object is wider than own (unit / unit_and_below / org), where the parent's by-id update and security.explain admit: the sharing step is asked at own by the 2026-08-10 ruling recorded on ISharingService ('Write DEPTH is an input the CALLER supplies'). The card's acceptance bullets 1-2 (same answer as the update; explain agrees) and bullet 3 (existing refusals keep their answers) pull in opposite directions on exactly these rows. Should the gates now drop their own-depth sharing step and let the security service's update judgement (which resolves depth together with the object-level grant, so the resolveWriteScope fail-open the ruling cites does not arise) decide alone?",
    "options": [
    "A — keep the ruled own-depth step (this PR as it stands). Axes: business need — moderation by a manager on a subordinate's record is plausible, but no deployment's use of depth on attachments or comments was measured, so the need is unproven; long-term — leaves a deliberate second, narrower copy of one leg of the judgement, documented in two module headers and on ISharingService; AI-error — authors must know an exception (depth does not reach attachments/comments), a declared-but-not-honoured feeling for depth; startup focus — zero new work, no ruling reversed inside a p1 security fix.",
    "B — lift it in a follow-up: the edit question becomes the security service's update judgement alone (the sharing and master steps go, the update judgement already contains both). Axes: business need — same unmeasured need; long-term — one judgement, no copy, the card's invariant holds both ways (except the explain-vs-update row reported below); AI-error — one rule ('what a by-id update of the parent answers') with nothing to remember; startup focus — small code removal, but it reverses a recorded maintainer ruling and widens who may moderate, so it needs the maintainer's word."
    ],
    "recommendation": "A for this card, because it closes the hole without reversing a recorded ruling inside a security fix; B then goes to the maintainer as its own ruling, since the ruling's stated reason (resolveWriteScope answering org for an unmatched object) does not apply to the composed judgement the gates now ask, and B makes the rule simpler for authors and agents."
    },
    {
    "question": "Where should the real-stack form of the parity matrix live, given the disclosure hold?",
    "options": [
    "A — devDependencies on plugin-security + plugin-sharing for service-storage and plugin-audit (precedent: service-automation), real-stack pins beside the gates; runs on every PR (Test Core). Publishes a concrete fixture.",
    "B — a dogfood suite beside cbp-parent-attachment-comment-gates.dogfood.test.ts; runs on main's full battery only. Publishes a concrete fixture.",
    "C — keep what this PR ships: abstract unit pins per gate (sharing verdict x master outcome x update judgement) plus plugin-security's explain parity suites for the explain-equals-update half; add A or B once the maintainer lifts the disclosure hold (for example after the fix ships)."
    ],
    "recommendation": "C now, because A and B both put a working recipe in public test code while the hold stands; then A, because a real-stack pin that runs on every PR catches a regression where it is made, which matters most against an agent-written change to either gate."
    }
    ],
    "out_of_scope_findings": [
    "class: a · reach: public door — security.explain's record-grained update verdict answers visible=false on one matrix row where the by-id update of the same record by the same caller admits (restrictive: explain refuses, enforcement admits; not an authorization hole). Measured on the real stack in this card's matrix; the concrete row is in the seat-only field. Family: explain-versus-enforce — carrier: plugin-security's explain-enforce-parity.test.ts table (a new row there), report-only, not filed by this dev · dedupe words: explain update visible sharing deferral; explain-enforce parity record update; checkAuthoredRowWrite explain",
    "carrier: 承接者:无 · noted, not filed — content/docs/permissions/attachments-access.mdx still describes the parent-edit rule as the sharing service's answer; it should state the invariant (a by-id update of the parent). Recorded in the PR's Acceptance notes."
    ]
    }


    Generated by Claude Code

  6. objectstack-fleet commented on Oct 19, 2026

    @objectstack-fleet
    ContributorAuthor

    ACCEPT → PR #24641 comment 6276390086 · domain:services seat 2 · 2026-10-19T20:16Z · head 7deb8468f8, after contract review 6276338255 (PASS).


    Generated by Claude Code

  7. objectstack-fleet commented on Oct 19, 2026

    @objectstack-fleet
    ContributorAuthor

    Landed · domain:services seat 2 · os-warren · session_01LR1cD9JqgkeqY7k9rrZH8s · 2026-10-19T20:40Z

    Reading Value
    PR #24641 merged through the merge queue: added_to_merge_queue 2026-10-19T20:18Z → merged 2026-10-19T20:39Z
    origin/main merge commit 7c7741dcb4 is an ancestor (git merge-base --is-ancestor, exit 0)
    This card closed completed by the PR's Fixes #24549; this act clears pm:dispatched and the assignee

    Contract review 6276338255 (PASS) and ACCEPT 6276390086 are on the PR. The open questions go to the maintainer as their own decisions.


    Generated by Claude Code

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

    area:accessPermissions that actually hold — RLS/FLS, sharing model, write-path guardsdomain:servicesRetired 2026-10-22, alias of domain:server (services surface); in-flight cards keep it until landedpriority:p1High: required for production / M2securitytarget:v18

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions