openscript-sdk 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.
- openscript_sdk/__init__.py +217 -0
- openscript_sdk/contracts/__init__.py +82 -0
- openscript_sdk/contracts/interceptor.py +98 -0
- openscript_sdk/contracts/server_types.py +87 -0
- openscript_sdk/contracts/session_graph.py +123 -0
- openscript_sdk/contracts/types.py +76 -0
- openscript_sdk/events/__init__.py +0 -0
- openscript_sdk/events/approvals.py +309 -0
- openscript_sdk/events/models.py +26 -0
- openscript_sdk/events/store.py +79 -0
- openscript_sdk/events/writer.py +100 -0
- openscript_sdk/hooks/claude_code.py +479 -0
- openscript_sdk/hooks/claude_code_example.yaml +62 -0
- openscript_sdk/hooks/claude_code_strict_example.yaml +73 -0
- openscript_sdk/hooks/config.py +144 -0
- openscript_sdk/hooks/settings_example.json +28 -0
- openscript_sdk/hooks/settings_strict_example.json +28 -0
- openscript_sdk/integrations/langchain.py +32 -0
- openscript_sdk/integrations/langgraph.py +32 -0
- openscript_sdk/interceptors/base.py +15 -0
- openscript_sdk/interceptors/event_writer.py +131 -0
- openscript_sdk/interceptors/pii.py +168 -0
- openscript_sdk/interceptors/threat.py +674 -0
- openscript_sdk/logging.py +94 -0
- openscript_sdk/middleware/middleware.py +543 -0
- openscript_sdk/middleware/tool.py +247 -0
- openscript_sdk/observability/__init__.py +11 -0
- openscript_sdk/observability/metrics.py +105 -0
- openscript_sdk/observability/risk.py +54 -0
- openscript_sdk/observability/tracing.py +67 -0
- openscript_sdk/policies/compliance.py +261 -0
- openscript_sdk/policies/config.py +244 -0
- openscript_sdk/policies/harmful_request.py +617 -0
- openscript_sdk/policies/normalize.py +489 -0
- openscript_sdk/policies/output_schema.py +353 -0
- openscript_sdk/policies/policies_example.yaml +36 -0
- openscript_sdk/policies/redaction.py +359 -0
- openscript_sdk/policies/secrets.py +239 -0
- openscript_sdk/policies/semantic.py +348 -0
- openscript_sdk/policies/semantic_head.py +9 -0
- openscript_sdk/policies/session_taint.py +399 -0
- openscript_sdk/policies/taint_store.py +111 -0
- openscript_sdk/policies/tool_firewall.py +434 -0
- openscript_sdk/policies/tools_example.yaml +81 -0
- openscript_sdk/policies/toxicity.py +247 -0
- openscript_sdk/py.typed +0 -0
- openscript_sdk/server/__init__.py +1 -0
- openscript_sdk/server/app.py +153 -0
- openscript_sdk/server/auth.py +19 -0
- openscript_sdk/server/guard.py +126 -0
- openscript_sdk/server/guard_example.yaml +55 -0
- openscript_sdk/server/routers/__init__.py +0 -0
- openscript_sdk/server/routers/approvals.py +69 -0
- openscript_sdk/server/routers/guard.py +231 -0
- openscript_sdk/server/routers/sessions.py +80 -0
- openscript_sdk/server/routers/sse.py +69 -0
- openscript_sdk/server/routers/threat.py +119 -0
- openscript_sdk/server/static/dashboard/index.html +209 -0
- openscript_sdk/server/static/dashboard/session.html +339 -0
- openscript_sdk-0.1.0.dist-info/METADATA +225 -0
- openscript_sdk-0.1.0.dist-info/RECORD +65 -0
- openscript_sdk-0.1.0.dist-info/WHEEL +5 -0
- openscript_sdk-0.1.0.dist-info/entry_points.txt +2 -0
- openscript_sdk-0.1.0.dist-info/licenses/LICENSE +190 -0
- openscript_sdk-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,434 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import math
|
|
4
|
+
import posixpath
|
|
5
|
+
import re
|
|
6
|
+
from collections.abc import Collection
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any, Literal
|
|
9
|
+
|
|
10
|
+
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
|
|
11
|
+
|
|
12
|
+
from openscript_sdk.contracts.interceptor import BasePolicy
|
|
13
|
+
from openscript_sdk.contracts.types import ActionContext, FailureMode, InterceptorDecision
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class ArgRule(BaseModel):
|
|
17
|
+
"""What one argument of a tool is allowed to be.
|
|
18
|
+
|
|
19
|
+
Every constraint that is set has to hold. A list or tuple is checked item
|
|
20
|
+
by item, so ``to=["a@x", "b@y"]`` passes only if both recipients do. A
|
|
21
|
+
value a constraint cannot be evaluated against — a dict where text was
|
|
22
|
+
expected, text where a number was — is refused, not waved through: a check
|
|
23
|
+
that could not run is not a check that passed.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
# A misspelt constraint must fail the load. Ignored, it would be a rule
|
|
27
|
+
# that reads as enforced and enforces nothing.
|
|
28
|
+
# Built on first validation, not at import: a caller holding rules that were
|
|
29
|
+
# validated already (ToolRules.from_validated) never pays for the schema.
|
|
30
|
+
model_config = ConfigDict(extra="forbid", defer_build=True)
|
|
31
|
+
|
|
32
|
+
required: bool = False
|
|
33
|
+
#: Exact values, compared with ==. True never matches 1.
|
|
34
|
+
one_of: list[Any] | None = None
|
|
35
|
+
#: A regular expression the whole value has to match (re.fullmatch), so
|
|
36
|
+
#: "example\\.com" cannot be satisfied by "example.com.attacker.io".
|
|
37
|
+
pattern: str | None = None
|
|
38
|
+
#: Directories the path has to sit inside, once ".." has been resolved.
|
|
39
|
+
path_prefix: list[str] | None = None
|
|
40
|
+
min: int | float | None = None
|
|
41
|
+
max: int | float | None = None
|
|
42
|
+
|
|
43
|
+
@field_validator("pattern")
|
|
44
|
+
@classmethod
|
|
45
|
+
def _pattern_compiles(cls, value: str | None) -> str | None:
|
|
46
|
+
if value is not None:
|
|
47
|
+
try:
|
|
48
|
+
re.compile(value)
|
|
49
|
+
except re.error as exc:
|
|
50
|
+
raise ValueError(
|
|
51
|
+
f"pattern {value!r} is not a valid regular expression: {exc}"
|
|
52
|
+
) from exc
|
|
53
|
+
return value
|
|
54
|
+
|
|
55
|
+
@field_validator("path_prefix", mode="before")
|
|
56
|
+
@classmethod
|
|
57
|
+
def _one_prefix_or_many(cls, value: Any) -> Any:
|
|
58
|
+
return [value] if isinstance(value, str) else value
|
|
59
|
+
|
|
60
|
+
@field_validator("min", "max", mode="before")
|
|
61
|
+
@classmethod
|
|
62
|
+
def _bound_is_a_number(cls, value: Any) -> Any:
|
|
63
|
+
if isinstance(value, bool) or (isinstance(value, float) and not math.isfinite(value)):
|
|
64
|
+
raise ValueError("a bound has to be a finite number")
|
|
65
|
+
return value
|
|
66
|
+
|
|
67
|
+
@model_validator(mode="after")
|
|
68
|
+
def _bounds_are_ordered(self) -> ArgRule:
|
|
69
|
+
if self.min is not None and self.max is not None and self.min > self.max:
|
|
70
|
+
raise ValueError(f"min {self.min} is greater than max {self.max}")
|
|
71
|
+
return self
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class ToolRule(BaseModel):
|
|
75
|
+
"""Firewall rule for a single named tool."""
|
|
76
|
+
|
|
77
|
+
# Built on first validation, not at import: a caller holding rules that were
|
|
78
|
+
# validated already (ToolRules.from_validated) never pays for the schema.
|
|
79
|
+
model_config = ConfigDict(extra="forbid", defer_build=True)
|
|
80
|
+
|
|
81
|
+
deny: bool = False
|
|
82
|
+
allowed_roles: list[str] = Field(default_factory=lambda: ["*"])
|
|
83
|
+
requires_approval: bool = False
|
|
84
|
+
#: Per-argument constraints, keyed by argument name.
|
|
85
|
+
args: dict[str, ArgRule] = Field(default_factory=dict)
|
|
86
|
+
#: What to do with an argument the caller passes that ``args`` does not
|
|
87
|
+
#: name. An allowlist on ``to`` means little while ``bcc`` is free, so a
|
|
88
|
+
#: rule that is meant to be the whole truth about what may be passed to a
|
|
89
|
+
#: tool should say "deny".
|
|
90
|
+
unlisted_args: Literal["allow", "deny"] = "allow"
|
|
91
|
+
#: The older spelling of a numeric bound: ``max_<arg>`` / ``min_<arg>``.
|
|
92
|
+
#: Still honoured, and folded into the same checks as ``args``.
|
|
93
|
+
arg_constraints: dict[str, int | float] = Field(default_factory=dict)
|
|
94
|
+
|
|
95
|
+
@field_validator("arg_constraints", mode="before")
|
|
96
|
+
@classmethod
|
|
97
|
+
def _limits_are_numbers(cls, value: Any) -> Any:
|
|
98
|
+
# Before pydantic coerces: True would otherwise arrive here as 1.
|
|
99
|
+
if isinstance(value, dict):
|
|
100
|
+
for key, limit in value.items():
|
|
101
|
+
if isinstance(limit, bool) or not isinstance(limit, (int, float)):
|
|
102
|
+
raise ValueError(f"arg_constraints {key!r} has to be a number")
|
|
103
|
+
return value
|
|
104
|
+
|
|
105
|
+
@model_validator(mode="after")
|
|
106
|
+
def _arg_constraints_make_sense(self) -> ToolRule:
|
|
107
|
+
# Raises on a key that is not a bound, or a bound set twice. Done at
|
|
108
|
+
# load so a bad rule file fails there and not on the first tool call.
|
|
109
|
+
self.arg_rules # noqa: B018
|
|
110
|
+
return self
|
|
111
|
+
|
|
112
|
+
@property
|
|
113
|
+
def arg_rules(self) -> dict[str, ArgRule]:
|
|
114
|
+
"""``args`` and ``arg_constraints`` as one set of per-argument rules."""
|
|
115
|
+
merged = dict(self.args)
|
|
116
|
+
for key, limit in self.arg_constraints.items():
|
|
117
|
+
bound, _, arg = key.partition("_")
|
|
118
|
+
if bound not in ("max", "min") or not arg:
|
|
119
|
+
raise ValueError(
|
|
120
|
+
f"arg_constraints key {key!r} is not max_<arg> or min_<arg>; "
|
|
121
|
+
"use args: for anything else"
|
|
122
|
+
)
|
|
123
|
+
current = merged.get(arg, ArgRule())
|
|
124
|
+
if getattr(current, bound) is not None:
|
|
125
|
+
raise ValueError(f"{bound} for {arg!r} is set under both args and arg_constraints")
|
|
126
|
+
merged[arg] = ArgRule.model_validate(
|
|
127
|
+
{**current.model_dump(exclude_none=True), bound: limit}
|
|
128
|
+
)
|
|
129
|
+
return merged
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
class ToolRules(BaseModel):
|
|
133
|
+
"""Top-level rules model -- maps tool name -> ToolRule.
|
|
134
|
+
|
|
135
|
+
Loaded from tools.yaml via load_rules(), or constructed directly.
|
|
136
|
+
"""
|
|
137
|
+
|
|
138
|
+
# Built on first validation, not at import: a caller holding rules that were
|
|
139
|
+
# validated already (ToolRules.from_validated) never pays for the schema.
|
|
140
|
+
model_config = ConfigDict(extra="forbid", defer_build=True)
|
|
141
|
+
|
|
142
|
+
rules: dict[str, ToolRule] = Field(default_factory=dict)
|
|
143
|
+
default_deny: bool = False
|
|
144
|
+
|
|
145
|
+
@classmethod
|
|
146
|
+
def from_validated(cls, data: dict[str, Any]) -> ToolRules:
|
|
147
|
+
"""Rules rebuilt from ``model_dump()`` of rules that were validated before,
|
|
148
|
+
without validating them again.
|
|
149
|
+
|
|
150
|
+
Only for data this package produced by validating: a hook that keeps
|
|
151
|
+
its checked config between calls, for one. Anything else goes through
|
|
152
|
+
model_validate.
|
|
153
|
+
"""
|
|
154
|
+
return cls.model_construct(
|
|
155
|
+
rules={
|
|
156
|
+
name: ToolRule.model_construct(
|
|
157
|
+
**{
|
|
158
|
+
**rule,
|
|
159
|
+
"args": {
|
|
160
|
+
arg: ArgRule.model_construct(**arg_rule)
|
|
161
|
+
for arg, arg_rule in rule.get("args", {}).items()
|
|
162
|
+
},
|
|
163
|
+
}
|
|
164
|
+
)
|
|
165
|
+
for name, rule in data["rules"].items()
|
|
166
|
+
},
|
|
167
|
+
default_deny=data["default_deny"],
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def load_rules(path: str | Path) -> ToolRules:
|
|
172
|
+
"""Load ToolRules from a YAML file at *path*."""
|
|
173
|
+
import yaml # only here: a caller with rules in hand never pays for it
|
|
174
|
+
|
|
175
|
+
with open(path) as fh:
|
|
176
|
+
data = yaml.safe_load(fh)
|
|
177
|
+
if data is None:
|
|
178
|
+
# An empty file would otherwise load as "no rules, allow everything".
|
|
179
|
+
raise ValueError(f"tool rules file {str(path)!r} is empty")
|
|
180
|
+
return ToolRules.model_validate(data)
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _show(value: Any) -> str:
|
|
184
|
+
"""A value for a reason string. The value is the caller's, so it is cut short."""
|
|
185
|
+
text = repr(value)
|
|
186
|
+
return text if len(text) <= 60 else text[:57] + "..."
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _as_number(value: Any) -> float | None:
|
|
190
|
+
"""The finite number *value* stands for, or None if it does not stand for one.
|
|
191
|
+
|
|
192
|
+
Numeric text counts, because a tool that takes "9999" will read it as 9999
|
|
193
|
+
and a bound that only looked at real numbers would let it past. NaN and
|
|
194
|
+
infinity do not: every comparison against NaN is false, which is how a
|
|
195
|
+
limit gets skipped without anything looking wrong.
|
|
196
|
+
"""
|
|
197
|
+
if isinstance(value, bool):
|
|
198
|
+
return None
|
|
199
|
+
try:
|
|
200
|
+
number = float(value)
|
|
201
|
+
except (TypeError, ValueError, OverflowError):
|
|
202
|
+
return None
|
|
203
|
+
return number if math.isfinite(number) else None
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _normalise_path(path: str) -> str | None:
|
|
207
|
+
if not path or "\x00" in path:
|
|
208
|
+
return None
|
|
209
|
+
# Backslashes first, so "reports\\..\\secrets" cannot skip the resolution.
|
|
210
|
+
return posixpath.normpath(path.replace("\\", "/"))
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def _within(path: str, prefix: str) -> bool:
|
|
214
|
+
"""Whether *path* sits inside *prefix* once ".." and "." are resolved.
|
|
215
|
+
|
|
216
|
+
Lexical only: nothing here touches a filesystem, so it runs the same in a
|
|
217
|
+
browser tab as on a server — and it cannot see a symlink inside the allowed
|
|
218
|
+
directory that points back out of it. "~" is not expanded either; a path
|
|
219
|
+
that begins with it only matches a prefix spelt the same way.
|
|
220
|
+
"""
|
|
221
|
+
target, root = _normalise_path(path), _normalise_path(prefix)
|
|
222
|
+
if target is None or root is None:
|
|
223
|
+
return False
|
|
224
|
+
if root == ".":
|
|
225
|
+
# "anything under the working directory": relative, not climbing out,
|
|
226
|
+
# and not something that only looks relative (C:/..., file:/..., ~/...).
|
|
227
|
+
head = target.split("/", 1)[0]
|
|
228
|
+
return not (target.startswith("/") or head == ".." or head.startswith("~") or ":" in head)
|
|
229
|
+
if target == root:
|
|
230
|
+
return True
|
|
231
|
+
# Compared a whole component at a time: "/srv/data-old" is not in "/srv/data".
|
|
232
|
+
return target.startswith(root.rstrip("/") + "/")
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def _check_value(arg: str, value: Any, rule: ArgRule) -> str | None:
|
|
236
|
+
"""Why *value* breaks *rule*, or None if it does not."""
|
|
237
|
+
if rule.one_of is not None and not any(
|
|
238
|
+
isinstance(permitted, bool) == isinstance(value, bool) and permitted == value
|
|
239
|
+
for permitted in rule.one_of
|
|
240
|
+
):
|
|
241
|
+
return f"arg '{arg}' value {_show(value)} is not one of the permitted values"
|
|
242
|
+
if rule.pattern is not None and (
|
|
243
|
+
not isinstance(value, str) or re.fullmatch(rule.pattern, value) is None
|
|
244
|
+
):
|
|
245
|
+
return f"arg '{arg}' value {_show(value)} does not match the permitted pattern"
|
|
246
|
+
if rule.path_prefix is not None and (
|
|
247
|
+
not isinstance(value, str) or not any(_within(value, p) for p in rule.path_prefix)
|
|
248
|
+
):
|
|
249
|
+
return f"arg '{arg}' value {_show(value)} is outside the permitted paths"
|
|
250
|
+
if rule.min is not None or rule.max is not None:
|
|
251
|
+
number = _as_number(value)
|
|
252
|
+
if number is None:
|
|
253
|
+
return (
|
|
254
|
+
f"arg '{arg}' value {_show(value)} is not a finite number, "
|
|
255
|
+
"so it cannot be checked against its limit"
|
|
256
|
+
)
|
|
257
|
+
if rule.max is not None and number > rule.max:
|
|
258
|
+
return f"arg '{arg}' value {_show(value)} exceeds max {rule.max}"
|
|
259
|
+
if rule.min is not None and number < rule.min:
|
|
260
|
+
return f"arg '{arg}' value {_show(value)} is below min {rule.min}"
|
|
261
|
+
return None
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def _check_arg(arg: str, args: dict[str, Any], rule: ArgRule) -> str | None:
|
|
265
|
+
value = args.get(arg)
|
|
266
|
+
if value is None:
|
|
267
|
+
return f"arg '{arg}' is required" if rule.required else None
|
|
268
|
+
for item in value if isinstance(value, (list, tuple)) else [value]:
|
|
269
|
+
problem = _check_value(arg, item, rule)
|
|
270
|
+
if problem is not None:
|
|
271
|
+
return problem
|
|
272
|
+
return None
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def _refuse(reason: str) -> dict[str, Any]:
|
|
276
|
+
return {"allowed": False, "reason": reason, "requires_approval": False}
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def validate_tool_call(
|
|
280
|
+
tool_call: dict[str, Any],
|
|
281
|
+
*,
|
|
282
|
+
role: str | None = None,
|
|
283
|
+
rules: ToolRules,
|
|
284
|
+
defaulted: Collection[str] = (),
|
|
285
|
+
) -> dict[str, Any]:
|
|
286
|
+
"""Validate *tool_call* against *rules* for an optional *role*.
|
|
287
|
+
|
|
288
|
+
Returns a dict with shape:
|
|
289
|
+
{"allowed": bool, "reason": str, "requires_approval": bool}
|
|
290
|
+
|
|
291
|
+
This is the standalone helper -- callable outside a policy pipeline
|
|
292
|
+
(e.g. directly from the /v1/tools.validate endpoint). To put the same
|
|
293
|
+
check in front of a tool an agent calls, wrap the tool in SecureTool.
|
|
294
|
+
|
|
295
|
+
*role* and *rules* are keyword-only and *rules* is required. Both used to
|
|
296
|
+
be optional and positional, which made ``validate_tool_call(call, rules)``
|
|
297
|
+
bind the rules to *role* and answer "allowed: no rules configured".
|
|
298
|
+
|
|
299
|
+
*defaulted* names the arguments in the call that the caller did not pass:
|
|
300
|
+
values the tool's own signature supplied. They are checked against any
|
|
301
|
+
rule that names them, like every other argument, but ``unlisted_args:
|
|
302
|
+
deny`` does not refuse them — that setting is about what a caller may
|
|
303
|
+
pass, and a default is the tool author's choice, not the caller's.
|
|
304
|
+
"""
|
|
305
|
+
if not isinstance(rules, ToolRules):
|
|
306
|
+
raise TypeError(f"rules must be a ToolRules, got {type(rules).__name__}")
|
|
307
|
+
|
|
308
|
+
tool_name = tool_call.get("name", "")
|
|
309
|
+
if not isinstance(tool_name, str) or not tool_name:
|
|
310
|
+
return _refuse("tool call has no name")
|
|
311
|
+
tool_args = tool_call.get("args")
|
|
312
|
+
if tool_args is None:
|
|
313
|
+
tool_args = {}
|
|
314
|
+
if not isinstance(tool_args, dict):
|
|
315
|
+
return _refuse(f"arguments for '{tool_name}' are not a mapping of name to value")
|
|
316
|
+
|
|
317
|
+
rule = rules.rules.get(tool_name)
|
|
318
|
+
|
|
319
|
+
# Tool not in rules
|
|
320
|
+
if rule is None:
|
|
321
|
+
if rules.default_deny:
|
|
322
|
+
return _refuse(f"tool '{tool_name}' is not in the allowlist")
|
|
323
|
+
return {"allowed": True, "reason": "no rule -- default allow", "requires_approval": False}
|
|
324
|
+
|
|
325
|
+
# Explicit deny
|
|
326
|
+
if rule.deny:
|
|
327
|
+
return _refuse(f"tool '{tool_name}' is explicitly denied")
|
|
328
|
+
|
|
329
|
+
# RBAC -- skip when wildcard is present. A call that names no role is not a
|
|
330
|
+
# call from every role: against a restricted tool it is refused.
|
|
331
|
+
if "*" not in rule.allowed_roles:
|
|
332
|
+
if role is None:
|
|
333
|
+
return _refuse(f"'{tool_name}' is restricted by role and the call carries none")
|
|
334
|
+
if role not in rule.allowed_roles:
|
|
335
|
+
return _refuse(f"role '{role}' is not permitted to call '{tool_name}'")
|
|
336
|
+
|
|
337
|
+
arg_rules = rule.arg_rules
|
|
338
|
+
if rule.unlisted_args == "deny":
|
|
339
|
+
unlisted = sorted(
|
|
340
|
+
str(arg) for arg in tool_args if arg not in arg_rules and arg not in defaulted
|
|
341
|
+
)
|
|
342
|
+
if unlisted:
|
|
343
|
+
return _refuse(f"arg '{unlisted[0]}' is not one '{tool_name}' is permitted to take")
|
|
344
|
+
for arg, arg_rule in arg_rules.items():
|
|
345
|
+
problem = _check_arg(arg, tool_args, arg_rule)
|
|
346
|
+
if problem is not None:
|
|
347
|
+
return _refuse(problem)
|
|
348
|
+
|
|
349
|
+
# Requires manual review
|
|
350
|
+
if rule.requires_approval:
|
|
351
|
+
return {
|
|
352
|
+
"allowed": True,
|
|
353
|
+
"reason": f"tool '{tool_name}' requires manual review",
|
|
354
|
+
"requires_approval": True,
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
return {"allowed": True, "reason": "tool call permitted", "requires_approval": False}
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
class ToolFirewallPolicy(BasePolicy):
|
|
361
|
+
"""Policy that validates tool calls against a YAML-configured rules set.
|
|
362
|
+
|
|
363
|
+
Only acts when context.action == "tool_call"; all other actions pass through.
|
|
364
|
+
SecureAgent never produces that action — it sees an agent's input and its
|
|
365
|
+
final output, not the tools called in between — so this policy does nothing
|
|
366
|
+
in a SecureAgent's list. It belongs in the list given to SecureTool, which
|
|
367
|
+
wraps one tool and runs these checks on every call to it.
|
|
368
|
+
|
|
369
|
+
Expected context.input_data shape:
|
|
370
|
+
{"name": str, "args": {<arg_name>: <value>, ...}}
|
|
371
|
+
|
|
372
|
+
Optional context.metadata keys:
|
|
373
|
+
"role": str — used for RBAC checks against allowed_roles
|
|
374
|
+
"defaulted_args": [str] — arguments the caller left to the tool's own
|
|
375
|
+
defaults (SecureTool fills this in); see validate_tool_call
|
|
376
|
+
|
|
377
|
+
Results are written to context.metadata["tool_firewall"] using the
|
|
378
|
+
standardized shape — the {"allowed", "reason", "requires_approval"} dict
|
|
379
|
+
returned by validate_tool_call plus "risk" (denied 0.8, requires
|
|
380
|
+
approval 0.6, allowed 0.0) and "category": "tool_firewall".
|
|
381
|
+
"""
|
|
382
|
+
|
|
383
|
+
failure_mode: FailureMode = FailureMode.FAIL_CLOSED
|
|
384
|
+
|
|
385
|
+
def __init__(
|
|
386
|
+
self,
|
|
387
|
+
rules: ToolRules | None = None,
|
|
388
|
+
rules_path: str | Path | None = None,
|
|
389
|
+
) -> None:
|
|
390
|
+
if rules is not None and rules_path is not None:
|
|
391
|
+
raise ValueError("pass rules or rules_path, not both")
|
|
392
|
+
if rules_path is not None:
|
|
393
|
+
self._rules = load_rules(rules_path)
|
|
394
|
+
elif rules is not None:
|
|
395
|
+
self._rules = rules
|
|
396
|
+
else:
|
|
397
|
+
# It used to be accepted and meant "allow every call", which is not
|
|
398
|
+
# what anyone constructing a firewall is asking for.
|
|
399
|
+
raise ValueError("ToolFirewallPolicy needs rules= or rules_path=")
|
|
400
|
+
|
|
401
|
+
async def before_action(self, context: ActionContext) -> ActionContext:
|
|
402
|
+
if context.action != "tool_call":
|
|
403
|
+
return context
|
|
404
|
+
|
|
405
|
+
result = validate_tool_call(
|
|
406
|
+
{
|
|
407
|
+
"name": context.input_data.get("name", ""),
|
|
408
|
+
"args": context.input_data.get("args"),
|
|
409
|
+
},
|
|
410
|
+
role=context.metadata.get("role"),
|
|
411
|
+
rules=self._rules,
|
|
412
|
+
defaulted=context.metadata.get("defaulted_args") or (),
|
|
413
|
+
)
|
|
414
|
+
|
|
415
|
+
if result.get("requires_approval"):
|
|
416
|
+
risk = 0.6
|
|
417
|
+
elif not result["allowed"]:
|
|
418
|
+
risk = 0.8
|
|
419
|
+
else:
|
|
420
|
+
risk = 0.0
|
|
421
|
+
context.metadata["tool_firewall"] = {
|
|
422
|
+
"risk": risk,
|
|
423
|
+
"category": "tool_firewall",
|
|
424
|
+
**result,
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
if result.get("requires_approval"):
|
|
428
|
+
context.decision = InterceptorDecision.REQUIRE_APPROVAL
|
|
429
|
+
context.decision_reason = result["reason"]
|
|
430
|
+
elif not result["allowed"]:
|
|
431
|
+
context.decision = InterceptorDecision.DENY
|
|
432
|
+
context.decision_reason = result["reason"]
|
|
433
|
+
|
|
434
|
+
return context
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# Reference config — copy and edit to create your own tools.yaml.
|
|
2
|
+
#
|
|
3
|
+
# These rules only do anything where a tool call actually passes through them:
|
|
4
|
+
# wrap the tool in SecureTool, or call validate_tool_call() yourself. A
|
|
5
|
+
# ToolFirewallPolicy in a SecureAgent's list sees the agent's input and output,
|
|
6
|
+
# never the tools it calls in between.
|
|
7
|
+
#
|
|
8
|
+
# A key this file does not recognise is an error at load, not a rule that is
|
|
9
|
+
# quietly ignored.
|
|
10
|
+
|
|
11
|
+
# Whether to deny tool calls for tools that have no rule.
|
|
12
|
+
# false = default allow (permissive); true = default deny (strict allowlist).
|
|
13
|
+
default_deny: false
|
|
14
|
+
|
|
15
|
+
rules:
|
|
16
|
+
# Wildcard role — any caller may use this tool
|
|
17
|
+
get_weather:
|
|
18
|
+
allowed_roles: ["*"]
|
|
19
|
+
|
|
20
|
+
# Role-restricted, no approval needed. A call that carries no role at all is
|
|
21
|
+
# refused, the same as one with the wrong role.
|
|
22
|
+
read_database:
|
|
23
|
+
allowed_roles: ["admin", "operator"]
|
|
24
|
+
|
|
25
|
+
# Role-restricted + requires human approval before execution.
|
|
26
|
+
# `args` constrains individual arguments; every constraint set must hold.
|
|
27
|
+
send_email:
|
|
28
|
+
allowed_roles: ["admin"]
|
|
29
|
+
requires_approval: true
|
|
30
|
+
# Refuse any argument not named under `args` — otherwise the allowlist on
|
|
31
|
+
# `to` could be walked around with `cc` or `bcc`.
|
|
32
|
+
unlisted_args: deny
|
|
33
|
+
args:
|
|
34
|
+
to:
|
|
35
|
+
required: true
|
|
36
|
+
# A regular expression the WHOLE value must match. A list of
|
|
37
|
+
# recipients is checked one by one.
|
|
38
|
+
pattern: '[^@\s]+@example\.com'
|
|
39
|
+
subject: {} # named, so permitted; otherwise unconstrained
|
|
40
|
+
body: {}
|
|
41
|
+
|
|
42
|
+
# Role-restricted + approval + numeric bounds
|
|
43
|
+
transfer_funds:
|
|
44
|
+
allowed_roles: ["finance"]
|
|
45
|
+
requires_approval: true
|
|
46
|
+
args:
|
|
47
|
+
amount:
|
|
48
|
+
required: true
|
|
49
|
+
min: 0 # a negative transfer is a transfer the other way
|
|
50
|
+
max: 1000.0 # over the limit is denied outright, not sent for approval
|
|
51
|
+
currency:
|
|
52
|
+
one_of: ["USD", "EUR"]
|
|
53
|
+
|
|
54
|
+
# A host allowlist is a pattern on the URL. Like every pattern it is matched
|
|
55
|
+
# against the whole value, so "https://vendor.example.com.attacker.io/" fails
|
|
56
|
+
# it. It is checked against the URL the agent asked for: where that page
|
|
57
|
+
# redirects to is not something the rule sees.
|
|
58
|
+
open_page:
|
|
59
|
+
args:
|
|
60
|
+
url:
|
|
61
|
+
required: true
|
|
62
|
+
pattern: 'https://vendor\.example\.com(/.*)?'
|
|
63
|
+
|
|
64
|
+
# Paths: the value must sit inside one of these directories once ".." has
|
|
65
|
+
# been resolved. The check is on the text of the path. It does not expand
|
|
66
|
+
# "~" and cannot see a symlink that leads back out.
|
|
67
|
+
read_file:
|
|
68
|
+
args:
|
|
69
|
+
path:
|
|
70
|
+
required: true
|
|
71
|
+
path_prefix: ["reports", "/srv/shared"]
|
|
72
|
+
|
|
73
|
+
# The older spelling of a numeric bound still works:
|
|
74
|
+
# arg_constraints: {max_amount: 1000.0, min_amount: 0}
|
|
75
|
+
refund:
|
|
76
|
+
arg_constraints:
|
|
77
|
+
max_amount: 100
|
|
78
|
+
|
|
79
|
+
# Always deny, regardless of role
|
|
80
|
+
delete_record:
|
|
81
|
+
deny: true
|