fred-runtime 3.1.0__tar.gz → 3.3.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (111) hide show
  1. {fred_runtime-3.1.0 → fred_runtime-3.3.0}/PKG-INFO +3 -3
  2. {fred_runtime-3.1.0 → fred_runtime-3.3.0}/fred_runtime/app/agent_app.py +280 -156
  3. {fred_runtime-3.1.0 → fred_runtime-3.3.0}/fred_runtime/app/config.py +9 -5
  4. {fred_runtime-3.1.0 → fred_runtime-3.3.0}/fred_runtime/app/openai_compat_router.py +52 -7
  5. {fred_runtime-3.1.0 → fred_runtime-3.3.0}/fred_runtime/cli/repl.py +1 -1
  6. {fred_runtime-3.1.0 → fred_runtime-3.3.0}/fred_runtime/integrations/v2_runtime/adapters.py +78 -7
  7. {fred_runtime-3.1.0 → fred_runtime-3.3.0}/fred_runtime/runtime_context.py +9 -0
  8. {fred_runtime-3.1.0 → fred_runtime-3.3.0}/fred_runtime.egg-info/PKG-INFO +3 -3
  9. {fred_runtime-3.1.0 → fred_runtime-3.3.0}/fred_runtime.egg-info/requires.txt +2 -2
  10. {fred_runtime-3.1.0 → fred_runtime-3.3.0}/pyproject.toml +3 -3
  11. {fred_runtime-3.1.0 → fred_runtime-3.3.0}/tests/test_agent_app.py +529 -76
  12. {fred_runtime-3.1.0 → fred_runtime-3.3.0}/tests/test_fred_workspace_fs.py +98 -14
  13. {fred_runtime-3.1.0 → fred_runtime-3.3.0}/tests/test_kf_workspace_client.py +3 -1
  14. {fred_runtime-3.1.0 → fred_runtime-3.3.0}/README.md +0 -0
  15. {fred_runtime-3.1.0 → fred_runtime-3.3.0}/fred_runtime/__init__.py +0 -0
  16. {fred_runtime-3.1.0 → fred_runtime-3.3.0}/fred_runtime/app/__init__.py +0 -0
  17. {fred_runtime-3.1.0 → fred_runtime-3.3.0}/fred_runtime/app/_catalogs.py +0 -0
  18. {fred_runtime-3.1.0 → fred_runtime-3.3.0}/fred_runtime/app/config_loader.py +0 -0
  19. {fred_runtime-3.1.0 → fred_runtime-3.3.0}/fred_runtime/app/container.py +0 -0
  20. {fred_runtime-3.1.0 → fred_runtime-3.3.0}/fred_runtime/app/context.py +0 -0
  21. {fred_runtime-3.1.0 → fred_runtime-3.3.0}/fred_runtime/app/dependencies.py +0 -0
  22. {fred_runtime-3.1.0 → fred_runtime-3.3.0}/fred_runtime/app/mcp_config.py +0 -0
  23. {fred_runtime-3.1.0 → fred_runtime-3.3.0}/fred_runtime/app/observability_factory.py +0 -0
  24. {fred_runtime-3.1.0 → fred_runtime-3.3.0}/fred_runtime/cli/__init__.py +0 -0
  25. {fred_runtime-3.1.0 → fred_runtime-3.3.0}/fred_runtime/cli/completion.py +0 -0
  26. {fred_runtime-3.1.0 → fred_runtime-3.3.0}/fred_runtime/cli/entrypoint.py +0 -0
  27. {fred_runtime-3.1.0 → fred_runtime-3.3.0}/fred_runtime/cli/history_display.py +0 -0
  28. {fred_runtime-3.1.0 → fred_runtime-3.3.0}/fred_runtime/cli/kpi_display.py +0 -0
  29. {fred_runtime-3.1.0 → fred_runtime-3.3.0}/fred_runtime/cli/pod_client.py +0 -0
  30. {fred_runtime-3.1.0 → fred_runtime-3.3.0}/fred_runtime/cli/repl_helpers.py +0 -0
  31. {fred_runtime-3.1.0 → fred_runtime-3.3.0}/fred_runtime/cli/url_helpers.py +0 -0
  32. {fred_runtime-3.1.0 → fred_runtime-3.3.0}/fred_runtime/client.py +0 -0
  33. {fred_runtime-3.1.0 → fred_runtime-3.3.0}/fred_runtime/common/__init__.py +0 -0
  34. {fred_runtime-3.1.0 → fred_runtime-3.3.0}/fred_runtime/common/context_aware_tool.py +0 -0
  35. {fred_runtime-3.1.0 → fred_runtime-3.3.0}/fred_runtime/common/kf_base_client.py +0 -0
  36. {fred_runtime-3.1.0 → fred_runtime-3.3.0}/fred_runtime/common/kf_fast_text_client.py +0 -0
  37. {fred_runtime-3.1.0 → fred_runtime-3.3.0}/fred_runtime/common/kf_http_client.py +0 -0
  38. {fred_runtime-3.1.0 → fred_runtime-3.3.0}/fred_runtime/common/kf_logs_client.py +0 -0
  39. {fred_runtime-3.1.0 → fred_runtime-3.3.0}/fred_runtime/common/kf_markdown_media_client.py +0 -0
  40. {fred_runtime-3.1.0 → fred_runtime-3.3.0}/fred_runtime/common/kf_vectorsearch_client.py +0 -0
  41. {fred_runtime-3.1.0 → fred_runtime-3.3.0}/fred_runtime/common/kf_workspace_client.py +1 -1
  42. {fred_runtime-3.1.0 → fred_runtime-3.3.0}/fred_runtime/common/mcp_interceptors.py +0 -0
  43. {fred_runtime-3.1.0 → fred_runtime-3.3.0}/fred_runtime/common/mcp_runtime.py +0 -0
  44. {fred_runtime-3.1.0 → fred_runtime-3.3.0}/fred_runtime/common/mcp_toolkit.py +0 -0
  45. {fred_runtime-3.1.0 → fred_runtime-3.3.0}/fred_runtime/common/mcp_utils.py +0 -0
  46. {fred_runtime-3.1.0 → fred_runtime-3.3.0}/fred_runtime/common/structures.py +0 -0
  47. {fred_runtime-3.1.0 → fred_runtime-3.3.0}/fred_runtime/common/token_expiry.py +0 -0
  48. {fred_runtime-3.1.0 → fred_runtime-3.3.0}/fred_runtime/common/tool_node_utils.py +0 -0
  49. {fred_runtime-3.1.0 → fred_runtime-3.3.0}/fred_runtime/deep/__init__.py +0 -0
  50. {fred_runtime-3.1.0 → fred_runtime-3.3.0}/fred_runtime/deep/deep_runtime.py +0 -0
  51. {fred_runtime-3.1.0 → fred_runtime-3.3.0}/fred_runtime/eval/__init__.py +0 -0
  52. {fred_runtime-3.1.0 → fred_runtime-3.3.0}/fred_runtime/eval/collector.py +0 -0
  53. {fred_runtime-3.1.0 → fred_runtime-3.3.0}/fred_runtime/graph/__init__.py +0 -0
  54. {fred_runtime-3.1.0 → fred_runtime-3.3.0}/fred_runtime/graph/graph_runtime.py +0 -0
  55. {fred_runtime-3.1.0 → fred_runtime-3.3.0}/fred_runtime/integrations/__init__.py +0 -0
  56. {fred_runtime-3.1.0 → fred_runtime-3.3.0}/fred_runtime/integrations/v2_runtime/__init__.py +0 -0
  57. {fred_runtime-3.1.0 → fred_runtime-3.3.0}/fred_runtime/model_routing/__init__.py +0 -0
  58. {fred_runtime-3.1.0 → fred_runtime-3.3.0}/fred_runtime/model_routing/catalog.py +0 -0
  59. {fred_runtime-3.1.0 → fred_runtime-3.3.0}/fred_runtime/model_routing/contracts.py +0 -0
  60. {fred_runtime-3.1.0 → fred_runtime-3.3.0}/fred_runtime/model_routing/provider.py +0 -0
  61. {fred_runtime-3.1.0 → fred_runtime-3.3.0}/fred_runtime/model_routing/resolver.py +0 -0
  62. {fred_runtime-3.1.0 → fred_runtime-3.3.0}/fred_runtime/react/__init__.py +0 -0
  63. {fred_runtime-3.1.0 → fred_runtime-3.3.0}/fred_runtime/react/react_langchain_adapter.py +0 -0
  64. {fred_runtime-3.1.0 → fred_runtime-3.3.0}/fred_runtime/react/react_message_codec.py +0 -0
  65. {fred_runtime-3.1.0 → fred_runtime-3.3.0}/fred_runtime/react/react_model_adapter.py +0 -0
  66. {fred_runtime-3.1.0 → fred_runtime-3.3.0}/fred_runtime/react/react_prompting.py +0 -0
  67. {fred_runtime-3.1.0 → fred_runtime-3.3.0}/fred_runtime/react/react_runtime.py +0 -0
  68. {fred_runtime-3.1.0 → fred_runtime-3.3.0}/fred_runtime/react/react_stream_adapter.py +0 -0
  69. {fred_runtime-3.1.0 → fred_runtime-3.3.0}/fred_runtime/react/react_tool_binding.py +0 -0
  70. {fred_runtime-3.1.0 → fred_runtime-3.3.0}/fred_runtime/react/react_tool_loop.py +0 -0
  71. {fred_runtime-3.1.0 → fred_runtime-3.3.0}/fred_runtime/react/react_tool_rendering.py +0 -0
  72. {fred_runtime-3.1.0 → fred_runtime-3.3.0}/fred_runtime/react/react_tool_resolution.py +0 -0
  73. {fred_runtime-3.1.0 → fred_runtime-3.3.0}/fred_runtime/react/react_tool_utils.py +0 -0
  74. {fred_runtime-3.1.0 → fred_runtime-3.3.0}/fred_runtime/react/react_tracing.py +0 -0
  75. {fred_runtime-3.1.0 → fred_runtime-3.3.0}/fred_runtime/runtime_support/__init__.py +0 -0
  76. {fred_runtime-3.1.0 → fred_runtime-3.3.0}/fred_runtime/runtime_support/checkpoints.py +0 -0
  77. {fred_runtime-3.1.0 → fred_runtime-3.3.0}/fred_runtime/runtime_support/model_metadata.py +0 -0
  78. {fred_runtime-3.1.0 → fred_runtime-3.3.0}/fred_runtime/runtime_support/request_context_helpers.py +0 -0
  79. {fred_runtime-3.1.0 → fred_runtime-3.3.0}/fred_runtime/runtime_support/sql_checkpointer.py +0 -0
  80. {fred_runtime-3.1.0 → fred_runtime-3.3.0}/fred_runtime/runtime_support/user_token_refresher.py +0 -0
  81. {fred_runtime-3.1.0 → fred_runtime-3.3.0}/fred_runtime/support/__init__.py +0 -0
  82. {fred_runtime-3.1.0 → fred_runtime-3.3.0}/fred_runtime/support/filesystem_context.py +0 -0
  83. {fred_runtime-3.1.0 → fred_runtime-3.3.0}/fred_runtime/support/thinking.py +0 -0
  84. {fred_runtime-3.1.0 → fred_runtime-3.3.0}/fred_runtime/support/tool_approval.py +0 -0
  85. {fred_runtime-3.1.0 → fred_runtime-3.3.0}/fred_runtime/support/tool_loop.py +0 -0
  86. {fred_runtime-3.1.0 → fred_runtime-3.3.0}/fred_runtime.egg-info/SOURCES.txt +0 -0
  87. {fred_runtime-3.1.0 → fred_runtime-3.3.0}/fred_runtime.egg-info/dependency_links.txt +0 -0
  88. {fred_runtime-3.1.0 → fred_runtime-3.3.0}/fred_runtime.egg-info/entry_points.txt +0 -0
  89. {fred_runtime-3.1.0 → fred_runtime-3.3.0}/fred_runtime.egg-info/top_level.txt +0 -0
  90. {fred_runtime-3.1.0 → fred_runtime-3.3.0}/setup.cfg +0 -0
  91. {fred_runtime-3.1.0 → fred_runtime-3.3.0}/tests/test_client.py +0 -0
  92. {fred_runtime-3.1.0 → fred_runtime-3.3.0}/tests/test_config_loader.py +0 -0
  93. {fred_runtime-3.1.0 → fred_runtime-3.3.0}/tests/test_context.py +0 -0
  94. {fred_runtime-3.1.0 → fred_runtime-3.3.0}/tests/test_context_aware_tool.py +0 -0
  95. {fred_runtime-3.1.0 → fred_runtime-3.3.0}/tests/test_conversational_memory.py +0 -0
  96. {fred_runtime-3.1.0 → fred_runtime-3.3.0}/tests/test_eval_collector.py +0 -0
  97. {fred_runtime-3.1.0 → fred_runtime-3.3.0}/tests/test_eval_trace.py +0 -0
  98. {fred_runtime-3.1.0 → fred_runtime-3.3.0}/tests/test_graph_runtime_invoke_agent.py +0 -0
  99. {fred_runtime-3.1.0 → fred_runtime-3.3.0}/tests/test_graph_runtime_observability.py +0 -0
  100. {fred_runtime-3.1.0 → fred_runtime-3.3.0}/tests/test_history.py +0 -0
  101. {fred_runtime-3.1.0 → fred_runtime-3.3.0}/tests/test_kpi_display.py +0 -0
  102. {fred_runtime-3.1.0 → fred_runtime-3.3.0}/tests/test_mcp_config.py +0 -0
  103. {fred_runtime-3.1.0 → fred_runtime-3.3.0}/tests/test_model_routing.py +0 -0
  104. {fred_runtime-3.1.0 → fred_runtime-3.3.0}/tests/test_openai_compat_router.py +0 -0
  105. {fred_runtime-3.1.0 → fred_runtime-3.3.0}/tests/test_pod_client.py +0 -0
  106. {fred_runtime-3.1.0 → fred_runtime-3.3.0}/tests/test_react_thinking.py +0 -0
  107. {fred_runtime-3.1.0 → fred_runtime-3.3.0}/tests/test_repl_helpers.py +0 -0
  108. {fred_runtime-3.1.0 → fred_runtime-3.3.0}/tests/test_smoke.py +0 -0
  109. {fred_runtime-3.1.0 → fred_runtime-3.3.0}/tests/test_token_expiry.py +0 -0
  110. {fred_runtime-3.1.0 → fred_runtime-3.3.0}/tests/test_url_helpers.py +0 -0
  111. {fred_runtime-3.1.0 → fred_runtime-3.3.0}/tests/test_user_token_refresher.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: fred-runtime
3
- Version: 3.1.0
3
+ Version: 3.3.0
4
4
  Summary: Runtime adapters and infrastructure wiring for Fred v2 agents.
5
5
  Author-email: Thales <noreply@thalesgroup.com>
6
6
  License: Apache-2.0
@@ -12,8 +12,8 @@ Classifier: Programming Language :: Python :: 3 :: Only
12
12
  Classifier: Operating System :: OS Independent
13
13
  Requires-Python: <3.13,>=3.12
14
14
  Description-Content-Type: text/markdown
15
- Requires-Dist: fred-core>=3.1.0
16
- Requires-Dist: fred-sdk>=3.1.0
15
+ Requires-Dist: fred-core>=3.4.0
16
+ Requires-Dist: fred-sdk>=3.3.0
17
17
  Requires-Dist: alembic>=1.18.4
18
18
  Requires-Dist: deepagents>=0.4.11
19
19
  Requires-Dist: httpx>=0.28.1
@@ -61,7 +61,10 @@ from fred_core.kpi import KPIMiddleware
61
61
  from fred_core.kpi.kpi_writer_structures import KPIActor
62
62
  from fred_core.logs.log_setup import log_setup
63
63
  from fred_core.logs.memory_log_store import RamLogStore
64
+ from fred_core.security.models import AuthorizationError
64
65
  from fred_core.security.oidc import get_keycloak_client_id, get_keycloak_url
66
+ from fred_core.security.rebac.rebac_engine import TeamPermission
67
+ from fred_core.security.rebac.rebac_factory import rebac_factory
65
68
  from fred_core.security.structure import KeycloakUser
66
69
  from fred_sdk.contracts.context import (
67
70
  AgentInvocationRequest,
@@ -75,9 +78,7 @@ from fred_sdk.contracts.context import (
75
78
  from fred_sdk.contracts.eval import EvalStep, EvalTrace
76
79
  from fred_sdk.contracts.execution import (
77
80
  ExecutionGrantAction,
78
- ExecutionGrantViolation,
79
81
  RuntimeExecuteRequest,
80
- validate_execution_grant,
81
82
  )
82
83
  from fred_sdk.contracts.models import (
83
84
  AgentTuning,
@@ -961,6 +962,7 @@ async def _resolve_agent_instance(
961
962
  registry: Mapping[str, ReActAgentDefinition | GraphAgentDefinition],
962
963
  access_token: str | None,
963
964
  control_plane_url: str | None,
965
+ team_id: str | None = None,
964
966
  ) -> _ResolvedExecutionTarget:
965
967
  """
966
968
  Resolve a direct or managed execution target into a concrete definition.
@@ -979,11 +981,15 @@ async def _resolve_agent_instance(
979
981
 
980
982
  if request.agent_id is not None:
981
983
  definition = registry.get(request.agent_id)
982
- if definition is None:
984
+ # Direct agent_id execution takes no grant, so it is the enforcement point
985
+ # for agent visibility: a non-public agent (AgentDefinition.public=False) is
986
+ # internal — it may only be executed through a managed instance (whose
987
+ # enrollment is admin-gated), never directly by id. Treat it as unknown so
988
+ # its existence is not even confirmed. See AGENT-VISIBILITY-RFC §3.1.
989
+ if definition is None or not getattr(definition, "public", True):
983
990
  raise HTTPException(
984
991
  status_code=status.HTTP_404_NOT_FOUND,
985
- detail=f"Unknown agent_id: {request.agent_id!r}. "
986
- f"Known agents: {list(registry.keys())}",
992
+ detail=f"Unknown agent_id: {request.agent_id!r}.",
987
993
  )
988
994
  if request.inline_tuning:
989
995
  available_mcp_servers = _available_mcp_servers_for_definition(definition)
@@ -1012,8 +1018,20 @@ async def _resolve_agent_instance(
1012
1018
  ),
1013
1019
  )
1014
1020
 
1021
+ # Team-scoped resolution (RUNTIME-07 rev. 2). The pod resolves the instance's
1022
+ # template + tuning from the control-plane binding scoped to the caller's team
1023
+ # (ReBAC-gated, store.get_for_team) — the replacement for the signed grant.
1024
+ # The end user has already been authorized at this pod (Keycloak + OpenFGA).
1025
+ if team_id is None:
1026
+ raise HTTPException(
1027
+ status_code=status.HTTP_403_FORBIDDEN,
1028
+ detail=(
1029
+ "Managed agent instance execution requires a team context "
1030
+ "(runtime_context.team_id)."
1031
+ ),
1032
+ )
1015
1033
  url = (
1016
- f"{control_plane_url.rstrip('/')}/agent-instances/"
1034
+ f"{control_plane_url.rstrip('/')}/teams/{team_id}/agent-instances/"
1017
1035
  f"{request.agent_instance_id}/runtime"
1018
1036
  )
1019
1037
  headers = {"Authorization": f"Bearer {access_token}"} if access_token else None
@@ -1086,84 +1104,239 @@ def _make_user_dependency(
1086
1104
  return _dep_noop
1087
1105
 
1088
1106
 
1089
- def _validate_grant_user_correlation(
1107
+ async def _authorize_execution_or_raise(
1090
1108
  request: RuntimeExecuteRequest,
1091
1109
  authenticated_user: KeycloakUser | None,
1092
1110
  container: PodApplicationContext,
1093
1111
  ) -> None:
1094
1112
  """
1095
- Enforce the bearer-token / grant user_id correlation check.
1096
-
1097
- Why this exists:
1098
- - The security report requires that user_id in the Keycloak bearer token
1099
- matches user_id in the ExecutionGrant.
1100
- - Without this check, a valid token for user A combined with a grant
1101
- issued for user B would be accepted by structural grant validation alone.
1102
- - This is the check that makes the dual-auth model meaningful.
1103
-
1104
- How to use it:
1105
- - Call after validate_execution_grant and only when an ExecutionGrant is
1106
- present (managed execution path).
1107
- - Pass the KeycloakUser from Depends(get_current_user), or None when
1108
- security is disabled (dev mode).
1109
-
1110
- Raises HTTPException 403 when the token user_id and grant user_id disagree.
1113
+ Pod-side OpenFGA authorization for one execution request (RUNTIME-07 rev. 2).
1114
+
1115
+ The pod is the execution authority. Identity is proven by the Keycloak JWT
1116
+ (`authenticated_user`); authorization is decided HERE, per request, by an
1117
+ OpenFGA check on the team the caller is acting in. This is the model already
1118
+ homologated on `main` (agentic-backend), re-instantiated per pod it replaces
1119
+ the control-plane-signed grant, which is being removed.
1120
+
1121
+ Behaviour:
1122
+ - security disabled (no authenticated user) → skip (dev/local).
1123
+ - ReBAC engine absent or disabled (Noop) skip (identity-only dev posture);
1124
+ the C3 profile guarantees an enabled engine in classified deployments.
1125
+ - otherwise require the caller to hold `CAN_READ` on the requested team — the
1126
+ same relation the control-plane required before it would mint a grant. The
1127
+ team is caller-supplied but safe: OpenFGA only authorizes teams the user
1128
+ actually holds a relation to. Authorization and denial are both audited;
1129
+ any OpenFGA denial fails closed (403).
1130
+
1131
+ Covers execute, execute/stream, evaluate AND resume — every path funnels
1132
+ through this call, so no half-authenticated session is possible.
1133
+
1134
+ Direct template execution (`agent_id`) is **forbidden under the c3 profile**
1135
+ (RUNTIME-07 F-D); in dev/non-c3 it stays identity-only. Managed execution
1136
+ (`agent_instance_id`) requires a team and an OpenFGA grant whenever ReBAC is
1137
+ active; a missing team then fails closed.
1111
1138
  """
1112
1139
  if authenticated_user is None:
1113
- # Security disabled (dev mode) — skip correlation check.
1140
+ # Security disabled (dev mode) — no identity to authorize.
1114
1141
  return
1115
- grant = request.execution_grant
1116
- if grant is None:
1117
- # Direct template execution no grant to correlate.
1142
+ profile = getattr(get_runtime_context().config, "security_profile", None)
1143
+
1144
+ # Direct template execution (agent_id): no team scope, no managed instance.
1145
+ if request.agent_id is not None:
1146
+ if profile == "c3":
1147
+ _emit_audit_event(
1148
+ container,
1149
+ "warning",
1150
+ "direct_execution_forbidden",
1151
+ user_id=authenticated_user.uid,
1152
+ agent_id=request.agent_id,
1153
+ )
1154
+ raise HTTPException(
1155
+ status_code=status.HTTP_403_FORBIDDEN,
1156
+ detail=(
1157
+ "direct agent_id execution is not permitted under the c3 "
1158
+ "security profile; use a managed agent instance"
1159
+ ),
1160
+ )
1161
+ # dev / non-c3: identity-only (no team to authorize against).
1118
1162
  return
1119
- if grant.user_id != authenticated_user.uid:
1163
+
1164
+ # Managed execution (agent_instance_id).
1165
+ rebac = get_runtime_context().config.rebac_engine
1166
+ if rebac is None or not rebac.enabled:
1167
+ # ReBAC not active (Noop / unconfigured) — identity-only dev posture. The
1168
+ # c3 profile guarantees an enabled engine in production (fail-closed).
1169
+ return
1170
+ team_id = request.effective_team_id()
1171
+ if team_id is None:
1120
1172
  _emit_audit_event(
1121
1173
  container,
1122
1174
  "warning",
1123
- "grant_user_mismatch",
1124
- grant_user_id=grant.user_id,
1125
- token_user_id=authenticated_user.uid,
1175
+ "managed_execution_without_team",
1176
+ user_id=authenticated_user.uid,
1177
+ agent_instance_id=request.agent_instance_id,
1126
1178
  )
1127
1179
  raise HTTPException(
1128
1180
  status_code=status.HTTP_403_FORBIDDEN,
1129
1181
  detail=(
1130
- f"grant user_id {grant.user_id!r} does not match "
1131
- f"authenticated user {authenticated_user.uid!r}"
1182
+ "managed agent instance execution requires a team context "
1183
+ "(runtime_context.team_id)"
1132
1184
  ),
1133
1185
  )
1186
+ try:
1187
+ await rebac.check_user_team_permission_or_raise(
1188
+ authenticated_user, TeamPermission.CAN_READ, team_id
1189
+ )
1190
+ except AuthorizationError as exc:
1191
+ _emit_audit_event(
1192
+ container,
1193
+ "warning",
1194
+ "rebac_denied",
1195
+ user_id=authenticated_user.uid,
1196
+ team_id=team_id,
1197
+ agent_instance_id=request.agent_instance_id,
1198
+ reason=str(exc),
1199
+ )
1200
+ raise HTTPException(
1201
+ status_code=status.HTTP_403_FORBIDDEN,
1202
+ detail=(
1203
+ f"user {authenticated_user.uid!r} is not authorized for "
1204
+ f"team {team_id!r}"
1205
+ ),
1206
+ ) from exc
1134
1207
  _emit_audit_event(
1135
1208
  container,
1136
1209
  "info",
1137
- "grant_user_correlated",
1210
+ "rebac_authorized",
1138
1211
  user_id=authenticated_user.uid,
1212
+ team_id=team_id,
1139
1213
  agent_instance_id=request.agent_instance_id,
1140
1214
  )
1141
1215
 
1142
1216
 
1143
- def _expected_execution_action(
1217
+ async def _enforce_session_ownership(
1144
1218
  request: RuntimeExecuteRequest,
1145
- ) -> ExecutionGrantAction:
1219
+ authenticated_user: KeycloakUser | None,
1220
+ container: PodApplicationContext,
1221
+ ) -> None:
1146
1222
  """
1147
- Resolve the required grant action for one runtime request.
1148
-
1149
- Why this exists:
1150
- - managed HITL resumes must require `resume` grants while normal turns
1151
- require `execute`
1152
- - centralising that rule keeps both execute endpoints aligned
1223
+ Private-per-owner session policy (RUNTIME-07 rev. 2, finding F-C).
1224
+
1225
+ Conversations are private to their owner. When security is enabled and the
1226
+ request targets an EXISTING session, the authenticated user must own it. A
1227
+ brand-new session is allowed (the caller becomes its owner). This blocks a
1228
+ same-team user from continuing or resuming another user's private session by
1229
+ guessing its `session_id` / `checkpoint_id` — the team OpenFGA check alone
1230
+ would not catch an intra-team cross-user access.
1231
+ """
1232
+ if authenticated_user is None:
1233
+ return # security disabled (dev) — no identity to enforce
1234
+ session_id = request.effective_session_id()
1235
+ if not session_id:
1236
+ return
1237
+ history_store = get_runtime_context().config.history_store
1238
+ if history_store is None:
1239
+ return
1240
+ if not await history_store.session_exists(session_id):
1241
+ return # new session — the caller becomes its owner
1242
+ if await history_store.session_belongs_to_user(session_id, authenticated_user.uid):
1243
+ return # caller owns this session
1244
+ _emit_audit_event(
1245
+ container,
1246
+ "warning",
1247
+ "session_owner_mismatch",
1248
+ user_id=authenticated_user.uid,
1249
+ session_id=session_id,
1250
+ agent_instance_id=request.agent_instance_id,
1251
+ )
1252
+ raise HTTPException(
1253
+ status_code=status.HTTP_403_FORBIDDEN,
1254
+ detail=f"session {session_id!r} does not belong to the authenticated user",
1255
+ )
1153
1256
 
1154
- How to use it:
1155
- - call immediately before `validate_execution_grant(...)`
1156
- - pass the returned action as `expected_action`
1157
1257
 
1158
- Example:
1159
- - `expected_action = _expected_execution_action(request)`
1258
+ async def _authorize_and_resolve(
1259
+ request: RuntimeExecuteRequest,
1260
+ *,
1261
+ authenticated_user: KeycloakUser | None,
1262
+ container: PodApplicationContext,
1263
+ registry: Mapping[str, ReActAgentDefinition | GraphAgentDefinition],
1264
+ access_token: str | None,
1265
+ ) -> tuple["_AgentExecuteRequest", _ResolvedExecutionTarget]:
1160
1266
  """
1267
+ Shared pre-execution gate for execute / execute-stream / evaluate (and HITL
1268
+ resume, which is a field on those endpoints) — RUNTIME-07 rev. 2.
1161
1269
 
1162
- return (
1163
- ExecutionGrantAction.RESUME
1164
- if request.resume_payload is not None
1165
- else ExecutionGrantAction.EXECUTE
1270
+ The pod is the execution authority: there is NO control-plane-signed grant.
1271
+ 1. validate checkpoint/session access,
1272
+ 2. authorize the caller against OpenFGA on their team (identity = Keycloak JWT),
1273
+ 3. resolve the managed instance template + tuning from the control-plane,
1274
+ team-scoped and ReBAC-gated (config only — never a secret or capability),
1275
+ 4. cross-check the resolved owner team against the caller's claimed team.
1276
+
1277
+ Returns the internal request plus the resolved execution target.
1278
+ """
1279
+ # F-B: identity is the validated Keycloak JWT, never the request body. Stamp
1280
+ # user_id from the token and neutralize any body-supplied credentials — the pod
1281
+ # uses the header bearer for downstream (knowledge-flow) calls and trusts no
1282
+ # caller-provided user_id / access_token / refresh_token.
1283
+ if authenticated_user is not None:
1284
+ base_ctx = request.runtime_context or RuntimeContext()
1285
+ request.runtime_context = base_ctx.model_copy(
1286
+ # F-B: neutralize body-supplied tokens (not secrets — set to None).
1287
+ update={
1288
+ "user_id": authenticated_user.uid,
1289
+ "access_token": access_token,
1290
+ "refresh_token": None, # nosec B105
1291
+ "access_token_expires_at": None, # nosec B105
1292
+ }
1293
+ )
1294
+ await _validate_session_checkpoint_access(request)
1295
+ await _enforce_session_ownership(request, authenticated_user, container)
1296
+ await _authorize_execution_or_raise(request, authenticated_user, container)
1297
+ internal_req = _to_internal_request(request)
1298
+ target = await _resolve_agent_instance(
1299
+ request=internal_req,
1300
+ registry=registry,
1301
+ access_token=access_token,
1302
+ control_plane_url=get_runtime_context().config.control_plane_url,
1303
+ team_id=request.effective_team_id(),
1166
1304
  )
1305
+ _validate_resolved_team(request, target.team_id, container)
1306
+ return internal_req, target
1307
+
1308
+
1309
+ def _validate_resolved_team(
1310
+ request: RuntimeExecuteRequest,
1311
+ resolved_team_id: str | None,
1312
+ container: PodApplicationContext,
1313
+ ) -> None:
1314
+ """
1315
+ Cross-check the resolved instance owner team against the caller's claim.
1316
+
1317
+ Team-scoped resolution already restricts the lookup to the caller's team, so a
1318
+ mismatch should be impossible; this is defense-in-depth and an audit anchor.
1319
+ Skipped for direct template execution (no team scope).
1320
+ """
1321
+ if resolved_team_id is None:
1322
+ return
1323
+ claimed = request.effective_team_id()
1324
+ if claimed is not None and claimed != resolved_team_id:
1325
+ _emit_audit_event(
1326
+ container,
1327
+ "warning",
1328
+ "team_binding_mismatch",
1329
+ claimed_team_id=claimed,
1330
+ resolved_team_id=resolved_team_id,
1331
+ agent_instance_id=request.agent_instance_id,
1332
+ )
1333
+ raise HTTPException(
1334
+ status_code=status.HTTP_403_FORBIDDEN,
1335
+ detail=(
1336
+ f"resolved owner team {resolved_team_id!r} does not match "
1337
+ f"requested team {claimed!r}"
1338
+ ),
1339
+ )
1167
1340
 
1168
1341
 
1169
1342
  async def _validate_session_checkpoint_access(
@@ -1858,6 +2031,11 @@ async def _iterate_runtime_event_payloads(
1858
2031
  include_session_scope=ctx.get("include_session_scope"),
1859
2032
  include_corpus_scope=ctx.get("include_corpus_scope"),
1860
2033
  deep_search=ctx.get("deep_search"),
2034
+ # The marketplace/library prompt selected for the conversation. The
2035
+ # control-plane resolves the session's attached prompts into this scalar
2036
+ # at prepare-execution and the frontend forwards it — but it was also
2037
+ # silently dropped here, so no agent ever received a selected prompt.
2038
+ context_prompt_text=ctx.get("context_prompt_text"),
1861
2039
  )
1862
2040
 
1863
2041
  binding = BoundRuntimeContext(
@@ -2061,7 +2239,9 @@ def _build_agent_router(
2061
2239
  return events[: max(1, limit)]
2062
2240
 
2063
2241
  @router.get("/templates")
2064
- async def list_agent_templates() -> list[_AgentTemplateSummary]:
2242
+ async def list_agent_templates(
2243
+ include_non_public: bool = False,
2244
+ ) -> list[_AgentTemplateSummary]:
2065
2245
  """
2066
2246
  Return the executable agent templates registered in this pod.
2067
2247
 
@@ -2072,6 +2252,9 @@ def _build_agent_router(
2072
2252
 
2073
2253
  How to use it:
2074
2254
  - call from control-plane to aggregate template metadata across pods
2255
+ - pass `include_non_public=true` to also list internal agents
2256
+ (`AgentDefinition.public=False`) for tooling such as the self-test
2257
+ harness; the default catalog hides them (see AGENT-VISIBILITY-RFC)
2075
2258
 
2076
2259
  Example:
2077
2260
  - `GET /fred/agents/v2/agents/templates`
@@ -2088,6 +2271,7 @@ def _build_agent_router(
2088
2271
  available_mcp_servers=_available_mcp_servers_for_definition(definition),
2089
2272
  )
2090
2273
  for definition in registry.values()
2274
+ if include_non_public or getattr(definition, "public", True)
2091
2275
  ]
2092
2276
 
2093
2277
  @router.get("/mcp-catalog")
@@ -2559,15 +2743,15 @@ def _build_agent_router(
2559
2743
 
2560
2744
  POST <configured base_url>/agents/execute
2561
2745
  Authorization: Bearer <user JWT>
2562
- Body: RuntimeExecuteRequest (agent_instance_id + execution_grant for managed exec)
2746
+ Body: RuntimeExecuteRequest (agent_instance_id + runtime_context.team_id for managed exec)
2563
2747
  Response: application/json containing the terminal runtime payload
2564
2748
 
2565
- Security:
2566
- - For managed execution (agent_instance_id), an execution_grant issued by
2567
- control-plane is required. The runtime validates it structurally before
2568
- proceeding (expiry, field consistency, action).
2569
- - RBAC via Keycloak and REBAC via OpenFGA protect this endpoint.
2570
- - The runtime validates; control-plane decides access.
2749
+ Security (RUNTIME-07 rev. 2 — the pod is the execution authority):
2750
+ - Identity is the caller's Keycloak JWT (validated against Keycloak JWKS).
2751
+ - Authorization is a pod-side OpenFGA check on runtime_context.team_id,
2752
+ enforced per request. There is NO control-plane-signed grant.
2753
+ - Managed instances resolve their template+tuning from the control-plane
2754
+ team-scoped binding (config only).
2571
2755
 
2572
2756
  Architectural note:
2573
2757
  - This endpoint does not implement pod discovery or routing.
@@ -2576,42 +2760,14 @@ def _build_agent_router(
2576
2760
  auth = http_request.headers.get("Authorization", "")
2577
2761
  access_token = auth.removeprefix("Bearer ").strip() or None
2578
2762
 
2579
- expected_action = _expected_execution_action(request)
2580
-
2581
- # Validate ExecutionGrant for managed execution paths
2582
- try:
2583
- validate_execution_grant(request, expected_action=expected_action)
2584
- except ExecutionGrantViolation as exc:
2585
- _emit_audit_event(
2586
- container,
2587
- "warning",
2588
- "grant_validation_failed",
2589
- agent_instance_id=request.agent_instance_id,
2590
- user_id=request.effective_user_id(),
2591
- action=expected_action.value,
2592
- reason=str(exc),
2593
- )
2594
- raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=str(exc))
2595
- if request.execution_grant is not None:
2596
- _emit_audit_event(
2597
- container,
2598
- "info",
2599
- "grant_validated",
2600
- agent_instance_id=request.agent_instance_id,
2601
- user_id=request.effective_user_id(),
2602
- action=expected_action.value,
2603
- )
2604
- _validate_grant_user_correlation(request, authenticated_user, container)
2605
- await _validate_session_checkpoint_access(request)
2606
-
2607
2763
  exchange_id = str(uuid4())
2608
2764
  turn_start = time.monotonic()
2609
- internal_req = _to_internal_request(request)
2610
- target = await _resolve_agent_instance(
2611
- request=internal_req,
2765
+ internal_req, target = await _authorize_and_resolve(
2766
+ request,
2767
+ authenticated_user=authenticated_user,
2768
+ container=container,
2612
2769
  registry=registry,
2613
2770
  access_token=access_token,
2614
- control_plane_url=get_runtime_context().config.control_plane_url,
2615
2771
  )
2616
2772
  payloads = [
2617
2773
  payload
@@ -2680,41 +2836,14 @@ def _build_agent_router(
2680
2836
  auth = http_request.headers.get("Authorization", "")
2681
2837
  access_token = auth.removeprefix("Bearer ").strip() or None
2682
2838
 
2683
- expected_action = _expected_execution_action(request)
2684
-
2685
- try:
2686
- validate_execution_grant(request, expected_action=expected_action)
2687
- except ExecutionGrantViolation as exc:
2688
- _emit_audit_event(
2689
- container,
2690
- "warning",
2691
- "grant_validation_failed",
2692
- agent_instance_id=request.agent_instance_id,
2693
- user_id=request.effective_user_id(),
2694
- action=expected_action.value,
2695
- reason=str(exc),
2696
- )
2697
- raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=str(exc))
2698
- if request.execution_grant is not None:
2699
- _emit_audit_event(
2700
- container,
2701
- "info",
2702
- "grant_validated",
2703
- agent_instance_id=request.agent_instance_id,
2704
- user_id=request.effective_user_id(),
2705
- action=expected_action.value,
2706
- )
2707
- _validate_grant_user_correlation(request, authenticated_user, container)
2708
- await _validate_session_checkpoint_access(request)
2709
-
2710
2839
  exchange_id = str(uuid4())
2711
2840
  turn_start = time.monotonic()
2712
- internal_req = _to_internal_request(request)
2713
- target = await _resolve_agent_instance(
2714
- request=internal_req,
2841
+ internal_req, target = await _authorize_and_resolve(
2842
+ request,
2843
+ authenticated_user=authenticated_user,
2844
+ container=container,
2715
2845
  registry=registry,
2716
2846
  access_token=access_token,
2717
- control_plane_url=get_runtime_context().config.control_plane_url,
2718
2847
  )
2719
2848
  payloads = [
2720
2849
  payload
@@ -2779,7 +2908,7 @@ def _build_agent_router(
2779
2908
 
2780
2909
  POST <configured base_url>/agents/execute/stream
2781
2910
  Authorization: Bearer <user JWT>
2782
- Body: RuntimeExecuteRequest (agent_instance_id + execution_grant for managed exec)
2911
+ Body: RuntimeExecuteRequest (agent_instance_id + runtime_context.team_id for managed exec)
2783
2912
  Response: text/event-stream, each `data:` line is a RuntimeEvent JSON
2784
2913
 
2785
2914
  Stream termination:
@@ -2807,40 +2936,12 @@ def _build_agent_router(
2807
2936
  auth = http_request.headers.get("Authorization", "")
2808
2937
  access_token = auth.removeprefix("Bearer ").strip() or None
2809
2938
 
2810
- expected_action = _expected_execution_action(request)
2811
-
2812
- # Validate ExecutionGrant for managed execution paths
2813
- try:
2814
- validate_execution_grant(request, expected_action=expected_action)
2815
- except ExecutionGrantViolation as exc:
2816
- _emit_audit_event(
2817
- container,
2818
- "warning",
2819
- "grant_validation_failed",
2820
- agent_instance_id=request.agent_instance_id,
2821
- user_id=request.effective_user_id(),
2822
- action=expected_action.value,
2823
- reason=str(exc),
2824
- )
2825
- raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=str(exc))
2826
- if request.execution_grant is not None:
2827
- _emit_audit_event(
2828
- container,
2829
- "info",
2830
- "grant_validated",
2831
- agent_instance_id=request.agent_instance_id,
2832
- user_id=request.effective_user_id(),
2833
- action=expected_action.value,
2834
- )
2835
- _validate_grant_user_correlation(request, authenticated_user, container)
2836
- await _validate_session_checkpoint_access(request)
2837
-
2838
- internal_req = _to_internal_request(request)
2839
- target = await _resolve_agent_instance(
2840
- request=internal_req,
2939
+ internal_req, target = await _authorize_and_resolve(
2940
+ request,
2941
+ authenticated_user=authenticated_user,
2942
+ container=container,
2841
2943
  registry=registry,
2842
2944
  access_token=access_token,
2843
- control_plane_url=get_runtime_context().config.control_plane_url,
2844
2945
  )
2845
2946
  return StreamingResponse(
2846
2947
  _stream(
@@ -2942,6 +3043,16 @@ def create_agent_app(
2942
3043
  from fred_core.security.oidc import initialize_user_security
2943
3044
 
2944
3045
  initialize_user_security(user_security)
3046
+ if security is not None:
3047
+ # Enforce the hardened profile (C3) at startup — fails closed.
3048
+ from fred_core.security.oidc import apply_security_profile
3049
+
3050
+ apply_security_profile(security)
3051
+ # Pod-side authorization engine (RUNTIME-07 rev. 2). The pod authorizes
3052
+ # every execution against OpenFGA; a disabled/Noop engine (dev) means
3053
+ # identity-only. Safe in all modes — the factory returns a Noop with a
3054
+ # KeycloackDisabled admin client when user/m2m auth is off.
3055
+ rebac_engine = rebac_factory(security) if security is not None else None
2945
3056
  chat_factory = _build_chat_model_factory(config)
2946
3057
  await container.initialize_sql()
2947
3058
  container.start_metrics_exporter()
@@ -2963,6 +3074,10 @@ def create_agent_app(
2963
3074
  history_store=history_store,
2964
3075
  mcp_configuration=config.get_mcp_configuration(),
2965
3076
  control_plane_url=config.platform.control_plane_url,
3077
+ rebac_engine=rebac_engine,
3078
+ security_profile=(
3079
+ security.profile if security is not None else None
3080
+ ),
2966
3081
  kpi_writer=container.get_kpi_writer(),
2967
3082
  )
2968
3083
  )
@@ -3019,10 +3134,19 @@ def create_agent_app(
3019
3134
  app.include_router(api_router)
3020
3135
 
3021
3136
  if config.app.openai_compat:
3137
+ # F-A: the OpenAI-compat surface executes by agent_id (direct template),
3138
+ # which is forbidden under c3. Fail closed rather than expose it there.
3139
+ if security is not None and security.profile == "c3":
3140
+ raise RuntimeError(
3141
+ "security.profile='c3' forbids the OpenAI-compat surface: "
3142
+ "/v1/chat/completions executes by agent_id (direct template), "
3143
+ "which is not permitted under c3. Set app.openai_compat=false."
3144
+ )
3022
3145
  from .openai_compat_router import create_openai_compat_router
3023
3146
 
3024
3147
  openai_router = create_openai_compat_router(
3025
- registry, security_enabled=security_enabled
3148
+ registry,
3149
+ security_enabled=security_enabled,
3026
3150
  )
3027
3151
  app.include_router(openai_router, prefix="/v1")
3028
3152
  logger.info("[fred-runtime] OpenAI-compat endpoints enabled at /v1")
@@ -121,7 +121,7 @@ class PodAppConfig(BaseModel):
121
121
  ),
122
122
  )
123
123
  gcu_version: str | None = None
124
- openai_compat: bool = True
124
+ openai_compat: bool = False
125
125
  """
126
126
  Enable the OpenAI-compatible /v1/chat/completions and /v1/models endpoints.
127
127
 
@@ -134,9 +134,12 @@ class PodAppConfig(BaseModel):
134
134
  citations, HITL) is carried in a top-level `fred` key on each SSE chunk
135
135
  and silently ignored by standard OpenAI clients.
136
136
 
137
- Enabled by default agent pods should be reachable from any OpenAI-
138
- compatible client without explicit configuration. Set to false in pods
139
- that should not advertise an OpenAI surface (e.g. internal workers).
137
+ OFF by default (RUNTIME-07 rev. 2, finding F-A): this surface executes by
138
+ `agent_id` (direct template), which is forbidden under the c3 profile, and it
139
+ must be authorized per request. When enabled it is gated by the same Keycloak
140
+ JWT + pod-side OpenFGA check (requires the `X-Fred-Team-Id` header). The c3
141
+ profile FAILS CLOSED at startup if this surface is enabled (see
142
+ `apply_security_profile`). Enable only for dev / eval harnesses.
140
143
  """
141
144
 
142
145
 
@@ -288,7 +291,8 @@ class PodPlatformConfig(BaseModel):
288
291
 
289
292
  How to use it:
290
293
  - set `control_plane_url` when the pod should accept `agent_instance_id`
291
- execution requests
294
+ execution requests (the pod resolves the instance's template+tuning from
295
+ the control-plane team-scoped binding)
292
296
 
293
297
  Example:
294
298
  - `PodPlatformConfig(control_plane_url="http://localhost:8222/control-plane/v1")`