langgraph-secure-agents 0.1.0__py3-none-any.whl
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.
- langgraph_secure_agents/__init__.py +20 -0
- langgraph_secure_agents/_resolver_safety.py +45 -0
- langgraph_secure_agents/guard.py +130 -0
- langgraph_secure_agents/middleware.py +344 -0
- langgraph_secure_agents/models.py +205 -0
- langgraph_secure_agents/policy.py +99 -0
- langgraph_secure_agents/py.typed +0 -0
- langgraph_secure_agents-0.1.0.dist-info/METADATA +238 -0
- langgraph_secure_agents-0.1.0.dist-info/RECORD +11 -0
- langgraph_secure_agents-0.1.0.dist-info/WHEEL +4 -0
- langgraph_secure_agents-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""Reusable security primitives for LangGraph agents."""
|
|
2
|
+
|
|
3
|
+
from .guard import AuthorizationDenied, protect_callable
|
|
4
|
+
from .models import AccessRequest, Decision, DecisionReason, Effect, PolicyRule, Principal
|
|
5
|
+
from .policy import PolicyEngine
|
|
6
|
+
|
|
7
|
+
__version__ = "0.1.0a0"
|
|
8
|
+
|
|
9
|
+
__all__ = [
|
|
10
|
+
"AccessRequest",
|
|
11
|
+
"AuthorizationDenied",
|
|
12
|
+
"Decision",
|
|
13
|
+
"DecisionReason",
|
|
14
|
+
"Effect",
|
|
15
|
+
"PolicyEngine",
|
|
16
|
+
"PolicyRule",
|
|
17
|
+
"Principal",
|
|
18
|
+
"__version__",
|
|
19
|
+
"protect_callable",
|
|
20
|
+
]
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""Internal helpers for fail-closed, side-effect-free policy resolvers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Callable
|
|
6
|
+
from copy import deepcopy
|
|
7
|
+
from typing import ParamSpec, TypeVar
|
|
8
|
+
|
|
9
|
+
P = ParamSpec("P")
|
|
10
|
+
R = TypeVar("R")
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def call_resolver_without_mutation(
|
|
14
|
+
resolver: Callable[P, R],
|
|
15
|
+
*args: P.args,
|
|
16
|
+
**kwargs: P.kwargs,
|
|
17
|
+
) -> R:
|
|
18
|
+
"""Call a trusted resolver on detached inputs and reject input mutation.
|
|
19
|
+
|
|
20
|
+
Resource/action resolvers participate in the authorization decision. Letting
|
|
21
|
+
them mutate a request object can cause the checked resource to differ from
|
|
22
|
+
the resource later consumed by the protected operation. Inputs are deep-copied
|
|
23
|
+
and snapshotted; if copying or comparison cannot be performed safely, the
|
|
24
|
+
caller fails closed before the protected operation executes.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
try:
|
|
28
|
+
resolver_args = deepcopy(args)
|
|
29
|
+
resolver_kwargs = deepcopy(kwargs)
|
|
30
|
+
args_before = deepcopy(resolver_args)
|
|
31
|
+
kwargs_before = deepcopy(resolver_kwargs)
|
|
32
|
+
except Exception as exc:
|
|
33
|
+
raise ValueError("resolver inputs must be safely copyable") from exc
|
|
34
|
+
|
|
35
|
+
result = resolver(*resolver_args, **resolver_kwargs)
|
|
36
|
+
|
|
37
|
+
try:
|
|
38
|
+
mutated = resolver_args != args_before or resolver_kwargs != kwargs_before
|
|
39
|
+
except Exception as exc:
|
|
40
|
+
raise ValueError("resolver input mutation could not be checked safely") from exc
|
|
41
|
+
|
|
42
|
+
if mutated:
|
|
43
|
+
raise ValueError("authorization resolvers must not mutate their inputs")
|
|
44
|
+
|
|
45
|
+
return result
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
"""Execution guards for ordinary Python callables."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Awaitable, Callable
|
|
6
|
+
from functools import wraps
|
|
7
|
+
from inspect import iscoroutinefunction
|
|
8
|
+
from typing import Any, ParamSpec, TypeVar, cast, overload
|
|
9
|
+
|
|
10
|
+
from ._resolver_safety import call_resolver_without_mutation
|
|
11
|
+
from .models import AccessRequest, Decision, Principal
|
|
12
|
+
from .policy import PolicyEngine
|
|
13
|
+
|
|
14
|
+
P = ParamSpec("P")
|
|
15
|
+
R = TypeVar("R")
|
|
16
|
+
AuditCallback = Callable[[AccessRequest, Decision], None]
|
|
17
|
+
ResourceResolver = Callable[P, str]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class AuthorizationDenied(PermissionError):
|
|
21
|
+
"""Raised when a protected callable is invoked without authorization."""
|
|
22
|
+
|
|
23
|
+
def __init__(self, request: AccessRequest, decision: Decision) -> None:
|
|
24
|
+
self.request = request
|
|
25
|
+
self.decision = decision
|
|
26
|
+
super().__init__(f"Access denied for action {request.action!r} ({decision.reason}).")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@overload
|
|
30
|
+
def protect_callable(
|
|
31
|
+
function: Callable[P, Awaitable[R]],
|
|
32
|
+
*,
|
|
33
|
+
policy: PolicyEngine,
|
|
34
|
+
principal_resolver: Callable[[], Principal],
|
|
35
|
+
action: str = "execute",
|
|
36
|
+
resource_resolver: ResourceResolver[P] | None = None,
|
|
37
|
+
audit_callback: AuditCallback | None = None,
|
|
38
|
+
) -> Callable[P, Awaitable[R]]: ...
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@overload
|
|
42
|
+
def protect_callable(
|
|
43
|
+
function: Callable[P, R],
|
|
44
|
+
*,
|
|
45
|
+
policy: PolicyEngine,
|
|
46
|
+
principal_resolver: Callable[[], Principal],
|
|
47
|
+
action: str = "execute",
|
|
48
|
+
resource_resolver: ResourceResolver[P] | None = None,
|
|
49
|
+
audit_callback: AuditCallback | None = None,
|
|
50
|
+
) -> Callable[P, R]: ...
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def protect_callable(
|
|
54
|
+
function: Callable[P, Any],
|
|
55
|
+
*,
|
|
56
|
+
policy: PolicyEngine,
|
|
57
|
+
principal_resolver: Callable[[], Principal],
|
|
58
|
+
action: str = "execute",
|
|
59
|
+
resource_resolver: ResourceResolver[P] | None = None,
|
|
60
|
+
audit_callback: AuditCallback | None = None,
|
|
61
|
+
) -> Callable[P, Any]:
|
|
62
|
+
"""Return a callable that authorizes immediately before protected execution.
|
|
63
|
+
|
|
64
|
+
Coroutine functions are authorized when their returned coroutine is awaited,
|
|
65
|
+
not when the coroutine object is created. This ensures the trusted principal
|
|
66
|
+
and policy are checked at the point execution begins.
|
|
67
|
+
|
|
68
|
+
The principal resolver must obtain identity from trusted application context,
|
|
69
|
+
not from arguments controlled by an LLM. The resource resolver must identify
|
|
70
|
+
the same resource the wrapped function will actually access. The resolver
|
|
71
|
+
receives detached argument copies; mutating them is rejected. Resolvers must
|
|
72
|
+
otherwise be trusted, side-effect-free application code. If auditing is
|
|
73
|
+
configured and the callback fails, execution fails closed.
|
|
74
|
+
"""
|
|
75
|
+
|
|
76
|
+
if not callable(function):
|
|
77
|
+
raise TypeError("function must be callable")
|
|
78
|
+
if not isinstance(policy, PolicyEngine):
|
|
79
|
+
raise TypeError("policy must be a PolicyEngine instance")
|
|
80
|
+
if not callable(principal_resolver):
|
|
81
|
+
raise TypeError("principal_resolver must be callable")
|
|
82
|
+
if resource_resolver is not None and not callable(resource_resolver):
|
|
83
|
+
raise TypeError("resource_resolver must be callable")
|
|
84
|
+
if audit_callback is not None and not callable(audit_callback):
|
|
85
|
+
raise TypeError("audit_callback must be callable")
|
|
86
|
+
if not isinstance(action, str) or not action.strip() or action.strip() == "*":
|
|
87
|
+
raise ValueError("action must be a non-empty concrete action")
|
|
88
|
+
|
|
89
|
+
normalized_action = action.strip()
|
|
90
|
+
if resource_resolver is None:
|
|
91
|
+
resource_name = getattr(function, "__name__", type(function).__name__)
|
|
92
|
+
|
|
93
|
+
def authorize(*args: P.args, **kwargs: P.kwargs) -> None:
|
|
94
|
+
principal = principal_resolver()
|
|
95
|
+
if not isinstance(principal, Principal):
|
|
96
|
+
raise TypeError("principal_resolver must return a Principal instance")
|
|
97
|
+
|
|
98
|
+
if resource_resolver is None:
|
|
99
|
+
resource = resource_name
|
|
100
|
+
else:
|
|
101
|
+
resource = call_resolver_without_mutation(resource_resolver, *args, **kwargs)
|
|
102
|
+
|
|
103
|
+
request = AccessRequest(
|
|
104
|
+
principal=principal,
|
|
105
|
+
action=normalized_action,
|
|
106
|
+
resource=resource,
|
|
107
|
+
)
|
|
108
|
+
decision = policy.evaluate(request)
|
|
109
|
+
|
|
110
|
+
if audit_callback is not None:
|
|
111
|
+
audit_callback(request, decision)
|
|
112
|
+
|
|
113
|
+
if not decision.allowed:
|
|
114
|
+
raise AuthorizationDenied(request, decision)
|
|
115
|
+
|
|
116
|
+
if iscoroutinefunction(function):
|
|
117
|
+
|
|
118
|
+
@wraps(function)
|
|
119
|
+
async def guarded_async(*args: P.args, **kwargs: P.kwargs) -> Any:
|
|
120
|
+
authorize(*args, **kwargs)
|
|
121
|
+
return await cast(Awaitable[Any], function(*args, **kwargs))
|
|
122
|
+
|
|
123
|
+
return guarded_async
|
|
124
|
+
|
|
125
|
+
@wraps(function)
|
|
126
|
+
def guarded(*args: P.args, **kwargs: P.kwargs) -> Any:
|
|
127
|
+
authorize(*args, **kwargs)
|
|
128
|
+
return function(*args, **kwargs)
|
|
129
|
+
|
|
130
|
+
return guarded
|
|
@@ -0,0 +1,344 @@
|
|
|
1
|
+
"""Optional middleware that enforces policies before agent tools execute."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
import math
|
|
8
|
+
from collections.abc import Callable, Mapping
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from langchain.agents.middleware import AgentMiddleware, wrap_tool_call
|
|
12
|
+
from langchain_core.messages import ToolMessage
|
|
13
|
+
from langgraph.types import interrupt
|
|
14
|
+
|
|
15
|
+
from ._resolver_safety import call_resolver_without_mutation
|
|
16
|
+
from .models import AccessRequest, Decision, DecisionReason, PolicyRule, Principal
|
|
17
|
+
from .policy import PolicyEngine
|
|
18
|
+
|
|
19
|
+
PrincipalResolver = Callable[[object], Principal]
|
|
20
|
+
ActionResolver = Callable[[str, Mapping[str, Any]], str]
|
|
21
|
+
ResourceResolver = Callable[[str, Mapping[str, Any]], str]
|
|
22
|
+
AuditCallback = Callable[[AccessRequest, Decision], None]
|
|
23
|
+
ApprovalValidator = Callable[[Mapping[str, Any], AccessRequest, str], bool]
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _canonical_json_value(value: Any) -> Any:
|
|
27
|
+
"""Normalize supported values to unambiguous, standards-compliant JSON data."""
|
|
28
|
+
|
|
29
|
+
if value is None or type(value) in (str, bool, int):
|
|
30
|
+
return value
|
|
31
|
+
if type(value) is float:
|
|
32
|
+
if not math.isfinite(value):
|
|
33
|
+
raise ValueError("approval fingerprints cannot contain non-finite numbers")
|
|
34
|
+
return value
|
|
35
|
+
if isinstance(value, Mapping):
|
|
36
|
+
normalized: dict[str, Any] = {}
|
|
37
|
+
for key, item in value.items():
|
|
38
|
+
if not isinstance(key, str):
|
|
39
|
+
raise ValueError("approval fingerprints require string keys in JSON objects")
|
|
40
|
+
normalized[key] = _canonical_json_value(item)
|
|
41
|
+
return normalized
|
|
42
|
+
if isinstance(value, (list, tuple)):
|
|
43
|
+
return [_canonical_json_value(item) for item in value]
|
|
44
|
+
raise ValueError("approval fingerprints can only contain JSON-compatible values")
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _approval_execution_scope(runtime: Any) -> dict[str, str]:
|
|
48
|
+
"""Get stable checkpoint scope for binding a human approval to one execution."""
|
|
49
|
+
|
|
50
|
+
execution_info = getattr(runtime, "execution_info", None)
|
|
51
|
+
thread_id = getattr(execution_info, "thread_id", None)
|
|
52
|
+
checkpoint_id = getattr(execution_info, "checkpoint_id", None)
|
|
53
|
+
checkpoint_ns = getattr(execution_info, "checkpoint_ns", None)
|
|
54
|
+
task_id = getattr(execution_info, "task_id", None)
|
|
55
|
+
|
|
56
|
+
if (
|
|
57
|
+
not isinstance(thread_id, str)
|
|
58
|
+
or not thread_id
|
|
59
|
+
or not isinstance(checkpoint_id, str)
|
|
60
|
+
or not checkpoint_id
|
|
61
|
+
or not isinstance(checkpoint_ns, str)
|
|
62
|
+
or not isinstance(task_id, str)
|
|
63
|
+
or not task_id
|
|
64
|
+
):
|
|
65
|
+
raise RuntimeError(
|
|
66
|
+
"approval-required tool calls need LangGraph 1.2+ execution metadata "
|
|
67
|
+
"and an active checkpointer"
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
return {
|
|
71
|
+
"thread_id": thread_id,
|
|
72
|
+
"checkpoint_id": checkpoint_id,
|
|
73
|
+
"checkpoint_ns": checkpoint_ns,
|
|
74
|
+
"task_id": task_id,
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _approval_fingerprint(
|
|
79
|
+
*,
|
|
80
|
+
tool_name: str,
|
|
81
|
+
tool_args: Mapping[str, Any],
|
|
82
|
+
tool_call_id: str,
|
|
83
|
+
access_request: AccessRequest,
|
|
84
|
+
matched_rule_ids: tuple[str, ...],
|
|
85
|
+
matched_policy_rules: tuple[PolicyRule, ...],
|
|
86
|
+
execution_scope: Mapping[str, str],
|
|
87
|
+
) -> str:
|
|
88
|
+
"""Bind approval to one execution, request data, and matched policy definitions."""
|
|
89
|
+
|
|
90
|
+
payload = {
|
|
91
|
+
"execution_scope": dict(execution_scope),
|
|
92
|
+
"tool_name": tool_name,
|
|
93
|
+
"tool_args": dict(tool_args),
|
|
94
|
+
"tool_call_id": tool_call_id,
|
|
95
|
+
"principal_id": access_request.principal.principal_id,
|
|
96
|
+
"roles": sorted(access_request.principal.roles),
|
|
97
|
+
"action": access_request.action,
|
|
98
|
+
"resource": access_request.resource,
|
|
99
|
+
"matched_rule_ids": list(matched_rule_ids),
|
|
100
|
+
"matched_policy_rules": [
|
|
101
|
+
{
|
|
102
|
+
"rule_id": rule.rule_id,
|
|
103
|
+
"effect": rule.effect.value,
|
|
104
|
+
"actions": sorted(rule.actions),
|
|
105
|
+
"resources": sorted(rule.resources),
|
|
106
|
+
"principal_ids": sorted(rule.principal_ids),
|
|
107
|
+
"roles": sorted(rule.roles),
|
|
108
|
+
}
|
|
109
|
+
for rule in matched_policy_rules
|
|
110
|
+
],
|
|
111
|
+
}
|
|
112
|
+
try:
|
|
113
|
+
canonical_payload = _canonical_json_value(payload)
|
|
114
|
+
serialized = json.dumps(
|
|
115
|
+
canonical_payload,
|
|
116
|
+
ensure_ascii=False,
|
|
117
|
+
sort_keys=True,
|
|
118
|
+
separators=(",", ":"),
|
|
119
|
+
allow_nan=False,
|
|
120
|
+
)
|
|
121
|
+
except (TypeError, ValueError, RecursionError) as exc:
|
|
122
|
+
raise ValueError("approval-required tool arguments must be JSON-serializable") from exc
|
|
123
|
+
return hashlib.sha256(serialized.encode("utf-8")).hexdigest()
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def create_authorization_middleware(
|
|
127
|
+
policy: PolicyEngine,
|
|
128
|
+
principal_resolver: PrincipalResolver,
|
|
129
|
+
*,
|
|
130
|
+
action_resolver: ActionResolver | None = None,
|
|
131
|
+
resource_resolver: ResourceResolver | None = None,
|
|
132
|
+
audit_callback: AuditCallback | None = None,
|
|
133
|
+
approval_validator: ApprovalValidator | None = None,
|
|
134
|
+
) -> Any:
|
|
135
|
+
"""Create middleware that authorizes every tool call before execution.
|
|
136
|
+
|
|
137
|
+
The principal resolver receives the agent runtime's context. It must derive
|
|
138
|
+
identity from trusted application data, never from tool arguments or LLM
|
|
139
|
+
output. By default, the requested action is execute and the resource is
|
|
140
|
+
the tool name. Custom resolvers can map tool calls to domain actions and
|
|
141
|
+
resources. A denied call returns an error ToolMessage without invoking the
|
|
142
|
+
tool handler.
|
|
143
|
+
|
|
144
|
+
For REQUIRE_APPROVAL decisions, LangGraph interrupt() pauses execution before
|
|
145
|
+
delegation. Resume the same checkpoint/thread with a trusted approval result
|
|
146
|
+
containing both a boolean 'approved' field and the exact request_fingerprint
|
|
147
|
+
shown in the interrupt payload. The host application must authenticate the
|
|
148
|
+
reviewer and authorize access to the resume operation; the payload alone is
|
|
149
|
+
not proof that an authorized human approved the action. The fingerprint is
|
|
150
|
+
deterministic, not a one-time token. Configure approval_validator when the
|
|
151
|
+
host needs reviewer authentication/authorization and durable one-time
|
|
152
|
+
approval consumption. The callback receives the approval mapping, access
|
|
153
|
+
request, and fingerprint; it must authenticate/authorize the reviewer and
|
|
154
|
+
atomically consume the approval, returning literal True only for the first
|
|
155
|
+
valid use. Any other return value fails closed. If the callback raises, the
|
|
156
|
+
exception propagates and the tool handler is not invoked. Its result must
|
|
157
|
+
not be based on untrusted model output. Without this callback, the host
|
|
158
|
+
remains responsible for these controls externally.
|
|
159
|
+
|
|
160
|
+
Custom action/resource resolvers receive detached argument copies. Mutating
|
|
161
|
+
those copies raises an error before the protected tool runs. Resolvers must
|
|
162
|
+
otherwise be trusted, side-effect-free application code.
|
|
163
|
+
|
|
164
|
+
If configured auditing raises, the tool handler is not invoked (fail closed).
|
|
165
|
+
Approval outcomes are audited after the interrupt resumes.
|
|
166
|
+
"""
|
|
167
|
+
|
|
168
|
+
if not isinstance(policy, PolicyEngine):
|
|
169
|
+
raise TypeError("policy must be a PolicyEngine instance")
|
|
170
|
+
if not callable(principal_resolver):
|
|
171
|
+
raise TypeError("principal_resolver must be callable")
|
|
172
|
+
if action_resolver is not None and not callable(action_resolver):
|
|
173
|
+
raise TypeError("action_resolver must be callable")
|
|
174
|
+
if resource_resolver is not None and not callable(resource_resolver):
|
|
175
|
+
raise TypeError("resource_resolver must be callable")
|
|
176
|
+
if audit_callback is not None and not callable(audit_callback):
|
|
177
|
+
raise TypeError("audit_callback must be callable")
|
|
178
|
+
if approval_validator is not None and not callable(approval_validator):
|
|
179
|
+
raise TypeError("approval_validator must be callable")
|
|
180
|
+
|
|
181
|
+
@wrap_tool_call(name="secure_agent_authorization")
|
|
182
|
+
def enforce_authorization(request: Any, handler: Callable[[Any], Any]) -> Any:
|
|
183
|
+
tool_call = getattr(request, "tool_call", None)
|
|
184
|
+
if not isinstance(tool_call, Mapping):
|
|
185
|
+
raise TypeError("middleware request must contain a tool_call mapping")
|
|
186
|
+
|
|
187
|
+
tool_name = tool_call.get("name")
|
|
188
|
+
tool_args = tool_call.get("args", {})
|
|
189
|
+
tool_call_id = tool_call.get("id")
|
|
190
|
+
|
|
191
|
+
if not isinstance(tool_name, str) or not tool_name.strip():
|
|
192
|
+
raise ValueError("tool call must include a non-empty name")
|
|
193
|
+
if not isinstance(tool_args, Mapping):
|
|
194
|
+
raise TypeError("tool call args must be a mapping")
|
|
195
|
+
|
|
196
|
+
runtime = getattr(request, "runtime", None)
|
|
197
|
+
runtime_context = getattr(runtime, "context", None)
|
|
198
|
+
principal = principal_resolver(runtime_context)
|
|
199
|
+
if not isinstance(principal, Principal):
|
|
200
|
+
raise TypeError("principal_resolver must return a Principal instance")
|
|
201
|
+
|
|
202
|
+
action = (
|
|
203
|
+
"execute"
|
|
204
|
+
if action_resolver is None
|
|
205
|
+
else call_resolver_without_mutation(action_resolver, tool_name, tool_args)
|
|
206
|
+
)
|
|
207
|
+
resource = (
|
|
208
|
+
tool_name
|
|
209
|
+
if resource_resolver is None
|
|
210
|
+
else call_resolver_without_mutation(resource_resolver, tool_name, tool_args)
|
|
211
|
+
)
|
|
212
|
+
access_request = AccessRequest(
|
|
213
|
+
principal=principal,
|
|
214
|
+
action=action,
|
|
215
|
+
resource=resource,
|
|
216
|
+
)
|
|
217
|
+
decision = policy.evaluate(access_request)
|
|
218
|
+
|
|
219
|
+
if decision.requires_approval:
|
|
220
|
+
if not isinstance(tool_call_id, str) or not tool_call_id:
|
|
221
|
+
raise ValueError("approval-required tool calls must include a tool-call id")
|
|
222
|
+
|
|
223
|
+
matched_policy_rules = tuple(
|
|
224
|
+
rule for rule in policy.rules if rule.rule_id in decision.matched_rule_ids
|
|
225
|
+
)
|
|
226
|
+
if tuple(rule.rule_id for rule in matched_policy_rules) != decision.matched_rule_ids:
|
|
227
|
+
raise RuntimeError("policy decision refers to an unknown policy rule")
|
|
228
|
+
|
|
229
|
+
execution_scope = _approval_execution_scope(runtime)
|
|
230
|
+
fingerprint = _approval_fingerprint(
|
|
231
|
+
tool_name=tool_name,
|
|
232
|
+
tool_args=tool_args,
|
|
233
|
+
tool_call_id=tool_call_id,
|
|
234
|
+
access_request=access_request,
|
|
235
|
+
matched_rule_ids=decision.matched_rule_ids,
|
|
236
|
+
matched_policy_rules=matched_policy_rules,
|
|
237
|
+
execution_scope=execution_scope,
|
|
238
|
+
)
|
|
239
|
+
approval = interrupt(
|
|
240
|
+
{
|
|
241
|
+
"type": "authorization_approval",
|
|
242
|
+
"message": "A human decision is required before this tool can execute.",
|
|
243
|
+
"tool_name": tool_name,
|
|
244
|
+
"tool_call_id": tool_call_id,
|
|
245
|
+
"principal_id": principal.principal_id,
|
|
246
|
+
"roles": sorted(principal.roles),
|
|
247
|
+
"action": access_request.action,
|
|
248
|
+
"resource": access_request.resource,
|
|
249
|
+
"policy_rule_ids": list(decision.matched_rule_ids),
|
|
250
|
+
"execution_scope": execution_scope,
|
|
251
|
+
"request_fingerprint": fingerprint,
|
|
252
|
+
}
|
|
253
|
+
)
|
|
254
|
+
|
|
255
|
+
approval_valid = (
|
|
256
|
+
isinstance(approval, Mapping)
|
|
257
|
+
and type(approval.get("approved")) is bool
|
|
258
|
+
and approval.get("request_fingerprint") == fingerprint
|
|
259
|
+
)
|
|
260
|
+
if approval_valid and approval_validator is not None:
|
|
261
|
+
validation_result = approval_validator(
|
|
262
|
+
approval,
|
|
263
|
+
access_request,
|
|
264
|
+
fingerprint,
|
|
265
|
+
)
|
|
266
|
+
approval_valid = type(validation_result) is bool and validation_result
|
|
267
|
+
|
|
268
|
+
if not approval_valid:
|
|
269
|
+
outcome = Decision(
|
|
270
|
+
allowed=False,
|
|
271
|
+
reason=DecisionReason.APPROVAL_INVALID,
|
|
272
|
+
matched_rule_ids=decision.matched_rule_ids,
|
|
273
|
+
)
|
|
274
|
+
if audit_callback is not None:
|
|
275
|
+
audit_callback(access_request, outcome)
|
|
276
|
+
return ToolMessage(
|
|
277
|
+
content="Approval response invalid; tool was not executed.",
|
|
278
|
+
tool_call_id=tool_call_id,
|
|
279
|
+
status="error",
|
|
280
|
+
)
|
|
281
|
+
|
|
282
|
+
if not approval["approved"]:
|
|
283
|
+
outcome = Decision(
|
|
284
|
+
allowed=False,
|
|
285
|
+
reason=DecisionReason.APPROVAL_DENIED,
|
|
286
|
+
matched_rule_ids=decision.matched_rule_ids,
|
|
287
|
+
)
|
|
288
|
+
if audit_callback is not None:
|
|
289
|
+
audit_callback(access_request, outcome)
|
|
290
|
+
return ToolMessage(
|
|
291
|
+
content="Human approval denied.",
|
|
292
|
+
tool_call_id=tool_call_id,
|
|
293
|
+
status="error",
|
|
294
|
+
)
|
|
295
|
+
|
|
296
|
+
outcome = Decision(
|
|
297
|
+
allowed=True,
|
|
298
|
+
reason=DecisionReason.HUMAN_APPROVED,
|
|
299
|
+
matched_rule_ids=decision.matched_rule_ids,
|
|
300
|
+
)
|
|
301
|
+
if audit_callback is not None:
|
|
302
|
+
audit_callback(access_request, outcome)
|
|
303
|
+
return handler(request)
|
|
304
|
+
|
|
305
|
+
if audit_callback is not None:
|
|
306
|
+
audit_callback(access_request, decision)
|
|
307
|
+
|
|
308
|
+
if not decision.allowed:
|
|
309
|
+
if not isinstance(tool_call_id, str) or not tool_call_id:
|
|
310
|
+
raise ValueError("denied tool calls must include a tool-call id")
|
|
311
|
+
return ToolMessage(
|
|
312
|
+
content="Authorization denied.",
|
|
313
|
+
tool_call_id=tool_call_id,
|
|
314
|
+
status="error",
|
|
315
|
+
)
|
|
316
|
+
|
|
317
|
+
return handler(request)
|
|
318
|
+
|
|
319
|
+
sync_middleware = enforce_authorization
|
|
320
|
+
sentinel = ToolMessage(
|
|
321
|
+
content="authorization sentinel",
|
|
322
|
+
tool_call_id="__secure_agents_sentinel__",
|
|
323
|
+
)
|
|
324
|
+
|
|
325
|
+
async def enforce_authorization_async(request: Any, handler: Any) -> Any:
|
|
326
|
+
# Reuse the exact synchronous authorization path, substituting a sentinel
|
|
327
|
+
# handler so the real async tool is only awaited after authorization passes.
|
|
328
|
+
result = sync_middleware.wrap_tool_call(request, lambda _: sentinel)
|
|
329
|
+
if result is sentinel:
|
|
330
|
+
return await handler(request)
|
|
331
|
+
return result
|
|
332
|
+
|
|
333
|
+
async_middleware = wrap_tool_call(name="secure_agent_authorization_async")(
|
|
334
|
+
enforce_authorization_async
|
|
335
|
+
)
|
|
336
|
+
|
|
337
|
+
class _CombinedAuthorizationMiddleware(AgentMiddleware[Any, Any, Any]):
|
|
338
|
+
def wrap_tool_call(self, request: Any, handler: Any) -> Any:
|
|
339
|
+
return sync_middleware.wrap_tool_call(request, handler)
|
|
340
|
+
|
|
341
|
+
async def awrap_tool_call(self, request: Any, handler: Any) -> Any:
|
|
342
|
+
return await async_middleware.awrap_tool_call(request, handler)
|
|
343
|
+
|
|
344
|
+
return _CombinedAuthorizationMiddleware()
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
"""Immutable domain models for authorization decisions."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Iterable
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from enum import StrEnum
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _normalized_text(value: str, field_name: str) -> str:
|
|
11
|
+
if not isinstance(value, str) or not value.strip():
|
|
12
|
+
raise ValueError(f"{field_name} must be a non-empty string")
|
|
13
|
+
return value.strip()
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _normalized_set(
|
|
17
|
+
values: Iterable[str],
|
|
18
|
+
field_name: str,
|
|
19
|
+
*,
|
|
20
|
+
allow_empty: bool = False,
|
|
21
|
+
) -> frozenset[str]:
|
|
22
|
+
if isinstance(values, (str, bytes)):
|
|
23
|
+
raise ValueError(f"{field_name} must be a collection of strings, not a string")
|
|
24
|
+
normalized: set[str] = set()
|
|
25
|
+
try:
|
|
26
|
+
for value in values:
|
|
27
|
+
normalized.add(_normalized_text(value, field_name))
|
|
28
|
+
except TypeError as exc:
|
|
29
|
+
raise ValueError(f"{field_name} must be an iterable of strings") from exc
|
|
30
|
+
if not normalized and not allow_empty:
|
|
31
|
+
raise ValueError(f"{field_name} must contain at least one value")
|
|
32
|
+
return frozenset(normalized)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class Effect(StrEnum):
|
|
36
|
+
"""The effect a matching policy rule has on an access request."""
|
|
37
|
+
|
|
38
|
+
ALLOW = "allow"
|
|
39
|
+
DENY = "deny"
|
|
40
|
+
REQUIRE_APPROVAL = "require_approval"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass(frozen=True, slots=True)
|
|
44
|
+
class Principal:
|
|
45
|
+
"""An authenticated identity supplied by trusted application code.
|
|
46
|
+
|
|
47
|
+
This object does not authenticate users. Applications must construct it
|
|
48
|
+
from a trusted identity source, never directly from untrusted LLM output.
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
principal_id: str
|
|
52
|
+
roles: frozenset[str] = frozenset()
|
|
53
|
+
|
|
54
|
+
def __post_init__(self) -> None:
|
|
55
|
+
normalized_id = _normalized_text(self.principal_id, "principal_id")
|
|
56
|
+
object.__setattr__(self, "principal_id", normalized_id)
|
|
57
|
+
object.__setattr__(
|
|
58
|
+
self,
|
|
59
|
+
"roles",
|
|
60
|
+
_normalized_set(self.roles, "roles", allow_empty=True),
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@dataclass(frozen=True, slots=True)
|
|
65
|
+
class AccessRequest:
|
|
66
|
+
"""A concrete request to perform an action on a resource."""
|
|
67
|
+
|
|
68
|
+
principal: Principal
|
|
69
|
+
action: str
|
|
70
|
+
resource: str
|
|
71
|
+
|
|
72
|
+
def __post_init__(self) -> None:
|
|
73
|
+
if not isinstance(self.principal, Principal):
|
|
74
|
+
raise ValueError("principal must be a Principal instance")
|
|
75
|
+
action = _normalized_text(self.action, "action")
|
|
76
|
+
resource = _normalized_text(self.resource, "resource")
|
|
77
|
+
if action == "*" or resource == "*":
|
|
78
|
+
raise ValueError("access requests must name a concrete action and resource")
|
|
79
|
+
object.__setattr__(self, "action", action)
|
|
80
|
+
object.__setattr__(self, "resource", resource)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@dataclass(frozen=True, slots=True)
|
|
84
|
+
class PolicyRule:
|
|
85
|
+
"""A policy rule with explicit subject, action, resource, and effect.
|
|
86
|
+
|
|
87
|
+
Selectors are exact matches unless the whole action/resource selector is
|
|
88
|
+
the wildcard. A principal wildcard explicitly targets every principal.
|
|
89
|
+
When both principal IDs and roles are supplied, both constraints must match.
|
|
90
|
+
"""
|
|
91
|
+
|
|
92
|
+
rule_id: str
|
|
93
|
+
effect: Effect
|
|
94
|
+
actions: frozenset[str]
|
|
95
|
+
resources: frozenset[str]
|
|
96
|
+
principal_ids: frozenset[str] = frozenset()
|
|
97
|
+
roles: frozenset[str] = frozenset()
|
|
98
|
+
|
|
99
|
+
def __post_init__(self) -> None:
|
|
100
|
+
object.__setattr__(self, "rule_id", _normalized_text(self.rule_id, "rule_id"))
|
|
101
|
+
try:
|
|
102
|
+
effect = Effect(self.effect)
|
|
103
|
+
except (TypeError, ValueError) as exc:
|
|
104
|
+
raise ValueError("effect must be ALLOW, DENY, or REQUIRE_APPROVAL") from exc
|
|
105
|
+
object.__setattr__(self, "effect", effect)
|
|
106
|
+
|
|
107
|
+
actions = _normalized_set(self.actions, "actions")
|
|
108
|
+
resources = _normalized_set(self.resources, "resources")
|
|
109
|
+
principal_ids = _normalized_set(self.principal_ids, "principal_ids", allow_empty=True)
|
|
110
|
+
roles = _normalized_set(self.roles, "roles", allow_empty=True)
|
|
111
|
+
|
|
112
|
+
for field_name, selectors in (("actions", actions), ("resources", resources)):
|
|
113
|
+
if "*" in selectors and selectors != frozenset({"*"}):
|
|
114
|
+
raise ValueError(f"{field_name} cannot combine '*' with specific selectors")
|
|
115
|
+
if any("*" in selector and selector != "*" for selector in selectors):
|
|
116
|
+
raise ValueError(f"{field_name} only supports the standalone '*' wildcard")
|
|
117
|
+
|
|
118
|
+
if not principal_ids and not roles:
|
|
119
|
+
raise ValueError("a policy rule must specify principal_ids or roles")
|
|
120
|
+
if "*" in principal_ids and principal_ids != frozenset({"*"}):
|
|
121
|
+
raise ValueError("principal_ids cannot combine '*' with specific identities")
|
|
122
|
+
if "*" in principal_ids and roles:
|
|
123
|
+
raise ValueError("principal_ids={'*'} cannot be combined with role constraints")
|
|
124
|
+
|
|
125
|
+
object.__setattr__(self, "actions", actions)
|
|
126
|
+
object.__setattr__(self, "resources", resources)
|
|
127
|
+
object.__setattr__(self, "principal_ids", principal_ids)
|
|
128
|
+
object.__setattr__(self, "roles", roles)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
class DecisionReason(StrEnum):
|
|
132
|
+
"""Stable machine-readable reasons emitted by the policy layer."""
|
|
133
|
+
|
|
134
|
+
EXPLICIT_ALLOW = "explicit_allow"
|
|
135
|
+
EXPLICIT_DENY = "explicit_deny"
|
|
136
|
+
DEFAULT_DENY = "default_deny"
|
|
137
|
+
APPROVAL_REQUIRED = "approval_required"
|
|
138
|
+
APPROVAL_INVALID = "approval_invalid"
|
|
139
|
+
APPROVAL_DENIED = "approval_denied"
|
|
140
|
+
HUMAN_APPROVED = "human_approved"
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
@dataclass(frozen=True, slots=True)
|
|
144
|
+
class Decision:
|
|
145
|
+
"""A deterministic authorization outcome and its explainable basis."""
|
|
146
|
+
|
|
147
|
+
allowed: bool
|
|
148
|
+
reason: DecisionReason
|
|
149
|
+
matched_rule_ids: tuple[str, ...] = ()
|
|
150
|
+
requires_approval: bool = False
|
|
151
|
+
|
|
152
|
+
def __post_init__(self) -> None:
|
|
153
|
+
if type(self.allowed) is not bool:
|
|
154
|
+
raise ValueError("allowed must be a boolean")
|
|
155
|
+
|
|
156
|
+
normalized_reason = _normalized_text(self.reason, "reason")
|
|
157
|
+
try:
|
|
158
|
+
reason = DecisionReason(normalized_reason)
|
|
159
|
+
except ValueError as exc:
|
|
160
|
+
raise ValueError("reason must be a known DecisionReason") from exc
|
|
161
|
+
|
|
162
|
+
if type(self.requires_approval) is not bool:
|
|
163
|
+
raise ValueError("requires_approval must be a boolean")
|
|
164
|
+
|
|
165
|
+
if isinstance(self.matched_rule_ids, (str, bytes)):
|
|
166
|
+
raise ValueError("matched_rule_ids must be a collection of rule IDs")
|
|
167
|
+
try:
|
|
168
|
+
rule_ids = tuple(
|
|
169
|
+
_normalized_text(rule_id, "matched_rule_ids") for rule_id in self.matched_rule_ids
|
|
170
|
+
)
|
|
171
|
+
except TypeError as exc:
|
|
172
|
+
raise ValueError("matched_rule_ids must be an iterable of strings") from exc
|
|
173
|
+
|
|
174
|
+
if len(set(rule_ids)) != len(rule_ids):
|
|
175
|
+
raise ValueError("matched_rule_ids cannot contain duplicates")
|
|
176
|
+
|
|
177
|
+
if self.requires_approval != (reason is DecisionReason.APPROVAL_REQUIRED):
|
|
178
|
+
raise ValueError(
|
|
179
|
+
"requires_approval must be true exactly when reason is 'approval_required'"
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
allowed_reasons = {
|
|
183
|
+
DecisionReason.EXPLICIT_ALLOW,
|
|
184
|
+
DecisionReason.HUMAN_APPROVED,
|
|
185
|
+
}
|
|
186
|
+
if self.allowed and reason not in allowed_reasons:
|
|
187
|
+
raise ValueError(f"reason {reason.value!r} cannot describe an allowed decision")
|
|
188
|
+
if not self.allowed and reason in allowed_reasons:
|
|
189
|
+
raise ValueError(f"reason {reason.value!r} cannot describe a denied decision")
|
|
190
|
+
|
|
191
|
+
rule_required_reasons = {
|
|
192
|
+
DecisionReason.EXPLICIT_ALLOW,
|
|
193
|
+
DecisionReason.EXPLICIT_DENY,
|
|
194
|
+
DecisionReason.APPROVAL_REQUIRED,
|
|
195
|
+
DecisionReason.APPROVAL_INVALID,
|
|
196
|
+
DecisionReason.APPROVAL_DENIED,
|
|
197
|
+
DecisionReason.HUMAN_APPROVED,
|
|
198
|
+
}
|
|
199
|
+
if reason in rule_required_reasons and not rule_ids:
|
|
200
|
+
raise ValueError(f"reason {reason.value!r} requires matched_rule_ids")
|
|
201
|
+
if reason is DecisionReason.DEFAULT_DENY and rule_ids:
|
|
202
|
+
raise ValueError("default_deny cannot have matched_rule_ids")
|
|
203
|
+
|
|
204
|
+
object.__setattr__(self, "reason", reason)
|
|
205
|
+
object.__setattr__(self, "matched_rule_ids", rule_ids)
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"""Deterministic policy evaluation with deny-overrides semantics."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Iterable
|
|
6
|
+
|
|
7
|
+
from .models import AccessRequest, Decision, DecisionReason, Effect, PolicyRule
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class PolicyEngine:
|
|
11
|
+
"""Evaluate authorization requests without involving an LLM.
|
|
12
|
+
|
|
13
|
+
A matching DENY rule always overrides every other effect. Otherwise a
|
|
14
|
+
matching REQUIRE_APPROVAL rule takes precedence over ALLOW and returns a
|
|
15
|
+
non-allowing decision that an approval-aware integration may handle.
|
|
16
|
+
Requests not covered by an allow or approval rule are denied by default.
|
|
17
|
+
|
|
18
|
+
Rules are immutable after construction. Create a new engine to change policy.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
__slots__ = ("_rules",)
|
|
22
|
+
|
|
23
|
+
def __setattr__(self, name: str, value: object) -> None:
|
|
24
|
+
if name == "_rules" and hasattr(self, "_rules"):
|
|
25
|
+
raise AttributeError("PolicyEngine rules are immutable after construction")
|
|
26
|
+
object.__setattr__(self, name, value)
|
|
27
|
+
|
|
28
|
+
def __init__(self, rules: Iterable[PolicyRule] = ()) -> None:
|
|
29
|
+
materialized_rules = tuple(rules)
|
|
30
|
+
seen_ids: set[str] = set()
|
|
31
|
+
for rule in materialized_rules:
|
|
32
|
+
if not isinstance(rule, PolicyRule):
|
|
33
|
+
raise TypeError("rules must contain only PolicyRule instances")
|
|
34
|
+
if rule.rule_id in seen_ids:
|
|
35
|
+
raise ValueError(f"duplicate policy rule id: {rule.rule_id}")
|
|
36
|
+
seen_ids.add(rule.rule_id)
|
|
37
|
+
self._rules = materialized_rules
|
|
38
|
+
|
|
39
|
+
@property
|
|
40
|
+
def rules(self) -> tuple[PolicyRule, ...]:
|
|
41
|
+
"""Return the policy rules as an immutable tuple."""
|
|
42
|
+
|
|
43
|
+
return self._rules
|
|
44
|
+
|
|
45
|
+
def evaluate(self, request: AccessRequest) -> Decision:
|
|
46
|
+
"""Return the authorization decision for a concrete access request."""
|
|
47
|
+
|
|
48
|
+
if not isinstance(request, AccessRequest):
|
|
49
|
+
raise TypeError("request must be an AccessRequest instance")
|
|
50
|
+
|
|
51
|
+
matching_rules = tuple(rule for rule in self._rules if self._matches(rule, request))
|
|
52
|
+
denying_rule_ids = tuple(
|
|
53
|
+
rule.rule_id for rule in matching_rules if rule.effect is Effect.DENY
|
|
54
|
+
)
|
|
55
|
+
if denying_rule_ids:
|
|
56
|
+
return Decision(
|
|
57
|
+
allowed=False,
|
|
58
|
+
reason=DecisionReason.EXPLICIT_DENY,
|
|
59
|
+
matched_rule_ids=denying_rule_ids,
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
approval_rule_ids = tuple(
|
|
63
|
+
rule.rule_id for rule in matching_rules if rule.effect is Effect.REQUIRE_APPROVAL
|
|
64
|
+
)
|
|
65
|
+
if approval_rule_ids:
|
|
66
|
+
return Decision(
|
|
67
|
+
allowed=False,
|
|
68
|
+
reason=DecisionReason.APPROVAL_REQUIRED,
|
|
69
|
+
matched_rule_ids=approval_rule_ids,
|
|
70
|
+
requires_approval=True,
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
allowing_rule_ids = tuple(
|
|
74
|
+
rule.rule_id for rule in matching_rules if rule.effect is Effect.ALLOW
|
|
75
|
+
)
|
|
76
|
+
if allowing_rule_ids:
|
|
77
|
+
return Decision(
|
|
78
|
+
allowed=True,
|
|
79
|
+
reason=DecisionReason.EXPLICIT_ALLOW,
|
|
80
|
+
matched_rule_ids=allowing_rule_ids,
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
return Decision(allowed=False, reason=DecisionReason.DEFAULT_DENY)
|
|
84
|
+
|
|
85
|
+
@staticmethod
|
|
86
|
+
def _matches(rule: PolicyRule, request: AccessRequest) -> bool:
|
|
87
|
+
if rule.actions != frozenset({"*"}) and request.action not in rule.actions:
|
|
88
|
+
return False
|
|
89
|
+
if rule.resources != frozenset({"*"}) and request.resource not in rule.resources:
|
|
90
|
+
return False
|
|
91
|
+
|
|
92
|
+
if rule.principal_ids and rule.principal_ids != frozenset({"*"}):
|
|
93
|
+
if request.principal.principal_id not in rule.principal_ids:
|
|
94
|
+
return False
|
|
95
|
+
|
|
96
|
+
if rule.roles and not request.principal.roles.intersection(rule.roles):
|
|
97
|
+
return False
|
|
98
|
+
|
|
99
|
+
return True
|
|
File without changes
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: langgraph-secure-agents
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Deterministic access control for LangGraph agents.
|
|
5
|
+
Project-URL: Homepage, https://github.com/peymanpro/LangGraph-SecureAgents
|
|
6
|
+
Project-URL: Repository, https://github.com/peymanpro/LangGraph-SecureAgents
|
|
7
|
+
Project-URL: Issues, https://github.com/peymanpro/LangGraph-SecureAgents/issues
|
|
8
|
+
Project-URL: Security, https://github.com/peymanpro/LangGraph-SecureAgents/blob/main/SECURITY.md
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: access-control,ai-agents,authorization,langgraph,security
|
|
12
|
+
Classifier: Development Status :: 3 - Alpha
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
19
|
+
Classifier: Typing :: Typed
|
|
20
|
+
Requires-Python: >=3.11
|
|
21
|
+
Provides-Extra: dev
|
|
22
|
+
Requires-Dist: langchain<2.0,>=1.0; extra == 'dev'
|
|
23
|
+
Requires-Dist: langgraph<2.0,>=1.2; extra == 'dev'
|
|
24
|
+
Requires-Dist: mypy>=1.13; extra == 'dev'
|
|
25
|
+
Requires-Dist: pytest-cov>=6.0; extra == 'dev'
|
|
26
|
+
Requires-Dist: pytest>=8.3; extra == 'dev'
|
|
27
|
+
Requires-Dist: pyyaml>=6.0; extra == 'dev'
|
|
28
|
+
Requires-Dist: ruff>=0.9; extra == 'dev'
|
|
29
|
+
Provides-Extra: langchain-agents
|
|
30
|
+
Requires-Dist: langchain-core<2.0,>=1.0; extra == 'langchain-agents'
|
|
31
|
+
Requires-Dist: langchain<2.0,>=1.0; extra == 'langchain-agents'
|
|
32
|
+
Requires-Dist: langgraph<2.0,>=1.2; extra == 'langchain-agents'
|
|
33
|
+
Provides-Extra: langgraph
|
|
34
|
+
Requires-Dist: langgraph<2.0,>=1.2; extra == 'langgraph'
|
|
35
|
+
Description-Content-Type: text/markdown
|
|
36
|
+
|
|
37
|
+
# LangGraph-SecureAgents
|
|
38
|
+
|
|
39
|
+
**Deterministic access control for LangGraph-powered agents.**
|
|
40
|
+
|
|
41
|
+
LangGraph-SecureAgents is an experimental Python library for enforcing authorization at agent tool-execution boundaries. Agent orchestration may be handled by LangGraph; authorization decisions are made by trusted application code, never by an LLM.
|
|
42
|
+
|
|
43
|
+
> **Status:** early development. The policy engine, guarded-callable primitive, and optional agent middleware are implemented, but the API is not stable and has not received an independent security audit. Do not use this project to protect production data yet.
|
|
44
|
+
|
|
45
|
+
## Security principles
|
|
46
|
+
|
|
47
|
+
Read the [threat model](docs/threat-model.md) for trust boundaries and deployment guidance, and [SECURITY.md](SECURITY.md) for vulnerability reporting and security limitations.
|
|
48
|
+
|
|
49
|
+
1. **Default deny:** no matching allow policy means access is denied.
|
|
50
|
+
2. **Deny overrides:** an explicit deny rule wins over any matching allow rule.
|
|
51
|
+
3. **Deterministic authorization:** model output cannot grant permissions.
|
|
52
|
+
4. **Enforce at execution:** evaluate policy immediately before a protected operation.
|
|
53
|
+
5. **Trusted identity:** the application must resolve principal identity and roles from authenticated context, never from LLM-controlled tool arguments.
|
|
54
|
+
6. **Fail closed:** resolver or configured audit failures prevent protected execution.
|
|
55
|
+
7. **Least privilege:** rules specify a principal or role, action, and resource.
|
|
56
|
+
|
|
57
|
+
## Installation
|
|
58
|
+
|
|
59
|
+
Requires Python 3.11 or newer.
|
|
60
|
+
|
|
61
|
+
### Install the published package
|
|
62
|
+
|
|
63
|
+
After the first PyPI release is available, install the alpha package with its optional agent middleware:
|
|
64
|
+
|
|
65
|
+
```bash
|
|
66
|
+
python -m pip install --pre "langgraph-secure-agents[langchain-agents]"
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
For the core policy engine and callable guard without LangGraph middleware:
|
|
70
|
+
|
|
71
|
+
```bash
|
|
72
|
+
python -m pip install --pre langgraph-secure-agents
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
The `--pre` flag is needed while the latest published version is an alpha pre-release. Until the package is available on PyPI, use the source-install instructions below.
|
|
76
|
+
|
|
77
|
+
### Install from a source checkout
|
|
78
|
+
|
|
79
|
+
Clone this repository, then install the core package and development tools:
|
|
80
|
+
|
|
81
|
+
```bash
|
|
82
|
+
python -m pip install -e ".[dev]"
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
Install the optional middleware dependencies from the same checkout:
|
|
86
|
+
|
|
87
|
+
```bash
|
|
88
|
+
python -m pip install -e ".[langchain-agents]"
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
The core policy engine and callable guard do not require LangGraph or an LLM provider.
|
|
92
|
+
|
|
93
|
+
## Run the model-free LangGraph example
|
|
94
|
+
|
|
95
|
+
The example uses fake payroll records and a trusted runtime context. It needs no model API key:
|
|
96
|
+
|
|
97
|
+
```bash
|
|
98
|
+
python examples/policy_guarded_workflow.py
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
It demonstrates an allowed HR request, a denied request for an unapproved resource, and a denied request from a user without the required role.
|
|
102
|
+
|
|
103
|
+
## Quick start: deterministic policy engine
|
|
104
|
+
|
|
105
|
+
```python
|
|
106
|
+
from langgraph_secure_agents import (
|
|
107
|
+
AccessRequest,
|
|
108
|
+
DecisionReason,
|
|
109
|
+
Effect,
|
|
110
|
+
PolicyEngine,
|
|
111
|
+
PolicyRule,
|
|
112
|
+
Principal,
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
policy = PolicyEngine(
|
|
116
|
+
[
|
|
117
|
+
PolicyRule(
|
|
118
|
+
rule_id="analysts-read-reports",
|
|
119
|
+
effect=Effect.ALLOW,
|
|
120
|
+
principal_ids=frozenset(),
|
|
121
|
+
roles=frozenset({"analyst"}),
|
|
122
|
+
actions=frozenset({"read"}),
|
|
123
|
+
resources=frozenset({"reports"}),
|
|
124
|
+
)
|
|
125
|
+
]
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
decision = policy.evaluate(
|
|
129
|
+
AccessRequest(
|
|
130
|
+
principal=Principal("user-123", roles=frozenset({"analyst"})),
|
|
131
|
+
action="read",
|
|
132
|
+
resource="reports",
|
|
133
|
+
)
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
assert decision.allowed
|
|
137
|
+
assert decision.reason is DecisionReason.EXPLICIT_ALLOW
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
### Decision outcomes and policy updates
|
|
141
|
+
|
|
142
|
+
`Decision.reason` is a public `DecisionReason` enum with stable machine-readable values: `explicit_allow`, `explicit_deny`, `default_deny`, `approval_required`, `approval_invalid`, `approval_denied`, and `human_approved`. Use the enum constants in application code rather than comparing free-form strings. `matched_rule_ids` identifies the policy rules responsible for a rule-based outcome; a default-deny decision has no matched rule IDs. `requires_approval` is true only for `approval_required`.
|
|
143
|
+
|
|
144
|
+
A `PolicyEngine`'s rules cannot be replaced after construction. To deploy a policy change, construct and validate a new engine, then switch the application to it using the application's own configuration/reload mechanism. Do not mutate private state on a live engine. This matters for approval pauses: the request fingerprint binds approval to the matched rule definitions, so a resumed decision must still refer to the policy that produced the interrupt.
|
|
145
|
+
|
|
146
|
+
## Protect a Python callable
|
|
147
|
+
|
|
148
|
+
```python
|
|
149
|
+
from langgraph_secure_agents import PolicyEngine, Principal, protect_callable
|
|
150
|
+
|
|
151
|
+
# This resolver must read a trusted, request-scoped identity source.
|
|
152
|
+
current_principal = lambda: Principal("user-123", roles=frozenset({"analyst"}))
|
|
153
|
+
|
|
154
|
+
protected_lookup = protect_callable(
|
|
155
|
+
lookup_report,
|
|
156
|
+
policy=policy,
|
|
157
|
+
principal_resolver=current_principal,
|
|
158
|
+
action="read",
|
|
159
|
+
resource_resolver=lambda report_id: f"report:{report_id}",
|
|
160
|
+
)
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
The resource resolver must describe the same resource the wrapped function actually accesses. Resolver callbacks receive detached copies of arguments, and attempts to mutate those copies fail closed before execution. Resolvers must otherwise be trusted, side-effect-free application code. For multi-user applications, the principal resolver must read request-scoped trusted context; do not capture one user's identity in a process-wide shared tool.
|
|
164
|
+
|
|
165
|
+
## Optional agent middleware
|
|
166
|
+
|
|
167
|
+
The optional module `langgraph_secure_agents.middleware` provides `create_authorization_middleware` for LangChain agents built on LangGraph. It intercepts tool calls before execution. By default, the policy action is `execute` and the resource is the tool name. Custom resolvers can map tool calls to domain-level actions and resources.
|
|
168
|
+
|
|
169
|
+
The middleware receives runtime context from the agent invocation. The application must configure a principal resolver that validates this context and creates a `Principal` from trusted identity data. Custom action/resource resolvers receive detached copies of tool arguments; attempts to mutate these copies fail closed. Denied calls return an error tool message and do not invoke the tool handler.
|
|
170
|
+
|
|
171
|
+
## Human approval for sensitive actions
|
|
172
|
+
|
|
173
|
+
A policy can explicitly require approval instead of granting access immediately:
|
|
174
|
+
|
|
175
|
+
```python
|
|
176
|
+
approval_policy = PolicyEngine(
|
|
177
|
+
[
|
|
178
|
+
PolicyRule(
|
|
179
|
+
rule_id="approval-required-payroll-read",
|
|
180
|
+
effect=Effect.REQUIRE_APPROVAL,
|
|
181
|
+
actions=frozenset({"read"}),
|
|
182
|
+
resources=frozenset({"payroll:employee-7"}),
|
|
183
|
+
roles=frozenset({"hr"}),
|
|
184
|
+
)
|
|
185
|
+
]
|
|
186
|
+
)
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
When this policy is used with the optional agent middleware, LangGraph pauses at an interrupt before the tool handler executes. The paused payload describes the principal, action, resource, policy rule, tool, and a request fingerprint. After a reviewer makes a decision, the host application resumes the same checkpoint/thread with a value shaped like:
|
|
190
|
+
|
|
191
|
+
```python
|
|
192
|
+
{
|
|
193
|
+
"approved": True,
|
|
194
|
+
"request_fingerprint": fingerprint_from_interrupt_payload,
|
|
195
|
+
}
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
The middleware rejects missing, malformed, or mismatched approval responses. The fingerprint binds the response to the current principal, roles, tool call, arguments, action, resource, exact matched policy definitions, and LangGraph execution scope (`thread_id`, `checkpoint_id`, `checkpoint_ns`, and `task_id`). This makes the fingerprint different in another checkpointed execution even when request fields and the tool-call ID are repeated. Approval-required calls need LangGraph 1.2 or newer with an active checkpointer; missing execution metadata fails closed. Fingerprinting rejects non-string JSON object keys, non-finite numbers, cyclic structures, and other non-JSON argument values. **The fingerprint is a deterministic request-binding check, not proof of reviewer identity, permission, or one-time use.** The host application must authenticate the reviewer, authorize who can resume the paused run, and protect the resume endpoint. For side-effecting tools, ensure request IDs are unique per operation and durably consume each approval once. The `approval_validator` callback described below can enforce reviewer validation and atomic consumption at the middleware boundary. An explicit matching deny rule always takes precedence over approval.
|
|
199
|
+
|
|
200
|
+
For deployments that need enforcement in the library boundary, pass an `approval_validator` callback to `create_authorization_middleware`. It receives the resumed approval mapping, the resolved `AccessRequest`, and the request fingerprint. It should authenticate and authorize the reviewer, then atomically claim/consume the fingerprint in a durable store; return the literal `True` only for a valid first use. A false or non-boolean result rejects the approval, and an exception fails closed by preventing tool execution. Implement this callback with your application's identity provider and transactional storage—do not use an in-memory set for multi-process or persistent workloads. The callback is invoked for validly-shaped approval and rejection responses, so the host can consume either human decision once. If the callback is omitted, the host must enforce these controls outside the library. An explicit matching deny rule always takes precedence over approval.
|
|
201
|
+
|
|
202
|
+
## Validation
|
|
203
|
+
|
|
204
|
+
```bash
|
|
205
|
+
ruff check .
|
|
206
|
+
ruff format --check .
|
|
207
|
+
mypy src
|
|
208
|
+
pytest
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
GitHub Actions runs these checks across Python 3.11, 3.12, and 3.13 on pull requests and pushes to `main`, with a 98% minimum line-coverage gate. A separate packaging job builds both a wheel and source distribution, validates metadata, smoke-tests the installed wheel in a clean environment, and installs the `langchain-agents` extra in a separate environment to verify the optional middleware dependencies.
|
|
212
|
+
|
|
213
|
+
After a change is merged into `main`, the automatic release workflow analyzes Conventional Commit messages. If a release-worthy change exists and all release quality checks pass, it updates the version and changelog, creates a version commit and Git tag, builds distributions, creates a GitHub Release, and publishes to PyPI using Trusted Publishing. No manual tag creation is required. See [the release process](docs/releasing.md).
|
|
214
|
+
|
|
215
|
+
## Roadmap
|
|
216
|
+
|
|
217
|
+
- [x] Typed package foundation, license, and CI
|
|
218
|
+
- [x] Policy models and default-deny evaluation
|
|
219
|
+
- [x] Deny-overrides semantics and constrained wildcards
|
|
220
|
+
- [x] Guarded callable and fail-closed auditing
|
|
221
|
+
- [x] Optional agent tool-call middleware
|
|
222
|
+
- [x] Model-free LangGraph workflow example with mock payroll data
|
|
223
|
+
- [ ] Example conversational agent using a model provider
|
|
224
|
+
- [x] Human-approval interrupt integration for selected sensitive actions
|
|
225
|
+
- [x] Initial threat model and security reporting guidance
|
|
226
|
+
- [ ] API review and release-readiness checks
|
|
227
|
+
|
|
228
|
+
## Changelog
|
|
229
|
+
|
|
230
|
+
See [CHANGELOG.md](CHANGELOG.md) for user-facing changes and release notes.
|
|
231
|
+
|
|
232
|
+
## License
|
|
233
|
+
|
|
234
|
+
MIT. See [LICENSE](LICENSE).
|
|
235
|
+
|
|
236
|
+
## Disclaimer
|
|
237
|
+
|
|
238
|
+
This project is experimental and is not a substitute for a complete application security review. Integrators remain responsible for authenticating principals, validating trusted context, enforcing tenant boundaries, and protecting downstream resources.
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
langgraph_secure_agents/__init__.py,sha256=Z7aXJLBKqH68CG4SdJko1U02p4Gbo59tUbD_g0gvvl8,477
|
|
2
|
+
langgraph_secure_agents/_resolver_safety.py,sha256=-HLqzybPuhMc4kLk9zh4bldKQGJLvOZldZt1DnZCoVI,1521
|
|
3
|
+
langgraph_secure_agents/guard.py,sha256=Rsu-IjZewoRuO06vRx-_C2QEq_fi8D5nOmmOOAiBwmg,4722
|
|
4
|
+
langgraph_secure_agents/middleware.py,sha256=6LDWo78mF-ZkXlyD2v0Af8uIbpxMSLMaEIqGhdSUoDk,14536
|
|
5
|
+
langgraph_secure_agents/models.py,sha256=yOxktNz4mKELSeTZwek1e0b-tRRhe1bL7KXF0lZhS64,7937
|
|
6
|
+
langgraph_secure_agents/policy.py,sha256=JjnMwkIIj2qn4ZJUvx3wJiiIcEpCZxicdGuPczfydL4,3763
|
|
7
|
+
langgraph_secure_agents/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
8
|
+
langgraph_secure_agents-0.1.0.dist-info/METADATA,sha256=9nfHqEP3fM1VWLSO-V9wsqnrv35h5ibMrM2mEAbsEFY,12881
|
|
9
|
+
langgraph_secure_agents-0.1.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
10
|
+
langgraph_secure_agents-0.1.0.dist-info/licenses/LICENSE,sha256=PlEz0DYFMMxhspuBc-XnidURFTIdmRkYIYMCqmo2tBc,1070
|
|
11
|
+
langgraph_secure_agents-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Peyman Salimi
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|