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.
Files changed (65) hide show
  1. openscript_sdk/__init__.py +217 -0
  2. openscript_sdk/contracts/__init__.py +82 -0
  3. openscript_sdk/contracts/interceptor.py +98 -0
  4. openscript_sdk/contracts/server_types.py +87 -0
  5. openscript_sdk/contracts/session_graph.py +123 -0
  6. openscript_sdk/contracts/types.py +76 -0
  7. openscript_sdk/events/__init__.py +0 -0
  8. openscript_sdk/events/approvals.py +309 -0
  9. openscript_sdk/events/models.py +26 -0
  10. openscript_sdk/events/store.py +79 -0
  11. openscript_sdk/events/writer.py +100 -0
  12. openscript_sdk/hooks/claude_code.py +479 -0
  13. openscript_sdk/hooks/claude_code_example.yaml +62 -0
  14. openscript_sdk/hooks/claude_code_strict_example.yaml +73 -0
  15. openscript_sdk/hooks/config.py +144 -0
  16. openscript_sdk/hooks/settings_example.json +28 -0
  17. openscript_sdk/hooks/settings_strict_example.json +28 -0
  18. openscript_sdk/integrations/langchain.py +32 -0
  19. openscript_sdk/integrations/langgraph.py +32 -0
  20. openscript_sdk/interceptors/base.py +15 -0
  21. openscript_sdk/interceptors/event_writer.py +131 -0
  22. openscript_sdk/interceptors/pii.py +168 -0
  23. openscript_sdk/interceptors/threat.py +674 -0
  24. openscript_sdk/logging.py +94 -0
  25. openscript_sdk/middleware/middleware.py +543 -0
  26. openscript_sdk/middleware/tool.py +247 -0
  27. openscript_sdk/observability/__init__.py +11 -0
  28. openscript_sdk/observability/metrics.py +105 -0
  29. openscript_sdk/observability/risk.py +54 -0
  30. openscript_sdk/observability/tracing.py +67 -0
  31. openscript_sdk/policies/compliance.py +261 -0
  32. openscript_sdk/policies/config.py +244 -0
  33. openscript_sdk/policies/harmful_request.py +617 -0
  34. openscript_sdk/policies/normalize.py +489 -0
  35. openscript_sdk/policies/output_schema.py +353 -0
  36. openscript_sdk/policies/policies_example.yaml +36 -0
  37. openscript_sdk/policies/redaction.py +359 -0
  38. openscript_sdk/policies/secrets.py +239 -0
  39. openscript_sdk/policies/semantic.py +348 -0
  40. openscript_sdk/policies/semantic_head.py +9 -0
  41. openscript_sdk/policies/session_taint.py +399 -0
  42. openscript_sdk/policies/taint_store.py +111 -0
  43. openscript_sdk/policies/tool_firewall.py +434 -0
  44. openscript_sdk/policies/tools_example.yaml +81 -0
  45. openscript_sdk/policies/toxicity.py +247 -0
  46. openscript_sdk/py.typed +0 -0
  47. openscript_sdk/server/__init__.py +1 -0
  48. openscript_sdk/server/app.py +153 -0
  49. openscript_sdk/server/auth.py +19 -0
  50. openscript_sdk/server/guard.py +126 -0
  51. openscript_sdk/server/guard_example.yaml +55 -0
  52. openscript_sdk/server/routers/__init__.py +0 -0
  53. openscript_sdk/server/routers/approvals.py +69 -0
  54. openscript_sdk/server/routers/guard.py +231 -0
  55. openscript_sdk/server/routers/sessions.py +80 -0
  56. openscript_sdk/server/routers/sse.py +69 -0
  57. openscript_sdk/server/routers/threat.py +119 -0
  58. openscript_sdk/server/static/dashboard/index.html +209 -0
  59. openscript_sdk/server/static/dashboard/session.html +339 -0
  60. openscript_sdk-0.1.0.dist-info/METADATA +225 -0
  61. openscript_sdk-0.1.0.dist-info/RECORD +65 -0
  62. openscript_sdk-0.1.0.dist-info/WHEEL +5 -0
  63. openscript_sdk-0.1.0.dist-info/entry_points.txt +2 -0
  64. openscript_sdk-0.1.0.dist-info/licenses/LICENSE +190 -0
  65. 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