Skip to content

security(search): a field-narrowed search still matches through the name field's pinyin companion #21880

Description

@objectstack-fleet

Found by the independent code review of PR #21879, the fix for #21836. That PR narrows each swept object's search to the fields the caller may query. One search clause the engine adds is not governed by that narrowing.


Generated by Claude Code

Activity

  1. objectstack-fleet commented on Oct 5, 2026

    @objectstack-fleet
    ContributorAuthor

    Path: business objects and records — searchable, with consistent permissions | search.field-scoped-narrowing | P2

    Triage: first grade — bug · security · priority:p3 · domain:engine · area:access · pm:queue (finding removed). The companion clause follows the effective search-field set

    Triage seat (objectstack-wide, seat post #6015) · session_01AavokzJ5DndAwitDXvKy4U · 2026-10-05T13:51Z. ⛔ 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 in the engine's search expansion (not searchAll) ⇒ domain:engine; rationale: the narrowing to queryable fields is applied before the expansion adds the companion clause, so the clause escapes it.


    Generated by Claude Code

  2. objectstack-fleet commented on Oct 5, 2026

    @objectstack-fleet
    ContributorAuthor

    Claim: PM loop round 38 · 2026-10-05T23:54Z
    Session: session_017ErfyP2Rx7XWHJA27QjyUi
    Account: os-project-manager (the seat's linked user as GET /user answers it; always the card's assignee)
    Branch: claude/issue-21880-search-companion-scope
    Worktree: objectstack-issue-21880
    Domain: domain:engine
    Seat: domain:engine#1
    Provenance: the seat asked the maintainer in this seat's session (2026-10-05) whether to take this card, which had stayed in pm:queue with no claim since triage. The maintainer answered, verbatim: 「并发3」. This lane runs three concurrent claims, and this card is the only eligible pm:queue card. The withheld detail (RUNNER rule 2, held in session_018zT8d8NpiQ1ExhuNd5TxY6) is not requested and not needed: triage's direction (5995863103) is public and names the fix. The PR describes the fix only in the terms already public on this card.
    File surface (at origin/main dcb11c2ec9), per triage's grade and direction 5995863103:

  3. objectstack-fleet commented on Oct 6, 2026

    @objectstack-fleet
    ContributorAuthor

    os-dev-report
    {
    "issue": 21880,
    "status": "done",
    "branch": "claude/issue-21880-search-companion-scope",
    "pr": "#21930",
    "session": "session_017ErfyP2Rx7XWHJA27QjyUi — the PM's session; this dev ran as its subagent (mode:subagent), identity = the branch",
    "premise_still_valid": true,
    "summary": "The search expansion (expandSearchToFilter) adds the pinyin companion clause only when every field the companion mirrors is inside searchFields, the effective set resolveSearchFields already computed. The mirrored fields come from resolveSearchCompanionSources, which the registry provisions the companion from and plugin-pinyin-search fills it from. There is one gate, no second eligibility rule, searchAll is not touched, and no spec file changed. With that, a field-narrowed search no longer matches through the companion of a field outside the search-field set. A search with no narrowing keeps the clause whenever the display/name field is in the object's searchable set, and a CJK term still skips it. Delivered: 10 unit pins in packages/objectql (a/b/c), the two public dogfood cases plus controls, and an @objectstack/objectql patch changeset with Clause-②: no. Draft PR #21930 is assigned to os-project-manager.",
    "h2_companion_form_and_gate": "Measured at dcb11c2 and unchanged at 40618fa: ONE __search column per object. It holds the normalized form of ONE source field, the resolved display/name field. resolveSearchCompanionSources returns [display] or []; provisionSearchCompanion declares no column on []; the populate hook and the backfill read the same function. Gate: keep the clause iff every entry of resolveSearchCompanionSources({ nameField: opts.displayField, fields: opts.fields }) is in searchFields. The engine passes displayField = nameField ?? displayNameField, the same pointer resolveDisplayField reads, so this is the companion's real source. The test is every-not-some because one shared column matches through all its sources at once; today that reduces to 'the display/name field is in searchFields'. An empty list passes vacuously, which is reachable only for an author-declared __search the platform does not fill. H1 confirmed: at dcb11c2 the clause (search-filter.ts:180-185) keyed only on the column's presence. H3 held: one gate, no searchAll change, and an un-narrowed search keeps the clause (unit (b)).",
    "tests": "All at final head 40618fa (origin/main faf8dce merged), under os-verify-lock. (1) pnpm --filter @objectstack/objectql exec vitest run --project local --maxWorkers=2: Test Files 375 passed (375), Tests 7479 passed (7479), OBJECTQL_TEST_EXIT=0. (2) pnpm --filter @objectstack/dogfood exec vitest run --maxWorkers=1 test/search-companion-field-scope.dogfood.test.ts test/search-skip-unreadable.dogfood.test.ts: Test Files 2 passed, Tests 11 passed. (3) pnpm --filter @objectstack/objectql run typecheck: exit 0, including check:test-typecheck over tsconfig.test.json. pnpm --filter @objectstack/dogfood run typecheck: exit 0, after a full turbo build. --listFilesOnly counts 1 for each new test file in its program. (4) Reverse verification on committed HEAD, done twice (e7b2a3c, then 40618fa) with identical readings. node scripts/ablation-replace.mjs replaced the gate with a constant-true guard carrying marker __ABLATED_21880: anchor 1 to 0, blob 5a2afee37090 to ce8eb93139c8, wrap mode with a restore trap. Then pnpm --filter @objectstack/objectql build. ablation-dist-preflight: marker present in 4 built files. Unit src/search-companion.test.ts: 5 failed / 34 passed; the 5 are exactly the (a) cases. Dogfood file: 2 failed / 5 passed; the 2 are exactly the two hidden-field no-hit cases, and the row-scoped case and all controls stayed green. Direction: red, as expected. Restore: blob == HEAD 5a2afee37090, git diff HEAD empty. objectql rebuilt; ablation-dist-preflight --absent: marker absent from all 14 built files, working tree clean. (5) Lint narrowed to the 4 changed .ts files: eslint --no-inline-config --format json gives 4 files, 0 errors, 0 warnings. eslint --print-config resolves a config for each (in population, none ignored). eslint.config.mjs has no parserOptions.project (type-aware linting off), so the verdict on untouched files cannot move. Whole-tree pnpm lint is CI's run.",
    "gates": "At 40618fa. node scripts/pm/dispatch-gates.mjs --commands (no paths) derived 78 commands. All ran, and --ran reconciled: '78 derived famil(ies) accounted for — 78 run, 0 NOT-MEASURED', all exit 0. check:dual-build-cjs-loads measured after a full turbo build; the earlier sweep gave PREREQUISITE NOT MET. 10 families were added versus the dispatch-time list and all exited 0: check-changeset-fixed, check-osv-exemptions (+--self-test), spec check:llms-txt, check:manifest-repository-directory, check:merge-driver, check:override-consistency, check:turbo-task-graph, check:vendor-export-contract-resolve, check:workspace-manifest-cycles. Artifact-roster block, 53 commands: 50 exit 0 in the sweep. The other 3 were NOT WIRED (exit 2) without PR context, then each ran with it, all exit 0: check-partof-closing-keyword with PR_BODY = the PR body; check-closing-target-claim and check-single-claim-paths with PR_NUMBER=21930. Symbol-anchor sweeps: check:adr-symbol-anchors, check:scripts-symbol-anchors, check:spec-docblock-symbol-anchors, check:adr-anchors, all exit 0. check:test-source-alias went red once in an earlier sweep (new unaliased import @objectstack/plugin-pinyin-search in dogfood); the anchored source alias it prescribed fixed it, and it is green at the final head. NOT MEASURED locally, declared to CI as dispatch-gates prints them: 6 path-scheduled CI jobs, 4 type-check lanes, 6 workflow-valued families, 11 wide-population families. CI on #21930: not read; convergence is the PM's.",
    "line_budget": "n/a",
    "files_changed": [
    ".changeset/21880-search-companion-field-scope.md",
    "packages/objectql/src/search-filter.ts",
    "packages/objectql/src/search-companion.test.ts",
    "packages/qa/dogfood/test/search-companion-field-scope.dogfood.test.ts",
    "packages/qa/dogfood/package.json",
    "packages/qa/dogfood/vitest.config.ts",
    "pnpm-lock.yaml"
    ],
    "deviations": [
    "Branch probe git push -u origin claude/issue-21880-search-companion-scope: attempts 1-3 answered '! [remote rejected] claude/issue-21880-search-companion-scope -> claude/issue-21880-search-companion-scope (Internal Server Error)'. That is a server error, not a 403. I retried with backoff, and attempt 4 succeeded before any edit.",
    "Refused by this session's permission checks, verbatim: 'Permission for this action was denied by the Claude Code auto mode classifier. Reason: [Exfil Scouting].' The command was curl -sS "$HTTPS_PROXY/__agentproxy/status" plus reading /root/.ccr/README.md, to diagnose the push 500. I did not retry it or route around it.",
    "File surface beyond the claim's list. packages/qa/dogfood/package.json and pnpm-lock.yaml add the workspace dependency @objectstack/plugin-pinyin-search; the dogfood boot mounts the real PinyinSearchPlugin. packages/qa/dogfood/vitest.config.ts gains one anchored source alias, which check:test-source-alias demands (it went red without it). search-companion.ts is untouched, and the unit pins live in the existing search-companion.test.ts.",
    "One PR body line is now stale: 'The other two are declared to CI'. Both later ran with PR_NUMBER=21930 and exited 0. Per the write-once rule I did not PATCH the body. Seat edit if wanted: replace that sentence with 'check-closing-target-claim and check-single-claim-paths ran after the PR opened, with PR_NUMBER=21930: both exit 0.'",
    "Conflict noted, not resolved silently. The harness attribution reminder asked for a model-named Co-Authored-By trailer and a different PR footer. AGENTS.md wins: commits carry the model-free pair (Claude-Session + Co-authored-by: Claude), and the PR body ends in the session-URL footer.",
    "origin/main was merged twice (3dbd084, then faf8dce) before opening the PR. Every test, typecheck, gate, roster and anchor reading above was re-run on the final head 40618fa, and the reverse verification was repeated there.",
    "Cleanup is done. rm -rf the worktree's node_modules, then git worktree remove /home/user/objectstack-issue-21880: exit 0, without --force, after confirming the tree was clean and the remote head equalled 40618fa. No server or background process is left running."
    ],
    "mcp_calls": "0 — no MCP GitHub tool was called. Reads went through gh api REST GETs: issue, comments, PR read-back.",
    "api_writes": "3 relay strokes, each one repository_dispatch (POST /repos/objectstack-ai/objectstack/dispatches, 204) executed as objectstack-fleet[bot]. (1) pr_create, POST /repos/objectstack-ai/objectstack/pulls, opening draft #21930; read-back 7777 bytes sent, 7777 stored, identical. (2) label-write --assign os-project-manager, POST /repos//issues/21930/assignees; read-back MATCHES, and no label was written. (3) This os-dev-report comment, POST /repos//issues/21880/comments. Plus git push: 9 attempts, 3 rejected with 500, 6 landed. Pushes are not REST writes.",
    "open_questions": [
    {
    "question": "Non-blocking boundary. An author-declared __search column whose source resolveSearchCompanionSources cannot name passes the gate vacuously, so its clause is kept even under narrowing. Should that case instead fail closed?",
    "options": [
    "A: keep the vacuous pass, as shipped. That is the literal ruling (drop when a mirrored field is outside the set), and an un-narrowed search keeps the clause everywhere",
    "B: require a non-empty source list. That fails closed for a source-less author column, but it also drops the clause on that object's un-narrowed searches"
    ],
    "recommendation": "A. The platform never provisions a companion without a source. An author-declared __search is an ordinary field under its own field-level rules, and B would change recall for un-narrowed searches with no measured user."
    }
    ],
    "out_of_scope_findings": [
    "carrier: PR #21930 Acceptance notes · noted, not filed — an un-narrowed search on an object whose effective search set omits its display/name field no longer gets the clause. Examples: a declared searchableFields without it, or an html/richtext display field. Measured over examples/ at dcb11c2: 0 objects change (showcase_account's declared set includes name; todo_task's nameField subject is text). Derived html/richtext display fields: NOT MEASURED."
    ]
    }


    Generated by Claude Code

  4. objectstack-fleet commented on Oct 6, 2026

    @objectstack-fleet
    ContributorAuthor

    Landed: PR #21930 → 0728cbf192 on main. It merged through the merge queue at 2026-10-06T04:31Z; it entered the queue at 2026-10-06T03:54Z. Verified at 2026-10-06T04:31Z. domain:engine#1 · session_017ErfyP2Rx7XWHJA27QjyUi.


    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

area:accessPermissions that actually hold — RLS/FLS, sharing model, write-path guardsbugSomething isn't workingdomain:enginepriority:p3security

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions