close
Skip to content

feat(security): operator keystone extension for OAuth consent-endpoint allowlist (#1341) - #2402

Merged
pepmach merged 1 commit into
mainfrom
fix/oauth-endpoint-extension-1341
Aug 9, 2026
Merged

feat(security): operator keystone extension for OAuth consent-endpoint allowlist (#1341)#2402
pepmach merged 1 commit into
mainfrom
fix/oauth-endpoint-extension-1341

Conversation

@bolichen97

@bolichen97 bolichen97 commented Aug 9, 2026

Copy link
Copy Markdown
Collaborator

Summary

Adds an operator-owned, keystone-fenced extension to the OAuth consent-endpoint allowlist (_OAUTH_AUTHORIZATION_ENDPOINTS), so an identity provider outside the code-owned launch set (Okta orgs, Auth0, self-hosted OIDC, tenant-scoped Entra /{tenant}/oauth2/v2.0/authorize) can be approved by the operator without a Kiro Crew code release — while keeping the ceiling un-widenable by the agent.

  • New keystone file <config_dir>/oauth_endpoints.json{"additional_authorization_endpoints": [{"host": "acme.okta.com", "path": "/oauth2/v1/authorize"}]}. Hand-edited out-of-band; deliberately no dashboard writer (minimal surface).
  • Fail-soft loader (_load_operator_oauth_endpoints): missing / unreadable / corrupt / non-object file → EMPTY set (mirrors computer_use.enable_state.load_state). A mangled file never widens trust.
  • Strict per-entry validation, invalid entries skipped individually with a warning: exact lowercase-normalized DNS host (letter TLD; rejects wildcards, schemes, ports, userinfo, %, whitespace, backslash, IP literals, leading/trailing dots), exact case-sensitive path (rejects ; ? # % \, whitespace, ..); list truncated at 50 before validation so a mangled file cannot amplify.
  • Both checkpoints union builtin + operator entries at check time via _approved_oauth_authorization_endpoint(), memoized on the file's stat (mtime_ns, size) — a hand-edit takes effect on the next check, no restart; an unchanged file costs one stat.
  • Exemption scope unchanged: only the base64-blob/query-length heuristics on known _OAUTH_QUERY_PARAMS are skipped. Fixed-credential patterns, heavy percent-encoding, userinfo, fragments, backslashes, unknown-param heuristics, HTTPS-only, and no-explicit-port remain unconditional and are NOT relaxable via the file.
  • SEL audit: operator-entry approvals emit a best-effort oauth_endpoint_extension_used event (deduped per process per endpoint).
  • Keystone fence: oauth_endpoints.json joins _CREW_SECRET_LEAVES — agent reads AND writes blocked on the tool path and shell forms, under both crew home prefixes.
  • Corpus: new OPERATOR_EXTENSION_OAUTH_URLS list restores the generic id.example-idp.com long-state case (plus Okta org and tenant-scoped Entra shapes) under the operator-extension contract — asserted rejected with default config and passing only with the operator file (deliberately NOT in LEGIT_OAUTH_URLS, which pins default-config behavior).

Enforcement-point caveat (verified, documented)

security.oauth_url_contains_credential — the module's tested ACP banner-safety contract and the only gate that consults the endpoint allowlist — has no production caller on current main: the dashboard's live MCP OAuth banner uses a separate, more permissive per-param gate local to chat_runner.py (no allowlist, no query-length heuristic). Per the task scoping, this PR lands the extension at the allowlist's gate (the tested contract) and does not rewire the banner path: swapping the strict gate into chat_runner would newly reject currently-working IdPs and is a behavioral change needing its own review. The new security.md keystone bullet states this caveat explicitly. Follow-up issues: gate consolidation is #2403; the pre-existing keystone bash-fence gap is #2404.

Pre-push review fleet

Two model-pinned read-only reviewers (GPT 5.6 Sol + Opus 5) ran before push:

  • Opus BLOCKING (doc accuracy): original doc bullet implied the extension governs the live banner — fixed; enforcement-point caveat added to security.md and the commit message.
  • Opus advisories applied: stat-keyed memo (no per-check disk parse), cap slices the iteration (a 100k-entry mangled file no longer walks fully), corrected the deferred-import rationale, removed all unrelated formatter churn (diff is now 100% feature hunks).
  • Test gaps closed: hand-edit-takes-effect re-read contract, is_sensitive_write_path pinned, distinct-endpoint SEL emit after dedupe, autouse reset of process-global memo/dedupe state.
  • GPT findings: banner wiring (same as Opus blocking — see caveat above); relative-cd bash-fence gap — verified pre-existing for every _CREW_SECRET_LEAVES trust root (denied_commands.json, security_policy.json behave identically), so it needs a systemic matcher hardening covering all keystone leaves, not a bespoke rule here — tracked in Keystone bash fence does not catch relative-path access after cd into the crew home #2404.

Testing

  • 60+ new tests in TestOperatorOAuthEndpointExtension (fail-soft ×5, hostile hosts ×16, hostile paths ×9, malformed entries ×5, cap bounds acceptance AND iteration, exact-match negatives ×5 incl. lookalike suffix, credentials-still-rejected ×2, general redactors unaffected, SEL emit/no-emit/dedupe/fail-open ×4, keystone fence read+write under both home prefixes, corpus contract both directions ×6, re-read contract).
  • Full local gates green: isort --check-only, flake8, mypy (856 files), python -m pytest (39,568 passed; remaining failures are this host's pre-existing environment baseline — verified identical failure families on unmodified main, all pass standalone), brand gate, docs-lint.
  • With no file present, every existing test passes unmodified — default behavior is byte-for-byte unchanged.

Closes #1341

Comment thread src/kiro_crew/security.py Fixed
@github-actions

github-actions Bot commented Aug 9, 2026

Copy link
Copy Markdown
Contributor

Opus 4.8 Review — ✅ no blocking findings

Reviewed 0a2652fb10e737b7dd3ddcf6f69cde273c19cbc0 — this comment is updated in place on each push.

Review details

I've examined the diff exhaustively and falsified my candidates:

  • Blocking file IO on the event loop (no-blocking-call-on-event-loop): _load_operator_oauth_endpoints does a stat + small read_text. The rule targets large synchronous IO/filesystem walks; a single memoized small-config read on the already-synchronous security-check path (same posture as computer_use.enable_state.load_state, which this mirrors) is not that class. Also only reached on the banner path (allow_oauth_entropy + https + no-port), never general text. Dropped.
  • Agent self-widening trust (backend-security-controls keystone): oauth_endpoints.json is added to _CREW_SECRET_LEAVES, and tests pin read+write blocking under both home prefixes and every shell form. Keystone intact. Dropped.
  • Fixed-credential bypass at extended endpoint: the carve-out only skips base64/length heuristics on known OAuth params; _contains_fixed_credential/_text_contains_bare_secret still scan. Confirmed by code and tests. Dropped.
  • Fail-open on defective file: _load_operator_oauth_endpoints / _validate_operator_oauth_entries fail soft to EMPTY on every defect, entries strictly validated and individually skipped, cap bounds iteration. No trust widening from a mangled file. Dropped.
  • Docs change is additive; no blocking rule weakened.

No findings.

[OPUS-REVIEWED] 0a2652f

Verdict parsed from the review's SHA-scoped output markers for commit 0a2652fb10e737b7dd3ddcf6f69cde273c19cbc0.

False positive or not applicable? A repository writer can comment:
/ai-review override fable 0a2652fb10e737b7dd3ddcf6f69cde273c19cbc0: <one-sentence reason>

@github-actions

github-actions Bot commented Aug 9, 2026

Copy link
Copy Markdown
Contributor

Design Review (Fable 5) — 🟡 CONCERNS

Advisory design-level review of 0a2652fb10e737b7dd3ddcf6f69cde273c19cbc0 — updated in place on each push; does not block merge.

I have what I need for the design review. The diff is well-scoped (security.py extension + loader path + docs + tests), and I've verified the key claim: oauth_url_contains_credential — the only gate consulting the allowlist — indeed has no production caller; the live MCP OAuth banner in chat_runner.py:1030 uses its own permissive per-param gate that never touches the allowlist.

Design-Verdict: CONCERNS

The extension lands on a gate with no production caller, so the operator harm it targets isn't relieved until an unfiled gate-consolidation follow-up ships.

Watch

  • Sequencing inversion: the PR's own caveat ("has no production caller on current main… the dashboard's live MCP OAuth banner uses a separate, more permissive per-param gate") means a user with an unlisted IdP sees zero behavior change from this merge — the value is entirely contingent on the promised, still-unfiled consolidation issue. File and link it before merge, and decide the gate direction there first: if consolidation adopts chat_runner.py's per-param model (which needs no allowlist and already passes these IdPs), this whole extension becomes dead surface.
  • One-way door on operator-facing schema: oauth_endpoints.json's shape, keystone-leaf status, and SEL event type get pinned now, before the enforcement point they presuppose is chosen; operators who hand-author files against this schema make it costly to reshape later.

Suggestions

  • Consider holding this until the consolidation decision, or landing it in the same PR as the wiring — the extension's shape should follow the surviving gate, not precede it.

[DESIGN-REVIEWED] 0a2652f

@github-actions

github-actions Bot commented Aug 9, 2026

Copy link
Copy Markdown
Contributor

GPT 5.6 Review — ✅ no blocking findings

GPT 5.6 completed its review of 0a2652fb10e737b7dd3ddcf6f69cde273c19cbc0 and found no blocking issues.

This comment is updated in place on each push.

Review details

No findings.
[GPT-REVIEWED] 0a2652f

False positive or not applicable? A repository writer can comment:
/ai-review override gpt 0a2652fb10e737b7dd3ddcf6f69cde273c19cbc0: <one-sentence reason>

…t allowlist (#1341)

The security module's OAuth banner-safety contract exempts standard
front-channel params from the exfiltration heuristics only at an
exact-match endpoint in the code-owned _OAUTH_AUTHORIZATION_ENDPOINTS
set. Any identity provider outside that launch set (Okta orgs, Auth0,
self-hosted OIDC, tenant-scoped Entra paths) fails the gate closed with
no remedy short of a code release.

Add an operator-owned, keystone-fenced extension:

- <config_dir>/oauth_endpoints.json holds
  {additional_authorization_endpoints: [{host, path}]}; the operator
  hand-edits it out-of-band (no dashboard writer).
- security._load_operator_oauth_endpoints() fails soft to the EMPTY set
  on a missing/unreadable/corrupt/non-object file and strictly validates
  every entry (exact lowercase DNS host with letter TLD, exact
  case-sensitive path; no wildcards, schemes, ports, userinfo,
  percent-escapes, IP literals, whitespace, backslashes, or dot-dot).
  The entry list is truncated at 50 BEFORE validation so a mangled file
  cannot amplify, and the parsed set is memoized on the file's stat so a
  hand-edit takes effect on the next check without a restart.
- Both checkpoints (_exfil_url_warning under allow_oauth_entropy and
  oauth_url_contains_credential) consult the union at check time via
  _approved_oauth_authorization_endpoint(). HTTPS-only, no explicit
  port, and every unconditional heuristic are unchanged and not
  relaxable via the file.
- An approval that came from an operator entry emits a best-effort
  oauth_endpoint_extension_used SEL event, deduped per process per
  endpoint.
- oauth_endpoints.json joins _CREW_SECRET_LEAVES so the agent can
  neither read nor write its own trust widening, under every crew home
  prefix.

The dashboard's live MCP OAuth banner currently validates with a
separate, more permissive per-param gate local to chat_runner.py that
consults no endpoint allowlist; the extension governs the security-
module contract gate and its consumers. Consolidating the two gates is
tracked as a follow-up. The security.md keystone bullet states this
enforcement-point caveat explicitly.

Adds OPERATOR_EXTENSION_OAUTH_URLS to the corpus (the generic long-state
IdP shape plus Okta org and tenant-scoped Entra) asserted rejected by
default config and passing only with the operator file present.

Closes #1341
@bolichen97
bolichen97 force-pushed the fix/oauth-endpoint-extension-1341 branch from 8f3a35c to 0a2652f Compare August 9, 2026 21:26
@bolichen97

Copy link
Copy Markdown
Collaborator Author

CodeQL alert resolved (py/clear-text-logging-sensitive-data, security.py:5407): the flagged expression was _OAUTH_ENDPOINT_EXTENSION_CAP — an integer constant (50) passed to a logger.warning format string. CodeQL's name-based heuristic classifies any OAUTH-prefixed identifier as credential material; no secret is involved (the other logged values are an entry count and a truncated operator-authored hostname). Rather than suppress, commit 0a2652f renames the two logged constants to scanner-neutral names (_ENDPOINT_EXTENSION_CAP, _ENDPOINT_EXTENSION_ENTRIES_KEY) so the rule cannot re-trip here. No behavior change; all gates re-run green.

@bolichen97

Copy link
Copy Markdown
Collaborator Author

Re: Design Review CONCERNS (sequencing inversion / dead-surface risk) — answering each point:

  1. "File and link it before merge" — done before this review posted, but the PR body still said to be filed; now fixed. Gate consolidation is Consolidate the dashboard MCP OAuth banner gate onto security.oauth_url_contains_credential #2403; the pre-existing keystone bash-fence gap found in the same review pass is Keystone bash fence does not catch relative-path access after cd into the crew home #2404.

  2. "If consolidation adopts chat_runner's per-param model, this extension becomes dead surface" — the repo's own trajectory points the other way. The in-code record on _OAUTH_AUTHORIZATION_ENDPOINTS documents entries being added because banners failed closed against the allowlist model ('Every entry added to the Connections registry needs its MCP authorization server here too'), and chat_runner's local gate is the one whose docstring frames its heuristic omissions as a compromise. OAuth consent-URL allowlist is 7 hardcoded endpoints — no operator escape hatch for other IdPs #1341 itself asks for an operator extension to the allowlist. If the Consolidate the dashboard MCP OAuth banner gate onto security.oauth_url_contains_credential #2403 decision nonetheless lands on the per-param model, the worst case for this surface is inert, not harmful: the file is fail-soft (absent/corrupt → empty set), has no dashboard writer, and its schema — exact (host, path) pairs — mirrors the builtin set, so it reshapes with whichever gate survives.

  3. "Zero behavior change for a user with an unlisted IdP until consolidation ships" — correct, and stated in the PR body and commit message as the enforcement-point caveat. The sequencing rationale: landing the tested contract + operator schema + keystone fence first means Consolidate the dashboard MCP OAuth banner gate onto security.oauth_url_contains_credential #2403's wiring flip ships with its migration story (the escape hatch) already in place — the reverse order is the one that strands users, because consolidating first would make the banner strictly reject unlisted IdPs with no remedy.

Holding vs. merging is the maintainers' call; the pieces are now cleanly separable either way.

@github-actions github-actions Bot added readiness: passed Eligible automated validation passed for the current revision and removed readiness: checking Automated validation is still running labels Aug 9, 2026
@pepmach
pepmach merged commit e28e847 into main Aug 9, 2026
59 checks passed
@pepmach
pepmach deleted the fix/oauth-endpoint-extension-1341 branch August 9, 2026 22:54
@github-actions github-actions Bot removed the readiness: passed Eligible automated validation passed for the current revision label Aug 9, 2026
iamwhatever pushed a commit that referenced this pull request Aug 17, 2026
#4007)

The MCP OAuth rejection banner names the symptom but not the remedy, so a
legitimate consent URL at an unlisted identity provider reads as an
unfixable failure.

Two different causes reach this branch. A genuinely bogus URL embedding a
credential is one. The other is a legitimate OAuth 2.1 + PKCE consent URL
whose authorization endpoint is simply not in _OAUTH_AUTHORIZATION_ENDPOINTS:
the entropy carve-out in oauth_url_contains_credential applies only at an
approved (host, path), so an unlisted self-hosted IdP has its 43-byte
code_challenge scanned as a bare secret and fails closed.

That second case already has a remedy - the operator keystone extension
oauth_endpoints.json added in #2402 (closing #1341) - but it is on
_CREW_SECRET_LEAVES with deliberately no dashboard writer, and is documented
only in docs/system-specs/modules/security.md. Nothing the user can see names
it. In #3310 two people independently root-caused this from source instead of
finding the one-line config fix, which is the migration story #2403 asked for
and that was never shipped.

Name the file in the banner and add a 'remedy' meta key. The rejection itself
is unchanged: still failed/rejected_url, oauth_url still withheld, the URL
still never rendered. HTTPS-only, no-explicit-port and exact-match remain
enforced by the gate and are not relaxable via the file, so this widens
nothing - it only tells the operator where the sanctioned lever is.

Tests: test_rejection_banner_names_the_operator_remedy pins that the remedy is
named and that the hint does not soften the rejection. 76 passed in
test_mcp_oauth_banner.py, 234 in test_chat_runner_coverage.py +
test_display_time_redaction.py; flake8 and isort clean.

Co-authored-by: William Laws <jeeshofone@users.noreply.github.com>
encomjp pushed a commit to encomjp/kirocrew-customapi that referenced this pull request Aug 22, 2026
…t allowlist (kirodotdev#1341) (kirodotdev#2402)

The security module's OAuth banner-safety contract exempts standard
front-channel params from the exfiltration heuristics only at an
exact-match endpoint in the code-owned _OAUTH_AUTHORIZATION_ENDPOINTS
set. Any identity provider outside that launch set (Okta orgs, Auth0,
self-hosted OIDC, tenant-scoped Entra paths) fails the gate closed with
no remedy short of a code release.

Add an operator-owned, keystone-fenced extension:

- <config_dir>/oauth_endpoints.json holds
  {additional_authorization_endpoints: [{host, path}]}; the operator
  hand-edits it out-of-band (no dashboard writer).
- security._load_operator_oauth_endpoints() fails soft to the EMPTY set
  on a missing/unreadable/corrupt/non-object file and strictly validates
  every entry (exact lowercase DNS host with letter TLD, exact
  case-sensitive path; no wildcards, schemes, ports, userinfo,
  percent-escapes, IP literals, whitespace, backslashes, or dot-dot).
  The entry list is truncated at 50 BEFORE validation so a mangled file
  cannot amplify, and the parsed set is memoized on the file's stat so a
  hand-edit takes effect on the next check without a restart.
- Both checkpoints (_exfil_url_warning under allow_oauth_entropy and
  oauth_url_contains_credential) consult the union at check time via
  _approved_oauth_authorization_endpoint(). HTTPS-only, no explicit
  port, and every unconditional heuristic are unchanged and not
  relaxable via the file.
- An approval that came from an operator entry emits a best-effort
  oauth_endpoint_extension_used SEL event, deduped per process per
  endpoint.
- oauth_endpoints.json joins _CREW_SECRET_LEAVES so the agent can
  neither read nor write its own trust widening, under every crew home
  prefix.

The dashboard's live MCP OAuth banner currently validates with a
separate, more permissive per-param gate local to chat_runner.py that
consults no endpoint allowlist; the extension governs the security-
module contract gate and its consumers. Consolidating the two gates is
tracked as a follow-up. The security.md keystone bullet states this
enforcement-point caveat explicitly.

Adds OPERATOR_EXTENSION_OAUTH_URLS to the corpus (the generic long-state
IdP shape plus Okta org and tenant-scoped Entra) asserted rejected by
default config and passing only with the operator file present.

Closes kirodotdev#1341

Co-authored-by: bolichen97 <bolichen97@users.noreply.github.com>
encomjp pushed a commit to encomjp/kirocrew-customapi that referenced this pull request Aug 22, 2026
kirodotdev#4007)

The MCP OAuth rejection banner names the symptom but not the remedy, so a
legitimate consent URL at an unlisted identity provider reads as an
unfixable failure.

Two different causes reach this branch. A genuinely bogus URL embedding a
credential is one. The other is a legitimate OAuth 2.1 + PKCE consent URL
whose authorization endpoint is simply not in _OAUTH_AUTHORIZATION_ENDPOINTS:
the entropy carve-out in oauth_url_contains_credential applies only at an
approved (host, path), so an unlisted self-hosted IdP has its 43-byte
code_challenge scanned as a bare secret and fails closed.

That second case already has a remedy - the operator keystone extension
oauth_endpoints.json added in kirodotdev#2402 (closing kirodotdev#1341) - but it is on
_CREW_SECRET_LEAVES with deliberately no dashboard writer, and is documented
only in docs/system-specs/modules/security.md. Nothing the user can see names
it. In kirodotdev#3310 two people independently root-caused this from source instead of
finding the one-line config fix, which is the migration story kirodotdev#2403 asked for
and that was never shipped.

Name the file in the banner and add a 'remedy' meta key. The rejection itself
is unchanged: still failed/rejected_url, oauth_url still withheld, the URL
still never rendered. HTTPS-only, no-explicit-port and exact-match remain
enforced by the gate and are not relaxable via the file, so this widens
nothing - it only tells the operator where the sanctioned lever is.

Tests: test_rejection_banner_names_the_operator_remedy pins that the remedy is
named and that the hint does not soften the rejection. 76 passed in
test_mcp_oauth_banner.py, 234 in test_chat_runner_coverage.py +
test_display_time_redaction.py; flake8 and isort clean.

Co-authored-by: William Laws <jeeshofone@users.noreply.github.com>
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.

OAuth consent-URL allowlist is 7 hardcoded endpoints — no operator escape hatch for other IdPs

3 participants