amanai 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.
- amanai-0.1.0/.gitignore +16 -0
- amanai-0.1.0/PKG-INFO +142 -0
- amanai-0.1.0/README.md +126 -0
- amanai-0.1.0/amanai/__init__.py +121 -0
- amanai-0.1.0/amanai/client.py +229 -0
- amanai-0.1.0/amanai/coverage.py +139 -0
- amanai-0.1.0/amanai/guardrails.py +142 -0
- amanai-0.1.0/amanai/judge.py +143 -0
- amanai-0.1.0/amanai/mcp_adapter.py +72 -0
- amanai-0.1.0/amanai/monitor.py +65 -0
- amanai-0.1.0/amanai/operators.py +131 -0
- amanai-0.1.0/amanai/policy.py +317 -0
- amanai-0.1.0/amanai/py.typed +0 -0
- amanai-0.1.0/amanai/testing.py +93 -0
- amanai-0.1.0/pyproject.toml +28 -0
amanai-0.1.0/.gitignore
ADDED
amanai-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: amanai
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Action Policy Engine for AI agents: policy-as-code for tool-calls — evaluate, enforce (block/warn/approve), and trace agent actions, with the same policy asserted in CI. Plus local guardrails and an offline judge. Pure-Python, zero dependencies.
|
|
5
|
+
Author-email: Aji Purnomo <hi@aji.is-a.dev>
|
|
6
|
+
Keywords: agent,ai,guardrails,llm,owasp,pii,policy-as-code,prompt-injection,security,tool-call
|
|
7
|
+
Classifier: Development Status :: 4 - Beta
|
|
8
|
+
Classifier: Intended Audience :: Developers
|
|
9
|
+
Classifier: License :: OSI Approved :: Apache Software License
|
|
10
|
+
Classifier: Operating System :: OS Independent
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Topic :: Security
|
|
13
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
14
|
+
Requires-Python: >=3.10
|
|
15
|
+
Description-Content-Type: text/markdown
|
|
16
|
+
|
|
17
|
+
# amanai
|
|
18
|
+
|
|
19
|
+
**Action Policy Engine for AI agents — policy-as-code for tool-calls. Pure-Python, zero dependencies.**
|
|
20
|
+
|
|
21
|
+
Write **one policy** for risky agent actions. Amanai evaluates every tool-call
|
|
22
|
+
against it before the tool runs, enforces the decision, and records a structured
|
|
23
|
+
trace as evidence. The same policy file asserts in CI — what you test is what you
|
|
24
|
+
enforce. Decisions are deterministic: no LLM decides whether a high-risk tool runs.
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
pip install amanai
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
## Action Policy Engine
|
|
31
|
+
|
|
32
|
+
Five verbs: **load** a policy, **evaluate** an action, **protect** a tool,
|
|
33
|
+
**collect** a trace, **assert** it in tests.
|
|
34
|
+
|
|
35
|
+
```python
|
|
36
|
+
from amanai import set_policy, tool, ToolBlocked, ApprovalRequired, collect_trace
|
|
37
|
+
|
|
38
|
+
set_policy("amanai.policy.json") # load + validate (raises PolicyError if bad)
|
|
39
|
+
|
|
40
|
+
@tool(name="billing.refund", capability="money_movement", risk="high")
|
|
41
|
+
def refund_payment(amount): ...
|
|
42
|
+
|
|
43
|
+
refund_payment(amount=500) # ToolBlocked / ApprovalRequired per policy
|
|
44
|
+
trace = collect_trace() # canonical evidence: action + decision + status
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
A **policy** is a list of rules. A rule matches on `tool` and/or `capability`,
|
|
48
|
+
with optional `args` and `context` predicates, and carries an `action`:
|
|
49
|
+
|
|
50
|
+
```json
|
|
51
|
+
[
|
|
52
|
+
{ "id": "discount-cap", "tool": "apply_discount",
|
|
53
|
+
"args": [{ "arg": "pct", "op": ">=", "value": 50 }],
|
|
54
|
+
"action": "block", "reason": "discount of 50% or more is never allowed" },
|
|
55
|
+
|
|
56
|
+
{ "id": "refund-approval", "tool": "refund_payment",
|
|
57
|
+
"args": [{ "arg": "amount", "op": ">", "value": 100 }],
|
|
58
|
+
"action": "require_approval" },
|
|
59
|
+
|
|
60
|
+
{ "id": "external-email", "capability": "external_comms",
|
|
61
|
+
"args": [{ "arg": "to", "op": "email_external", "value": ["acme.com"] }],
|
|
62
|
+
"action": "block" }
|
|
63
|
+
]
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
- **Outcomes:** `allow` · `block` · `warn` · `require_approval`.
|
|
67
|
+
- **Operators:** `>= > <= < == !=` · `contains` · `regex` · `in` / `not_in` ·
|
|
68
|
+
`email_external` · `domain_in` / `domain_not_in`.
|
|
69
|
+
- **Modes** (per request, never global): `enforce` blocks before execution ·
|
|
70
|
+
`shadow` records what it *would* block but lets the call run · `test`
|
|
71
|
+
evaluates with no side effects.
|
|
72
|
+
- The legacy flat rule `{tool, arg, op, value}` still loads (it defaults to
|
|
73
|
+
`block`). Rules without an `id` get a deterministic generated one.
|
|
74
|
+
|
|
75
|
+
```python
|
|
76
|
+
from amanai import set_mode, set_context, evaluate, ActionRequest
|
|
77
|
+
|
|
78
|
+
set_mode("shadow") # observe before enforcing
|
|
79
|
+
set_context(role="support", tenant="t1", environment="prod") # authz context for rules
|
|
80
|
+
evaluate(ActionRequest("apply_discount", {"pct": 90})) # -> PolicyDecision(outcome="block", ...)
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
## Assert the same policy in CI
|
|
84
|
+
|
|
85
|
+
```python
|
|
86
|
+
from amanai.testing import assert_blocked, assert_no_violations
|
|
87
|
+
|
|
88
|
+
def test_excessive_discount_is_blocked():
|
|
89
|
+
set_policy("amanai.policy.json")
|
|
90
|
+
assert_blocked(apply_discount, pct=90)
|
|
91
|
+
|
|
92
|
+
def test_attack_trace_is_clean():
|
|
93
|
+
run_agent(attack_prompt) # produces a trace
|
|
94
|
+
assert_no_violations(collect_trace()) # replays actions against the active policy
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
`amanai.testing` also ships `assert_requires_approval`,
|
|
98
|
+
`using_mode`, and `replay` (re-evaluate a recorded trace against a policy).
|
|
99
|
+
|
|
100
|
+
## Runtime guardrails (input / output, supporting)
|
|
101
|
+
|
|
102
|
+
```python
|
|
103
|
+
from amanai import guard_input, guard_output, GuardrailBlocked
|
|
104
|
+
|
|
105
|
+
try:
|
|
106
|
+
msg = guard_input(user_msg) # raises GuardrailBlocked on injection
|
|
107
|
+
except GuardrailBlocked:
|
|
108
|
+
reply = "I can't help with that."
|
|
109
|
+
else:
|
|
110
|
+
reply = guard_output(run_agent(msg)) # redacts emails, cards, secrets
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
## Offline judge & MCP checks (supporting)
|
|
114
|
+
|
|
115
|
+
Score an agent's input / response / tool-calls against detectors (string match,
|
|
116
|
+
tool assertions, MCP checks; LLM-as-judge when you pass a gateway). The
|
|
117
|
+
`tool_assertion` detector reuses the engine's operators, so the test side can't
|
|
118
|
+
drift from runtime — a contract test guarantees it.
|
|
119
|
+
|
|
120
|
+
## Monitoring (optional — needs an Amanai server)
|
|
121
|
+
|
|
122
|
+
```python
|
|
123
|
+
from amanai import Monitor, collect_trace
|
|
124
|
+
|
|
125
|
+
mon = Monitor("http://localhost:8000", PUBLIC_KEY, SECRET_KEY)
|
|
126
|
+
mon.log_trace(collect_trace(), user_id="u123") # canonical events, PII redacted
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
## API
|
|
130
|
+
|
|
131
|
+
- **Engine:** `load_policy` `set_policy` `get_policy` `evaluate` `Policy` `Rule`
|
|
132
|
+
`ActionRequest` `PolicyDecision` `TraceEvent` `PendingAction` `PolicyError`
|
|
133
|
+
- **Modes / context:** `set_mode` `get_mode` `set_context` `get_context` `clear_context`
|
|
134
|
+
- **Protect / collect:** `tool` `collect_trace` `collect_tool_calls` `record_tool_call`
|
|
135
|
+
`reset` `registered_tools` `uncovered_tools` `ToolBlocked` `ApprovalRequired`
|
|
136
|
+
- **Test:** `amanai.testing` — `assert_blocked`
|
|
137
|
+
`assert_requires_approval` `assert_no_violations` `using_mode` `replay`
|
|
138
|
+
- **Policy lifecycle:** `clear_tool_policy` (deactivate the active policy)
|
|
139
|
+
- **Guardrails:** `guard_input` `guard_output` `redact_pii` `detect_injection` `detect_pii` `GuardResult` `GuardrailBlocked`
|
|
140
|
+
- **Judge / monitor:** `judge` `Monitor`
|
|
141
|
+
|
|
142
|
+
Apache-2.0. Zero runtime dependencies.
|
amanai-0.1.0/README.md
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
# amanai
|
|
2
|
+
|
|
3
|
+
**Action Policy Engine for AI agents — policy-as-code for tool-calls. Pure-Python, zero dependencies.**
|
|
4
|
+
|
|
5
|
+
Write **one policy** for risky agent actions. Amanai evaluates every tool-call
|
|
6
|
+
against it before the tool runs, enforces the decision, and records a structured
|
|
7
|
+
trace as evidence. The same policy file asserts in CI — what you test is what you
|
|
8
|
+
enforce. Decisions are deterministic: no LLM decides whether a high-risk tool runs.
|
|
9
|
+
|
|
10
|
+
```bash
|
|
11
|
+
pip install amanai
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
## Action Policy Engine
|
|
15
|
+
|
|
16
|
+
Five verbs: **load** a policy, **evaluate** an action, **protect** a tool,
|
|
17
|
+
**collect** a trace, **assert** it in tests.
|
|
18
|
+
|
|
19
|
+
```python
|
|
20
|
+
from amanai import set_policy, tool, ToolBlocked, ApprovalRequired, collect_trace
|
|
21
|
+
|
|
22
|
+
set_policy("amanai.policy.json") # load + validate (raises PolicyError if bad)
|
|
23
|
+
|
|
24
|
+
@tool(name="billing.refund", capability="money_movement", risk="high")
|
|
25
|
+
def refund_payment(amount): ...
|
|
26
|
+
|
|
27
|
+
refund_payment(amount=500) # ToolBlocked / ApprovalRequired per policy
|
|
28
|
+
trace = collect_trace() # canonical evidence: action + decision + status
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
A **policy** is a list of rules. A rule matches on `tool` and/or `capability`,
|
|
32
|
+
with optional `args` and `context` predicates, and carries an `action`:
|
|
33
|
+
|
|
34
|
+
```json
|
|
35
|
+
[
|
|
36
|
+
{ "id": "discount-cap", "tool": "apply_discount",
|
|
37
|
+
"args": [{ "arg": "pct", "op": ">=", "value": 50 }],
|
|
38
|
+
"action": "block", "reason": "discount of 50% or more is never allowed" },
|
|
39
|
+
|
|
40
|
+
{ "id": "refund-approval", "tool": "refund_payment",
|
|
41
|
+
"args": [{ "arg": "amount", "op": ">", "value": 100 }],
|
|
42
|
+
"action": "require_approval" },
|
|
43
|
+
|
|
44
|
+
{ "id": "external-email", "capability": "external_comms",
|
|
45
|
+
"args": [{ "arg": "to", "op": "email_external", "value": ["acme.com"] }],
|
|
46
|
+
"action": "block" }
|
|
47
|
+
]
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
- **Outcomes:** `allow` · `block` · `warn` · `require_approval`.
|
|
51
|
+
- **Operators:** `>= > <= < == !=` · `contains` · `regex` · `in` / `not_in` ·
|
|
52
|
+
`email_external` · `domain_in` / `domain_not_in`.
|
|
53
|
+
- **Modes** (per request, never global): `enforce` blocks before execution ·
|
|
54
|
+
`shadow` records what it *would* block but lets the call run · `test`
|
|
55
|
+
evaluates with no side effects.
|
|
56
|
+
- The legacy flat rule `{tool, arg, op, value}` still loads (it defaults to
|
|
57
|
+
`block`). Rules without an `id` get a deterministic generated one.
|
|
58
|
+
|
|
59
|
+
```python
|
|
60
|
+
from amanai import set_mode, set_context, evaluate, ActionRequest
|
|
61
|
+
|
|
62
|
+
set_mode("shadow") # observe before enforcing
|
|
63
|
+
set_context(role="support", tenant="t1", environment="prod") # authz context for rules
|
|
64
|
+
evaluate(ActionRequest("apply_discount", {"pct": 90})) # -> PolicyDecision(outcome="block", ...)
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
## Assert the same policy in CI
|
|
68
|
+
|
|
69
|
+
```python
|
|
70
|
+
from amanai.testing import assert_blocked, assert_no_violations
|
|
71
|
+
|
|
72
|
+
def test_excessive_discount_is_blocked():
|
|
73
|
+
set_policy("amanai.policy.json")
|
|
74
|
+
assert_blocked(apply_discount, pct=90)
|
|
75
|
+
|
|
76
|
+
def test_attack_trace_is_clean():
|
|
77
|
+
run_agent(attack_prompt) # produces a trace
|
|
78
|
+
assert_no_violations(collect_trace()) # replays actions against the active policy
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
`amanai.testing` also ships `assert_requires_approval`,
|
|
82
|
+
`using_mode`, and `replay` (re-evaluate a recorded trace against a policy).
|
|
83
|
+
|
|
84
|
+
## Runtime guardrails (input / output, supporting)
|
|
85
|
+
|
|
86
|
+
```python
|
|
87
|
+
from amanai import guard_input, guard_output, GuardrailBlocked
|
|
88
|
+
|
|
89
|
+
try:
|
|
90
|
+
msg = guard_input(user_msg) # raises GuardrailBlocked on injection
|
|
91
|
+
except GuardrailBlocked:
|
|
92
|
+
reply = "I can't help with that."
|
|
93
|
+
else:
|
|
94
|
+
reply = guard_output(run_agent(msg)) # redacts emails, cards, secrets
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
## Offline judge & MCP checks (supporting)
|
|
98
|
+
|
|
99
|
+
Score an agent's input / response / tool-calls against detectors (string match,
|
|
100
|
+
tool assertions, MCP checks; LLM-as-judge when you pass a gateway). The
|
|
101
|
+
`tool_assertion` detector reuses the engine's operators, so the test side can't
|
|
102
|
+
drift from runtime — a contract test guarantees it.
|
|
103
|
+
|
|
104
|
+
## Monitoring (optional — needs an Amanai server)
|
|
105
|
+
|
|
106
|
+
```python
|
|
107
|
+
from amanai import Monitor, collect_trace
|
|
108
|
+
|
|
109
|
+
mon = Monitor("http://localhost:8000", PUBLIC_KEY, SECRET_KEY)
|
|
110
|
+
mon.log_trace(collect_trace(), user_id="u123") # canonical events, PII redacted
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
## API
|
|
114
|
+
|
|
115
|
+
- **Engine:** `load_policy` `set_policy` `get_policy` `evaluate` `Policy` `Rule`
|
|
116
|
+
`ActionRequest` `PolicyDecision` `TraceEvent` `PendingAction` `PolicyError`
|
|
117
|
+
- **Modes / context:** `set_mode` `get_mode` `set_context` `get_context` `clear_context`
|
|
118
|
+
- **Protect / collect:** `tool` `collect_trace` `collect_tool_calls` `record_tool_call`
|
|
119
|
+
`reset` `registered_tools` `uncovered_tools` `ToolBlocked` `ApprovalRequired`
|
|
120
|
+
- **Test:** `amanai.testing` — `assert_blocked`
|
|
121
|
+
`assert_requires_approval` `assert_no_violations` `using_mode` `replay`
|
|
122
|
+
- **Policy lifecycle:** `clear_tool_policy` (deactivate the active policy)
|
|
123
|
+
- **Guardrails:** `guard_input` `guard_output` `redact_pii` `detect_injection` `detect_pii` `GuardResult` `GuardrailBlocked`
|
|
124
|
+
- **Judge / monitor:** `judge` `Monitor`
|
|
125
|
+
|
|
126
|
+
Apache-2.0. Zero runtime dependencies.
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
"""Amanai SDK — policy-as-code for AI agent actions.
|
|
2
|
+
|
|
3
|
+
Test the policy in CI. Enforce the same policy at runtime. Keep evidence for audit.
|
|
4
|
+
|
|
5
|
+
The Action Policy Engine is the product center. Five verbs:
|
|
6
|
+
|
|
7
|
+
from amanai import load_policy, set_policy, evaluate, tool, collect_trace
|
|
8
|
+
|
|
9
|
+
set_policy(load_policy("amanai.policy.json")) # 1. load policy
|
|
10
|
+
|
|
11
|
+
@tool(capability="money_movement", risk="high") # 3. protect tool
|
|
12
|
+
def refund_payment(amount): ...
|
|
13
|
+
|
|
14
|
+
evaluate(ActionRequest("refund_payment", {"amount": 500})) # 2. evaluate action
|
|
15
|
+
trace = collect_trace() # 4. collect trace
|
|
16
|
+
# 5. assert in tests → amanai.testing
|
|
17
|
+
|
|
18
|
+
Decisions are deterministic — no LLM decides whether a high-risk tool runs.
|
|
19
|
+
Prompt-injection and PII guardrails remain supporting modules, not the center.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from amanai.client import (
|
|
23
|
+
collect_tool_calls,
|
|
24
|
+
collect_trace,
|
|
25
|
+
record_tool_call,
|
|
26
|
+
registered_tools,
|
|
27
|
+
reset,
|
|
28
|
+
tool,
|
|
29
|
+
uncovered_tools,
|
|
30
|
+
)
|
|
31
|
+
from amanai.coverage import COVERAGE, coverage_for
|
|
32
|
+
from amanai.guardrails import (
|
|
33
|
+
GuardrailBlocked,
|
|
34
|
+
GuardResult,
|
|
35
|
+
detect_injection,
|
|
36
|
+
detect_pii,
|
|
37
|
+
guard_input,
|
|
38
|
+
guard_output,
|
|
39
|
+
redact_pii,
|
|
40
|
+
scan,
|
|
41
|
+
)
|
|
42
|
+
from amanai.judge import run_detector, run_mcp_check
|
|
43
|
+
from amanai.mcp_adapter import guard_mcp_call
|
|
44
|
+
from amanai.monitor import Monitor
|
|
45
|
+
from amanai.policy import (
|
|
46
|
+
MODES,
|
|
47
|
+
OUTCOMES,
|
|
48
|
+
ActionRequest,
|
|
49
|
+
ApprovalRequired,
|
|
50
|
+
PendingAction,
|
|
51
|
+
Policy,
|
|
52
|
+
PolicyDecision,
|
|
53
|
+
PolicyError,
|
|
54
|
+
Rule,
|
|
55
|
+
ToolBlocked,
|
|
56
|
+
TraceEvent,
|
|
57
|
+
clear_context,
|
|
58
|
+
clear_tool_policy,
|
|
59
|
+
evaluate,
|
|
60
|
+
get_context,
|
|
61
|
+
get_mode,
|
|
62
|
+
get_policy,
|
|
63
|
+
load_policy,
|
|
64
|
+
set_context,
|
|
65
|
+
set_mode,
|
|
66
|
+
set_policy,
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
__all__ = [
|
|
70
|
+
# --- Action Policy Engine ---
|
|
71
|
+
"load_policy",
|
|
72
|
+
"set_policy",
|
|
73
|
+
"get_policy",
|
|
74
|
+
"evaluate",
|
|
75
|
+
"Policy",
|
|
76
|
+
"Rule",
|
|
77
|
+
"ActionRequest",
|
|
78
|
+
"PolicyDecision",
|
|
79
|
+
"TraceEvent",
|
|
80
|
+
"PendingAction",
|
|
81
|
+
"ToolBlocked",
|
|
82
|
+
"ApprovalRequired",
|
|
83
|
+
"PolicyError",
|
|
84
|
+
"OUTCOMES",
|
|
85
|
+
"MODES",
|
|
86
|
+
# modes & context
|
|
87
|
+
"set_mode",
|
|
88
|
+
"get_mode",
|
|
89
|
+
"set_context",
|
|
90
|
+
"get_context",
|
|
91
|
+
"clear_context",
|
|
92
|
+
# protect & collect
|
|
93
|
+
"tool",
|
|
94
|
+
"record_tool_call",
|
|
95
|
+
"collect_tool_calls",
|
|
96
|
+
"collect_trace",
|
|
97
|
+
"reset",
|
|
98
|
+
"registered_tools",
|
|
99
|
+
"uncovered_tools",
|
|
100
|
+
# MCP adapter
|
|
101
|
+
"guard_mcp_call",
|
|
102
|
+
# --- supporting: offline judge & MCP static checks ---
|
|
103
|
+
"run_detector",
|
|
104
|
+
"run_mcp_check",
|
|
105
|
+
# clear active policy
|
|
106
|
+
"clear_tool_policy",
|
|
107
|
+
# --- supporting: guardrails ---
|
|
108
|
+
"scan",
|
|
109
|
+
"guard_input",
|
|
110
|
+
"guard_output",
|
|
111
|
+
"redact_pii",
|
|
112
|
+
"detect_injection",
|
|
113
|
+
"detect_pii",
|
|
114
|
+
"GuardResult",
|
|
115
|
+
"GuardrailBlocked",
|
|
116
|
+
# --- supporting: monitoring & coverage ---
|
|
117
|
+
"Monitor",
|
|
118
|
+
"COVERAGE",
|
|
119
|
+
"coverage_for",
|
|
120
|
+
]
|
|
121
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
"""Protect tools and collect traces — the runtime entry point into the engine.
|
|
2
|
+
|
|
3
|
+
`@tool` is a thin adapter: it normalizes the call into an `ActionRequest`, asks
|
|
4
|
+
the Action Policy Engine for a decision, enforces it according to the current
|
|
5
|
+
mode, and records a `TraceEvent` as evidence. The policy logic lives in
|
|
6
|
+
`policy.py`; this module only wires real Python calls into it.
|
|
7
|
+
|
|
8
|
+
from amanai import tool, set_policy, collect_trace
|
|
9
|
+
|
|
10
|
+
@tool(capability="money_movement", risk="high")
|
|
11
|
+
def refund_payment(amount): ...
|
|
12
|
+
|
|
13
|
+
set_policy("amanai.policy.json")
|
|
14
|
+
refund_payment(amount=500) # ToolBlocked / ApprovalRequired per policy
|
|
15
|
+
trace = collect_trace() # canonical evidence for CI / monitoring
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import contextvars
|
|
21
|
+
import functools
|
|
22
|
+
import inspect
|
|
23
|
+
from typing import Any, Callable
|
|
24
|
+
|
|
25
|
+
from amanai.policy import (
|
|
26
|
+
ActionRequest,
|
|
27
|
+
ApprovalRequired,
|
|
28
|
+
PendingAction,
|
|
29
|
+
Policy,
|
|
30
|
+
ToolBlocked,
|
|
31
|
+
TraceEvent,
|
|
32
|
+
evaluate,
|
|
33
|
+
get_context,
|
|
34
|
+
get_mode,
|
|
35
|
+
get_policy,
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
# Context-local trace buffer — concurrent requests never see each other's calls.
|
|
39
|
+
_trace: contextvars.ContextVar[list | None] = contextvars.ContextVar("amanai_trace", default=None)
|
|
40
|
+
|
|
41
|
+
# Static, declaration-time inventory of every protected tool (for security review).
|
|
42
|
+
_REGISTRY: dict[str, dict] = {}
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _buffer() -> list:
|
|
46
|
+
buf = _trace.get()
|
|
47
|
+
if buf is None:
|
|
48
|
+
buf = []
|
|
49
|
+
_trace.set(buf)
|
|
50
|
+
return buf
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def record_event(event: TraceEvent) -> None:
|
|
54
|
+
_buffer().append(event)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _normalize_args(fn: Callable, args: tuple, kwargs: dict) -> dict:
|
|
58
|
+
"""Map a call to {param_name: value} so policies match regardless of whether
|
|
59
|
+
the tool was called positionally or by keyword (`apply_discount(50)` ==
|
|
60
|
+
`apply_discount(pct=50)`)."""
|
|
61
|
+
try:
|
|
62
|
+
sig = inspect.signature(fn)
|
|
63
|
+
bound = sig.bind_partial(*args, **kwargs)
|
|
64
|
+
out: dict[str, Any] = {}
|
|
65
|
+
for name, param in sig.parameters.items():
|
|
66
|
+
if name not in bound.arguments:
|
|
67
|
+
continue
|
|
68
|
+
val = bound.arguments[name]
|
|
69
|
+
if param.kind is inspect.Parameter.VAR_KEYWORD:
|
|
70
|
+
out.update(val) # **kwargs → flatten to top level
|
|
71
|
+
elif param.kind is inspect.Parameter.VAR_POSITIONAL:
|
|
72
|
+
out[name] = list(val) # *args → keep as a list
|
|
73
|
+
else:
|
|
74
|
+
out[name] = val
|
|
75
|
+
return out
|
|
76
|
+
except (TypeError, ValueError):
|
|
77
|
+
# Builtins / C-functions without an introspectable signature.
|
|
78
|
+
out = dict(kwargs)
|
|
79
|
+
if args:
|
|
80
|
+
out["_args"] = list(args)
|
|
81
|
+
return out
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def record_tool_call(
|
|
85
|
+
tool_name: str,
|
|
86
|
+
tool_input: dict,
|
|
87
|
+
tool_output: Any,
|
|
88
|
+
*,
|
|
89
|
+
capability: str | None = None,
|
|
90
|
+
context: dict | None = None,
|
|
91
|
+
metadata: dict | None = None,
|
|
92
|
+
) -> None:
|
|
93
|
+
"""Manually record an executed tool-call.
|
|
94
|
+
|
|
95
|
+
Use this when you can't decorate the function. Because the tool has already
|
|
96
|
+
run, Amanai cannot prevent it here; it still evaluates the active policy and
|
|
97
|
+
marks policy violations as `shadowed` so CI/monitoring can catch bypasses.
|
|
98
|
+
"""
|
|
99
|
+
action = ActionRequest(
|
|
100
|
+
tool_name,
|
|
101
|
+
dict(tool_input),
|
|
102
|
+
capability=capability,
|
|
103
|
+
context=context if context is not None else get_context(),
|
|
104
|
+
metadata=metadata or {},
|
|
105
|
+
)
|
|
106
|
+
decision = evaluate(action)
|
|
107
|
+
status = "shadowed" if decision.outcome in ("block", "require_approval") else "executed"
|
|
108
|
+
record_event(TraceEvent(action, decision, status=status, output=tool_output))
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def tool(
|
|
112
|
+
fn: Callable | None = None,
|
|
113
|
+
*,
|
|
114
|
+
name: str | None = None,
|
|
115
|
+
capability: str | None = None,
|
|
116
|
+
risk: str | None = None,
|
|
117
|
+
input_schema: dict | None = None,
|
|
118
|
+
):
|
|
119
|
+
"""Protect a function with the Action Policy Engine.
|
|
120
|
+
|
|
121
|
+
Usage: `@tool` or `@tool(name="billing.refund", capability="money_movement")`.
|
|
122
|
+
|
|
123
|
+
Behavior depends on the current mode (`enforce` by default):
|
|
124
|
+
* enforce — `block` raises `ToolBlocked`, `require_approval` raises
|
|
125
|
+
`ApprovalRequired`; neither runs the function.
|
|
126
|
+
* shadow — violations execute anyway but are recorded as evidence.
|
|
127
|
+
* test — nothing executes (no side effects); the decision is recorded.
|
|
128
|
+
"""
|
|
129
|
+
|
|
130
|
+
def decorate(fn: Callable) -> Callable:
|
|
131
|
+
tool_name = name or fn.__name__
|
|
132
|
+
meta = {
|
|
133
|
+
"capability": capability,
|
|
134
|
+
"risk": risk,
|
|
135
|
+
"input_schema": input_schema,
|
|
136
|
+
"python_name": fn.__name__,
|
|
137
|
+
}
|
|
138
|
+
_REGISTRY[tool_name] = meta
|
|
139
|
+
|
|
140
|
+
@functools.wraps(fn)
|
|
141
|
+
def wrapper(*args, **kwargs):
|
|
142
|
+
inp = _normalize_args(fn, args, kwargs)
|
|
143
|
+
action = ActionRequest(
|
|
144
|
+
tool_name, inp, capability=capability, context=get_context(), metadata=meta
|
|
145
|
+
)
|
|
146
|
+
decision = evaluate(action)
|
|
147
|
+
mode = get_mode()
|
|
148
|
+
|
|
149
|
+
if mode == "test":
|
|
150
|
+
record_event(TraceEvent(action, decision, status="evaluated"))
|
|
151
|
+
return None
|
|
152
|
+
|
|
153
|
+
if decision.outcome == "block" and mode == "enforce":
|
|
154
|
+
record_event(TraceEvent(action, decision, status="blocked"))
|
|
155
|
+
raise ToolBlocked(decision.reason or f"{tool_name} blocked by policy")
|
|
156
|
+
if decision.outcome == "require_approval" and mode == "enforce":
|
|
157
|
+
pending = PendingAction(action, decision)
|
|
158
|
+
record_event(TraceEvent(action, decision, status="pending"))
|
|
159
|
+
raise ApprovalRequired(pending)
|
|
160
|
+
|
|
161
|
+
try:
|
|
162
|
+
result = fn(*args, **kwargs)
|
|
163
|
+
except Exception as e:
|
|
164
|
+
record_event(TraceEvent(action, decision, status="error", error=str(e)))
|
|
165
|
+
raise
|
|
166
|
+
|
|
167
|
+
# In shadow mode a would-be-blocked call still ran — mark it.
|
|
168
|
+
shadowed = decision.outcome in ("block", "require_approval")
|
|
169
|
+
record_event(
|
|
170
|
+
TraceEvent(
|
|
171
|
+
action, decision, status="shadowed" if shadowed else "executed", output=result
|
|
172
|
+
)
|
|
173
|
+
)
|
|
174
|
+
return result
|
|
175
|
+
|
|
176
|
+
return wrapper
|
|
177
|
+
|
|
178
|
+
return decorate(fn) if callable(fn) else decorate
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def collect_trace() -> list[TraceEvent]:
|
|
182
|
+
"""Return the canonical trace events recorded so far and clear the buffer."""
|
|
183
|
+
buf = _trace.get() or []
|
|
184
|
+
_trace.set([])
|
|
185
|
+
return list(buf)
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def collect_tool_calls() -> list:
|
|
189
|
+
"""Legacy view: the executed tool-calls as `{tool, input, output}` dicts.
|
|
190
|
+
|
|
191
|
+
Derived from the trace — blocked/pending/evaluated actions are omitted, so a
|
|
192
|
+
test can prove a dangerous function did not run. Drains the buffer (use
|
|
193
|
+
`collect_trace` if you want the full evidence instead)."""
|
|
194
|
+
out = []
|
|
195
|
+
for e in collect_trace():
|
|
196
|
+
if e.status in ("executed", "shadowed"):
|
|
197
|
+
rec = {"tool": e.action.tool, "input": dict(e.action.input), "output": e.output}
|
|
198
|
+
if e.decision.outcome == "warn":
|
|
199
|
+
rec["policy_warning"] = True
|
|
200
|
+
out.append(rec)
|
|
201
|
+
return out
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def reset() -> None:
|
|
205
|
+
_trace.set([])
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def registered_tools() -> dict[str, dict]:
|
|
209
|
+
"""Every `@tool`-protected function and its declared capability/risk/schema —
|
|
210
|
+
the inventory a security engineer reviews for high-risk actions."""
|
|
211
|
+
return dict(_REGISTRY)
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def uncovered_tools(policy: Policy | None = None) -> list[str]:
|
|
215
|
+
"""Names of registered tools that no rule covers (by tool name or capability)
|
|
216
|
+
in the given (or active) policy — risky actions left silently unprotected."""
|
|
217
|
+
pol = policy if policy is not None else get_policy()
|
|
218
|
+
names, caps = set(), set()
|
|
219
|
+
if pol is not None:
|
|
220
|
+
for r in pol.rules:
|
|
221
|
+
if r.tool:
|
|
222
|
+
names.add(r.tool)
|
|
223
|
+
if r.capability:
|
|
224
|
+
caps.add(r.capability)
|
|
225
|
+
return [
|
|
226
|
+
name
|
|
227
|
+
for name, meta in _REGISTRY.items()
|
|
228
|
+
if name not in names and meta.get("capability") not in caps
|
|
229
|
+
]
|