canopy-runtime 0.1.1__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.
- canopy_runtime-0.1.1/LICENSE +21 -0
- canopy_runtime-0.1.1/PKG-INFO +94 -0
- canopy_runtime-0.1.1/README.md +71 -0
- canopy_runtime-0.1.1/pyproject.toml +30 -0
- canopy_runtime-0.1.1/setup.cfg +4 -0
- canopy_runtime-0.1.1/src/canopy/__init__.py +12 -0
- canopy_runtime-0.1.1/src/canopy/audit.py +129 -0
- canopy_runtime-0.1.1/src/canopy/client.py +33 -0
- canopy_runtime-0.1.1/src/canopy/core.py +257 -0
- canopy_runtime-0.1.1/src/canopy/demo.py +111 -0
- canopy_runtime-0.1.1/src/canopy/identity.py +33 -0
- canopy_runtime-0.1.1/src/canopy/middleware.py +74 -0
- canopy_runtime-0.1.1/src/canopy/policies/default.yaml +43 -0
- canopy_runtime-0.1.1/src/canopy/service.py +37 -0
- canopy_runtime-0.1.1/src/canopy_runtime.egg-info/PKG-INFO +94 -0
- canopy_runtime-0.1.1/src/canopy_runtime.egg-info/SOURCES.txt +21 -0
- canopy_runtime-0.1.1/src/canopy_runtime.egg-info/dependency_links.txt +1 -0
- canopy_runtime-0.1.1/src/canopy_runtime.egg-info/entry_points.txt +2 -0
- canopy_runtime-0.1.1/src/canopy_runtime.egg-info/requires.txt +16 -0
- canopy_runtime-0.1.1/src/canopy_runtime.egg-info/top_level.txt +1 -0
- canopy_runtime-0.1.1/tests/test_authorize_action.py +101 -0
- canopy_runtime-0.1.1/tests/test_client.py +19 -0
- canopy_runtime-0.1.1/tests/test_service.py +22 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Mavericksantander
|
|
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,94 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: canopy-runtime
|
|
3
|
+
Version: 0.1.1
|
|
4
|
+
Summary: Canopy Agent Safety Runtime: policy enforcement for tool-using agents
|
|
5
|
+
Author: Canopy
|
|
6
|
+
Requires-Python: >=3.9
|
|
7
|
+
Description-Content-Type: text/markdown
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Provides-Extra: http
|
|
10
|
+
Requires-Dist: requests>=2.25; extra == "http"
|
|
11
|
+
Provides-Extra: gateway
|
|
12
|
+
Requires-Dist: fastapi>=0.100; extra == "gateway"
|
|
13
|
+
Requires-Dist: pydantic>=2; extra == "gateway"
|
|
14
|
+
Requires-Dist: uvicorn[standard]>=0.23; extra == "gateway"
|
|
15
|
+
Provides-Extra: dev
|
|
16
|
+
Requires-Dist: pytest>=7; extra == "dev"
|
|
17
|
+
Requires-Dist: httpx>=0.24; extra == "dev"
|
|
18
|
+
Requires-Dist: requests>=2.25; extra == "dev"
|
|
19
|
+
Requires-Dist: fastapi>=0.100; extra == "dev"
|
|
20
|
+
Requires-Dist: pydantic>=2; extra == "dev"
|
|
21
|
+
Requires-Dist: uvicorn[standard]>=0.23; extra == "dev"
|
|
22
|
+
Dynamic: license-file
|
|
23
|
+
|
|
24
|
+
# Canopy Runtime
|
|
25
|
+
|
|
26
|
+
Minimal **Agent Safety Runtime** focused on a single primitive:
|
|
27
|
+
|
|
28
|
+
`authorize_action(agent_ctx, action_type, action_payload)` → `{decision, reason, avid}`
|
|
29
|
+
|
|
30
|
+
Decisions:
|
|
31
|
+
- `ALLOW`
|
|
32
|
+
- `DENY`
|
|
33
|
+
- `REQUIRE_APPROVAL`
|
|
34
|
+
|
|
35
|
+
Every decision is appended to a JSONL **hash-chain audit log** (`audit.log` by default).
|
|
36
|
+
|
|
37
|
+
## 3‑minute quickstart (library)
|
|
38
|
+
```bash
|
|
39
|
+
python3 -m venv .venv
|
|
40
|
+
source .venv/bin/activate
|
|
41
|
+
pip install -U pip
|
|
42
|
+
pip install canopy-runtime
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
```python
|
|
46
|
+
from canopy import authorize_action
|
|
47
|
+
|
|
48
|
+
decision = authorize_action(
|
|
49
|
+
agent_ctx={"env": "production"},
|
|
50
|
+
action_type="execute_shell",
|
|
51
|
+
action_payload={"command": "rm -rf /tmp/logs"},
|
|
52
|
+
)
|
|
53
|
+
print(decision["decision"]) # DENY
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
You should also see an `audit.log` file created in the current directory.
|
|
57
|
+
|
|
58
|
+
Try the included demo:
|
|
59
|
+
```bash
|
|
60
|
+
canopy-demo
|
|
61
|
+
canopy-demo --safe-path /tmp/
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
## Default policy pack (works out of the box)
|
|
65
|
+
Bundled defaults live at `src/canopy/policies/default.yaml` and aim to be conservative:
|
|
66
|
+
- `execute_shell`: deny common destructive patterns; require approval for network/install-style commands.
|
|
67
|
+
- `modify_file`: deny protected paths; require approval unless the path is in `agent_ctx["safe_paths"]`.
|
|
68
|
+
- `call_external_api`: require approval.
|
|
69
|
+
|
|
70
|
+
## Config
|
|
71
|
+
- `CANOPY_POLICY_FILE`: path to a YAML policy file (defaults to the bundled `default.yaml`)
|
|
72
|
+
- `CANOPY_AUDIT_LOG_PATH`: path to audit log (defaults to `audit.log`)
|
|
73
|
+
- `agent_ctx["safe_paths"]`: allowlist for `modify_file` (e.g. `["/repo/", "/tmp/"]`)
|
|
74
|
+
|
|
75
|
+
## Optional gateway (HTTP)
|
|
76
|
+
```bash
|
|
77
|
+
pip install canopy-runtime[gateway]
|
|
78
|
+
CANOPY_AUDIT_LOG_PATH=/tmp/canopy_audit.log python -m uvicorn canopy.service:app --port 8010
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
## Tests (dev)
|
|
82
|
+
```bash
|
|
83
|
+
pip install -e ".[dev]"
|
|
84
|
+
pytest -q
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
## Easter egg
|
|
88
|
+
```js
|
|
89
|
+
// Nunca se usa pero no quitar por si acaso
|
|
90
|
+
const _ = "QmUgYm9sZCBhbmQgbWlnaHR5IGZvcmNlcyB3aWxsIGNvbWUgdG8geW91ciBhaWQu"; // base64
|
|
91
|
+
|
|
92
|
+
// si algún día te aburres:
|
|
93
|
+
// console.log(atob(_))
|
|
94
|
+
```
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# Canopy Runtime
|
|
2
|
+
|
|
3
|
+
Minimal **Agent Safety Runtime** focused on a single primitive:
|
|
4
|
+
|
|
5
|
+
`authorize_action(agent_ctx, action_type, action_payload)` → `{decision, reason, avid}`
|
|
6
|
+
|
|
7
|
+
Decisions:
|
|
8
|
+
- `ALLOW`
|
|
9
|
+
- `DENY`
|
|
10
|
+
- `REQUIRE_APPROVAL`
|
|
11
|
+
|
|
12
|
+
Every decision is appended to a JSONL **hash-chain audit log** (`audit.log` by default).
|
|
13
|
+
|
|
14
|
+
## 3‑minute quickstart (library)
|
|
15
|
+
```bash
|
|
16
|
+
python3 -m venv .venv
|
|
17
|
+
source .venv/bin/activate
|
|
18
|
+
pip install -U pip
|
|
19
|
+
pip install canopy-runtime
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
```python
|
|
23
|
+
from canopy import authorize_action
|
|
24
|
+
|
|
25
|
+
decision = authorize_action(
|
|
26
|
+
agent_ctx={"env": "production"},
|
|
27
|
+
action_type="execute_shell",
|
|
28
|
+
action_payload={"command": "rm -rf /tmp/logs"},
|
|
29
|
+
)
|
|
30
|
+
print(decision["decision"]) # DENY
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
You should also see an `audit.log` file created in the current directory.
|
|
34
|
+
|
|
35
|
+
Try the included demo:
|
|
36
|
+
```bash
|
|
37
|
+
canopy-demo
|
|
38
|
+
canopy-demo --safe-path /tmp/
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
## Default policy pack (works out of the box)
|
|
42
|
+
Bundled defaults live at `src/canopy/policies/default.yaml` and aim to be conservative:
|
|
43
|
+
- `execute_shell`: deny common destructive patterns; require approval for network/install-style commands.
|
|
44
|
+
- `modify_file`: deny protected paths; require approval unless the path is in `agent_ctx["safe_paths"]`.
|
|
45
|
+
- `call_external_api`: require approval.
|
|
46
|
+
|
|
47
|
+
## Config
|
|
48
|
+
- `CANOPY_POLICY_FILE`: path to a YAML policy file (defaults to the bundled `default.yaml`)
|
|
49
|
+
- `CANOPY_AUDIT_LOG_PATH`: path to audit log (defaults to `audit.log`)
|
|
50
|
+
- `agent_ctx["safe_paths"]`: allowlist for `modify_file` (e.g. `["/repo/", "/tmp/"]`)
|
|
51
|
+
|
|
52
|
+
## Optional gateway (HTTP)
|
|
53
|
+
```bash
|
|
54
|
+
pip install canopy-runtime[gateway]
|
|
55
|
+
CANOPY_AUDIT_LOG_PATH=/tmp/canopy_audit.log python -m uvicorn canopy.service:app --port 8010
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
## Tests (dev)
|
|
59
|
+
```bash
|
|
60
|
+
pip install -e ".[dev]"
|
|
61
|
+
pytest -q
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
## Easter egg
|
|
65
|
+
```js
|
|
66
|
+
// Nunca se usa pero no quitar por si acaso
|
|
67
|
+
const _ = "QmUgYm9sZCBhbmQgbWlnaHR5IGZvcmNlcyB3aWxsIGNvbWUgdG8geW91ciBhaWQu"; // base64
|
|
68
|
+
|
|
69
|
+
// si algún día te aburres:
|
|
70
|
+
// console.log(atob(_))
|
|
71
|
+
```
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "canopy-runtime"
|
|
7
|
+
version = "0.1.1"
|
|
8
|
+
description = "Canopy Agent Safety Runtime: policy enforcement for tool-using agents"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.9"
|
|
11
|
+
authors = [{ name = "Canopy" }]
|
|
12
|
+
dependencies = []
|
|
13
|
+
|
|
14
|
+
[project.scripts]
|
|
15
|
+
canopy-demo = "canopy.demo:main"
|
|
16
|
+
|
|
17
|
+
[project.optional-dependencies]
|
|
18
|
+
http = ["requests>=2.25"]
|
|
19
|
+
gateway = ["fastapi>=0.100", "pydantic>=2", "uvicorn[standard]>=0.23"]
|
|
20
|
+
dev = ["pytest>=7", "httpx>=0.24", "requests>=2.25", "fastapi>=0.100", "pydantic>=2", "uvicorn[standard]>=0.23"]
|
|
21
|
+
|
|
22
|
+
[tool.setuptools]
|
|
23
|
+
package-dir = {"" = "src"}
|
|
24
|
+
|
|
25
|
+
[tool.setuptools.packages.find]
|
|
26
|
+
where = ["src"]
|
|
27
|
+
include = ["canopy*"]
|
|
28
|
+
|
|
29
|
+
[tool.setuptools.package-data]
|
|
30
|
+
canopy = ["policies/*.yaml"]
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"""Canopy Agent Safety Runtime (minimal).
|
|
2
|
+
|
|
3
|
+
Core primitive:
|
|
4
|
+
- authorize_action(agent_ctx, action_type, action_payload) -> decision dict
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from .client import CanopyClient
|
|
8
|
+
from .core import Decision, authorize_action
|
|
9
|
+
from .middleware import guard_tool, guard_tool_http
|
|
10
|
+
|
|
11
|
+
__all__ = ["CanopyClient", "Decision", "authorize_action", "guard_tool", "guard_tool_http"]
|
|
12
|
+
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import json
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from datetime import datetime, timezone
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any, Dict, Iterable, Optional
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _canonical_json(obj: Any) -> bytes:
|
|
12
|
+
return json.dumps(obj, sort_keys=True, separators=(",", ":"), ensure_ascii=False, default=str).encode("utf-8")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _sha256_hex(data: bytes) -> str:
|
|
16
|
+
return hashlib.sha256(data).hexdigest()
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _now_iso() -> str:
|
|
20
|
+
return datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass(frozen=True)
|
|
24
|
+
class AuditEntry:
|
|
25
|
+
timestamp: str
|
|
26
|
+
avid: str
|
|
27
|
+
action_type: str
|
|
28
|
+
decision: str
|
|
29
|
+
reason: str
|
|
30
|
+
prev_hash: str
|
|
31
|
+
entry_hash: str
|
|
32
|
+
|
|
33
|
+
def as_dict(self) -> Dict[str, Any]:
|
|
34
|
+
return {
|
|
35
|
+
"timestamp": self.timestamp,
|
|
36
|
+
"avid": self.avid,
|
|
37
|
+
"action_type": self.action_type,
|
|
38
|
+
"decision": self.decision,
|
|
39
|
+
"reason": self.reason,
|
|
40
|
+
"prev_hash": self.prev_hash,
|
|
41
|
+
"entry_hash": self.entry_hash,
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class HashChainAuditLog:
|
|
46
|
+
"""Append-only JSONL audit log with a simple hash chain."""
|
|
47
|
+
|
|
48
|
+
def __init__(self, path: str | Path = "audit.log"):
|
|
49
|
+
self.path = Path(path)
|
|
50
|
+
|
|
51
|
+
def _read_last_hash(self) -> str:
|
|
52
|
+
if not self.path.exists():
|
|
53
|
+
return ""
|
|
54
|
+
last = ""
|
|
55
|
+
with self.path.open("rb") as f:
|
|
56
|
+
for line in f:
|
|
57
|
+
if line.strip():
|
|
58
|
+
last = line.decode("utf-8")
|
|
59
|
+
if not last:
|
|
60
|
+
return ""
|
|
61
|
+
try:
|
|
62
|
+
obj = json.loads(last)
|
|
63
|
+
return str(obj.get("entry_hash", "") or "")
|
|
64
|
+
except Exception:
|
|
65
|
+
return ""
|
|
66
|
+
|
|
67
|
+
def append(
|
|
68
|
+
self,
|
|
69
|
+
*,
|
|
70
|
+
avid: str,
|
|
71
|
+
action_type: str,
|
|
72
|
+
decision: str,
|
|
73
|
+
reason: str,
|
|
74
|
+
timestamp: Optional[str] = None,
|
|
75
|
+
) -> AuditEntry:
|
|
76
|
+
prev_hash = self._read_last_hash()
|
|
77
|
+
ts = timestamp or _now_iso()
|
|
78
|
+
base = {
|
|
79
|
+
"timestamp": ts,
|
|
80
|
+
"avid": avid,
|
|
81
|
+
"action_type": action_type,
|
|
82
|
+
"decision": decision,
|
|
83
|
+
"reason": reason,
|
|
84
|
+
"prev_hash": prev_hash,
|
|
85
|
+
}
|
|
86
|
+
entry_hash = _sha256_hex(prev_hash.encode("utf-8") + _canonical_json(base))
|
|
87
|
+
entry = AuditEntry(
|
|
88
|
+
timestamp=ts,
|
|
89
|
+
avid=avid,
|
|
90
|
+
action_type=action_type,
|
|
91
|
+
decision=decision,
|
|
92
|
+
reason=reason,
|
|
93
|
+
prev_hash=prev_hash,
|
|
94
|
+
entry_hash=entry_hash,
|
|
95
|
+
)
|
|
96
|
+
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
97
|
+
with self.path.open("a", encoding="utf-8") as f:
|
|
98
|
+
f.write(json.dumps(entry.as_dict(), ensure_ascii=False) + "\n")
|
|
99
|
+
return entry
|
|
100
|
+
|
|
101
|
+
def iter_entries(self) -> Iterable[Dict[str, Any]]:
|
|
102
|
+
if not self.path.exists():
|
|
103
|
+
return []
|
|
104
|
+
with self.path.open("r", encoding="utf-8") as f:
|
|
105
|
+
for line in f:
|
|
106
|
+
line = line.strip()
|
|
107
|
+
if not line:
|
|
108
|
+
continue
|
|
109
|
+
yield json.loads(line)
|
|
110
|
+
|
|
111
|
+
def verify_integrity(self) -> bool:
|
|
112
|
+
prev_hash = ""
|
|
113
|
+
for entry in self.iter_entries():
|
|
114
|
+
if str(entry.get("prev_hash", "")) != prev_hash:
|
|
115
|
+
return False
|
|
116
|
+
base = {
|
|
117
|
+
"timestamp": entry.get("timestamp"),
|
|
118
|
+
"avid": entry.get("avid"),
|
|
119
|
+
"action_type": entry.get("action_type"),
|
|
120
|
+
"decision": entry.get("decision"),
|
|
121
|
+
"reason": entry.get("reason"),
|
|
122
|
+
"prev_hash": entry.get("prev_hash", ""),
|
|
123
|
+
}
|
|
124
|
+
expected = _sha256_hex(prev_hash.encode("utf-8") + _canonical_json(base))
|
|
125
|
+
if str(entry.get("entry_hash", "")) != expected:
|
|
126
|
+
return False
|
|
127
|
+
prev_hash = expected
|
|
128
|
+
return True
|
|
129
|
+
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from typing import Any, Dict, Optional
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@dataclass
|
|
8
|
+
class CanopyClient:
|
|
9
|
+
base_url: str = "http://127.0.0.1:8010"
|
|
10
|
+
timeout_s: float = 5.0
|
|
11
|
+
headers: Optional[Dict[str, str]] = None
|
|
12
|
+
|
|
13
|
+
# For tests or advanced usage you can inject a TestClient/httpx-style client.
|
|
14
|
+
http_client: Any = None
|
|
15
|
+
|
|
16
|
+
def authorize_action(self, agent_ctx: Dict[str, Any], action_type: str, action_payload: Any) -> Dict[str, Any]:
|
|
17
|
+
payload = {"agent_ctx": agent_ctx or {}, "action_type": action_type, "action_payload": action_payload}
|
|
18
|
+
if self.http_client is not None:
|
|
19
|
+
res = self.http_client.post("/authorize_action", json=payload)
|
|
20
|
+
res.raise_for_status()
|
|
21
|
+
return res.json()
|
|
22
|
+
|
|
23
|
+
try:
|
|
24
|
+
import requests # type: ignore
|
|
25
|
+
except ModuleNotFoundError as e:
|
|
26
|
+
raise ModuleNotFoundError(
|
|
27
|
+
"Optional dependency missing: install HTTP client support with `pip install canopy-runtime[http]`"
|
|
28
|
+
) from e
|
|
29
|
+
|
|
30
|
+
url = self.base_url.rstrip("/") + "/authorize_action"
|
|
31
|
+
res = requests.post(url, json=payload, headers=self.headers, timeout=self.timeout_s)
|
|
32
|
+
res.raise_for_status()
|
|
33
|
+
return res.json()
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import re
|
|
5
|
+
from enum import Enum
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any, Dict, List, Optional
|
|
8
|
+
|
|
9
|
+
from .audit import HashChainAuditLog
|
|
10
|
+
from .identity import generate_avid
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class Decision(str, Enum):
|
|
14
|
+
ALLOW = "ALLOW"
|
|
15
|
+
DENY = "DENY"
|
|
16
|
+
REQUIRE_APPROVAL = "REQUIRE_APPROVAL"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _parse_minimal_yaml(text: str) -> Dict[str, Any]:
|
|
20
|
+
"""Parse a tiny subset of YAML without external dependencies.
|
|
21
|
+
|
|
22
|
+
Supports:
|
|
23
|
+
rules:
|
|
24
|
+
- action_type: "execute_shell"
|
|
25
|
+
deny_if: "rm -rf"
|
|
26
|
+
- action_type: "execute_shell"
|
|
27
|
+
deny_if:
|
|
28
|
+
- "sudo"
|
|
29
|
+
- "mkfs"
|
|
30
|
+
deny_regex: 'rm\\s+-rf'
|
|
31
|
+
- action_type: "call_external_api"
|
|
32
|
+
require_approval: true
|
|
33
|
+
"""
|
|
34
|
+
lines = [ln.rstrip("\n") for ln in text.splitlines() if ln.strip() and not ln.strip().startswith("#")]
|
|
35
|
+
out: Dict[str, Any] = {"rules": []}
|
|
36
|
+
rules: List[Dict[str, Any]] = out["rules"]
|
|
37
|
+
|
|
38
|
+
def parse_scalar(v: str) -> Any:
|
|
39
|
+
v = v.strip()
|
|
40
|
+
if v.lower() in {"true", "false"}:
|
|
41
|
+
return v.lower() == "true"
|
|
42
|
+
if (v.startswith('"') and v.endswith('"')) or (v.startswith("'") and v.endswith("'")):
|
|
43
|
+
return v[1:-1]
|
|
44
|
+
return v
|
|
45
|
+
|
|
46
|
+
i = 0
|
|
47
|
+
in_rules = False
|
|
48
|
+
current: Optional[Dict[str, Any]] = None
|
|
49
|
+
while i < len(lines):
|
|
50
|
+
ln = lines[i]
|
|
51
|
+
stripped = ln.lstrip(" ")
|
|
52
|
+
indent = len(ln) - len(stripped)
|
|
53
|
+
if indent == 0 and stripped == "rules:":
|
|
54
|
+
in_rules = True
|
|
55
|
+
current = None
|
|
56
|
+
i += 1
|
|
57
|
+
continue
|
|
58
|
+
|
|
59
|
+
if not in_rules:
|
|
60
|
+
i += 1
|
|
61
|
+
continue
|
|
62
|
+
|
|
63
|
+
if stripped.startswith("- "):
|
|
64
|
+
current = {}
|
|
65
|
+
rules.append(current)
|
|
66
|
+
rest = stripped[2:].strip()
|
|
67
|
+
if rest and ":" in rest:
|
|
68
|
+
k, v = rest.split(":", 1)
|
|
69
|
+
current[k.strip()] = parse_scalar(v)
|
|
70
|
+
i += 1
|
|
71
|
+
continue
|
|
72
|
+
|
|
73
|
+
if current is not None and ":" in stripped:
|
|
74
|
+
k, v = stripped.split(":", 1)
|
|
75
|
+
key = k.strip()
|
|
76
|
+
val = v.strip()
|
|
77
|
+
if val == "":
|
|
78
|
+
items: List[Any] = []
|
|
79
|
+
i += 1
|
|
80
|
+
while i < len(lines):
|
|
81
|
+
ln2 = lines[i]
|
|
82
|
+
stripped2 = ln2.lstrip(" ")
|
|
83
|
+
indent2 = len(ln2) - len(stripped2)
|
|
84
|
+
if indent2 <= indent:
|
|
85
|
+
break
|
|
86
|
+
if stripped2.startswith("- "):
|
|
87
|
+
items.append(parse_scalar(stripped2[2:]))
|
|
88
|
+
i += 1
|
|
89
|
+
current[key] = items
|
|
90
|
+
continue
|
|
91
|
+
current[key] = parse_scalar(val)
|
|
92
|
+
i += 1
|
|
93
|
+
return out
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def load_policies(policy_file: str | Path) -> Dict[str, Any]:
|
|
97
|
+
return _parse_minimal_yaml(Path(policy_file).read_text(encoding="utf-8"))
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _default_policy_file() -> Path:
|
|
101
|
+
return Path(__file__).with_name("policies") / "default.yaml"
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _payload_text(payload: Any) -> str:
|
|
105
|
+
if payload is None:
|
|
106
|
+
return ""
|
|
107
|
+
if isinstance(payload, str):
|
|
108
|
+
return payload
|
|
109
|
+
if isinstance(payload, dict):
|
|
110
|
+
# Prefer deterministic rendering for matching/logging.
|
|
111
|
+
try:
|
|
112
|
+
import json
|
|
113
|
+
|
|
114
|
+
return json.dumps(payload, sort_keys=True, separators=(",", ":"), ensure_ascii=False, default=str)
|
|
115
|
+
except Exception:
|
|
116
|
+
return str(payload)
|
|
117
|
+
return str(payload)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _match_text(action_type: str, action_payload: Any) -> str:
|
|
121
|
+
"""Extract the primary matching string for a given action type.
|
|
122
|
+
|
|
123
|
+
This keeps policies stable when payloads are structured dicts.
|
|
124
|
+
"""
|
|
125
|
+
if isinstance(action_payload, dict):
|
|
126
|
+
if action_type == "execute_shell":
|
|
127
|
+
return str(action_payload.get("command", "") or "")
|
|
128
|
+
if action_type == "modify_file":
|
|
129
|
+
return str(action_payload.get("path", "") or "")
|
|
130
|
+
if action_type == "call_external_api":
|
|
131
|
+
return str(action_payload.get("url", "") or action_payload.get("endpoint", "") or "")
|
|
132
|
+
return _payload_text(action_payload)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _safe_paths(ctx: Dict[str, Any]) -> List[str]:
|
|
136
|
+
sp = ctx.get("safe_paths")
|
|
137
|
+
if isinstance(sp, list):
|
|
138
|
+
return [str(x) for x in sp]
|
|
139
|
+
return []
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def authorize_action(
|
|
143
|
+
agent_ctx: Dict[str, Any],
|
|
144
|
+
action_type: str,
|
|
145
|
+
action_payload: Any,
|
|
146
|
+
*,
|
|
147
|
+
policy_file: str | Path | None = None,
|
|
148
|
+
audit_log_path: str | Path | None = None,
|
|
149
|
+
) -> Dict[str, Any]:
|
|
150
|
+
"""Authorize an agent action and append a hash-chain audit entry."""
|
|
151
|
+
ctx = dict(agent_ctx or {})
|
|
152
|
+
avid = str(ctx.get("avid") or "") or generate_avid(ctx)
|
|
153
|
+
|
|
154
|
+
pf = Path(policy_file) if policy_file else _default_policy_file()
|
|
155
|
+
policies = load_policies(pf)
|
|
156
|
+
rules = policies.get("rules") if isinstance(policies.get("rules"), list) else []
|
|
157
|
+
|
|
158
|
+
decision = Decision.ALLOW
|
|
159
|
+
reason = "Allowed by default"
|
|
160
|
+
|
|
161
|
+
payload_text = _match_text(action_type, action_payload).lower()
|
|
162
|
+
for rule in rules:
|
|
163
|
+
if not isinstance(rule, dict):
|
|
164
|
+
continue
|
|
165
|
+
if str(rule.get("action_type", "")).strip() != action_type:
|
|
166
|
+
continue
|
|
167
|
+
|
|
168
|
+
if rule.get("require_approval") is True:
|
|
169
|
+
decision = Decision.REQUIRE_APPROVAL
|
|
170
|
+
reason = "Policy requires approval"
|
|
171
|
+
break
|
|
172
|
+
|
|
173
|
+
require_if = rule.get("require_approval_if")
|
|
174
|
+
require_items: List[str]
|
|
175
|
+
if isinstance(require_if, str):
|
|
176
|
+
require_items = [require_if]
|
|
177
|
+
elif isinstance(require_if, list):
|
|
178
|
+
require_items = [str(x) for x in require_if if str(x).strip()]
|
|
179
|
+
else:
|
|
180
|
+
require_items = []
|
|
181
|
+
for item in require_items:
|
|
182
|
+
if item.lower() in payload_text:
|
|
183
|
+
decision = Decision.REQUIRE_APPROVAL
|
|
184
|
+
reason = f"Policy requires approval: matched '{item}'"
|
|
185
|
+
break
|
|
186
|
+
if decision == Decision.REQUIRE_APPROVAL:
|
|
187
|
+
break
|
|
188
|
+
|
|
189
|
+
require_regex = rule.get("require_approval_regex")
|
|
190
|
+
req_regexes: List[str]
|
|
191
|
+
if isinstance(require_regex, str):
|
|
192
|
+
req_regexes = [require_regex]
|
|
193
|
+
elif isinstance(require_regex, list):
|
|
194
|
+
req_regexes = [str(x) for x in require_regex if str(x).strip()]
|
|
195
|
+
else:
|
|
196
|
+
req_regexes = []
|
|
197
|
+
for rx in req_regexes:
|
|
198
|
+
if re.search(rx, payload_text):
|
|
199
|
+
decision = Decision.REQUIRE_APPROVAL
|
|
200
|
+
reason = f"Policy requires approval: matched /{rx}/"
|
|
201
|
+
break
|
|
202
|
+
if decision == Decision.REQUIRE_APPROVAL:
|
|
203
|
+
break
|
|
204
|
+
|
|
205
|
+
deny_if = rule.get("deny_if")
|
|
206
|
+
deny_items: List[str]
|
|
207
|
+
if isinstance(deny_if, str):
|
|
208
|
+
deny_items = [deny_if]
|
|
209
|
+
elif isinstance(deny_if, list):
|
|
210
|
+
deny_items = [str(x) for x in deny_if if str(x).strip()]
|
|
211
|
+
else:
|
|
212
|
+
deny_items = []
|
|
213
|
+
for item in deny_items:
|
|
214
|
+
if item.lower() in payload_text:
|
|
215
|
+
decision = Decision.DENY
|
|
216
|
+
reason = f"Denied by policy: matched '{item}'"
|
|
217
|
+
break
|
|
218
|
+
if decision == Decision.DENY:
|
|
219
|
+
break
|
|
220
|
+
|
|
221
|
+
allow_if = rule.get("allow_if")
|
|
222
|
+
if allow_if == "safe_paths":
|
|
223
|
+
if isinstance(action_payload, dict):
|
|
224
|
+
path = str(action_payload.get("path", "") or "")
|
|
225
|
+
else:
|
|
226
|
+
path = str(action_payload or "")
|
|
227
|
+
safe_prefixes = _safe_paths(ctx)
|
|
228
|
+
if not safe_prefixes:
|
|
229
|
+
decision = Decision.REQUIRE_APPROVAL
|
|
230
|
+
reason = "Policy requires approval: safe_paths not configured"
|
|
231
|
+
elif any(path.startswith(prefix) for prefix in safe_prefixes):
|
|
232
|
+
decision = Decision.ALLOW
|
|
233
|
+
reason = "Allowed by policy: safe_paths"
|
|
234
|
+
else:
|
|
235
|
+
decision = Decision.DENY
|
|
236
|
+
reason = "Denied by policy: unsafe path"
|
|
237
|
+
break
|
|
238
|
+
|
|
239
|
+
deny_regex = rule.get("deny_regex")
|
|
240
|
+
regexes: List[str]
|
|
241
|
+
if isinstance(deny_regex, str):
|
|
242
|
+
regexes = [deny_regex]
|
|
243
|
+
elif isinstance(deny_regex, list):
|
|
244
|
+
regexes = [str(x) for x in deny_regex if str(x).strip()]
|
|
245
|
+
else:
|
|
246
|
+
regexes = []
|
|
247
|
+
for rx in regexes:
|
|
248
|
+
if re.search(rx, payload_text):
|
|
249
|
+
decision = Decision.DENY
|
|
250
|
+
reason = f"Denied by policy: matched /{rx}/"
|
|
251
|
+
break
|
|
252
|
+
if decision == Decision.DENY:
|
|
253
|
+
break
|
|
254
|
+
|
|
255
|
+
alp = Path(audit_log_path) if audit_log_path else Path(os.getenv("CANOPY_AUDIT_LOG_PATH", "audit.log"))
|
|
256
|
+
HashChainAuditLog(alp).append(avid=avid, action_type=action_type, decision=decision.value, reason=reason)
|
|
257
|
+
return {"decision": decision.value, "reason": reason, "avid": avid}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import sys
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any, Dict, List, Optional
|
|
7
|
+
|
|
8
|
+
from .core import authorize_action
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _print_decision(label: str, decision: Dict[str, Any]) -> None:
|
|
12
|
+
d = decision.get("decision")
|
|
13
|
+
reason = decision.get("reason")
|
|
14
|
+
avid = decision.get("avid")
|
|
15
|
+
print(f"{label}: {d} — {reason} (avid={avid})")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _parse_args(argv: Optional[List[str]] = None) -> argparse.Namespace:
|
|
19
|
+
p = argparse.ArgumentParser(description="Canopy demo: authorize actions and write an audit log.")
|
|
20
|
+
p.add_argument("--env", default="production", help="agent_ctx env label (default: production)")
|
|
21
|
+
p.add_argument(
|
|
22
|
+
"--safe-path",
|
|
23
|
+
action="append",
|
|
24
|
+
default=[],
|
|
25
|
+
help='repeatable; adds to agent_ctx["safe_paths"] (example: --safe-path /tmp/)',
|
|
26
|
+
)
|
|
27
|
+
p.add_argument("--policy-file", default=None, help="path to a YAML policy file (defaults to bundled policy)")
|
|
28
|
+
p.add_argument("--audit-log-path", default=None, help="path to audit log (default: ./audit.log)")
|
|
29
|
+
p.add_argument("--no-write", action="store_true", help="do not write any files (default: writes are simulated)")
|
|
30
|
+
return p.parse_args(argv)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def main(argv: Optional[List[str]] = None) -> int:
|
|
34
|
+
args = _parse_args(argv)
|
|
35
|
+
agent_ctx: Dict[str, Any] = {"env": args.env}
|
|
36
|
+
if args.safe_path:
|
|
37
|
+
agent_ctx["safe_paths"] = list(args.safe_path)
|
|
38
|
+
|
|
39
|
+
policy_file = args.policy_file
|
|
40
|
+
audit_log_path = args.audit_log_path
|
|
41
|
+
|
|
42
|
+
print("Canopy demo — decisions + audit log")
|
|
43
|
+
print(f"agent_ctx={agent_ctx}")
|
|
44
|
+
print(f"audit_log_path={audit_log_path or 'audit.log'}")
|
|
45
|
+
print("")
|
|
46
|
+
|
|
47
|
+
_print_decision(
|
|
48
|
+
"1) execute_shell rm -rf",
|
|
49
|
+
authorize_action(
|
|
50
|
+
agent_ctx,
|
|
51
|
+
"execute_shell",
|
|
52
|
+
{"command": "rm -rf /tmp/logs"},
|
|
53
|
+
policy_file=policy_file,
|
|
54
|
+
audit_log_path=audit_log_path,
|
|
55
|
+
),
|
|
56
|
+
)
|
|
57
|
+
_print_decision(
|
|
58
|
+
"2) execute_shell curl http",
|
|
59
|
+
authorize_action(
|
|
60
|
+
agent_ctx,
|
|
61
|
+
"execute_shell",
|
|
62
|
+
{"command": "curl http://example.com"},
|
|
63
|
+
policy_file=policy_file,
|
|
64
|
+
audit_log_path=audit_log_path,
|
|
65
|
+
),
|
|
66
|
+
)
|
|
67
|
+
_print_decision(
|
|
68
|
+
"3) modify_file /etc/passwd",
|
|
69
|
+
authorize_action(
|
|
70
|
+
agent_ctx,
|
|
71
|
+
"modify_file",
|
|
72
|
+
{"path": "/etc/passwd"},
|
|
73
|
+
policy_file=policy_file,
|
|
74
|
+
audit_log_path=audit_log_path,
|
|
75
|
+
),
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
demo_path = str(Path("/tmp/canopy_demo.txt"))
|
|
79
|
+
decision = authorize_action(
|
|
80
|
+
agent_ctx,
|
|
81
|
+
"modify_file",
|
|
82
|
+
{"path": demo_path},
|
|
83
|
+
policy_file=policy_file,
|
|
84
|
+
audit_log_path=audit_log_path,
|
|
85
|
+
)
|
|
86
|
+
_print_decision(f"4) modify_file {demo_path}", decision)
|
|
87
|
+
if not args.no_write and decision.get("decision") == "ALLOW":
|
|
88
|
+
Path(demo_path).write_text("hello from canopy\n", encoding="utf-8")
|
|
89
|
+
print(f" wrote {demo_path}")
|
|
90
|
+
else:
|
|
91
|
+
print(" (no write performed)")
|
|
92
|
+
|
|
93
|
+
_print_decision(
|
|
94
|
+
"5) call_external_api https://example.com",
|
|
95
|
+
authorize_action(
|
|
96
|
+
agent_ctx,
|
|
97
|
+
"call_external_api",
|
|
98
|
+
{"url": "https://example.com"},
|
|
99
|
+
policy_file=policy_file,
|
|
100
|
+
audit_log_path=audit_log_path,
|
|
101
|
+
),
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
print("")
|
|
105
|
+
print("Tip: to allow writes, run with e.g. `--safe-path /tmp/`.")
|
|
106
|
+
return 0
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
if __name__ == "__main__":
|
|
110
|
+
raise SystemExit(main(sys.argv[1:]))
|
|
111
|
+
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import json
|
|
5
|
+
from datetime import datetime, timezone
|
|
6
|
+
from typing import Any, Dict
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def _canonical_json(obj: Any) -> bytes:
|
|
10
|
+
return json.dumps(obj, sort_keys=True, separators=(",", ":"), ensure_ascii=False, default=str).encode("utf-8")
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def generate_avid(agent_ctx: Dict[str, Any]) -> str:
|
|
14
|
+
"""Generate an internal AVID for audit correlation.
|
|
15
|
+
|
|
16
|
+
If `created_at` is missing, it is set to a UTC ISO timestamp, which makes the AVID unique
|
|
17
|
+
but not stable across processes. Provide a stable `created_at` if you need stability.
|
|
18
|
+
"""
|
|
19
|
+
ctx = dict(agent_ctx or {})
|
|
20
|
+
public_key = str(ctx.get("public_key", "") or "")
|
|
21
|
+
created_at = ctx.get("created_at")
|
|
22
|
+
if not created_at:
|
|
23
|
+
created_at = datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z")
|
|
24
|
+
ctx["created_at"] = created_at
|
|
25
|
+
|
|
26
|
+
payload = {
|
|
27
|
+
"public_key": public_key,
|
|
28
|
+
"metadata": {k: v for k, v in ctx.items() if k not in {"public_key"}},
|
|
29
|
+
"created_at": created_at,
|
|
30
|
+
}
|
|
31
|
+
digest = hashlib.sha256(_canonical_json(payload)).hexdigest()
|
|
32
|
+
return f"AVID-{digest}"
|
|
33
|
+
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from functools import wraps
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Any, Callable, Dict, Optional, TypeVar
|
|
6
|
+
|
|
7
|
+
from .client import CanopyClient
|
|
8
|
+
from .core import authorize_action
|
|
9
|
+
|
|
10
|
+
T = TypeVar("T")
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _default_payload(args: tuple[Any, ...], kwargs: Dict[str, Any]) -> Any:
|
|
14
|
+
if len(args) == 1 and isinstance(args[0], str) and not kwargs:
|
|
15
|
+
return args[0]
|
|
16
|
+
return {"args": list(args), "kwargs": dict(kwargs)}
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def guard_tool(
|
|
20
|
+
*,
|
|
21
|
+
agent_ctx: Optional[Dict[str, Any]] = None,
|
|
22
|
+
action_type: Optional[str] = None,
|
|
23
|
+
payload_builder: Optional[Callable[..., Any]] = None,
|
|
24
|
+
policy_file: str | Path | None = None,
|
|
25
|
+
audit_log_path: str | Path | None = None,
|
|
26
|
+
) -> Callable[[Callable[..., T]], Callable[..., T]]:
|
|
27
|
+
"""Decorator that gates tool execution using local `authorize_action`."""
|
|
28
|
+
|
|
29
|
+
def _decorator(fn: Callable[..., T]) -> Callable[..., T]:
|
|
30
|
+
atype = action_type or fn.__name__
|
|
31
|
+
|
|
32
|
+
@wraps(fn)
|
|
33
|
+
def _wrapped(*args: Any, **kwargs: Any) -> T:
|
|
34
|
+
payload = payload_builder(*args, **kwargs) if payload_builder else _default_payload(args, kwargs)
|
|
35
|
+
decision = authorize_action(
|
|
36
|
+
agent_ctx or {},
|
|
37
|
+
atype,
|
|
38
|
+
payload,
|
|
39
|
+
policy_file=policy_file,
|
|
40
|
+
audit_log_path=audit_log_path,
|
|
41
|
+
)
|
|
42
|
+
d = (decision.get("decision") or "").upper()
|
|
43
|
+
if d != "ALLOW":
|
|
44
|
+
raise PermissionError(decision.get("reason") or "Blocked by policy")
|
|
45
|
+
return fn(*args, **kwargs)
|
|
46
|
+
|
|
47
|
+
return _wrapped
|
|
48
|
+
|
|
49
|
+
return _decorator
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def guard_tool_http(
|
|
53
|
+
*,
|
|
54
|
+
client: CanopyClient,
|
|
55
|
+
agent_ctx: Dict[str, Any],
|
|
56
|
+
action_type: str,
|
|
57
|
+
payload_builder: Optional[Callable[..., Any]] = None,
|
|
58
|
+
) -> Callable[[Callable[..., T]], Callable[..., T]]:
|
|
59
|
+
"""Decorator that gates execution via the HTTP gateway."""
|
|
60
|
+
|
|
61
|
+
def _decorator(fn: Callable[..., T]) -> Callable[..., T]:
|
|
62
|
+
@wraps(fn)
|
|
63
|
+
def _wrapped(*args: Any, **kwargs: Any) -> T:
|
|
64
|
+
payload = payload_builder(*args, **kwargs) if payload_builder else _default_payload(args, kwargs)
|
|
65
|
+
decision = client.authorize_action(agent_ctx, action_type, payload)
|
|
66
|
+
d = (decision.get("decision") or "").upper()
|
|
67
|
+
if d != "ALLOW":
|
|
68
|
+
raise PermissionError(decision.get("reason") or "Blocked by policy")
|
|
69
|
+
return fn(*args, **kwargs)
|
|
70
|
+
|
|
71
|
+
return _wrapped
|
|
72
|
+
|
|
73
|
+
return _decorator
|
|
74
|
+
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
rules:
|
|
2
|
+
- action_type: "execute_shell"
|
|
3
|
+
deny_regex:
|
|
4
|
+
- 'rm\s+-rf'
|
|
5
|
+
- ':\(\)\s*\{\s*:\s*\|\s*:\s*&\s*\};' # fork bomb
|
|
6
|
+
- action_type: "execute_shell"
|
|
7
|
+
require_approval_if:
|
|
8
|
+
- "curl http"
|
|
9
|
+
- "wget http"
|
|
10
|
+
- "ssh "
|
|
11
|
+
- "scp "
|
|
12
|
+
- "rsync "
|
|
13
|
+
- "apt-get "
|
|
14
|
+
- "yum "
|
|
15
|
+
- "brew install"
|
|
16
|
+
- "pip install"
|
|
17
|
+
deny_if:
|
|
18
|
+
- "rm -rf"
|
|
19
|
+
- "sudo"
|
|
20
|
+
- "mkfs"
|
|
21
|
+
- "dd "
|
|
22
|
+
- "chmod 777"
|
|
23
|
+
- "chown "
|
|
24
|
+
- "shutdown"
|
|
25
|
+
- "reboot"
|
|
26
|
+
- "kill -9 1"
|
|
27
|
+
- action_type: "modify_file"
|
|
28
|
+
deny_if:
|
|
29
|
+
- "/etc/"
|
|
30
|
+
- "/usr/"
|
|
31
|
+
- "/bin/"
|
|
32
|
+
- "/sbin/"
|
|
33
|
+
- "/var/lib/"
|
|
34
|
+
- ".ssh"
|
|
35
|
+
- ".aws"
|
|
36
|
+
- ".kube"
|
|
37
|
+
- ".env"
|
|
38
|
+
- action_type: "modify_file"
|
|
39
|
+
allow_if: "safe_paths"
|
|
40
|
+
- action_type: "call_external_api"
|
|
41
|
+
require_approval: true
|
|
42
|
+
- action_type: "spend_money"
|
|
43
|
+
require_approval: true
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Any, Dict
|
|
6
|
+
|
|
7
|
+
from fastapi import FastAPI
|
|
8
|
+
from pydantic import BaseModel, Field
|
|
9
|
+
|
|
10
|
+
from .core import authorize_action
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _default_policy_file() -> Path:
|
|
14
|
+
return Path(__file__).with_name("policies") / "default.yaml"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class ActionRequest(BaseModel):
|
|
18
|
+
agent_ctx: Dict[str, Any] = Field(default_factory=dict)
|
|
19
|
+
action_type: str
|
|
20
|
+
action_payload: Any = Field(default_factory=dict)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
app = FastAPI(title="Canopy Runtime", version="0.1.0")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@app.post("/authorize_action")
|
|
27
|
+
async def authorize(req: ActionRequest):
|
|
28
|
+
policy_file = os.getenv("CANOPY_POLICY_FILE")
|
|
29
|
+
audit_log_path = os.getenv("CANOPY_AUDIT_LOG_PATH", "audit.log")
|
|
30
|
+
return authorize_action(
|
|
31
|
+
req.agent_ctx,
|
|
32
|
+
req.action_type,
|
|
33
|
+
req.action_payload,
|
|
34
|
+
policy_file=policy_file or _default_policy_file(),
|
|
35
|
+
audit_log_path=audit_log_path,
|
|
36
|
+
)
|
|
37
|
+
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: canopy-runtime
|
|
3
|
+
Version: 0.1.1
|
|
4
|
+
Summary: Canopy Agent Safety Runtime: policy enforcement for tool-using agents
|
|
5
|
+
Author: Canopy
|
|
6
|
+
Requires-Python: >=3.9
|
|
7
|
+
Description-Content-Type: text/markdown
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Provides-Extra: http
|
|
10
|
+
Requires-Dist: requests>=2.25; extra == "http"
|
|
11
|
+
Provides-Extra: gateway
|
|
12
|
+
Requires-Dist: fastapi>=0.100; extra == "gateway"
|
|
13
|
+
Requires-Dist: pydantic>=2; extra == "gateway"
|
|
14
|
+
Requires-Dist: uvicorn[standard]>=0.23; extra == "gateway"
|
|
15
|
+
Provides-Extra: dev
|
|
16
|
+
Requires-Dist: pytest>=7; extra == "dev"
|
|
17
|
+
Requires-Dist: httpx>=0.24; extra == "dev"
|
|
18
|
+
Requires-Dist: requests>=2.25; extra == "dev"
|
|
19
|
+
Requires-Dist: fastapi>=0.100; extra == "dev"
|
|
20
|
+
Requires-Dist: pydantic>=2; extra == "dev"
|
|
21
|
+
Requires-Dist: uvicorn[standard]>=0.23; extra == "dev"
|
|
22
|
+
Dynamic: license-file
|
|
23
|
+
|
|
24
|
+
# Canopy Runtime
|
|
25
|
+
|
|
26
|
+
Minimal **Agent Safety Runtime** focused on a single primitive:
|
|
27
|
+
|
|
28
|
+
`authorize_action(agent_ctx, action_type, action_payload)` → `{decision, reason, avid}`
|
|
29
|
+
|
|
30
|
+
Decisions:
|
|
31
|
+
- `ALLOW`
|
|
32
|
+
- `DENY`
|
|
33
|
+
- `REQUIRE_APPROVAL`
|
|
34
|
+
|
|
35
|
+
Every decision is appended to a JSONL **hash-chain audit log** (`audit.log` by default).
|
|
36
|
+
|
|
37
|
+
## 3‑minute quickstart (library)
|
|
38
|
+
```bash
|
|
39
|
+
python3 -m venv .venv
|
|
40
|
+
source .venv/bin/activate
|
|
41
|
+
pip install -U pip
|
|
42
|
+
pip install canopy-runtime
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
```python
|
|
46
|
+
from canopy import authorize_action
|
|
47
|
+
|
|
48
|
+
decision = authorize_action(
|
|
49
|
+
agent_ctx={"env": "production"},
|
|
50
|
+
action_type="execute_shell",
|
|
51
|
+
action_payload={"command": "rm -rf /tmp/logs"},
|
|
52
|
+
)
|
|
53
|
+
print(decision["decision"]) # DENY
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
You should also see an `audit.log` file created in the current directory.
|
|
57
|
+
|
|
58
|
+
Try the included demo:
|
|
59
|
+
```bash
|
|
60
|
+
canopy-demo
|
|
61
|
+
canopy-demo --safe-path /tmp/
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
## Default policy pack (works out of the box)
|
|
65
|
+
Bundled defaults live at `src/canopy/policies/default.yaml` and aim to be conservative:
|
|
66
|
+
- `execute_shell`: deny common destructive patterns; require approval for network/install-style commands.
|
|
67
|
+
- `modify_file`: deny protected paths; require approval unless the path is in `agent_ctx["safe_paths"]`.
|
|
68
|
+
- `call_external_api`: require approval.
|
|
69
|
+
|
|
70
|
+
## Config
|
|
71
|
+
- `CANOPY_POLICY_FILE`: path to a YAML policy file (defaults to the bundled `default.yaml`)
|
|
72
|
+
- `CANOPY_AUDIT_LOG_PATH`: path to audit log (defaults to `audit.log`)
|
|
73
|
+
- `agent_ctx["safe_paths"]`: allowlist for `modify_file` (e.g. `["/repo/", "/tmp/"]`)
|
|
74
|
+
|
|
75
|
+
## Optional gateway (HTTP)
|
|
76
|
+
```bash
|
|
77
|
+
pip install canopy-runtime[gateway]
|
|
78
|
+
CANOPY_AUDIT_LOG_PATH=/tmp/canopy_audit.log python -m uvicorn canopy.service:app --port 8010
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
## Tests (dev)
|
|
82
|
+
```bash
|
|
83
|
+
pip install -e ".[dev]"
|
|
84
|
+
pytest -q
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
## Easter egg
|
|
88
|
+
```js
|
|
89
|
+
// Nunca se usa pero no quitar por si acaso
|
|
90
|
+
const _ = "QmUgYm9sZCBhbmQgbWlnaHR5IGZvcmNlcyB3aWxsIGNvbWUgdG8geW91ciBhaWQu"; // base64
|
|
91
|
+
|
|
92
|
+
// si algún día te aburres:
|
|
93
|
+
// console.log(atob(_))
|
|
94
|
+
```
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
src/canopy/__init__.py
|
|
5
|
+
src/canopy/audit.py
|
|
6
|
+
src/canopy/client.py
|
|
7
|
+
src/canopy/core.py
|
|
8
|
+
src/canopy/demo.py
|
|
9
|
+
src/canopy/identity.py
|
|
10
|
+
src/canopy/middleware.py
|
|
11
|
+
src/canopy/service.py
|
|
12
|
+
src/canopy/policies/default.yaml
|
|
13
|
+
src/canopy_runtime.egg-info/PKG-INFO
|
|
14
|
+
src/canopy_runtime.egg-info/SOURCES.txt
|
|
15
|
+
src/canopy_runtime.egg-info/dependency_links.txt
|
|
16
|
+
src/canopy_runtime.egg-info/entry_points.txt
|
|
17
|
+
src/canopy_runtime.egg-info/requires.txt
|
|
18
|
+
src/canopy_runtime.egg-info/top_level.txt
|
|
19
|
+
tests/test_authorize_action.py
|
|
20
|
+
tests/test_client.py
|
|
21
|
+
tests/test_service.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
canopy
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from canopy.audit import HashChainAuditLog
|
|
7
|
+
from canopy.core import authorize_action
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def test_deny_rm_rf(tmp_path: Path):
|
|
11
|
+
policy = tmp_path / "policy.yaml"
|
|
12
|
+
policy.write_text(
|
|
13
|
+
"\n".join(
|
|
14
|
+
[
|
|
15
|
+
"rules:",
|
|
16
|
+
' - action_type: "execute_shell"',
|
|
17
|
+
' deny_if: "rm -rf"',
|
|
18
|
+
]
|
|
19
|
+
)
|
|
20
|
+
+ "\n",
|
|
21
|
+
encoding="utf-8",
|
|
22
|
+
)
|
|
23
|
+
audit_path = tmp_path / "audit.log"
|
|
24
|
+
agent_ctx = {"public_key": "pk", "created_at": "2026-03-15T00:00:00Z"}
|
|
25
|
+
res = authorize_action(agent_ctx, "execute_shell", "rm -rf /", policy_file=policy, audit_log_path=audit_path)
|
|
26
|
+
assert res["decision"] == "DENY"
|
|
27
|
+
assert res["avid"].startswith("AVID-")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def test_require_approval(tmp_path: Path):
|
|
31
|
+
policy = tmp_path / "policy.yaml"
|
|
32
|
+
policy.write_text(
|
|
33
|
+
"\n".join(
|
|
34
|
+
[
|
|
35
|
+
"rules:",
|
|
36
|
+
' - action_type: "call_external_api"',
|
|
37
|
+
" require_approval: true",
|
|
38
|
+
]
|
|
39
|
+
)
|
|
40
|
+
+ "\n",
|
|
41
|
+
encoding="utf-8",
|
|
42
|
+
)
|
|
43
|
+
audit_path = tmp_path / "audit.log"
|
|
44
|
+
agent_ctx = {"public_key": "pk", "created_at": "2026-03-15T00:00:00Z"}
|
|
45
|
+
res = authorize_action(
|
|
46
|
+
agent_ctx,
|
|
47
|
+
"call_external_api",
|
|
48
|
+
{"url": "https://example.com"},
|
|
49
|
+
policy_file=policy,
|
|
50
|
+
audit_log_path=audit_path,
|
|
51
|
+
)
|
|
52
|
+
assert res["decision"] == "REQUIRE_APPROVAL"
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def test_safe_paths(tmp_path: Path):
|
|
56
|
+
policy = tmp_path / "policy.yaml"
|
|
57
|
+
policy.write_text(
|
|
58
|
+
"\n".join(
|
|
59
|
+
[
|
|
60
|
+
"rules:",
|
|
61
|
+
' - action_type: "modify_file"',
|
|
62
|
+
' allow_if: "safe_paths"',
|
|
63
|
+
]
|
|
64
|
+
)
|
|
65
|
+
+ "\n",
|
|
66
|
+
encoding="utf-8",
|
|
67
|
+
)
|
|
68
|
+
audit_path = tmp_path / "audit.log"
|
|
69
|
+
agent_ctx = {"public_key": "pk", "created_at": "2026-03-15T00:00:00Z", "safe_paths": [str(tmp_path)]}
|
|
70
|
+
ok = authorize_action(
|
|
71
|
+
agent_ctx,
|
|
72
|
+
"modify_file",
|
|
73
|
+
{"path": str(tmp_path / "x.txt")},
|
|
74
|
+
policy_file=policy,
|
|
75
|
+
audit_log_path=audit_path,
|
|
76
|
+
)
|
|
77
|
+
assert ok["decision"] == "ALLOW"
|
|
78
|
+
bad = authorize_action(
|
|
79
|
+
agent_ctx,
|
|
80
|
+
"modify_file",
|
|
81
|
+
{"path": "/etc/passwd"},
|
|
82
|
+
policy_file=policy,
|
|
83
|
+
audit_log_path=audit_path,
|
|
84
|
+
)
|
|
85
|
+
assert bad["decision"] == "DENY"
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def test_audit_hash_chain_integrity(tmp_path: Path):
|
|
89
|
+
audit_path = tmp_path / "audit.log"
|
|
90
|
+
log = HashChainAuditLog(audit_path)
|
|
91
|
+
log.append(avid="AVID-1", action_type="t1", decision="ALLOW", reason="ok", timestamp="2026-03-15T00:00:00Z")
|
|
92
|
+
log.append(avid="AVID-1", action_type="t2", decision="DENY", reason="no", timestamp="2026-03-15T00:00:01Z")
|
|
93
|
+
assert log.verify_integrity() is True
|
|
94
|
+
|
|
95
|
+
lines = audit_path.read_text(encoding="utf-8").splitlines()
|
|
96
|
+
obj = json.loads(lines[1])
|
|
97
|
+
obj["reason"] = "tampered"
|
|
98
|
+
lines[1] = json.dumps(obj, ensure_ascii=False)
|
|
99
|
+
audit_path.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
|
100
|
+
assert HashChainAuditLog(audit_path).verify_integrity() is False
|
|
101
|
+
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from fastapi.testclient import TestClient
|
|
4
|
+
|
|
5
|
+
from canopy.client import CanopyClient
|
|
6
|
+
from canopy.service import app
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def test_client_against_service(monkeypatch, tmp_path):
|
|
10
|
+
monkeypatch.setenv("CANOPY_AUDIT_LOG_PATH", str(tmp_path / "audit.log"))
|
|
11
|
+
http = TestClient(app)
|
|
12
|
+
client = CanopyClient(http_client=http)
|
|
13
|
+
res = client.authorize_action(
|
|
14
|
+
{"public_key": "pk", "created_at": "2026-03-15T00:00:00Z"},
|
|
15
|
+
"call_external_api",
|
|
16
|
+
{"url": "https://example.com"},
|
|
17
|
+
)
|
|
18
|
+
assert res["decision"] == "REQUIRE_APPROVAL"
|
|
19
|
+
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from fastapi.testclient import TestClient
|
|
4
|
+
|
|
5
|
+
from canopy.service import app
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def test_service_authorize_action(monkeypatch, tmp_path):
|
|
9
|
+
monkeypatch.setenv("CANOPY_AUDIT_LOG_PATH", str(tmp_path / "audit.log"))
|
|
10
|
+
client = TestClient(app)
|
|
11
|
+
res = client.post(
|
|
12
|
+
"/authorize_action",
|
|
13
|
+
json={
|
|
14
|
+
"agent_ctx": {"public_key": "pk", "created_at": "2026-03-15T00:00:00Z"},
|
|
15
|
+
"action_type": "execute_shell",
|
|
16
|
+
"action_payload": "rm -rf /",
|
|
17
|
+
},
|
|
18
|
+
)
|
|
19
|
+
assert res.status_code == 200
|
|
20
|
+
data = res.json()
|
|
21
|
+
assert data["decision"] == "DENY"
|
|
22
|
+
|