agent-action-policy 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- agent_action_policy-0.1.0/.gitignore +15 -0
- agent_action_policy-0.1.0/LICENSE +21 -0
- agent_action_policy-0.1.0/PKG-INFO +150 -0
- agent_action_policy-0.1.0/README.md +122 -0
- agent_action_policy-0.1.0/pyproject.toml +57 -0
- agent_action_policy-0.1.0/src/action_policy/__init__.py +18 -0
- agent_action_policy-0.1.0/src/action_policy/decision.py +39 -0
- agent_action_policy-0.1.0/src/action_policy/engine.py +123 -0
- agent_action_policy-0.1.0/src/action_policy/integrations/__init__.py +0 -0
- agent_action_policy-0.1.0/src/action_policy/loader.py +35 -0
- agent_action_policy-0.1.0/src/action_policy/patterns.py +74 -0
- agent_action_policy-0.1.0/src/action_policy/policy.py +84 -0
- agent_action_policy-0.1.0/src/action_policy/py.typed +0 -0
- agent_action_policy-0.1.0/src/action_policy/templates/safe_browsing.yaml +23 -0
- agent_action_policy-0.1.0/src/action_policy/templates/safe_coding.yaml +57 -0
- agent_action_policy-0.1.0/src/action_policy/templates/safe_database.yaml +24 -0
- agent_action_policy-0.1.0/src/action_policy/templates/strict.yaml +9 -0
- agent_action_policy-0.1.0/tests/__init__.py +0 -0
- agent_action_policy-0.1.0/tests/test_e2e.py +185 -0
- agent_action_policy-0.1.0/tests/test_engine.py +197 -0
- agent_action_policy-0.1.0/tests/test_patterns.py +71 -0
- agent_action_policy-0.1.0/tests/test_templates.py +123 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 QuartzUnit
|
|
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.
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: agent-action-policy
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Declarative action policies for AI agents — approve, deny, or escalate any tool call before execution
|
|
5
|
+
Project-URL: Homepage, https://github.com/QuartzUnit/agent-action-policy
|
|
6
|
+
Project-URL: Repository, https://github.com/QuartzUnit/agent-action-policy
|
|
7
|
+
Project-URL: Issues, https://github.com/QuartzUnit/agent-action-policy/issues
|
|
8
|
+
Author-email: hmj <hmj@quartzunit.com>
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: agent,ai-agent,guardrail,llm,policy,safety,security,tool-use
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
21
|
+
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
22
|
+
Classifier: Topic :: Security
|
|
23
|
+
Classifier: Typing :: Typed
|
|
24
|
+
Requires-Python: >=3.9
|
|
25
|
+
Provides-Extra: yaml
|
|
26
|
+
Requires-Dist: pyyaml>=6.0; extra == 'yaml'
|
|
27
|
+
Description-Content-Type: text/markdown
|
|
28
|
+
|
|
29
|
+
# agent-action-policy
|
|
30
|
+
|
|
31
|
+
Declarative action policies for AI agents — approve, deny, or escalate any tool call before execution.
|
|
32
|
+
|
|
33
|
+
## Install
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
pip install agent-action-policy
|
|
37
|
+
pip install agent-action-policy[yaml] # for YAML policy files
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
## Quick Start
|
|
41
|
+
|
|
42
|
+
```python
|
|
43
|
+
from action_policy import PolicyEngine, Action
|
|
44
|
+
|
|
45
|
+
engine = PolicyEngine.from_dict({
|
|
46
|
+
"policies": [{
|
|
47
|
+
"name": "no-force-push",
|
|
48
|
+
"match": {"tool": "bash", "args_pattern": "git push --force"},
|
|
49
|
+
"action": "deny",
|
|
50
|
+
"reason": "Force push requires human approval",
|
|
51
|
+
}]
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
decision = engine.evaluate(tool="bash", args={"command": "git push --force origin main"})
|
|
55
|
+
print(decision.denied) # True
|
|
56
|
+
print(decision.reason) # "Force push requires human approval"
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
## Sandboxing vs Policy
|
|
60
|
+
|
|
61
|
+
| | Sandboxing (containers) | Policy (this library) |
|
|
62
|
+
|---|---|---|
|
|
63
|
+
| **Controls** | *Where* code runs | *What* the agent can do |
|
|
64
|
+
| **Granularity** | Process-level | Per-tool-call |
|
|
65
|
+
| **Configuration** | Infrastructure | YAML/Python |
|
|
66
|
+
| **Use with** | Any runtime | Any agent framework |
|
|
67
|
+
|
|
68
|
+
Sandboxing and policies are complementary. Use both.
|
|
69
|
+
|
|
70
|
+
## Policy Definition (YAML)
|
|
71
|
+
|
|
72
|
+
```yaml
|
|
73
|
+
policies:
|
|
74
|
+
- name: no-destructive-git
|
|
75
|
+
match:
|
|
76
|
+
tool: bash
|
|
77
|
+
args_pattern: "git (push --force|reset --hard|branch -D)"
|
|
78
|
+
action: deny
|
|
79
|
+
reason: "Destructive git operations require human approval"
|
|
80
|
+
|
|
81
|
+
- name: escalate-system-files
|
|
82
|
+
match:
|
|
83
|
+
tool: "~(file_write|write_file)"
|
|
84
|
+
path_patterns:
|
|
85
|
+
- "/etc/*"
|
|
86
|
+
- "/usr/*"
|
|
87
|
+
action: escalate
|
|
88
|
+
reason: "System file modification needs confirmation"
|
|
89
|
+
|
|
90
|
+
- name: approve-reads
|
|
91
|
+
match:
|
|
92
|
+
tool: "~(read|search|grep)"
|
|
93
|
+
action: approve
|
|
94
|
+
priority: 10 # lower = higher priority
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
## Built-in Templates
|
|
98
|
+
|
|
99
|
+
```python
|
|
100
|
+
engine = PolicyEngine.from_template("safe_coding")
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
| Template | What it protects |
|
|
104
|
+
|----------|-----------------|
|
|
105
|
+
| `safe_coding` | Blocks force-push, rm -rf, system file writes, credential access, hook skipping |
|
|
106
|
+
| `safe_browsing` | Blocks internal URLs, file:// protocol, escalates downloads |
|
|
107
|
+
| `safe_database` | Blocks DDL (DROP/TRUNCATE), escalates DELETE and WHERE-less UPDATE |
|
|
108
|
+
| `strict` | Whitelist mode — only read operations allowed, everything else denied |
|
|
109
|
+
|
|
110
|
+
## Python API
|
|
111
|
+
|
|
112
|
+
```python
|
|
113
|
+
# From YAML file
|
|
114
|
+
engine = PolicyEngine.from_yaml("policies.yaml")
|
|
115
|
+
|
|
116
|
+
# From dict
|
|
117
|
+
engine = PolicyEngine.from_dict({"policies": [...]})
|
|
118
|
+
|
|
119
|
+
# From template
|
|
120
|
+
engine = PolicyEngine.from_template("safe_coding")
|
|
121
|
+
|
|
122
|
+
# Evaluate
|
|
123
|
+
decision = engine.evaluate(tool="bash", args={"command": "rm -rf /"})
|
|
124
|
+
decision.action # Action.DENY
|
|
125
|
+
decision.denied # True
|
|
126
|
+
decision.reason # "..."
|
|
127
|
+
decision.policy_name # "no-rm-rf"
|
|
128
|
+
|
|
129
|
+
# Fail-closed mode (deny by default)
|
|
130
|
+
engine = PolicyEngine.from_template("strict", default_action=Action.DENY)
|
|
131
|
+
|
|
132
|
+
# Decorator
|
|
133
|
+
@engine.guard
|
|
134
|
+
def execute_tool(tool: str, args: dict = None):
|
|
135
|
+
... # raises PolicyDenied or PolicyEscalated
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
## Pattern Matching
|
|
139
|
+
|
|
140
|
+
| Pattern type | Syntax | Example |
|
|
141
|
+
|-------------|--------|---------|
|
|
142
|
+
| Exact match | `tool_name` | `"bash"` |
|
|
143
|
+
| Glob | `*`, `?`, `[...]` | `"file_*"` |
|
|
144
|
+
| Regex | `~pattern` | `"~(bash\|shell\|exec)"` |
|
|
145
|
+
| Args regex | any regex | `"git\\s+push\\s+--force"` |
|
|
146
|
+
| Path glob | glob or `~regex` | `"/etc/*"`, `"~\\.env$"` |
|
|
147
|
+
|
|
148
|
+
## License
|
|
149
|
+
|
|
150
|
+
MIT
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
# agent-action-policy
|
|
2
|
+
|
|
3
|
+
Declarative action policies for AI agents — approve, deny, or escalate any tool call before execution.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pip install agent-action-policy
|
|
9
|
+
pip install agent-action-policy[yaml] # for YAML policy files
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
## Quick Start
|
|
13
|
+
|
|
14
|
+
```python
|
|
15
|
+
from action_policy import PolicyEngine, Action
|
|
16
|
+
|
|
17
|
+
engine = PolicyEngine.from_dict({
|
|
18
|
+
"policies": [{
|
|
19
|
+
"name": "no-force-push",
|
|
20
|
+
"match": {"tool": "bash", "args_pattern": "git push --force"},
|
|
21
|
+
"action": "deny",
|
|
22
|
+
"reason": "Force push requires human approval",
|
|
23
|
+
}]
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
decision = engine.evaluate(tool="bash", args={"command": "git push --force origin main"})
|
|
27
|
+
print(decision.denied) # True
|
|
28
|
+
print(decision.reason) # "Force push requires human approval"
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
## Sandboxing vs Policy
|
|
32
|
+
|
|
33
|
+
| | Sandboxing (containers) | Policy (this library) |
|
|
34
|
+
|---|---|---|
|
|
35
|
+
| **Controls** | *Where* code runs | *What* the agent can do |
|
|
36
|
+
| **Granularity** | Process-level | Per-tool-call |
|
|
37
|
+
| **Configuration** | Infrastructure | YAML/Python |
|
|
38
|
+
| **Use with** | Any runtime | Any agent framework |
|
|
39
|
+
|
|
40
|
+
Sandboxing and policies are complementary. Use both.
|
|
41
|
+
|
|
42
|
+
## Policy Definition (YAML)
|
|
43
|
+
|
|
44
|
+
```yaml
|
|
45
|
+
policies:
|
|
46
|
+
- name: no-destructive-git
|
|
47
|
+
match:
|
|
48
|
+
tool: bash
|
|
49
|
+
args_pattern: "git (push --force|reset --hard|branch -D)"
|
|
50
|
+
action: deny
|
|
51
|
+
reason: "Destructive git operations require human approval"
|
|
52
|
+
|
|
53
|
+
- name: escalate-system-files
|
|
54
|
+
match:
|
|
55
|
+
tool: "~(file_write|write_file)"
|
|
56
|
+
path_patterns:
|
|
57
|
+
- "/etc/*"
|
|
58
|
+
- "/usr/*"
|
|
59
|
+
action: escalate
|
|
60
|
+
reason: "System file modification needs confirmation"
|
|
61
|
+
|
|
62
|
+
- name: approve-reads
|
|
63
|
+
match:
|
|
64
|
+
tool: "~(read|search|grep)"
|
|
65
|
+
action: approve
|
|
66
|
+
priority: 10 # lower = higher priority
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
## Built-in Templates
|
|
70
|
+
|
|
71
|
+
```python
|
|
72
|
+
engine = PolicyEngine.from_template("safe_coding")
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
| Template | What it protects |
|
|
76
|
+
|----------|-----------------|
|
|
77
|
+
| `safe_coding` | Blocks force-push, rm -rf, system file writes, credential access, hook skipping |
|
|
78
|
+
| `safe_browsing` | Blocks internal URLs, file:// protocol, escalates downloads |
|
|
79
|
+
| `safe_database` | Blocks DDL (DROP/TRUNCATE), escalates DELETE and WHERE-less UPDATE |
|
|
80
|
+
| `strict` | Whitelist mode — only read operations allowed, everything else denied |
|
|
81
|
+
|
|
82
|
+
## Python API
|
|
83
|
+
|
|
84
|
+
```python
|
|
85
|
+
# From YAML file
|
|
86
|
+
engine = PolicyEngine.from_yaml("policies.yaml")
|
|
87
|
+
|
|
88
|
+
# From dict
|
|
89
|
+
engine = PolicyEngine.from_dict({"policies": [...]})
|
|
90
|
+
|
|
91
|
+
# From template
|
|
92
|
+
engine = PolicyEngine.from_template("safe_coding")
|
|
93
|
+
|
|
94
|
+
# Evaluate
|
|
95
|
+
decision = engine.evaluate(tool="bash", args={"command": "rm -rf /"})
|
|
96
|
+
decision.action # Action.DENY
|
|
97
|
+
decision.denied # True
|
|
98
|
+
decision.reason # "..."
|
|
99
|
+
decision.policy_name # "no-rm-rf"
|
|
100
|
+
|
|
101
|
+
# Fail-closed mode (deny by default)
|
|
102
|
+
engine = PolicyEngine.from_template("strict", default_action=Action.DENY)
|
|
103
|
+
|
|
104
|
+
# Decorator
|
|
105
|
+
@engine.guard
|
|
106
|
+
def execute_tool(tool: str, args: dict = None):
|
|
107
|
+
... # raises PolicyDenied or PolicyEscalated
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
## Pattern Matching
|
|
111
|
+
|
|
112
|
+
| Pattern type | Syntax | Example |
|
|
113
|
+
|-------------|--------|---------|
|
|
114
|
+
| Exact match | `tool_name` | `"bash"` |
|
|
115
|
+
| Glob | `*`, `?`, `[...]` | `"file_*"` |
|
|
116
|
+
| Regex | `~pattern` | `"~(bash\|shell\|exec)"` |
|
|
117
|
+
| Args regex | any regex | `"git\\s+push\\s+--force"` |
|
|
118
|
+
| Path glob | glob or `~regex` | `"/etc/*"`, `"~\\.env$"` |
|
|
119
|
+
|
|
120
|
+
## License
|
|
121
|
+
|
|
122
|
+
MIT
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "agent-action-policy"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Declarative action policies for AI agents — approve, deny, or escalate any tool call before execution"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
requires-python = ">=3.9"
|
|
12
|
+
authors = [{ name = "hmj", email = "hmj@quartzunit.com" }]
|
|
13
|
+
keywords = [
|
|
14
|
+
"agent",
|
|
15
|
+
"policy",
|
|
16
|
+
"safety",
|
|
17
|
+
"guardrail",
|
|
18
|
+
"tool-use",
|
|
19
|
+
"llm",
|
|
20
|
+
"ai-agent",
|
|
21
|
+
"security",
|
|
22
|
+
]
|
|
23
|
+
classifiers = [
|
|
24
|
+
"Development Status :: 4 - Beta",
|
|
25
|
+
"Intended Audience :: Developers",
|
|
26
|
+
"License :: OSI Approved :: MIT License",
|
|
27
|
+
"Programming Language :: Python :: 3",
|
|
28
|
+
"Programming Language :: Python :: 3.9",
|
|
29
|
+
"Programming Language :: Python :: 3.10",
|
|
30
|
+
"Programming Language :: Python :: 3.11",
|
|
31
|
+
"Programming Language :: Python :: 3.12",
|
|
32
|
+
"Programming Language :: Python :: 3.13",
|
|
33
|
+
"Topic :: Scientific/Engineering :: Artificial Intelligence",
|
|
34
|
+
"Topic :: Security",
|
|
35
|
+
"Typing :: Typed",
|
|
36
|
+
]
|
|
37
|
+
|
|
38
|
+
[project.optional-dependencies]
|
|
39
|
+
yaml = ["pyyaml>=6.0"]
|
|
40
|
+
|
|
41
|
+
[project.urls]
|
|
42
|
+
Homepage = "https://github.com/QuartzUnit/agent-action-policy"
|
|
43
|
+
Repository = "https://github.com/QuartzUnit/agent-action-policy"
|
|
44
|
+
Issues = "https://github.com/QuartzUnit/agent-action-policy/issues"
|
|
45
|
+
|
|
46
|
+
[tool.hatch.build.targets.wheel]
|
|
47
|
+
packages = ["src/action_policy"]
|
|
48
|
+
|
|
49
|
+
[tool.pytest.ini_options]
|
|
50
|
+
testpaths = ["tests"]
|
|
51
|
+
|
|
52
|
+
[tool.ruff]
|
|
53
|
+
line-length = 120
|
|
54
|
+
target-version = "py39"
|
|
55
|
+
|
|
56
|
+
[tool.ruff.lint]
|
|
57
|
+
select = ["E", "F", "I", "W", "UP"]
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""agent-action-policy — Declarative action policies for AI agents.
|
|
2
|
+
|
|
3
|
+
Approve, deny, or escalate any tool call before execution.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from action_policy.decision import Action, Decision
|
|
7
|
+
from action_policy.engine import PolicyDenied, PolicyEngine, PolicyEscalated
|
|
8
|
+
from action_policy.policy import PolicyRule
|
|
9
|
+
|
|
10
|
+
__all__ = [
|
|
11
|
+
"Action",
|
|
12
|
+
"Decision",
|
|
13
|
+
"PolicyDenied",
|
|
14
|
+
"PolicyEngine",
|
|
15
|
+
"PolicyEscalated",
|
|
16
|
+
"PolicyRule",
|
|
17
|
+
]
|
|
18
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""Decision types for policy evaluation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from enum import Enum, auto
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class Action(Enum):
|
|
10
|
+
"""Policy evaluation result."""
|
|
11
|
+
|
|
12
|
+
APPROVE = auto()
|
|
13
|
+
DENY = auto()
|
|
14
|
+
ESCALATE = auto()
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass(frozen=True, slots=True)
|
|
18
|
+
class Decision:
|
|
19
|
+
"""Result of a policy evaluation."""
|
|
20
|
+
|
|
21
|
+
action: Action
|
|
22
|
+
policy_name: str = ""
|
|
23
|
+
reason: str = ""
|
|
24
|
+
|
|
25
|
+
@property
|
|
26
|
+
def approved(self) -> bool:
|
|
27
|
+
return self.action == Action.APPROVE
|
|
28
|
+
|
|
29
|
+
@property
|
|
30
|
+
def denied(self) -> bool:
|
|
31
|
+
return self.action == Action.DENY
|
|
32
|
+
|
|
33
|
+
@property
|
|
34
|
+
def escalated(self) -> bool:
|
|
35
|
+
return self.action == Action.ESCALATE
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
# Singleton for default approve (no policy matched)
|
|
39
|
+
APPROVE_DEFAULT = Decision(action=Action.APPROVE, policy_name="", reason="No matching policy — default approve")
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
"""Core PolicyEngine — evaluate tool calls against policies."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import functools
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any, Callable
|
|
8
|
+
|
|
9
|
+
from action_policy.decision import APPROVE_DEFAULT, Action, Decision
|
|
10
|
+
from action_policy.loader import load_policies_from_dict, load_policies_from_yaml
|
|
11
|
+
from action_policy.policy import PolicyRule
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class PolicyEngine:
|
|
15
|
+
"""Declarative action policy engine for AI agents.
|
|
16
|
+
|
|
17
|
+
Evaluates tool calls against a set of policies and returns
|
|
18
|
+
APPROVE, DENY, or ESCALATE decisions.
|
|
19
|
+
|
|
20
|
+
Usage:
|
|
21
|
+
engine = PolicyEngine.from_yaml("policies.yaml")
|
|
22
|
+
decision = engine.evaluate(tool="bash", args={"command": "rm -rf /"})
|
|
23
|
+
# -> Decision(action=DENY, policy_name="no-destructive-bash", ...)
|
|
24
|
+
|
|
25
|
+
Default behavior (no matching policy): APPROVE (open by default).
|
|
26
|
+
Use `default_action=Action.DENY` for fail-closed mode.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
def __init__(
|
|
30
|
+
self,
|
|
31
|
+
policies: list[PolicyRule] | None = None,
|
|
32
|
+
default_action: Action = Action.APPROVE,
|
|
33
|
+
default_reason: str = "",
|
|
34
|
+
):
|
|
35
|
+
self._policies = sorted(policies or [], key=lambda p: p.priority)
|
|
36
|
+
self._default_action = default_action
|
|
37
|
+
self._default_reason = default_reason or (
|
|
38
|
+
"No matching policy — default approve" if default_action == Action.APPROVE
|
|
39
|
+
else "No matching policy — default deny (fail-closed)"
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
@classmethod
|
|
43
|
+
def from_yaml(cls, path: str | Path, **kwargs: Any) -> PolicyEngine:
|
|
44
|
+
"""Load policies from a YAML file."""
|
|
45
|
+
policies = load_policies_from_yaml(path)
|
|
46
|
+
return cls(policies=policies, **kwargs)
|
|
47
|
+
|
|
48
|
+
@classmethod
|
|
49
|
+
def from_dict(cls, data: dict[str, Any], **kwargs: Any) -> PolicyEngine:
|
|
50
|
+
"""Load policies from a dict."""
|
|
51
|
+
policies = load_policies_from_dict(data)
|
|
52
|
+
return cls(policies=policies, **kwargs)
|
|
53
|
+
|
|
54
|
+
@classmethod
|
|
55
|
+
def from_template(cls, template_name: str, **kwargs: Any) -> PolicyEngine:
|
|
56
|
+
"""Load a built-in policy template.
|
|
57
|
+
|
|
58
|
+
Available templates: safe_coding, safe_browsing, safe_database, strict
|
|
59
|
+
"""
|
|
60
|
+
templates_dir = Path(__file__).parent / "templates"
|
|
61
|
+
path = templates_dir / f"{template_name}.yaml"
|
|
62
|
+
if not path.exists():
|
|
63
|
+
available = [f.stem for f in templates_dir.glob("*.yaml")]
|
|
64
|
+
raise ValueError(f"Unknown template '{template_name}'. Available: {available}")
|
|
65
|
+
return cls.from_yaml(path, **kwargs)
|
|
66
|
+
|
|
67
|
+
def evaluate(self, tool: str, args: dict[str, Any] | str | None = None) -> Decision:
|
|
68
|
+
"""Evaluate a tool call against all policies.
|
|
69
|
+
|
|
70
|
+
First matching policy wins (ordered by priority).
|
|
71
|
+
"""
|
|
72
|
+
for policy in self._policies:
|
|
73
|
+
if policy.matches(tool, args):
|
|
74
|
+
return policy.to_decision()
|
|
75
|
+
|
|
76
|
+
if self._default_action == Action.APPROVE:
|
|
77
|
+
return APPROVE_DEFAULT
|
|
78
|
+
return Decision(
|
|
79
|
+
action=self._default_action,
|
|
80
|
+
policy_name="default",
|
|
81
|
+
reason=self._default_reason,
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
def guard(self, func: Callable) -> Callable:
|
|
85
|
+
"""Decorator that checks policy before executing the wrapped function.
|
|
86
|
+
|
|
87
|
+
The function must accept `tool` as its first argument and `args` as keyword.
|
|
88
|
+
Raises PolicyDenied if the policy denies the action.
|
|
89
|
+
"""
|
|
90
|
+
@functools.wraps(func)
|
|
91
|
+
def wrapper(tool: str, args: dict | str | None = None, **kwargs: Any) -> Any:
|
|
92
|
+
decision = self.evaluate(tool, args)
|
|
93
|
+
if decision.denied:
|
|
94
|
+
raise PolicyDenied(decision)
|
|
95
|
+
if decision.escalated:
|
|
96
|
+
raise PolicyEscalated(decision)
|
|
97
|
+
return func(tool, args=args, **kwargs)
|
|
98
|
+
return wrapper
|
|
99
|
+
|
|
100
|
+
@property
|
|
101
|
+
def policies(self) -> list[PolicyRule]:
|
|
102
|
+
return list(self._policies)
|
|
103
|
+
|
|
104
|
+
def add_policy(self, policy: PolicyRule) -> None:
|
|
105
|
+
"""Add a policy and re-sort by priority."""
|
|
106
|
+
self._policies.append(policy)
|
|
107
|
+
self._policies.sort(key=lambda p: p.priority)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
class PolicyDenied(Exception):
|
|
111
|
+
"""Raised when a policy denies an action."""
|
|
112
|
+
|
|
113
|
+
def __init__(self, decision: Decision):
|
|
114
|
+
self.decision = decision
|
|
115
|
+
super().__init__(f"Policy '{decision.policy_name}' denied: {decision.reason}")
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
class PolicyEscalated(Exception):
|
|
119
|
+
"""Raised when a policy requires human escalation."""
|
|
120
|
+
|
|
121
|
+
def __init__(self, decision: Decision):
|
|
122
|
+
self.decision = decision
|
|
123
|
+
super().__init__(f"Policy '{decision.policy_name}' requires escalation: {decision.reason}")
|
|
File without changes
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""Policy loader from YAML files and dicts."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from action_policy.policy import PolicyRule, policy_from_dict
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def load_policies_from_yaml(path: str | Path) -> list[PolicyRule]:
|
|
12
|
+
"""Load policies from a YAML file.
|
|
13
|
+
|
|
14
|
+
Requires PyYAML (optional dependency).
|
|
15
|
+
"""
|
|
16
|
+
try:
|
|
17
|
+
import yaml
|
|
18
|
+
except ImportError as e:
|
|
19
|
+
raise ImportError(
|
|
20
|
+
"PyYAML is required for YAML loading. Install with: pip install agent-action-policy[yaml]"
|
|
21
|
+
) from e
|
|
22
|
+
|
|
23
|
+
with open(path) as f:
|
|
24
|
+
data = yaml.safe_load(f)
|
|
25
|
+
|
|
26
|
+
return load_policies_from_dict(data)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def load_policies_from_dict(data: dict[str, Any]) -> list[PolicyRule]:
|
|
30
|
+
"""Load policies from a dict structure (e.g., already-parsed YAML)."""
|
|
31
|
+
policies_data = data.get("policies", [])
|
|
32
|
+
policies = [policy_from_dict(p) for p in policies_data]
|
|
33
|
+
# Sort by priority (lower = higher priority)
|
|
34
|
+
policies.sort(key=lambda p: p.priority)
|
|
35
|
+
return policies
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""Pattern matching utilities for policy rules."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import fnmatch
|
|
6
|
+
import re
|
|
7
|
+
from functools import lru_cache
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@lru_cache(maxsize=256)
|
|
11
|
+
def _compile_regex(pattern: str) -> re.Pattern:
|
|
12
|
+
"""Compile and cache a regex pattern."""
|
|
13
|
+
return re.compile(pattern)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def match_tool(tool_name: str, pattern: str) -> bool:
|
|
17
|
+
"""Match a tool name against a pattern.
|
|
18
|
+
|
|
19
|
+
Supports:
|
|
20
|
+
- Exact match: "bash"
|
|
21
|
+
- Glob: "file_*"
|
|
22
|
+
- Regex (prefixed with ~): "~(bash|shell|exec)"
|
|
23
|
+
"""
|
|
24
|
+
if pattern.startswith("~"):
|
|
25
|
+
return bool(_compile_regex(pattern[1:]).search(tool_name))
|
|
26
|
+
if any(c in pattern for c in ("*", "?", "[")):
|
|
27
|
+
return fnmatch.fnmatch(tool_name, pattern)
|
|
28
|
+
return tool_name == pattern
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def match_args(args: dict | str | None, pattern: str) -> bool:
|
|
32
|
+
"""Match tool arguments against a pattern.
|
|
33
|
+
|
|
34
|
+
The pattern is matched against the string representation of args.
|
|
35
|
+
Supports:
|
|
36
|
+
- Regex (always): searches the stringified args
|
|
37
|
+
"""
|
|
38
|
+
if args is None:
|
|
39
|
+
return False
|
|
40
|
+
text = _stringify_args(args)
|
|
41
|
+
try:
|
|
42
|
+
return bool(_compile_regex(pattern).search(text))
|
|
43
|
+
except re.error:
|
|
44
|
+
return False
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def match_path(path: str, patterns: list[str]) -> bool:
|
|
48
|
+
"""Match a file path against a list of glob/regex patterns.
|
|
49
|
+
|
|
50
|
+
Each pattern can be:
|
|
51
|
+
- Glob: "/etc/*", "~/.ssh/*"
|
|
52
|
+
- Regex (prefixed with ~): "~\\.env$"
|
|
53
|
+
"""
|
|
54
|
+
for pat in patterns:
|
|
55
|
+
if pat.startswith("~"):
|
|
56
|
+
if _compile_regex(pat[1:]).search(path):
|
|
57
|
+
return True
|
|
58
|
+
elif fnmatch.fnmatch(path, pat):
|
|
59
|
+
return True
|
|
60
|
+
return False
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _stringify_args(args: dict | str | None) -> str:
|
|
64
|
+
"""Convert args to string for pattern matching."""
|
|
65
|
+
if args is None:
|
|
66
|
+
return ""
|
|
67
|
+
if isinstance(args, str):
|
|
68
|
+
return args
|
|
69
|
+
if isinstance(args, dict):
|
|
70
|
+
parts = []
|
|
71
|
+
for k, v in sorted(args.items()):
|
|
72
|
+
parts.append(f"{k}={v}")
|
|
73
|
+
return " ".join(parts)
|
|
74
|
+
return str(args)
|