Skip to content

Commit 0820dea

Browse files
committed
feat: added token delegation hooks and fixed CMF, CMF hooks, and made single functions have multiple hooks.
Signed-off-by: Teryl Taylor <terylt@ibm.com>
1 parent ac6fdde commit 0820dea

13 files changed

Lines changed: 620 additions & 126 deletions

File tree

cpex/framework/base.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -455,7 +455,7 @@ def __init__(self, hook: str, plugin_ref: PluginRef):
455455

456456
# Check for @hook decorator metadata
457457
metadata = get_hook_metadata(method)
458-
if metadata and metadata.hook_type == hook:
458+
if metadata and metadata.matches(hook):
459459
self._func = method
460460
break
461461

cpex/framework/cmf/message.py

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -896,12 +896,8 @@ class Message(BaseModel):
896896
role: Role = Field(description="Who is speaking.")
897897
content: list[ContentPartUnion] = Field(default_factory=list, description="List of typed content parts.")
898898
channel: Channel | None = Field(default=None, description="Optional output classification.")
899-
extensions: Extensions | None = Field(
900-
default=None,
901-
description="Contextual metadata (identity, security, governance, etc.).",
902-
)
903899

904-
def iter_views(self, hook: str | None = None) -> Iterator[MessageView]:
900+
def iter_views(self, hook: str | None = None, extensions: Extensions | None = None) -> Iterator[MessageView]:
905901
"""Decompose this message into individually addressable MessageViews.
906902
907903
Yields one MessageView per content part. Each view provides a
@@ -938,7 +934,7 @@ def iter_views(self, hook: str | None = None) -> Iterator[MessageView]:
938934
"""
939935
from cpex.framework.cmf.view import iter_views # pylint: disable=import-outside-toplevel
940936

941-
return iter_views(self, hook=hook)
937+
return iter_views(self, hook=hook, extensions=extensions)
942938

943939

944940
if TYPE_CHECKING:

cpex/framework/cmf/view.py

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -210,27 +210,31 @@ class MessageView:
210210
True
211211
"""
212212

213-
__slots__ = ("_part", "_kind", "_message", "_hook")
213+
__slots__ = ("_part", "_kind", "_message", "_extensions", "_hook")
214214

215215
def __init__(
216216
self,
217217
part: ContentPart,
218218
kind: ViewKind,
219219
message: Message,
220220
hook: str | None = None,
221+
extensions: Any = None,
221222
) -> None:
222223
"""Initialize a MessageView.
223224
224225
Args:
225226
part: The underlying content part.
226227
kind: The kind of content.
227-
message: The parent message (for role and extensions access).
228+
message: The parent message (for role access).
228229
hook: The hook location where this view is being evaluated
229230
(e.g., "llm_input", "tool_post_invoke"). None if unset.
231+
extensions: The Extensions object, passed separately from the
232+
message for capability-gated filtering.
230233
"""
231234
self._part = part
232235
self._kind = kind
233236
self._message = message
237+
self._extensions = extensions
234238
self._hook = hook
235239

236240
# =========================================================================
@@ -631,8 +635,8 @@ def is_media(self) -> bool:
631635
# =========================================================================
632636

633637
def _ext(self) -> Any:
634-
"""Get the message extensions, or None."""
635-
return self._message.extensions
638+
"""Get the extensions, or None."""
639+
return self._extensions
636640

637641
# --- Base tier (no capability required) ---
638642

@@ -1169,7 +1173,7 @@ def __repr__(self) -> str:
11691173
# ---------------------------------------------------------------------------
11701174

11711175

1172-
def iter_views(message: Message, hook: str | None = None) -> Iterator[MessageView]:
1176+
def iter_views(message: Message, hook: str | None = None, extensions: Any = None) -> Iterator[MessageView]:
11731177
"""Iterate over a message yielding one MessageView per content part.
11741178
11751179
Memory-efficient: views are yielded one at a time and hold only
@@ -1216,4 +1220,4 @@ def iter_views(message: Message, hook: str | None = None) -> Iterator[MessageVie
12161220
if kind is None:
12171221
logger.warning("Unknown content type %r in iter_views", part.content_type)
12181222
raise ValueError(f"Unknown content type: {part.content_type!r}")
1219-
yield MessageView(part, kind, message, hook=hook)
1223+
yield MessageView(part, kind, message, hook=hook, extensions=extensions)

cpex/framework/decorator.py

Lines changed: 42 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ def tool_pre_invoke(self, payload, context):
3838
"""
3939

4040
# Standard
41-
from typing import Callable, Optional, Type, TypeVar
41+
from typing import Callable, Optional, Sequence, Type, TypeVar, Union
4242

4343
# Third-Party
4444
from pydantic import BaseModel
@@ -58,55 +58,72 @@ class HookMetadata:
5858
"""Metadata stored on decorated hook methods.
5959
6060
Attributes:
61-
hook_type: The hook type identifier (e.g., 'tool_pre_invoke')
62-
payload_type: Optional payload class for hook registration
63-
result_type: Optional result class for hook registration
61+
hook_types: The hook type identifiers this method handles.
62+
payload_type: Optional payload class for hook registration.
63+
result_type: Optional result class for hook registration.
6464
"""
6565

6666
def __init__(
6767
self,
68-
hook_type: str,
68+
hook_types: list[str],
6969
payload_type: Optional[Type[BaseModel]] = None,
7070
result_type: Optional[Type[BaseModel]] = None,
7171
):
7272
"""Initialize hook metadata.
7373
7474
Args:
75-
hook_type: The hook type identifier
76-
payload_type: Optional payload class for registering new hooks
77-
result_type: Optional result class for registering new hooks
75+
hook_types: List of hook type identifiers this method handles.
76+
payload_type: Optional payload class for registering new hooks.
77+
result_type: Optional result class for registering new hooks.
7878
"""
79-
self.hook_type = hook_type
79+
self.hook_types = hook_types
8080
self.payload_type = payload_type
8181
self.result_type = result_type
8282

83+
@property
84+
def hook_type(self) -> str:
85+
"""Primary hook type (first in list). For backward compatibility."""
86+
return self.hook_types[0] if self.hook_types else ""
87+
88+
def matches(self, hook_type: str) -> bool:
89+
"""Check if this metadata handles the given hook type."""
90+
return hook_type in self.hook_types
91+
8392

8493
def hook(
85-
hook_type: str,
94+
hook_type: Union[str, Sequence[str]],
8695
payload_type: Optional[Type[P]] = None,
8796
result_type: Optional[Type[R]] = None,
8897
) -> Callable[[Callable], Callable]:
8998
"""Decorator to mark a method as a plugin hook handler.
9099
91100
This decorator attaches metadata to a method so the Plugin class can
92101
discover it during initialization and register it with the appropriate
93-
hook type.
102+
hook type(s).
94103
95104
Args:
96-
hook_type: The hook type identifier (e.g., 'tool_pre_invoke')
97-
payload_type: Optional payload class for registering new hook types
98-
result_type: Optional result class for registering new hook types
105+
hook_type: One or more hook type identifiers. Pass a string for
106+
a single hook, or a list/tuple to register the same method
107+
for multiple hook points.
108+
payload_type: Optional payload class for registering new hook types.
109+
result_type: Optional result class for registering new hook types.
99110
100111
Returns:
101-
Decorator function that marks the method with hook metadata
112+
Decorator function that marks the method with hook metadata.
102113
103114
Examples:
104-
Override method name::
115+
Single hook::
105116
106117
@hook(ToolHookType.TOOL_PRE_INVOKE)
107118
def my_custom_method_name(self, payload, context):
108119
return ToolPreInvokeResult(continue_processing=True)
109120
121+
Multiple hooks (same method handles both)::
122+
123+
@hook([CmfHookType.TOOL_PRE_INVOKE, CmfHookType.TOOL_POST_INVOKE])
124+
def evaluate(self, payload, context):
125+
return MessageResult()
126+
110127
Register new hook type::
111128
112129
@hook("email_pre_send", EmailPayload, EmailResult)
@@ -123,8 +140,13 @@ def decorator(func: Callable) -> Callable:
123140
Returns:
124141
The same function with metadata attached
125142
"""
126-
# Store metadata on the function object
127-
metadata = HookMetadata(hook_type, payload_type, result_type)
143+
# Normalize to list
144+
if isinstance(hook_type, str):
145+
types = [hook_type]
146+
else:
147+
types = list(hook_type)
148+
149+
metadata = HookMetadata(types, payload_type, result_type)
128150
setattr(func, _HOOK_METADATA_ATTR, metadata)
129151
return func
130152

@@ -147,6 +169,8 @@ def get_hook_metadata(func: Callable) -> Optional[HookMetadata]:
147169
>>> metadata = get_hook_metadata(test_func)
148170
>>> metadata.hook_type
149171
'test_hook'
172+
>>> metadata.matches("test_hook")
173+
True
150174
>>> get_hook_metadata(lambda: None) is None
151175
True
152176
"""
Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
# -*- coding: utf-8 -*-
2+
"""Location: ./cpex/framework/extensions/delegation.py
3+
Copyright 2025
4+
SPDX-License-Identifier: Apache-2.0
5+
Authors: Teryl Taylor
6+
7+
Delegation extension models.
8+
Carries the delegation chain state through the CMF message for policy
9+
evaluation. The chain grows monotonically — each hop appends, never
10+
removes. Scope narrowing is enforced at the framework level.
11+
12+
See: docs/delegation-hooks-design.md
13+
"""
14+
15+
# Standard
16+
from datetime import datetime
17+
18+
# Third-Party
19+
from pydantic import BaseModel, ConfigDict, Field
20+
21+
22+
class DelegationHop(BaseModel):
23+
"""One hop in the delegation chain.
24+
25+
Each hop represents one step: "entity X delegated to entity Y
26+
for audience Z with these scopes." Immutable once created.
27+
28+
Attributes:
29+
subject_id: Who is acting at this hop.
30+
subject_type: Entity kind (user, agent, service).
31+
audience: Target audience for this hop's token.
32+
scopes_granted: What this hop's token can do.
33+
timestamp: When this hop was created.
34+
ttl_seconds: Token lifetime for this hop.
35+
strategy: How the token was obtained (token_exchange, ucan, etc.).
36+
from_cache: Whether the token came from cache.
37+
38+
Examples:
39+
>>> hop = DelegationHop(
40+
... subject_id="alice@corp.com",
41+
... subject_type="user",
42+
... scopes_granted=("read:compensation",),
43+
... timestamp=datetime(2025, 1, 1),
44+
... strategy="token_exchange",
45+
... )
46+
>>> hop.subject_id
47+
'alice@corp.com'
48+
"""
49+
50+
model_config = ConfigDict(frozen=True)
51+
52+
subject_id: str = Field(description="Who is acting at this hop.")
53+
subject_type: str = Field(description="Entity kind: user, agent, service, system.")
54+
audience: str | None = Field(default=None, description="Target audience for this hop's token.")
55+
scopes_granted: tuple[str, ...] = Field(default=(), description="Scopes this hop's token grants.")
56+
timestamp: datetime = Field(default_factory=datetime.utcnow, description="When this hop was created.")
57+
ttl_seconds: int | None = Field(default=None, description="Token lifetime in seconds.")
58+
strategy: str | None = Field(default=None, description="Token strategy: token_exchange, ucan, passthrough, etc.")
59+
from_cache: bool = Field(default=False, description="Whether the token came from cache.")
60+
61+
62+
class DelegationExtension(BaseModel):
63+
"""Delegation chain state carried in the CMF message.
64+
65+
Mutability tiers:
66+
- chain: monotonic (grows with each hop, never shrinks)
67+
- origin_subject_id, delegated: immutable (set at first delegation)
68+
- actor_subject_id: updates per hop (current actor changes)
69+
70+
The chain is available to the DSL via the delegation.* namespace:
71+
delegation.origin, delegation.actor, delegation.depth,
72+
delegation.age, delegated
73+
74+
Attributes:
75+
chain: Ordered list of delegation hops (monotonic growth).
76+
depth: Number of hops in the chain.
77+
origin_subject_id: Original caller (immutable once set).
78+
actor_subject_id: Current actor (latest hop's subject).
79+
delegated: Whether this request is delegated.
80+
age_seconds: Seconds since the original delegation.
81+
82+
Examples:
83+
>>> ext = DelegationExtension()
84+
>>> ext.delegated
85+
False
86+
>>> ext.depth
87+
0
88+
"""
89+
90+
model_config = ConfigDict(frozen=True)
91+
92+
chain: tuple[DelegationHop, ...] = Field(default=(), description="Ordered delegation hops.")
93+
depth: int = Field(default=0, description="Number of hops.")
94+
origin_subject_id: str | None = Field(default=None, description="Original caller.")
95+
actor_subject_id: str | None = Field(default=None, description="Current actor.")
96+
delegated: bool = Field(default=False, description="Whether this is a delegated request.")
97+
age_seconds: float = Field(default=0.0, description="Seconds since original delegation.")
98+
99+
def with_new_hop(self, hop: DelegationHop) -> "DelegationExtension":
100+
"""Create a new DelegationExtension with an appended hop.
101+
102+
Returns a new instance — the original is unchanged (immutable).
103+
The framework enforces scope narrowing before calling this.
104+
105+
Args:
106+
hop: The new delegation hop to append.
107+
108+
Returns:
109+
New DelegationExtension with the hop appended.
110+
"""
111+
new_chain = self.chain + (hop,)
112+
origin = self.origin_subject_id or hop.subject_id
113+
age = (datetime.utcnow() - self.chain[0].timestamp).total_seconds() if self.chain else 0.0
114+
return DelegationExtension(
115+
chain=new_chain,
116+
depth=len(new_chain),
117+
origin_subject_id=origin,
118+
actor_subject_id=hop.subject_id,
119+
delegated=True,
120+
age_seconds=age,
121+
)

cpex/framework/extensions/extensions.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
# First-Party
2020
from cpex.framework.extensions.agent import AgentExtension
2121
from cpex.framework.extensions.completion import CompletionExtension
22+
from cpex.framework.extensions.delegation import DelegationExtension
2223
from cpex.framework.extensions.framework import FrameworkExtension
2324
from cpex.framework.extensions.http import HttpExtension
2425
from cpex.framework.extensions.llm import LLMExtension
@@ -82,6 +83,7 @@ class Extensions(BaseModel):
8283
agent: AgentExtension | None = Field(default=None, description="Agent execution context.")
8384
http: HttpExtension | None = Field(default=None, description="HTTP request context.")
8485
security: SecurityExtension | None = Field(default=None, description="Security labels and identity.")
86+
delegation: DelegationExtension | None = Field(default=None, description="Delegation chain state.")
8587
mcp: MCPExtension | None = Field(default=None, description="MCP entity metadata.")
8688
completion: CompletionExtension | None = Field(default=None, description="LLM completion information.")
8789
provenance: ProvenanceExtension | None = Field(default=None, description="Origin and threading.")

0 commit comments

Comments
 (0)