Skip to content

refactor(agentex): shrink identity-link config, surface the connect link, stop leaking ids - #414

Merged
michael-chou359 merged 4 commits into
mainfrom
mc/identity-link-fewer-flags
Aug 30, 2026
Merged

refactor(agentex): shrink identity-link config, surface the connect link, stop leaking ids#414
michael-chou359 merged 4 commits into
mainfrom
mc/identity-link-fewer-flags

Conversation

@michael-chou359

@michael-chou359 michael-chou359 commented Aug 30, 2026

Copy link
Copy Markdown
Contributor

Four fixes to the identity-link work. Three were found by using it — the config surface
was too large, the connect link was somewhere people don't look, the confirmation page
printed internal ids — and the fourth is a P1 the first one introduced, caught in review.


1. Config surface: 8 vars → 0

#409#412 accumulated eight environment variables. One was a hazard, one a trap,
six were knobs nobody has ever turned.

IDENTITY_LINK_SESSION_COOKIE_NAME    ->  derived from the delegation allowlist
IDENTITY_LINK_REQUIRE_EMAIL_MATCH    ->  enables itself when the scope exists
IDENTITY_LINK_NONCE_TTL              ->  constant
IDENTITY_LINK_MAX_DMS                ->  constant
IDENTITY_LINK_CACHE_TTL              ->  constant
IDENTITY_LINK_NEGATIVE_CACHE_TTL     ->  constant
IDENTITY_LINK_FALLBACK_TTL_DAYS      ->  constant
SLACK_LINK_OFFER_COOLDOWN_S          ->  constant

No behavior change at current settings — every constant equals the default it
replaced.

The hazard: a cookie name with two sources of truth

identity_link_service:  IDENTITY_LINK_SESSION_COOKIE_NAME         default _identityJwt
delegation_headers:     AGENTEX_DELEGATION_SESSION_COOKIE_NAMES   default _identityJwt

acting_headers() emits a Cookie header that build_delegation_headers filters down
to its allowlist. Had the two disagreed, the credential would be stripped in transit
— every linked turn silently losing its acting identity, while the link sat in the
database looking stored, valid and healthy.

Now derived, so divergence is unrepresentable rather than warned about in a comment. An
empty allowlist returns None and acting_headers() refuses, since emitting a
credential that will certainly be stripped is worse than admitting we can't act.

The trap: a flag that had to move in lockstep with a Slack scope

The email check needs users:read.email, which isn't granted. The flag kept it off
until then — but flag and scope had to be flipped together: the flag alone refused
every link, the scope alone protected nothing.

It now enables itself: enforces whenever Slack answers with an email, stands down when
it won't. Granting the scope switches the protection on by itself.

⚠️ This inverts the unverifiable case from refuse to allow — weaker, deliberately. With
no flag to distinguish "scope missing" from "Slack had a bad minute", failing closed
would make linking fail at random. The gap isn't attacker-reachable: nobody outside our
infrastructure influences whether our Slack lookup succeeds. Against what's deployed
today (flag off, verifying nothing) it's strictly stronger.

What remains

Variable Why
AGENTEX_CREDENTIAL_ENCRYPTION_KEY Secret, no default possible
SLACK_GATEWAY_PUBLIC_BASE_URL Deployment-specific; the feature's on-switch
SLACK_GATEWAY_REQUIRE_LINKED_USER Pre-existing, a real product choice

2. The connect link was invisible

The first real offer in production was delivered correctly and reported as never
received
. Both were true: chat.postMessage returned ok and
conversations.history confirmed the message in the DM channel — and Slack files bot
conversations under Apps, not in the Direct messages list. "I've DM'd you a link"
pointed at the one place it wasn't.

before                                  after
ephemeral: "I've DM'd you a link —      ephemeral: "<Connect your SGP account>
            check your DMs"                         …also sent to <our DM>"
clicks to connect: 2, if you find it    clicks to connect: 1

Why this doesn't weaken anything

An ephemeral has exactly the same audience as a DM: rendered for one user, absent
from channel history and search. The exposure argument that made this DM-only never
applied to an ephemeral — so routing someone through a conversation they can't find, to
click a link we could hand them directly, bought nothing.

The DM stays because ephemerals are transient: reload before clicking and it's gone,
and the offer cooldown would then block a retry for an hour. So the ephemeral carries
the link plus a deep link to the DM as the durable copy.

The invariant, stated properly

The nonce is a bearer token. It may go anywhere exactly one person can see it (the
user's DM, an ephemeral addressed to them) and nowhere that lands in channel
history
.

The test that asserted "never in a payload addressed to the origin channel" now
asserts "never in a chat.postMessage outside the user's own DM". That matters — the
old wording would have blocked this change while the actual risk was never touched by
it. A test encoding the implementation rather than the property blocks correct changes.

Also

  • conversations.open moved above the send-cap check (idempotent; both branches need
    the channel id).
  • Past the DM cap the user still gets the live link — the cap limits DMs, not what
    we can show the person in front of us.
  • slack.com/app_redirect, not slack://, which fails on the web client.

3. The connect page printed internal ids

It fell back to raw identifiers when it couldn't name an identity:

slack_who = link_request.display_name or link_request.external_user_id  # U0B01457V24
<dd>{email or sgp_user_id}</dd>                                         # 5da8f784-…

That leaks an internal id and buys nothing for it. The two identity rows exist so the
person clicking can answer "is this my Slack account?" and stop if it isn't — the
only defence against a link forwarded to them. Nobody recognises their own Slack member
id or SGP uuid, so the fallback never made that question answerable. It made an
unanswerable question look answered, which is worse than showing nothing.

  • Neither side ever falls back to an id; an unnameable identity renders a placeholder.
  • A missing Slack name is now re-read live — a transient Slack failure when the
    nonce was minted shouldn't permanently degrade the page, and users.info needs only
    users:read, which is granted.
  • When either side is unnamed, the caution changes from "if either name above isn't
    you, don't continue"
    to saying the match can't be confirmed here. Claiming someone
    verified something they couldn't is the actual harm.
  • The success page likewise stops printing the uuid when there's no email.

4. A multi-name cookie allowlist got narrowed (P1, introduced by §1)

Deriving the cookie name fixed one hazard and introduced another. session_cookie_name()
returned only the first entry of AGENTEX_DELEGATION_SESSION_COOKIE_NAMES, and the
read path matched against that alone — so with a multi-name allowlist, a session carried
by any later name was rejected. Linking failed with "couldn't read your session" for a
cookie the delegation layer would have forwarded quite happily.

The allowlist is the set of names a deployment treats as valid sessions, so one arriving
under the second entry is exactly as legitimate as the first.

Reading and writing genuinely differ, so they're now separate functions rather than one
doing double duty — which is how the bug got in:

session_cookie_names()   every accepted name, in preference order   (READ)
session_cookie_name()    the single canonical name to emit under    (WRITE)

Allowlist order beats header order when several are present, so the stored credential is
the canonical cookie whenever it's there — which is also the name we emit under, keeping
the common case exact. Emitting a value that arrived under a later name as the canonical
one is safe: the credential is a session JWT, validated downstream on its contents, not
on its label. Flagged in the docstring — if a downstream ever became name-sensitive this
would need the originating name stored alongside the credential, which is a schema change
and not worth making speculatively.


Testing

26 new, 6 rewritten. The rewrites are the interesting ones — they invert assertions
that encoded superseded designs:

Was Now
nonce must not appear in the ephemeral nonce must (single-viewer)
nothing addressed to the origin channel carries it nothing broadcast carries it
email flag off → no Slack call check always runs; scope decides
unreadable email fails closed fails open, loudly logged

New coverage: the cookie name follows the delegation allowlist and the emitted cookie
survives the delegation filter under a non-default name; acting_headers refuses when
delegation is off; the ephemeral carries the link and points at the durable copy; an
unreachable DM offers nothing.

708 unit tests pass. The 14 Redis integration tests still pass against a real Redis
after the TTL constants moved. ruff clean.

Not fixed here

The nonce is still 10 minutes — sized assuming prompt discovery, which this incident
showed was optimistic. With the link now inline the find-it delay largely disappears,
so I'd rather see whether it's still a problem than change two things at once.

🤖 Generated with Claude Code

Greptile Summary

The PR simplifies identity-link configuration, improves Slack link discoverability, removes raw identifiers from confirmation pages, and fixes session extraction for multi-cookie delegation allowlists.

  • Derives identity-link cookie handling from the delegation allowlist and accepts sessions under any configured cookie name.
  • Places the connection URL in the user-specific ephemeral response while retaining the DM as a durable copy.
  • Replaces internal identifier fallbacks with explicit unknown-identity messaging and performs best-effort profile refreshes.
  • Converts identity-link TTLs, limits, and cooldowns from environment settings to fixed defaults.

Confidence Score: 5/5

The PR appears safe to merge because the previously reported multi-cookie allowlist failure is fixed and no blocking eligible failure remains.

No blocking failure remains.

Important Files Changed

Filename Overview
agentex/src/api/routes/integrations.py Accepts every allowlisted session-cookie name, adds best-effort identity naming and self-enabling email comparison, and removes raw identifier fallbacks.
agentex/src/domain/services/identity_link_service.py Derives accepted and emitted cookie names from the delegation allowlist and refuses acting headers when cookie delegation is disabled.
agentex/src/domain/services/link_nonce_service.py Replaces nonce lifetime and DM-send environment settings with constants matching their former defaults.
agentex/src/domain/use_cases/slack_gateway_use_case.py Surfaces the bearer link in a user-specific ephemeral response, retains a durable DM copy, and adds DM deep links.
agentex/tests/unit/api/test_integrations_routes.py Adds regression coverage for later allowlisted cookies, identity placeholders, live Slack-name recovery, and email comparison behavior.
agentex/tests/unit/services/test_identity_link_service.py Verifies canonical cookie derivation, delegation forwarding, and disabled-delegation behavior.
agentex/tests/unit/use_cases/test_slack_gateway_use_case.py Verifies that connection links remain single-viewer, discoverable, durable through DM delivery, and available after the DM cap.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart LR
    Request[Authenticated link confirmation] --> Parse[Parse inbound cookies]
    Allowlist[Delegation cookie allowlist] --> Parse
    Parse --> Select[Select first present allowlisted cookie]
    Select --> Store[Encrypt and store session JWT]
    Store --> Resolve[Resolve linked Slack identity]
    Allowlist --> Canonical[Choose canonical first cookie name]
    Resolve --> Emit[Emit JWT under canonical cookie name]
    Emit --> Delegate[Delegation filter forwards acting-user cookie]
Loading

Reviews (4): Last reviewed commit: "fix(agentex): accept a session under any..." | Re-trigger Greptile

The identity-link work accumulated eight environment variables. One was a real
hazard, one was a trap, and six were knobs nobody has ever turned. Removing them
also removes two failure modes.

The hazard: IDENTITY_LINK_SESSION_COOKIE_NAME

The session cookie name was configurable in two places -- here and
AGENTEX_DELEGATION_SESSION_COOKIE_NAMES in delegation_headers. acting_headers()
emits a Cookie header that build_delegation_headers then filters down to its
allowlist, so if the two ever disagreed the credential would be stripped in transit
and every linked turn would silently lose its acting identity, while the link sat
in the database looking stored, valid and healthy.

It is now derived: session_cookie_name() reads the delegation allowlist. One
source of truth, so divergence is unrepresentable rather than merely documented.
Empty allowlist (cookie delegation disabled) returns None and acting_headers()
refuses, since emitting a credential that will certainly be stripped is worse than
admitting we cannot act.

The trap: IDENTITY_LINK_REQUIRE_EMAIL_MATCH

The email check needs the users:read.email Slack scope, which isn't granted. The
flag existed to keep it off until the scope lands -- but flag and scope then had to
be flipped together: the flag alone refused every link (unreadable email treated as
mismatch), and the scope alone protected nothing.

It now enables itself. _email_mismatch() enforces whenever Slack answers with an
email and stands down when it won't, so granting the scope switches the protection
on with no config change and no ordering hazard.

That inverts the unverifiable case from refuse to allow, which is weaker, and
deliberately so: with no flag to distinguish "scope missing" from "Slack had a bad
minute", failing closed would make linking fail at random. The gap is not
attacker-reachable -- nobody outside our infrastructure influences whether our own
Slack lookup succeeds -- and the previous shipped state (flag off) verified nothing
at all, so this is strictly stronger than what it replaces.

The knobs -> module constants

IDENTITY_LINK_NONCE_TTL, _MAX_DMS, _CACHE_TTL, _NEGATIVE_CACHE_TTL,
_FALLBACK_TTL_DAYS and SLACK_LINK_OFFER_COOLDOWN_S are now constants at their
former defaults. Each was a config surface and a branch carrying a value that has
never been set to anything else, and therefore never tested at anything else. They
crept in by pattern-matching the surrounding file, which isn't a reason.

What remains: AGENTEX_CREDENTIAL_ENCRYPTION_KEY and SLACK_GATEWAY_PUBLIC_BASE_URL,
both deployment-specific with no sensible default, plus the pre-existing
SLACK_GATEWAY_REQUIRE_LINKED_USER, which is a genuine product choice.

No behavior change at current settings: every constant equals the default it
replaced, and the email check's effective behavior in production (no scope, flag
off -> no verification) is unchanged until the scope is granted.

Testing: 10 new unit tests. The cookie-name ones assert the end-to-end property
that whatever name is configured, what acting_headers emits is what
build_delegation_headers forwards -- and that a disabled allowlist refuses rather
than emitting something that gets stripped. The email ones pin both directions of
the asymmetry: verified-different refuses (and leaves the nonce intact for the
legitimate owner), while missing scope, lookup failure and a principal without an
email all allow. Full unit suite 691 passed; the 14 Redis integration tests still
pass against a real Redis after the TTL constants moved.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@michael-chou359
michael-chou359 requested a review from a team as a code owner August 30, 2026 05:14
Comment on lines +66 to +67
names = session_cookie_names_to_forward()
return names[0] if names else None

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Multi-cookie allowlist gets narrowed

If AGENTEX_DELEGATION_SESSION_COOKIE_NAMES contains multiple names and the authenticated session uses a non-first cookie, session_cookie_name() selects only the first entry and _session_credential() rejects the valid session, causing Slack linking to fail with “Couldn't read your session” even though the cookie is explicitly allowlisted for delegation.

Prompt To Fix With AI
This is a comment left during a code review.
Path: agentex/src/domain/services/identity_link_service.py
Line: 66-67

Comment:
**Multi-cookie allowlist gets narrowed**

If `AGENTEX_DELEGATION_SESSION_COOKIE_NAMES` contains multiple names and the authenticated session uses a non-first cookie, `session_cookie_name()` selects only the first entry and `_session_credential()` rejects the valid session, causing Slack linking to fail with “Couldn't read your session” even though the cookie is explicitly allowlisted for delegation.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Fix in Cursor Fix in Claude Code Fix in Codex

The first real link offer in production was delivered correctly and reported as
never received. Both were true: chat.postMessage returned ok, conversations.history
confirmed the message sitting in the DM channel -- and Slack files bot
conversations under "Apps", not in the Direct messages list, so "I've DM'd you a
link" pointed at the one place it wasn't.

The link now goes in the ephemeral as well as the DM.

An ephemeral has exactly the same audience as a DM: Slack renders it for one user,
keeps it out of channel history and out of search. So the exposure argument that
made this DM-only never applied to an ephemeral -- and routing someone through a
conversation they can't find, to click a link we could have handed them directly,
bought nothing.

The DM stays, because ephemerals are transient: reload Slack before clicking and
it's gone, and the offer cooldown would then block a retry for an hour. So the
ephemeral carries the link plus a deep link to the DM as the durable copy.

The security invariant is unchanged but is now stated precisely, because this
change moves the line: the nonce is a bearer token, so it may go anywhere exactly
one person can see it (the DM, an ephemeral) and nowhere that lands in channel
history. The test that used to assert "never in a payload addressed to the origin
channel" now asserts "never in a chat.postMessage outside the user's own DM",
which is the property that actually matters -- the old wording would have failed
this change while the real risk was untouched.

conversations.open moved above the send-cap check, since both branches now need
the channel id for the deep link. It's idempotent (returns the existing DM), and
past the cap the user still gets the live link -- the cap limits DMs, not what we
can show the person in front of us.

Deep links use slack.com/app_redirect rather than a slack:// URI, which doesn't
work on the web client.

Testing: 3 new tests plus 3 rewritten. The rewrites are the interesting ones --
they invert assertions that encoded the old design (nonce must not appear in the
ephemeral; nothing addressed to the origin channel may carry it) into the ones
that encode the new invariant. 93 in the gateway suite, 689 across unit.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@michael-chou359 michael-chou359 changed the title refactor(agentex): cut the identity-link config surface from 8 vars to 1 refactor(agentex): cut identity-link config to one var, and surface the connect link Aug 30, 2026
The confirmation page fell back to raw identifiers when it couldn't name an
identity:

    slack_who = link_request.display_name or link_request.external_user_id  # U0B01457V24
    <dd>{email or sgp_user_id}</dd>                                         # 5da8f784-...

That leaks an internal id, and it doesn't even buy anything in exchange. The two
identity rows exist for one purpose: so the person clicking can answer "is this MY
Slack account?" and stop if it isn't -- the only defence against a link that was
forwarded to them. Nobody recognises their own Slack member id or SGP uuid, so the
fallback never made that question answerable. It just made an unanswerable question
look answered, which is worse than showing nothing.

Now:

- The Slack side prefers the name captured when the nonce was minted, and re-reads
  it live when that came back empty -- a transient Slack failure at offer time
  shouldn't permanently degrade the page, and users.info needs only users:read,
  which is granted.
- Neither side ever falls back to an id. An unnameable identity renders a
  placeholder.
- When either side is unnamed, the caution line changes from "if either name above
  isn't you, don't continue" to saying the match can't be confirmed here and to
  continue only if you just asked for the link yourself. Claiming someone verified
  something they had no way to verify is the actual harm.
- The success page likewise stops printing the uuid when there's no email.

Testing: 6 new unit tests -- named identities still shown; a missing Slack name
does not fall back to the member id; a missing name IS recovered by a live lookup;
a missing email does not fall back to the uuid; the unnamed case says the check is
unavailable; the success page prints no uuid. 701 unit tests pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@michael-chou359 michael-chou359 changed the title refactor(agentex): cut identity-link config to one var, and surface the connect link refactor(agentex): shrink identity-link config, surface the connect link, stop leaking ids Aug 30, 2026
session_cookie_name() returned only the FIRST entry of
AGENTEX_DELEGATION_SESSION_COOKIE_NAMES, and _session_credential() matched against
that alone. With a multi-name allowlist, a session carried by any later name was
rejected: linking failed with "couldn't read your session" for a cookie the
delegation layer would have forwarded quite happily. Nothing was wrong with the
request; the read was just too narrow.

The allowlist is the set of cookie names a deployment treats as valid sessions, so
one arriving under the second entry is exactly as legitimate as the first. Reading
now accepts any of them.

Reading and writing genuinely differ, so they are now separate functions rather
than one doing double duty -- which is how the bug got in:

  session_cookie_names()  every accepted name, in preference order (READ)
  session_cookie_name()   the single canonical name to emit under  (WRITE)

Allowlist order beats header order when a request carries several, so the stored
credential is the deployment's canonical cookie whenever it is present -- which is
also the name acting_headers() emits under, keeping the common case exact.

Emitting a value that arrived under a later name as the canonical one is safe: the
credential is a session JWT, validated downstream on its contents rather than on
the label it travels under. Noted in the docstring, because if a downstream ever
became name-sensitive this would need the originating name stored alongside the
credential -- a schema change, not worth making speculatively.

Parsing mirrors delegation_headers._minimal_session_cookie: split on ';',
first occurrence of a name wins, never trust a non-allowlisted morsel. Widening to
"any cookie that looks like a session" would let a caller nominate which of their
cookies we store.

Testing: 7 new unit tests -- a session under a later name is accepted; allowlist
order wins when several are present; a later name survives a realistic browser
header full of analytics morsels; names outside the allowlist are still rejected;
an empty allowlist reads nothing; a blank value is not a session; and the emitted
name stays canonical. 708 unit tests pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@michael-chou359
michael-chou359 merged commit e5a2a32 into main Aug 30, 2026
47 checks passed
@michael-chou359
michael-chou359 deleted the mc/identity-link-fewer-flags branch August 30, 2026 06:08
@michael-chou359
michael-chou359 restored the mc/identity-link-fewer-flags branch August 31, 2026 05:44
@michael-chou359
michael-chou359 deleted the mc/identity-link-fewer-flags branch August 31, 2026 05:48
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.

1 participant