fix(file_send): add the two missing auth rungs to the Slack leg - #7524
Conversation
3b4d18b to
be418ee
Compare
GPT 5.6 Review — ✅ no blocking findingsGPT 5.6 completed its review of This comment is updated in place on each push. Review detailsNo findings. False positive or not applicable? A repository writer can comment: |
Design Review (Fable 5) — ✅ PASSDesign-level review of All claims verify against the code: the direct-vet precedent exists in Design-Verdict: PASS Closes a real governance/privacy asymmetry at the shared oracle layer, fail-closed, before destination work — the right shape, following shipped precedent. [DESIGN-REVIEWED] 98a6c26 |
Opus 4.8 Review — ✅ no blocking findingsReviewed Review detailsNo findings. Both candidates share one root cause: the two new Slack-leg ceilings (
Candidate 2 is weaker still by its own terms (fails in the safe direction) and merges into the same root cause. Step 2: the [OPUS-REVIEWED] 98a6c26 Verdict parsed from the review's SHA-scoped output markers for commit False positive or not applicable? A repository writer can comment: |
First Principles Review (Fable 5) — 🟡 CONCERNSPremise-level review of All claims verified. Producing the review. First-Principles-Verdict: CONCERNS Every rung earns its place against a reported defect (#7290), but What this change shipsIntent: make the Slack
Watch
Subtractions
[FIRST-PRINCIPLES-REVIEWED] 98a6c26 |
be418ee to
72f96a0
Compare
72f96a0 to
0eb5bbf
Compare
0eb5bbf to
3d227a4
Compare
3d227a4 to
cc3d4e4
Compare
cc3d4e4 to
29f4f90
Compare
The Slack upload leg ran neither the channels-scope governance vet nor the restricted-session ceiling the channel leg applies on every send, so an incognito session still shipped local file bytes to Slack and a profile denying the channels scope refused a Telegram upload while allowing a Slack one -- on the broadest-audience leg, and the only one whose destination a request can name. Both ceilings land in the destination oracle, ahead of any destination work, so a denied caller never opens an owner DM or reads the session map. The governance vet is a direct call, following the shipped precedent for a Slack egress that cannot reach the transport ladder, rather than a channel_transports entry. A denial is a Refusal, not a Skip, so the caller learns the file did not leave. A sessionless owner-DM caller is vetted under the host sentinel and so stays permitted on an ungoverned host rather than being muted.
29f4f90 to
98a6c26
Compare
Open PR relationship auditThis is a consolidated, point-in-time code-level audit note. It compares complete merge-base diffs and current/merged code; it does not treat a shared topic as duplication or partial coverage as completion. Relationship findings
No PR, Issue, label, branch, or review state was changed by the relationship-note portion of this audit. |
Closes #7290.
file_send's Slack leg ran neither of the two authorization rungs the channel leg runs on every send. So an incognito/temporary session that refuses to write a transcript still uploaded local file bytes into a Slack channel or DM, and a profile denying thechannelsscope refused a Telegram upload while allowing a Slack one — on the leg with the broadest audience and the only leg whose destination a request can name.This is an authorization change, not a refactor: a send that succeeds today can now be denied.
Rebased onto
mainafter #7293 landed, so both rungs sit in the destination oracle (dashboard/upload_destination.resolve_slack) — the landing site #7290 names — instead of inline in the handler.Rung shape: the direct-vet precedent
Rung 1 is a direct
vet_and_audit("channels", SLACK_NAMESPACE, fail_closed=True)call (upload_destination._slack_egress_permitted), not achannel_transportsregistry entry. This follows the shipped precedentdashboard/chat_compaction_notice._channel_egress_permitted, whose docstring names this exact case ("Slack is deliberately absent fromchannel_transportsand so never reaches that ladder"). The registry keeps meaning "transports the shared ladder can drive"; no registry change.Rung 2 is the existing shared predicate
upload_gate.uploads_restricted(channel_type="slack")— the same oneresolve_channeland the Telegram/Discord renderers' extraction path use. Itspersisted_probeis passed in from the handler, matching the module's existing parameter-not-import contract for ambient lookups.Both run ahead of any destination work, so a denied caller never opens an owner DM and never reads the session map — a refusal leaks nothing about where the file would have gone. Both are
Refusals, notSkips:403with a machine-readablecode(channels_governance_denied/restricted_session) plus the leg's usual SEL denial record, so the caller is told the file did not leave rather than reading success. That is deliberately unlike the channel leg, where "cannot deliver here" is the common case and a skip is correct.The oracle's rung table is updated: the two
NOT RUN (#6060 step 2)rows now read as run on both legs, and the off-the-event-loop row records that these two ceilings are offloaded even thoughopen_dmkeeps the rest of the ladder on the loop.Blast radius honored
X-Session-Key(cron, heartbeat, out-of-band host action reaching the owner-DM fallback) is vetted underHOST_SESSION_KEY(_host), the same sentinelhandlers.messaginguses on its channel legs: an empty key classifies asunknownand matches no profile at all, so vetting under it would make host-side governance inert here, while_hostis the stable bind target operators attach it to. On an ungoverned host the outcome is unchanged, and the restricted rung reads no slot and no channel privacy mode for such a key._resolve_session_key(), including the/procancestor walk) while the channel leg gets the strict key. Both rungs read that same key; neither adds an identity source.PlatformCompositionErrorpropagates rather than being reported as a routine authorization answer.Tests
New
TestSlackUploadAuthorizationRungs(7 tests). Revert-verified by removing only the two rung blocks while keeping the signature: six fail, and the seventh is the regression guard that must pass on both sides.channelsscope denies the leg, and writes exactly one denied SEL record withdownstream_service=slack("channels", "slack")withfail_closed=Trueunder the caller's keyHOST_SESSION_KEYopen_dm, noget_slack_link)Pinned outcomes changed
No shipped Slack outcome changed. Three test setups did, because the new restricted rung reads a dashboard slot that an auto-attribute
MagicMockanswers as truthy (i.e. restricted) for sessions none of those cases is about:TestFileUploadSlotThreading._make_state_with_link— setsstate.get_slot.return_value.is_restricted = False, with a comment saying why. Covers its sixdashboard:-keyed tests.TestDestinationOracleEquivalence._state— same, for the destination-resolution cases.TestDestinationOracleEquivalence.test_an_unreadable_credential_store_is_a_skip_not_a_500— additionally pinsvet_and_auditpermitted. ItsKiroCrewConfig.loadpatch raises for every reader, including the lazy platform-context build behind the governance vet, so the fail-closed rung would answer403before the owner-DM fallback this case is about ever ran.Gates
pytest(touched surface 116 passed; full suite's 117 reds reproduce identically on a cleanorigin/mainin this environment — 118 in the same file set — and none are infile_send/upload_destination/ Slack-upload tests),isort,flake8,mypy,tsc -b,vitest, plus the four diff-scoped gates (brand, changelog-history, focus-cue, harness-parity) run with their base refs exported.Pattern harvest
Rule candidate: parity test (the repo's existing guard shape), not semgrep — the defect is an ABSENCE on one branch, which a pattern matcher cannot see.
Pattern: a feature with two sibling egress legs where an authorization rung is applied on the leg that reaches a shared ladder and silently skipped on the leg that is deliberately absent from that ladder's registry. Here
channel_transportsis the registry,_resolve_mirror_targetis the ladder, and Slack's non-membership is what made both ceilings vanish from its leg while reading as intentional.Generalizable guard: for each egress leg in
upload_destination, assert the resolver reaches both thechannels-scope vet anduploads_restricted. That test would have failed onmainbefore this change and now passes on both legs, so it is a real ratchet rather than a restatement. The broader lesson for reviewers: "not in the registry" is a routing fact, never an authorization exemption — every registry-absent transport needs its rungs restated by direct call, which is exactly the precedentchat_compaction_notice._channel_egress_permittedset and this change follows.Round 2 — GPT finding fixed at the shared predicate
GPT flagged (correctly) that the restricted-session ceiling can be bypassed after a gateway restart: for a channel-native key it reads
privacy_mode's process-local trackers, which only an INBOUND channel message populates, so a turn no inbound message drove — a cron, a webhook resume, a monitor/auto-nudge re-injection, an explicitfile_send— reads empty trackers and ships bytes the user's!incognitoforbade.Real, and pre-existing in the shared predicate rather than introduced here:
dashboard/handlers/_shared._is_restricted_sessionalready documents this exact restart gap and callsprivacy_mode.hydrateto close it.Fixed in
messaging/upload_gate.uploads_restricted— the shared predicate — not inresolve_slackas the finding suggested. A leg-local hydrate would have fixed the Slack leg and left the channel leg and the renderers' extraction path exposed, re-creating the exact asymmetry this PR exists to remove.hydrateis idempotent, allocation-free for an unflagged key, and an in-memorySessionMapread rather than disk, so it is safe on the loop at every decision point.Tests, in
test/test_telegram_parity.py::TestUploadGate:hydratecall fails this and nothing elseOne existing assertion changed:
test_hydration_uses_the_post_rotation_keycompared the hydrate-call list by equality, which pinned an incidental CALL COUNT rather than the key it names. It is now a set comparison, so it still proves the post-rotation key is restored and the pre-rotation one never is, without failing because a second gate on the same turn also runs the idempotent restore.Round 3 — rebased onto main, GPT's test-side-effect finding fixed
Rebased onto
origin/main 7eb5d8f94(33 commits). Clean — no conflicts. Two of those commits touch files this PR also edits (#7705rewrote a chunk ofdocs/system-specs/modules/security.md,#7678editeddashboard/handlers/files.py), and neither collided with these hunks.test_security_posture.py(47 tests) passes on the rebased head, so#7705's spec-census changes and this PR'ssecurity.mdaddition coexist.GPT flagged
test/test_telegram_parity.py:1429underno-test-side-effects:sm.set_flag(...)leaves aSessionMapflush task pending across loop teardown. Real, and confirmed empirically rather than from the finding text —set_flag→_save(), and_saveon a thread with a running loop takes theloop.create_task(self._flush_async())branch, so the async test schedules a debounced flush on its own loop. Running the test alone printed:Fixed with
await sm.aclose()in afinally—acloseisSessionMap's documented retirement path, cancelling the registered task and landing any owed snapshot. The leak count on that test is now 0.Scoped to the one test that mutates: the sibling
test_an_unflagged_channel_key_stays_allowed_after_the_restoreconstructs aSessionMapbut never writes, so_saveis never reached and there is no task to retire — verified at 0 leaks without a change. Addingaclosethere too would have been cargo cult.Worth noting for anyone reading the earlier CI: the leak did not reproduce when the whole
TestUploadGateclass ran, only when that test ran alone — the warning fires at GC time, so scheduling decides whether it surfaces. That is an argument for fixing it rather than waiting for it to fail, since the destroyed task lands on whichever test happens to run next.Round 4 — the three CI reds were main-side, fixed by rebase
Rebased onto
origin/main 9580a8b98(27 commits). Clean, payload unchanged at 506 insertions across the same 6 files.Three FAILUREs on the prior head, none in a file this PR touches:
test_session_control.py::test_the_created_agent_name_is_sanitized_before_storagetest_session_control.py::test_the_audit_write_does_not_run_on_the_event_looptest_external_logout_detection.py::TestStoreRelocation::test_a_leftover_default_store_is_not_read_when_relocatedCoverage Gatewas a cascade of the two backend reds, not an independent failure.The two
test_session_controlreds were bisected rather than assumed.test/test_session_control.pyrun ALONE, with no xdist and no sharding, reproduced both failures on this PR's head — which ruled out shard-membership reshuffling as the cause. The same file then passed on currentorigin/mainand failed on7eb5d8f94, this PR's own base, with this PR's commit absent. That isolates the cause to the base commit, not to this change.The mechanism:
dashboard/create_rate_limit.pyholds a process-global_bucketsdict with a budget ofMAX_SESSION_CREATES_PER_WINDOW = 20, andsession_control.py:821refuses the 21st create withcreate_rate_limited. The module ships areset_for_tests()for exactly this, andtest_session_control.pyalready resets its OTHER piece of process-wide state (stop_retry.reset_for_tests()in an autouse fixture) — but nothing reset the rate limiter, so session-creating tests accumulated across the file until the budget ran out.77db85951(#7840, "test: isolate the creation rate limiter's module-level buckets between tests") wires it. Verified: the two files go from 2 failed / 225 passed to 227 passed on the rebased head.The
test_external_logout_detectionred is a genuine flake, not a main-side fix: no commit in the range touches that test,kiro_prerequisite,hooks, orsel, and the test passes 3/3 in isolation here. Its CI failure carriedOSError: SEL chain lock is held by another writer; refusing to wait for it on the event-loop thread, soidentity_fingerprintreturned""and the assertion readassert '' != ''— lock contention between parallel xdist workers, which a rerun clears. Flagging rather than patching, since the contention is in the SEL audit chain and not in this PR's surface.Local gates on the rebased head: 530 passed on the touched surface,
check_black_formatting/ isort / flake8 clean, mypy clean on 1264 files, and all four diff-scoped gates PASS withBASE_REFexported.Round 5 — rebased onto main to pick up the fast-uri dependency fix
Rebased onto
origin/main ca55e411a(22 commits). Clean, still one commit, payload unchanged at 506 insertions across the same 6 files.Dependency Audit / Audit Production Dependencieswas the only real FAILURE on the prior head — the other reds were CANCELLED runs from the superseded head. It failed on four high-severityfast-uriadvisories inwebsite/electron/package-lock.json:Main-side, not introduced here: this PR touches no dependency manifest at all — its six files are Python, tests, and one spec doc.
0545b668e(#7936, "fix(deps): unpin fast-uri so the patched 3.1.7 can resolve") landed on main after this branch's base, and is now an ancestor of this head withfast-uriresolving to3.1.7.The rebase also carries
51198168b(#7780, Python floor to 3.12 / 3.10 CI lane dropped), so theBackend Tests (3.10, ...)lane that reported thetest_external_logout_detectionSEL chain-lock flake in Round 4 no longer runs.Local gates on the rebased head: 763 passed across the touched surface plus the two files that were red in Round 4 (
test_session_control.py,test_external_logout_detection.py), isort / flake8 clean, mypy clean on 1264 files, and all five scripted gates PASS (check_black_formatting,check_brand_name,check_changelog_history,check_focus_cue,check_harness_parity) withBASE_REFexported.