Skip to content

fix(session-ingest): refuse unauthorized organization_id claims; hide never-ingested placeholders - #4780

Merged
iscekic merged 14 commits into
mainfrom
fix/session-ingest-org-authz
Jul 27, 2026
Merged

fix(session-ingest): refuse unauthorized organization_id claims; hide never-ingested placeholders#4780
iscekic merged 14 commits into
mainfrom
fix/session-ingest-org-authz

Conversation

@iscekic

@iscekic iscekic commented Jul 26, 2026

Copy link
Copy Markdown
Contributor

Batch J — PR #4777 follow-ups: ingest org authorization + placeholder sessions

Stacked on fix/mobile-cost-and-exit (#4777). GitHub retargets this PR to main automatically
when #4777 merges. Two follow-ups from #4777's out-of-scope list, both reproduced on the
unmodified baseline before fixing.

Item 1 — session-ingest persisted organization_id with no membership check (security)

POST /api/session/:id/ingest persisted cli_sessions_v2.organization_id verbatim from the
client-supplied kilo_meta.orgId (validated only as uuid().optional()), with no check that the
caller belongs to the claimed organization.

Measured blast radius (reproduced on the baseline; narrower than "steal a session into your
organization"):

  1. The owner loses access to their own session. After re-tenanting, the owner's own message
    read, export, and further ingest all return session_not_found 404, because session reads
    require membership once organization_id is set. A data-availability loss — the most concrete
    user harm.
  2. A row enters an organization's organization_id space with no membership relationship — a
    data-integrity violation. Two org-scoped reads filter on organization_id without
    kilo_user_id (shareForWebhookTrigger's org branch, feature-adoption), but neither is
    exploitable for a cross-tenant read today: the first still requires webhook-trigger access to
    the org, the second additionally requires cloud_agent_session_id IS NOT NULL, which ingest
    cannot set.
  3. Not cross-tenant message disclosure: a member of the claimed org who is not the owner
    still gets 404 — every session read also requires kilo_user_id = caller.

A second bug closed by the same guard: a claimed orgId that is a valid uuid but no existing
organization raised the FK inside the metadata transaction, so the entire metadata batch was
lost — title, platform, status, git fields — while the client was told HTTP 200 {"success":true}
. With the check in front of the write, an unauthorized or nonexistent
organization is refused as a field and everything else in the batch persists.

Fix. New hasOrganizationAccess in @kilocode/worker-utils (direct
organization_memberships row for the pair + organizations.deleted_at IS NULL; mirrors the
existing worker-side session-access predicates as a standalone query; no parent-org inheritance,
no admin bypass — uniform with every worker-side check), called inside applyMetadataChanges
the single funnel for all three ingest callers (direct, queue, partial flush) — on the same
transaction as the UPDATE.

Decision: refuse the field, don't reject the request. Content is committed to the DO and R2
before metadata is projected, so a 403 would report failure for data already stored; the queue
path has no client to reject; and rejecting would freeze title/status for legitimate users whose
membership was revoked mid-session or whose org was soft-deleted. Refusing loses nothing: the
session stays exactly where it legitimately is. Refusals are console.warn'd (ids only).
Access-cache invalidation and the non-status-change signal now key on an actually-applied org
write, so a refusal is not misreported as a scope change. An ingest with no orgId performs zero
additional queries.

Tests: new applyMetadataChanges suite (member persists + cache invalidated; non-member
refused while title/git/status persist; refusal is not a scope change — no cache drop, no phantom
session.updated; refusal + parentId still emits; soft-deleted refused; no-orgId identical with
zero membership queries; explicit null still clears; nonexistent org no longer loses the batch)
plus helper unit tests (member / non-member / soft-deleted member).

Item 2 — stray Untitled session / UNKNOWN rows in the Agents list (data hygiene)

POST /api/session creates a bare placeholder row before any user turn; the CLI fires it eagerly
on local session creation and metadata arrives later through a ~1s-debounced queue. If the CLI
dies inside that window the row is permanently title NULL, status NULL,
created_on_platform 'unknown', total_cost_microdollars NULL — and renders in the list as
Untitled session / UNKNOWN. Reproduced on the baseline (kill-in-debounce), with a healthy
control session writing all four columns.

Deleting or not creating the row is not available (mobile-initiated create_session depends on
the cloud row existing so the session can attach). The fix is at the shared query boundary:
cliSessionsV2.list and cliSessionsV2.search now exclude rows where all four list columns
are unwritten.

What the predicate proves, precisely: all four unwritten means no metadata projection ever
succeeded and no metrics emission ever persisted a cost. It does not prove the row has no
content — content lives in the DO/R2 and commits independently of the projection, and
total_cost_microdollars is written only by the alarm-driven metrics emission (best-effort), not
per flush: presence proves a metrics emission happened, absence proves nothing.

Accepted residual, as an invariant: any row whose four list columns are all unwritten is
hidden, regardless of whether the DO holds content. No Postgres-visible signal can distinguish
that from a bare placeholder on a paginated list query. In practice the exposure window is the
~1s startup debounce (a single successful projection reveals the row), and item 1 removes the
largest durable cause — a bogus orgId in CLI config previously aborted every projection on the
FK.

Interactions: 'unknown' is not in KNOWN_PLATFORMS, so these rows previously fell into the
other platform filter bucket; that bucket no longer lists placeholders (intended).
recentRepositories is unchanged (already requires git_url IS NOT NULL). No mobile change: the
live/pinned tray already neutralises 'unknown' to LIVE for freshly created sessions.

Tests: placeholder hidden from list; titled-but-'unknown' shown; status-but-no-title
shown; cost-only (including total_cost_microdollars = 0) shown; pagination stable with
placeholders interleaved; search by exact session id hides the placeholder.

Existing-rows audit (local only — production was NOT queried)

SELECT s.session_id, s.kilo_user_id, s.organization_id
FROM cli_sessions_v2 s
WHERE s.organization_id IS NOT NULL
  AND NOT EXISTS (
    SELECT 1 FROM organization_memberships m
    WHERE m.organization_id = s.organization_id
      AND m.kilo_user_id = s.kilo_user_id
  );

Local result: 0 rows. This workflow has no production database access — production was not
queried
, and no data migration is included. Note the FK's ON DELETE SET NULL means rows
pointing at since-deleted organizations were already nulled and are invisible to this query.
Post-merge manual action: run the audit against production and decide whether any rows need
repair.

Follow-ups reported, deliberately not fixed here

  • session-ingest-rpc / cloud-agent-next session-prepare organization write:
    createSessionForCloudAgent writes organization_id from a zod-string().optional() field
    with no membership check, and session-prepare (unlike session-start) performs no
    membership assertion. Verified not client-reachable with an attacker-chosen organization:
    prepareSession is internalApiProtectedProcedure (INTERNAL_API_SECRET + customer token), and
    both entrances establish the organization server-side — the org router injects the
    membership-checked id, and the personal router's schema strips any client-supplied org field.
    Defense-in-depth candidate behind two server boundaries; not this PR.
  • DO ingest-meta staleness after a refused org write: the DO records the claimed orgId and
    emits only on change, so after a refusal the DO believes the org is set while Postgres does
    not; re-sending the same orgId won't re-emit (desirable for the attack case; a user who later
    genuinely joins the org stays personal until the CLI sends a different value). Clearing one DO
    ingest-meta key needs a new DO method + RPC surface — out of proportion for a security fix.
  • kilocode-side create_session rollback leaves an orphan cloud row (mobile-initiated
    session with no prompt rolls back locally only on attach failure). One source of placeholder
    rows; item 2 hides them rather than deleting them.

Security review

A dedicated security review of the final diff ran (gating for item 1). Scope: the new
hasOrganizationAccess helper, the applyMetadataChanges guard and its side-effect
re-keying, the list/search predicate, every caller path that reaches them (direct ingest,
queue consumer, partial flush), the o11y metrics path, and the FK behavior for
refused/nonexistent claims. Outcome: no findings. A fresh full-diff review (standin for
the automated reviewer, which posts no summary on PRs whose base is not main) also
returned no valid actionable findings; its non-blocking observations are the DO
ingest-meta residual and the RPC-path follow-up already documented above, plus a note that
the helper's unit tests mock at the chain level (the predicates mirror
cloud-agent-session-access.ts on inspection).

Verification

  • @kilocode/worker-utils: 326 tests, typecheck, lint — green.
  • services/session-ingest: 604 tests (3 skipped), typecheck, lint — green on the final
    head. pnpm test:integration fails at import time identically on the clean baseline
    (pg CJS/ESM shim under vitest-pool-workers; 2 files, 0 tests) — pre-existing toolchain
    issue, not introduced here; the DO integration sources are unmodified and no CI job runs
    this suite.
  • apps/web: typecheck, lint, Jest cli-sessions-v2-router 74/74 — green.
  • Repository root: pnpm format, pnpm typecheck, pnpm lint, git diff --check — clean.
  • GitHub Actions: on this stacked PR (base fix/mobile-cost-and-exit) only trufflehog and
    the Kilo Code Review check trigger — ci.yml, kilo-app-ci.yml and codeql.yml run on
    pull_request events targeting main only. The full suite runs automatically when GitHub
    retargets this PR to main after fix(mobile): one canonical session cost across list and detail #4777 merges; the local equivalents above are green on
    the exact final head.
  • Device E2E: re-ran the baseline repro (org claim refused, rest of batch persists; no
    UNKNOWN/Untitled session placeholders in the Agents list for an account that has them in
    Postgres; real CLI session and mobile-created session still listed). Results posted in a
    follow-up comment.

iscekic added 7 commits July 26, 2026 07:25
…meState

Add the persisted per-session cost column to the getWithRuntimeState
output schema and return projection, thread it through FetchedSessionData
as an optional field, and populate it in the mobile fetchSession mapping.
The mobile session detail screen will use it to render the same canonical
cost the list reads from the Postgres column. Web fetchSession behavior
is unchanged (the new field is optional).
The list rendered the persisted column at 2 decimals with a </bin/zsh.01 floor
while the detail rendered a client-side message sum at 4 decimals, plus a
hand-rolled toFixed(4) duplicate in the header fallback, and spoken labels
that dropped sub-cent costs entirely. The same session read as up to four
different costs, and an empty-snapshot session showed its cost in the list
but nothing on the detail screen.

Derive every surface from one canonical microdollar total: the detail
combines the persisted column and the live message sum via
selectSessionCostInputs (max of two lower bounds, cost being monotonic),
all surfaces render through formatSessionTotalCost, and all spoken labels
through formatSpokenCost on the same threshold. The context sheet splits
its total (combined microdollars) from its breakdown input (live USD sum)
so no phantom subagent residual appears, and omits the Total cost row
instead of rendering $0.0000 when no cost has been reported.
The best-effort total_cost_microdollars write sat after the unguarded
O11Y ingestSessionMetrics call, so an analytics binding failure skipped
the cost persist entirely and the column stayed NULL. Reorder so the cost
write runs first; the O11Y call stays unguarded and the metricsEmitted
dedup is unchanged, preserving the existing alarm retry semantics.
Standalone worker-side query mirroring the session-access predicates in
cloud-agent-session-access.ts: a direct organization_memberships row for
(kilo_user_id, organization_id) and a non-soft-deleted organization.
Parent-organization inheritance and kilocode_users.is_admin are
deliberately excluded, matching every existing worker-side check.
…ingest

applyMetadataChanges persisted cli_sessions_v2.organization_id verbatim
from the client-supplied kilo_meta.orgId with no membership check, so any
caller could re-tenant their own session into any existing organization,
and a nonexistent organization aborted the whole metadata batch on the FK
while the client still got HTTP 200. Gate the write on
hasOrganizationAccess inside the same transaction; on refusal drop only
organization_id, persist the rest of the batch, and warn. Access-cache
invalidation and changedNonStatus now key on an actually-applied org
write so a refusal is not reported as a scope change.
…ist and search

POST /api/session creates bare placeholder rows before any user turn; if
the CLI dies before its first debounced flush the row stays permanently
title/status/cost NULL with created_on_platform 'unknown' and renders as
Untitled session / UNKNOWN in the Agents list. Exclude rows where all
four list columns are unwritten; any row with a title, a status, a known
platform, or a persisted cost (including 0) still appears.
@iscekic iscekic self-assigned this Jul 26, 2026
iscekic added 2 commits July 26, 2026 14:19
Regression coverage for the guarantee that the Postgres
total_cost_microdollars persist completes before the unguarded
O11Y.ingestSessionMetrics RPC: drives alarm() with O11Y rejecting and
asserts the update chain (connection, value, session/user filter) was
awaited first. Marker records at await time via a thenable, so a
build-now-await-later refactor also fails.
@iscekic

iscekic commented Jul 26, 2026

Copy link
Copy Markdown
Contributor Author

(bot) E2E verification complete on head 43558b0 (iOS simulator + local stack, port offset 1600):

Item 1 — org authorization (service-level, against session-ingest):

  • Unauthorized claim (E2E user → Acme Corp org): HTTP 200, organization_id stays NULL, title/platform from the same batch persist, refusal console.warn logged — PASS
  • Authorized claim (org member alice → Acme Corp): organization_id persists — PASS (no regression)
  • Nonexistent org uuid: HTTP 200, org field refused, rest of batch persists (baseline bug was whole-batch loss on the FK) — PASS
  • Soft-deleted org (DeletedCo): refused, rest persists — PASS

Item 2 — placeholder filter (on-device):

  • Bare placeholder row in Postgres (title/status/cost NULL, platform 'unknown'): no Untitled session/UNKNOWN rows in the Agents list; search 'Untitled' → no match — PASS
  • Real CLI session: appears with its title (remote-session eyebrow label) — PASS
  • Mobile-created session (via advertised kilo remote instance, real prompt on kilo-auto/efficient): appears in the live tray immediately and in the stored list after first flush with title, platform cli, cost persisted — PASS

Criterion 8a adversarial row (content committed to DO, no metadata projection, cost NULL): hidden from list/search — the documented accepted residual; the row is not deleted (export API still returns its content).

All fixture rows (13) deleted; simulator released with its original name restored.

@iscekic

iscekic commented Jul 26, 2026

Copy link
Copy Markdown
Contributor Author

(bot) Standin review of head 90a5cbe95411eb37c19696f689078f554a5f1cad (full diff vs origin/fix/mobile-cost-and-exit). Posted because the automated reviewer produces no review summary on PRs whose base is not main — this comment records a standin review, not that bot's output.

Full-diff review (fresh reviewer): No valid actionable findings. Non-blocking observations: the documented DO ingest-meta residual after a refused org write; the helper's unit tests assert at the mock-chain level (predicates mirror cloud-agent-session-access.ts on inspection); the createSessionForCloudAgent org write is unchanged — that is the follow-up already documented in the PR body, verified not client-reachable with an attacker-chosen organization.

Security review of the final diff: No findings. Both outcomes are now also recorded in the PR body.

Local validation on this exact head:

  • @kilocode/worker-utils: 326 tests, typecheck, lint — green.
  • services/session-ingest: 604 tests (3 skipped), typecheck, lint — green. test:integration fails at import identically on the clean baseline (pg CJS/ESM shim under vitest-pool-workers) — pre-existing toolchain issue, not introduced here.
  • apps/web: typecheck, lint, cli-sessions-v2-router Jest 74/74 — green.
  • Repository root: pnpm format, pnpm typecheck, pnpm lint, git diff --check — clean.

CI note (corrected): on head 90a5cbe95 only trufflehog was present; the Kilo Code Review check was not present on that head (it appeared on the later empty retrigger head ef306dc12, green, with no review comment posted). ci.yml, kilo-app-ci.yml, and codeql.yml run for main-targeting PRs only, so the full suite runs automatically when this PR retargets to main after #4777 merges.

@Kilo-Org Kilo-Org deleted a comment from kilo-code-bot Bot Jul 26, 2026
@iscekic

iscekic commented Jul 26, 2026

Copy link
Copy Markdown
Contributor Author

(bot) Standin review of head ef306dc122debaaa8b12fd48f584d7c9729b02d0 (full diff vs origin/fix/mobile-cost-and-exit). This comment records a standin review, not the automated bot's output — the reviewer bot posts no review summary on PRs whose base is not main. Provenance: this head's tree is byte-identical to previously reviewed 90a5cbe95 (they differ by exactly one empty commit); this run is the fresh review of record for the current head, executed end-to-end against the full diff.

Result: No findings.

Verification performed (all against the full diff origin/fix/mobile-cost-and-exit...ef306dc12):

  • Org authz funnel. hasOrganizationAccess (packages/worker-utils/src/organization-membership.ts) requires a direct organization_memberships row for the exact (org, user) pair plus organizations.deleted_at IS NULL; verified line-by-line against the mirrored worker predicate in cloud-agent-session-access.ts. It is called inside applyMetadataChanges on the same transaction, and all three ingest callers (direct-ingest.ts:361, queue-consumer.ts:61, flushPartialMetadataChanges via queue-consumer.ts:69) route through it. The only other organization_id writer is session-ingest-rpc.ts:101/111, the documented deferred follow-up; no client-reachable tRPC path writes the column.
  • Required behaviors all hold and are tested: refusal drops only the org field (title/platform/status/git persist), nonexistent org never reaches the FK, console.warn carries ids only, refused orgId-only batches produce no UPDATE/cache invalidation/session.updated (both signals key on organizationIdWriteApplied), absent orgId performs zero membership queries, explicit null clears without a check and invalidates the cache.
  • Placeholder hiding. The predicate matches the spec exactly (created_on_platform is NOT NULL DEFAULT 'unknown', schema.ts:5552), is applied identically to list and search (count query shares baseWhere), and POST /api/session inserts only session_id/kilo_user_id, so the predicate targets the real placeholder shape. Pagination, titled/status-only/cost-only (incl. 0) visibility, and exact-id search hiding are covered by real-database router tests. Mobile consumes exactly cliSessionsV2.list/search for stored sessions; activeSessions.list only enriches live-connection ids, so no third surface bypasses the filter.
  • Checks run by the reviewer on this head (all green): worker-utils 326 tests, session-ingest 604 tests, apps/web cli-sessions-v2-router 74 tests; tsgo typecheck clean in all three packages; oxlint clean in all three; oxfmt clean on every changed file.
  • Four-states check: not applicable — the PR introduces no mobile UI surface or user-facing state; it changes which rows an existing list query returns and adds a server-side field refusal, so no new happy/unhappy/empty states can exist (no mobile files touched).
  • Deferred follow-ups (DO ingest-meta staleness, session-ingest-rpc org write, kilocode rollback orphan) confirmed accurately documented, including the in-code residual note at metadata.ts:95-100.

Residual testing risks:

  1. The hasOrganizationAccess unit tests assert at the mock-chain level; they cannot catch a semantically wrong join/where (e.g., an inverted predicate). Mitigated by direct review and parity with the production session-access query; no real-Postgres coverage exists anywhere in this helper family (cloud-agent-session-access.ts has no unit tests at all), so this exceeds repo convention.
  2. The applyMetadataChanges test double distinguishes query kinds by chain shape (innerJoin vs .for('update')); a future refactor of the function's query shapes could silently reroute the double and weaken the assertions.
  3. No test exercises a membership-check database error (transaction abort → queue retry); that path relies on the queue's standard retry semantics, verified by reading only.
  4. The placeholder predicate is untested in combination with the updatedSince/RECENT_DAYS_LIMIT path and with an explicit organizationId filter, though both share the same whereConditions array as the tested paths.

Base automatically changed from fix/mobile-cost-and-exit to main July 27, 2026 09:09
@iscekic
iscekic enabled auto-merge (squash) July 27, 2026 09:13
@iscekic
iscekic merged commit 96f051d into main Jul 27, 2026
16 of 18 checks passed
@iscekic
iscekic deleted the fix/session-ingest-org-authz branch July 27, 2026 09:16
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants