Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/system-specs/modules/security.md
Original file line number Diff line number Diff line change
Expand Up @@ -171,7 +171,7 @@ under `(allow default)`, never an edition-resolved or user-writable executable.

- **Browsing has no keystone capability gate, deliberately (see [browser.md](browser.md)).** Presence of the `playwright-cli` binary on `PATH` makes the capability available, so there is no flag file to protect: the CLI exposes no capability gating to subset once an approved shell turn runs it. Presence is NOT an approval signal. Every dashboard invocation follows the ordinary shell approval ladder, and only an explicit trusted pattern, session trust, or auto-approve grant may skip the prompt. This prevents an unrelated existing install — or a planted launcher in an agent-writable PATH directory — from manufacturing its own grant. Uninstalling revokes availability. Because browsing is a shell command, it is governed on the `commands` plane and an `mcp`-scope deny does not reach it.

- **Operator OAuth consent-endpoint extension (keystone leaf `oauth_endpoints.json`)** — the security module's OAuth banner-safety contract (`security.oauth_url_contains_credential`, and `_exfil_url_warning` under `allow_oauth_entropy=True`) exempts standard front-channel params (`state`, PKCE, …) from the base64-blob/query-length heuristics only at an exact-match `(host, path)` in the code-owned `_OAUTH_AUTHORIZATION_ENDPOINTS`. `~/.kiro/crew/oauth_endpoints.json` (`{"additional_authorization_endpoints": [{"host", "path"}]}`) is the operator's escape hatch for identity providers outside that launch set (Okta orgs, Auth0, self-hosted OIDC, tenant-scoped Entra paths): `security._load_operator_oauth_endpoints()` unions strictly validated entries with the builtin set at check time (`_approved_oauth_authorization_endpoint`, memoized on the file's stat so a hand-edit takes effect on the next check without a restart). **Enforcement point:** the dashboard's live MCP OAuth banner validates URLs with this same gate — `_emit_mcp_oauth_request` in `chat_runner.py` calls `security.oauth_url_contains_credential` directly — so an operator endpoint entry governs the banner path as well as every other consumer wired to the contract gate. Each entry widens a trust boundary, so the file is on `_CREW_SECRET_LEAVES` (full read+write keystone block on both the tool path and every shell form) — an agent must not be able to author its own exemption — and there is deliberately no dashboard writer; the operator hand-edits it out-of-band. Every read fails soft to the EMPTY set (missing/unreadable/corrupt/non-object file, mirroring `computer_use.enable_state.load_state`), invalid entries are skipped individually with a warning (no wildcards, schemes, ports, userinfo, percent-escapes, IP literals, `..`, whitespace, or backslashes; hosts are lowercase-normalized DNS names with a letter TLD, paths exact and case-sensitive), and the entry list is truncated at 50 before validation so a mangled file cannot amplify. HTTPS-only / no-explicit-port / exact-match stay enforced by the gate logic and are NOT relaxable via the file, and the exemption grants exactly what the builtin set grants — fixed-credential patterns, heavy percent-encoding, userinfo, fragments, backslashes, and unknown-param heuristics remain unconditional. The markerless bare-secret entropy heuristic follows the same exact endpoint/parameter scope instead of scanning entropy-bearing recognized parameter values first; parameter names, unknown parameters, and non-query components remain in its scan target. That exemption is additionally bounded to the shapes the protocol itself can emit (`_oauth_entropy_value_is_protocol_shaped`, judged on EVERY decoded form: it percent-decodes until the text stops changing, bounded by `_MAX_URL_DECODE_PASSES`, and refuses a value still decodable at the bound, so `%252F` cannot launder the standard alphabet past a single decode): base64url emits `-`/`_` and never `+`/`/`, and an S256 `code_challenge` is base64url of a 32-byte digest, so it is exactly 43 characters. A base64-standard-alphabet run — the shape of an AWS secret key — therefore cannot ride `state`, `nonce`, or `code_challenge` into the blanked set. The residual is narrower but real: a markerless 40-character credential that happens to be alphanumeric is indistinguishable from ordinary base64url state entropy and is accepted only at this boundary; general output redactors retain the heuristic. An approval that came from an operator entry (not the builtin set) emits a best-effort `oauth_endpoint_extension_used` SEL event, deduped per process per endpoint.
- **Operator OAuth consent-endpoint extension (keystone leaf `oauth_endpoints.json`)** — the security module's OAuth banner-safety contract (`security.oauth_url_contains_credential`, and `_exfil_url_warning` under `allow_oauth_entropy=True`) exempts standard front-channel params (`state`, PKCE, …) from the base64-blob/query-length heuristics only at an exact-match `(host, path)` in the code-owned `_OAUTH_AUTHORIZATION_ENDPOINTS`. `~/.kiro/crew/oauth_endpoints.json` (`{"additional_authorization_endpoints": [{"host", "path"}]}`) is the operator's escape hatch for identity providers outside that launch set (Okta orgs, Auth0, self-hosted OIDC, tenant-scoped Entra paths): `security._load_operator_oauth_endpoints()` unions strictly validated entries with the builtin set at check time (`_approved_oauth_authorization_endpoint`, memoized on the file's stat so a hand-edit takes effect on the next check without a restart). **Enforcement point:** the dashboard's live MCP OAuth banner validates URLs with this same gate — `_emit_mcp_oauth_request` in `chat_runner.py` calls `security.oauth_url_contains_credential` directly — so an operator endpoint entry governs the banner path as well as every other consumer wired to the contract gate. Each entry widens a trust boundary, so the file is on `_CREW_SECRET_LEAVES` (full read+write keystone block on both the tool path and every shell form) — an agent must not be able to author its own exemption — and there is deliberately no dashboard writer; the operator hand-edits it out-of-band. Every read fails soft to the EMPTY set (missing/unreadable/corrupt/non-object file, mirroring `computer_use.enable_state.load_state`), invalid entries are skipped individually with a warning (no wildcards, schemes, ports, userinfo, percent-escapes, IP literals, `..`, whitespace, or backslashes; hosts are lowercase-normalized DNS names with a letter TLD, paths exact and case-sensitive), and the entry list is truncated at 50 before validation so a mangled file cannot amplify. HTTPS-only / no-explicit-port / exact-match stay enforced by the gate logic and are NOT relaxable via the file, and the exemption grants exactly what the builtin set grants — fixed-credential patterns, heavy percent-encoding, userinfo, fragments, backslashes, and unknown-param heuristics remain unconditional. The markerless bare-secret entropy heuristic follows the same exact endpoint/parameter scope instead of scanning entropy-bearing recognized parameter values first; parameter names, unknown parameters, and non-query components remain in its scan target. That exemption is additionally bounded to the shapes the protocol itself can emit (`_oauth_entropy_value_is_protocol_shaped`, judged on EVERY decoded form: it percent-decodes until the text stops changing, bounded by `_MAX_URL_DECODE_PASSES`, and refuses a value still decodable at the bound, so `%252F` cannot launder the standard alphabet past a single decode): base64url emits `-`/`_` and never `+`/`/`, and an S256 `code_challenge` is base64url of a 32-byte digest, so it is exactly 43 characters. A base64-standard-alphabet run — the shape of an AWS secret key — therefore cannot ride `state`, `nonce`, or `code_challenge` into the blanked set. The residual is narrower but real: a markerless 40-character credential that happens to be alphanumeric is indistinguishable from ordinary base64url state entropy and is accepted only at this boundary; general output redactors retain the heuristic. An approval that came from an operator entry (not the builtin set) emits a best-effort `oauth_endpoint_extension_used` SEL event, deduped per process per endpoint. **Rejections name the endpoint, never the values:** `security.sanitized_oauth_endpoint(url)` returns the lowercase host + path of a rejected authorization URL (query/fragment/port/userinfo are never included; both components are scanned at every percent-decode layer up to the gate's own `_MAX_URL_DECODE_PASSES` budget — a credential-bearing or budget-exhausting path self-redacts to the shared tag, a credential-bearing host makes the helper return `None`, a non-ASCII host is surfaced in IDNA A-label form; both components are length-capped; unparseable URLs return `None`). The banner path (`_emit_mcp_oauth_request`) surfaces that pair in the rejection text — which also spells the `{"additional_authorization_endpoints": [{"host", "path"}]}` entry shape — and inside the `error` meta field the dashboard's failed banner actually renders; no additional meta keys are emitted because no shipped surface reads any. So the user can tell WHICH endpoint tripped the scanner and what to write into `oauth_endpoints.json`, without the rejection ever echoing state/PKCE material (#7578).

**Privacy-safe OAuth rejection diagnostics.** `security.diagnose_oauth_url_credential()` returns `None` for an accepted URL or an `OAuthUrlCredentialDiagnostic` for the first rejecting sub-check. The record carries only a stable `rule`, a URL-component category, an optional code-owned standard query-parameter name, and a shape profile: total length plus counts of ASCII uppercase, ASCII lowercase, digits, percent signs, URL punctuation, and all other characters. `oauth_url_contains_credential()` retains its boolean caller contract and logs that same bounded signature when it rejects. The diagnostic path does not change a rule, add a bypass, retry, or retain a URL. The URL and parameter value are never returned, logged, persisted, hashed, sampled, or represented by a prefix/suffix; malformed, credential-shaped, and unrecognized parameter names are omitted rather than echoed. This is sufficient for a controlled mint loop to distinguish standard OAuth entropy false positives (for example, `credential_scan_bare_secret_raw` on `state` versus `exfil_query_length`) without creating a second credential-bearing sink. An entropy-bearing recognized parameter value at an approved endpoint produces no diagnostic on entropy alone, while the same shape in an unknown parameter retains the stable `credential_scan_bare_secret_raw` rejection signature.

Expand Down
49 changes: 38 additions & 11 deletions src/kiro_crew/dashboard/chat_runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -250,6 +250,7 @@
redact_and_truncate,
redact_credentials,
redact_exfiltration_urls,
sanitized_oauth_endpoint,
)
from kiro_crew.sel import sel
from kiro_crew.session import SessionClosingError, SpeculativeResumeRefused
Expand Down Expand Up @@ -1683,21 +1684,47 @@ def _emit_mcp_oauth_request(
"ACP: rejecting MCP OAuth URL with credential/exfil pattern for %s",
server_name or "(unknown)",
)
# Name the endpoint so case 2 is actionable: without the host+path the
# user cannot know what to write into oauth_endpoints.json. The helper
# returns host and path ONLY (query/PKCE material is never echoed) and
# self-redacts a credential-bearing path, so surfacing it does not
# weaken the rejection.
endpoint = sanitized_oauth_endpoint(oauth_url)
rejected_meta: dict[str, Any] = {
"server_name": safe_name,
"failed": True,
"rejected_url": True,
"error": "URL contained credential or exfiltration pattern",
"remedy": "oauth_endpoints.json",
}
endpoint_detail = ""
if endpoint is not None:
rejected_host, rejected_path = endpoint
# The dashboard's failed-banner renderer (McpOAuthBanner) displays
# meta["error"], not the content string — the endpoint must ride in
# the error field to actually reach the user's screen. The banner
# content below additionally spells the oauth_endpoints.json entry
# shape, so text + error together carry the whole remedy; no extra
# meta keys are emitted because no surface reads them.
rejected_meta["error"] = (
"URL contained credential or exfiltration pattern "
f"(endpoint: {rejected_host}{rejected_path})"
)
endpoint_detail = (
f" The rejected authorization endpoint was "
f"{rejected_host}{rejected_path} (query values withheld)."
)
slot.append(
"mcp_oauth",
f"🚫 {label} sent an authentication URL containing a credential "
"pattern (rejected). If this is a self-hosted or otherwise "
"unlisted identity provider, its authorization endpoint may need "
"adding to oauth_endpoints.json in the Kiro Crew data home; "
"otherwise ask the server owner to fix the URL.",
f"pattern (rejected).{endpoint_detail} If this is a self-hosted "
"or otherwise unlisted identity provider, its authorization "
"endpoint may need adding to oauth_endpoints.json in the Kiro "
'Crew data home, shaped {"additional_authorization_endpoints": '
'[{"host": ..., "path": ...}]}; otherwise ask the server owner '
"to fix the URL.",
"msg msg-warn",
meta={
"server_name": safe_name,
"failed": True,
"rejected_url": True,
"error": "URL contained credential or exfiltration pattern",
"remedy": "oauth_endpoints.json",
},
meta=rejected_meta,
)
return
# A new authorize request for this server means kiro-cli started a FRESH
Expand Down
Loading