Skip to content
Open
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
6 changes: 6 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,12 @@ AGENT_DESCRIPTION="Red Hat Lightspeed Agent for Google Cloud"
AGENT_HOST=0.0.0.0
AGENT_PORT=8000

# Org tool guardrails (ADK GuardrailPlugin): when true, MCP tool args that carry
# organization identifiers (org_id, organization_id, rh_org_id, etc.) must match
# the JWT org_id. Set false only for emergency debugging. Same deploy surfaces as
# AGENT_HOST/AGENT_PORT: deploy/cloudrun/service.yaml, deploy/podman/lightspeed-agent-configmap.yaml, Containerfile, cloudbuild.yaml
GUARDRAIL_ORG_ARGS_ENABLED=true

# -----------------------------------------------------------------------------
# Database Configuration
# -----------------------------------------------------------------------------
Expand Down
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,7 @@ All configuration is via environment variables, managed through Pydantic setting

**Agent:**
- `AGENT_HOST`, `AGENT_PORT`
- `GUARDRAIL_ORG_ARGS_ENABLED` (default: true — bind MCP tool `org_id`-style args to JWT `org_id`)

**Service Control:**
- `SERVICE_CONTROL_SERVICE_NAME`, `SERVICE_CONTROL_ENABLED`
Expand Down
3 changes: 2 additions & 1 deletion Containerfile
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,8 @@ RUN mkdir -p /opt/app-root/src/data
ENV PYTHONUNBUFFERED=1 \
PYTHONDONTWRITEBYTECODE=1 \
AGENT_HOST=0.0.0.0 \
AGENT_PORT=8000
AGENT_PORT=8000 \
GUARDRAIL_ORG_ARGS_ENABLED=true

# Expose the agent port
EXPOSE 8000
Expand Down
2 changes: 1 addition & 1 deletion cloudbuild.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ steps:
- '--concurrency'
- '80'
- '--set-env-vars'
- 'GOOGLE_GENAI_USE_VERTEXAI=TRUE,GOOGLE_CLOUD_PROJECT=${PROJECT_ID},GOOGLE_CLOUD_LOCATION=${_REGION},AGENT_HOST=0.0.0.0,AGENT_PORT=8000,LOG_FORMAT=json'
- 'GOOGLE_GENAI_USE_VERTEXAI=TRUE,GOOGLE_CLOUD_PROJECT=${PROJECT_ID},GOOGLE_CLOUD_LOCATION=${_REGION},AGENT_HOST=0.0.0.0,AGENT_PORT=8000,GUARDRAIL_ORG_ARGS_ENABLED=true,LOG_FORMAT=json'
- '--set-secrets'
- 'RED_HAT_SSO_CLIENT_ID=redhat-sso-client-id:latest,RED_HAT_SSO_CLIENT_SECRET=redhat-sso-client-secret:latest,DATABASE_URL=database-url:latest,REDIS_URL=redis-url:latest'
- '--service-account'
Expand Down
1 change: 1 addition & 0 deletions deploy/cloudrun/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -670,6 +670,7 @@ Bearer token that is active and carries the `api.console` and `api.ocm` scopes.
| `AGENT_PROVIDER_ORGANIZATION_URL` | Provider's organization website URL (default: `https://www.redhat.com`). Used in AgentCard `provider.url` and as the expected JWT audience for Google DCR `software_statement` validation. Set in YAML configs, not changed by `deploy.sh`. |
| `AGENT_REQUIRED_SCOPE` | Comma-separated OAuth scopes required in tokens (default: `api.console,api.ocm`) |
| `AGENT_ALLOWED_SCOPES` | Comma-separated allowlist of permitted scopes (default: `openid,profile,email,api.console,api.ocm`). Tokens with scopes outside this list are rejected (403). |
| `GUARDRAIL_ORG_ARGS_ENABLED` | When `true` (default), MCP tool arguments that include org-related fields must match the JWT `org_id`. Set in [`service.yaml`](service.yaml). Behavior: [API docs — Tool guardrails](../docs/api.md#tool-guardrails-organization-arguments); env table: [Configuration](../docs/configuration.md#agent-configuration). |

### Development Mode

Expand Down
4 changes: 4 additions & 0 deletions deploy/cloudrun/service.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,10 @@ spec:
value: "json"
- name: AGENT_LOGGING_DETAIL
value: "basic"
# When true, block MCP tool calls if org-related args (e.g. org_id) do not
# match the authenticated request org_id. Set false only for emergency debug.
- name: GUARDRAIL_ORG_ARGS_ENABLED
value: "true"
# Red Hat SSO Configuration
- name: RED_HAT_SSO_ISSUER
value: "https://sso.redhat.com/auth/realms/redhat-external"
Expand Down
2 changes: 2 additions & 0 deletions deploy/podman/lightspeed-agent-configmap.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,8 @@ data:
AGENT_DESCRIPTION: "Red Hat Lightspeed Agent for Google Cloud"
AGENT_HOST: "0.0.0.0"
AGENT_PORT: "8000"
# Reject tool calls when org_id / organization_id / rh_org_id in args ≠ JWT org_id
GUARDRAIL_ORG_ARGS_ENABLED: "true"

# Session Configuration
# Session backend: "memory" for dev (no persistence), "database" for persistent sessions
Expand Down
8 changes: 8 additions & 0 deletions docs/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -331,6 +331,14 @@ See [Authentication](authentication.md) for details on obtaining tokens.
> Authentication enforcement on the A2A JSON-RPC endpoint should be implemented via
> middleware or request dependencies depending on deployment requirements.

### Tool guardrails (organization arguments)

The ADK `GuardrailPlugin` (enabled by default via `GUARDRAIL_ORG_ARGS_ENABLED`) runs on every MCP tool call. It only inspects arguments that use organization-style parameter names (e.g. `org_id`, `organization_id`, `rh_org_id`, matched case-insensitively with hyphen/underscore normalization). **If a call has no such parameters, the guardrail does nothing.** If it does, every extracted value must equal the authenticated request’s `org_id` from Red Hat SSO. **If the request has no `org_id` in context but the tool args include those fields, the call is blocked** (same error code). Otherwise the tool is short-circuited with a structured error (`code: guardrail_org_mismatch`) and is not sent to the MCP server. In development with `SKIP_JWT_VALIDATION` and a `Bearer` header, middleware typically sets `org_id` to `dev-org`, so the “missing org context” path is uncommon.

This complements MCP/API-side enforcement and mitigates cross-tenant argument injection from the model.

For the environment variable, defaults, and where it is set in images and deploy manifests, see [Configuration — Agent Configuration](configuration.md#agent-configuration).

## A2A Protocol Endpoints

The agent implements the [A2A (Agent-to-Agent) protocol](https://google.github.io/A2A/) for interoperability with other agents.
Expand Down
2 changes: 2 additions & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ MCP_READ_ONLY=true
| `AGENT_DESCRIPTION` | Red Hat Lightspeed Agent for Google Cloud | Agent description |
| `AGENT_HOST` | `0.0.0.0` | Server bind address |
| `AGENT_PORT` | `8000` | Server port |
| `GUARDRAIL_ORG_ARGS_ENABLED` | `true` | Turns on [tool org guardrails](api.md#tool-guardrails-organization-arguments). Set `false` only for emergency debugging. Wired in the same deploy surfaces as `AGENT_HOST` / `AGENT_PORT` ([`Containerfile`](../Containerfile), [`deploy/cloudrun/service.yaml`](../deploy/cloudrun/service.yaml), [`deploy/podman/lightspeed-agent-configmap.yaml`](../deploy/podman/lightspeed-agent-configmap.yaml), [`cloudbuild.yaml`](../cloudbuild.yaml)). |

**Example:**

Expand All @@ -94,6 +95,7 @@ AGENT_PROVIDER_URL=https://lightspeed-agent.example.com
AGENT_NAME=lightspeed_agent
AGENT_HOST=0.0.0.0
AGENT_PORT=8000
GUARDRAIL_ORG_ARGS_ENABLED=true
```

### Database
Expand Down
15 changes: 14 additions & 1 deletion docs/troubleshooting.md
Original file line number Diff line number Diff line change
Expand Up @@ -227,6 +227,19 @@ echo $MCP_SERVER_URL
| `Connection refused` | MCP server not running | Start MCP server |
| `Timeout` | Network/server issues | Increase timeout |

### Tool blocked: organization mismatch (`guardrail_org_mismatch`)

**Symptom**: Tool result / logs show `blocked`, `code: guardrail_org_mismatch`, or messages like “organization parameter does not match the authenticated caller”.

**Cause**: The model supplied an `org_id` (or related field) in tool arguments that disagrees with the Red Hat SSO `org_id` on the HTTP request, or arguments include org fields while the request has no org context.

**What to do**:

1. Confirm the client’s Bearer token includes the expected `org_id` (introspection / JWT claims).
2. Ensure prompts do not ask the model to query another organization’s id.
3. If MCP tools use different parameter names that falsely trigger the guardrail, extend the allowlist logic in code — do not disable in production without review.
4. **Temporary** local bypass (not for production): `GUARDRAIL_ORG_ARGS_ENABLED=false` (see [API — Tool guardrails](api.md#tool-guardrails-organization-arguments) and [Configuration — Agent Configuration](configuration.md#agent-configuration)).

## Database Issues

### Connection Failures
Expand Down Expand Up @@ -444,7 +457,7 @@ Before reporting an issue, collect:

2. **Configuration** (redact secrets):
```bash
env | grep -E '^(AGENT|GOOGLE|RED_HAT|MCP|LOG)' | sed 's/=.*/=REDACTED/'
env | grep -E '^(AGENT|GOOGLE|RED_HAT|MCP|LOG|GUARDRAIL)' | sed 's/=.*/=REDACTED/'
```

3. **Version Info**:
Expand Down
9 changes: 7 additions & 2 deletions src/lightspeed_agent/api/a2a/a2a_setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
from google.adk.sessions import InMemorySessionService

from lightspeed_agent.api.a2a.agent_card import build_agent_card
from lightspeed_agent.api.a2a.guardrail_plugin import GuardrailPlugin
from lightspeed_agent.api.a2a.logging_plugin import AgentLoggingPlugin
from lightspeed_agent.api.a2a.usage_plugin import UsageTrackingPlugin
from lightspeed_agent.config import get_settings
Expand Down Expand Up @@ -113,11 +114,15 @@ def _create_runner() -> Runner:
settings = get_settings()
agent = create_agent()

# Create App with usage tracking plugin
# Create App with guardrails (before_tool first), logging, and usage tracking
app = App(
name=settings.agent_name,
root_agent=agent,
plugins=[AgentLoggingPlugin(), UsageTrackingPlugin()],
plugins=[
GuardrailPlugin(),
AgentLoggingPlugin(),
UsageTrackingPlugin(),
],
)

# Use database-backed session service for production
Expand Down
149 changes: 149 additions & 0 deletions src/lightspeed_agent/api/a2a/guardrail_plugin.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
"""ADK plugin: bind MCP tool org arguments to the authenticated caller.

If tool arguments contain no org-style parameters, the plugin does not run.
Otherwise every org value must equal ``get_request_org_id()`` from the JWT
request context; mismatches are blocked. If org parameters are present but
request ``org_id`` is missing, the call is blocked as well (cannot verify tenant).

Downstream MCP and APIs still enforce auth; this mitigates cross-tenant
argument injection at the agent layer.
"""

from __future__ import annotations

import logging
from typing import Any

from google.adk.plugins.base_plugin import BasePlugin
from google.adk.tools.base_tool import BaseTool
from google.adk.tools.tool_context import ToolContext

from lightspeed_agent.auth.middleware import get_request_org_id
from lightspeed_agent.config import get_settings

logger = logging.getLogger(__name__)

# Normalized key names (lowercase, underscores) that may carry tenant org IDs.
_ORG_ARG_NAMES = frozenset(
{
"org_id",
"organization_id",
"rh_org_id",
"orgid",
"organizationid",
}
)

_BLOCK_CODE = "guardrail_org_mismatch"
_MSG_MISMATCH = (
"Tool blocked: organization parameter does not match the authenticated caller."
)
_MSG_NO_ORG_CONTEXT = (
"Tool blocked: organization parameter present but caller has no organization "
"context."
)


def _normalize_key(key: str) -> str:
return key.lower().replace("-", "_")


def _scalar_org_value(val: Any) -> str | None:
"""Extract a comparable org id string, or None if not a scalar."""
if val is None or isinstance(val, bool):
return None
if isinstance(val, str):
s = val.strip()
return s or None
if isinstance(val, int):
return str(val)
if isinstance(val, float):
return str(int(val)) if val.is_integer() else str(val)
return None


def _append_org_values_for_key(v: Any, found: list[str]) -> None:
"""Append scalar org id strings for *v* when the parent key is org-related.

``v`` may be a single scalar or a list/tuple of scalars (e.g. ``org_id``:
``["111", "222"]``). Non-scalar elements (dicts) are skipped here and left
to recursive traversal.
"""
if isinstance(v, list | tuple):
for item in v:
s = _scalar_org_value(item)
if s is not None:
found.append(s)
else:
s = _scalar_org_value(v)
if s is not None:
found.append(s)


def _collect_org_values(obj: Any) -> list[str]:
"""Recursively collect org-related scalar values from tool arguments."""
found: list[str] = []
if isinstance(obj, dict):
for k, v in obj.items():
if _normalize_key(str(k)) in _ORG_ARG_NAMES:
_append_org_values_for_key(v, found)
found.extend(_collect_org_values(v))
elif isinstance(obj, list):
for item in obj:
found.extend(_collect_org_values(item))
return found


def _block_response(message: str) -> dict[str, Any]:
return {
"error": message,
"code": _BLOCK_CODE,
"blocked": True,
}


class GuardrailPlugin(BasePlugin):
"""Plugin that enforces org/tenant consistency on tool inputs."""

def __init__(self) -> None:
super().__init__(name="guardrail")

async def before_tool_callback(
self,
*,
tool: BaseTool,
tool_args: dict[str, Any],
tool_context: ToolContext,
) -> dict[str, Any] | None:
if not get_settings().guardrail_org_args_enabled:
return None

tool_name = getattr(tool, "name", type(tool).__name__)
org_values = _collect_org_values(tool_args)
if not org_values:
return None

expected = get_request_org_id()
if expected is None:
logger.warning(
"Guardrail blocked tool=%s invocation_id=%s: org args %s but "
"no request org_id",
tool_name,
tool_context.invocation_id,
org_values,
)
return _block_response(_MSG_NO_ORG_CONTEXT)

exp = expected.strip()
for value in org_values:
if value != exp:
logger.warning(
"Guardrail blocked tool=%s invocation_id=%s: org arg %r != %r",
tool_name,
tool_context.invocation_id,
value,
exp,
)
return _block_response(_MSG_MISMATCH)

return None
7 changes: 7 additions & 0 deletions src/lightspeed_agent/config/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,13 @@ class Settings(BaseSettings):
"'basic' logs tool names and token counts. "
"'detailed' also logs tool arguments and truncated results.",
)
guardrail_org_args_enabled: bool = Field(
default=True,
description=(
"When true, block MCP tool calls whose arguments include org-related "
"fields that do not match the authenticated request org_id."
),
)

# DCR (Dynamic Client Registration) Configuration
dcr_enabled: bool = Field(
Expand Down
Loading
Loading