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.
@@ -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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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,2 @@
1
+ [console_scripts]
2
+ canopy-demo = canopy.demo:main
@@ -0,0 +1,16 @@
1
+
2
+ [dev]
3
+ pytest>=7
4
+ httpx>=0.24
5
+ requests>=2.25
6
+ fastapi>=0.100
7
+ pydantic>=2
8
+ uvicorn[standard]>=0.23
9
+
10
+ [gateway]
11
+ fastapi>=0.100
12
+ pydantic>=2
13
+ uvicorn[standard]>=0.23
14
+
15
+ [http]
16
+ requests>=2.25
@@ -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
+