custos-mcp 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.
- custos_mcp-0.1.0/.gitignore +10 -0
- custos_mcp-0.1.0/PKG-INFO +108 -0
- custos_mcp-0.1.0/README.md +58 -0
- custos_mcp-0.1.0/pyproject.toml +51 -0
- custos_mcp-0.1.0/src/custos/__init__.py +35 -0
- custos_mcp-0.1.0/src/custos/adapters/__init__.py +1 -0
- custos_mcp-0.1.0/src/custos/adapters/cedar.py +55 -0
- custos_mcp-0.1.0/src/custos/adapters/opa.py +55 -0
- custos_mcp-0.1.0/src/custos/bundle.py +103 -0
- custos_mcp-0.1.0/src/custos/canonical.py +31 -0
- custos_mcp-0.1.0/src/custos/cli.py +126 -0
- custos_mcp-0.1.0/src/custos/dashboard.py +126 -0
- custos_mcp-0.1.0/src/custos/ids.py +34 -0
- custos_mcp-0.1.0/src/custos/keys.py +71 -0
- custos_mcp-0.1.0/src/custos/ledger.py +105 -0
- custos_mcp-0.1.0/src/custos/otel.py +31 -0
- custos_mcp-0.1.0/src/custos/policy.py +164 -0
- custos_mcp-0.1.0/src/custos/proxy.py +189 -0
- custos_mcp-0.1.0/src/custos/record.py +125 -0
- custos_mcp-0.1.0/src/custos/sdk.py +178 -0
- custos_mcp-0.1.0/src/custos/verify.py +88 -0
- custos_mcp-0.1.0/tests/test_bundle.py +23 -0
- custos_mcp-0.1.0/tests/test_canonical.py +19 -0
- custos_mcp-0.1.0/tests/test_ledger_and_verify.py +61 -0
- custos_mcp-0.1.0/tests/test_policy.py +57 -0
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: custos-mcp
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Runtime governance, policy enforcement, and cryptographic audit ledger for MCP tool calls
|
|
5
|
+
Project-URL: Homepage, https://github.com/sanjaynandanj/custos
|
|
6
|
+
Project-URL: Documentation, https://github.com/sanjaynandanj/custos
|
|
7
|
+
Project-URL: Repository, https://github.com/sanjaynandanj/custos
|
|
8
|
+
Author: Custos contributors
|
|
9
|
+
License: Apache-2.0
|
|
10
|
+
Keywords: ai-agents,audit,ed25519,governance,ledger,mcp,policy
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: License :: OSI Approved :: Apache Software License
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
18
|
+
Classifier: Topic :: Security
|
|
19
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
20
|
+
Requires-Python: >=3.10
|
|
21
|
+
Requires-Dist: click>=8.1
|
|
22
|
+
Requires-Dist: cryptography>=41.0
|
|
23
|
+
Requires-Dist: pyyaml>=6.0
|
|
24
|
+
Provides-Extra: all
|
|
25
|
+
Requires-Dist: cedarpy>=4.0; extra == 'all'
|
|
26
|
+
Requires-Dist: fastapi>=0.110; extra == 'all'
|
|
27
|
+
Requires-Dist: httpx>=0.27; extra == 'all'
|
|
28
|
+
Requires-Dist: jinja2>=3.1; extra == 'all'
|
|
29
|
+
Requires-Dist: opentelemetry-api>=1.24; extra == 'all'
|
|
30
|
+
Requires-Dist: opentelemetry-sdk>=1.24; extra == 'all'
|
|
31
|
+
Requires-Dist: pytest-asyncio>=0.23; extra == 'all'
|
|
32
|
+
Requires-Dist: pytest>=8; extra == 'all'
|
|
33
|
+
Requires-Dist: ruff>=0.5; extra == 'all'
|
|
34
|
+
Requires-Dist: uvicorn[standard]>=0.29; extra == 'all'
|
|
35
|
+
Provides-Extra: cedar
|
|
36
|
+
Requires-Dist: cedarpy>=4.0; extra == 'cedar'
|
|
37
|
+
Provides-Extra: dev
|
|
38
|
+
Requires-Dist: httpx>=0.27; extra == 'dev'
|
|
39
|
+
Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
|
|
40
|
+
Requires-Dist: pytest>=8; extra == 'dev'
|
|
41
|
+
Requires-Dist: ruff>=0.5; extra == 'dev'
|
|
42
|
+
Provides-Extra: otel
|
|
43
|
+
Requires-Dist: opentelemetry-api>=1.24; extra == 'otel'
|
|
44
|
+
Requires-Dist: opentelemetry-sdk>=1.24; extra == 'otel'
|
|
45
|
+
Provides-Extra: web
|
|
46
|
+
Requires-Dist: fastapi>=0.110; extra == 'web'
|
|
47
|
+
Requires-Dist: jinja2>=3.1; extra == 'web'
|
|
48
|
+
Requires-Dist: uvicorn[standard]>=0.29; extra == 'web'
|
|
49
|
+
Description-Content-Type: text/markdown
|
|
50
|
+
|
|
51
|
+
# custos-mcp
|
|
52
|
+
|
|
53
|
+
[](https://pypi.org/project/custos-mcp/)
|
|
54
|
+
[](https://pypi.org/project/custos-mcp/)
|
|
55
|
+
[](https://github.com/sanjaynandanj/custos/blob/main/LICENSE)
|
|
56
|
+
|
|
57
|
+
Runtime governance, policy enforcement, and cryptographic audit for MCP tool calls.
|
|
58
|
+
|
|
59
|
+
Every `tools/call` from an AI agent is evaluated against a policy, allowed or denied, timed, and appended to an Ed25519-signed hash-chained ledger. The ledger format is wire-compatible with the [Node package](https://www.npmjs.com/package/custos-mcp) — audit anywhere.
|
|
60
|
+
|
|
61
|
+
```bash
|
|
62
|
+
pip install custos-mcp[web]
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
## Quickstart
|
|
66
|
+
|
|
67
|
+
```python
|
|
68
|
+
from custos import Gate, Ledger, Actor, Server, generate_keypair, load_policy
|
|
69
|
+
|
|
70
|
+
kp = generate_keypair()
|
|
71
|
+
kp.save(".custos")
|
|
72
|
+
ledger = Ledger(".custos/ledger.jsonl", kp)
|
|
73
|
+
policy = load_policy("policy.yaml")
|
|
74
|
+
|
|
75
|
+
gate = Gate(policy, ledger, Actor("agent-1"), Server("fs"))
|
|
76
|
+
|
|
77
|
+
result = gate.call("read_file", {"path": "/workspace/x"}, fn=open_file)
|
|
78
|
+
if result.allowed:
|
|
79
|
+
print(result.result)
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
## CLI
|
|
83
|
+
|
|
84
|
+
```bash
|
|
85
|
+
custos keygen # write .custos/ledger.key + ledger.pub
|
|
86
|
+
custos proxy --policy policy.yaml -- python -m my_mcp_server
|
|
87
|
+
custos verify --ledger .custos/ledger.jsonl
|
|
88
|
+
custos bundle out.tar.gz # export portable evidence
|
|
89
|
+
custos verify-bundle out.tar.gz
|
|
90
|
+
custos serve # dashboard on :8787
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
## Policy DSL
|
|
94
|
+
|
|
95
|
+
```yaml
|
|
96
|
+
version: 1
|
|
97
|
+
id: default
|
|
98
|
+
default: deny
|
|
99
|
+
rules:
|
|
100
|
+
- id: allow-read
|
|
101
|
+
when:
|
|
102
|
+
tool: read_file
|
|
103
|
+
args.path: {prefix: "/workspace/"}
|
|
104
|
+
decision: allow
|
|
105
|
+
reason: workspace-only reads
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
See `spec/POLICY.md` for the full grammar.
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# custos-mcp
|
|
2
|
+
|
|
3
|
+
[](https://pypi.org/project/custos-mcp/)
|
|
4
|
+
[](https://pypi.org/project/custos-mcp/)
|
|
5
|
+
[](https://github.com/sanjaynandanj/custos/blob/main/LICENSE)
|
|
6
|
+
|
|
7
|
+
Runtime governance, policy enforcement, and cryptographic audit for MCP tool calls.
|
|
8
|
+
|
|
9
|
+
Every `tools/call` from an AI agent is evaluated against a policy, allowed or denied, timed, and appended to an Ed25519-signed hash-chained ledger. The ledger format is wire-compatible with the [Node package](https://www.npmjs.com/package/custos-mcp) — audit anywhere.
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
pip install custos-mcp[web]
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
## Quickstart
|
|
16
|
+
|
|
17
|
+
```python
|
|
18
|
+
from custos import Gate, Ledger, Actor, Server, generate_keypair, load_policy
|
|
19
|
+
|
|
20
|
+
kp = generate_keypair()
|
|
21
|
+
kp.save(".custos")
|
|
22
|
+
ledger = Ledger(".custos/ledger.jsonl", kp)
|
|
23
|
+
policy = load_policy("policy.yaml")
|
|
24
|
+
|
|
25
|
+
gate = Gate(policy, ledger, Actor("agent-1"), Server("fs"))
|
|
26
|
+
|
|
27
|
+
result = gate.call("read_file", {"path": "/workspace/x"}, fn=open_file)
|
|
28
|
+
if result.allowed:
|
|
29
|
+
print(result.result)
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
## CLI
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
custos keygen # write .custos/ledger.key + ledger.pub
|
|
36
|
+
custos proxy --policy policy.yaml -- python -m my_mcp_server
|
|
37
|
+
custos verify --ledger .custos/ledger.jsonl
|
|
38
|
+
custos bundle out.tar.gz # export portable evidence
|
|
39
|
+
custos verify-bundle out.tar.gz
|
|
40
|
+
custos serve # dashboard on :8787
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
## Policy DSL
|
|
44
|
+
|
|
45
|
+
```yaml
|
|
46
|
+
version: 1
|
|
47
|
+
id: default
|
|
48
|
+
default: deny
|
|
49
|
+
rules:
|
|
50
|
+
- id: allow-read
|
|
51
|
+
when:
|
|
52
|
+
tool: read_file
|
|
53
|
+
args.path: {prefix: "/workspace/"}
|
|
54
|
+
decision: allow
|
|
55
|
+
reason: workspace-only reads
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
See `spec/POLICY.md` for the full grammar.
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "custos-mcp"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Runtime governance, policy enforcement, and cryptographic audit ledger for MCP tool calls"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
license = {text = "Apache-2.0"}
|
|
12
|
+
authors = [{name = "Custos contributors"}]
|
|
13
|
+
keywords = ["mcp", "governance", "policy", "audit", "ed25519", "ledger", "ai-agents"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 4 - Beta",
|
|
16
|
+
"License :: OSI Approved :: Apache Software License",
|
|
17
|
+
"Programming Language :: Python :: 3",
|
|
18
|
+
"Programming Language :: Python :: 3.10",
|
|
19
|
+
"Programming Language :: Python :: 3.11",
|
|
20
|
+
"Programming Language :: Python :: 3.12",
|
|
21
|
+
"Programming Language :: Python :: 3.13",
|
|
22
|
+
"Topic :: Security",
|
|
23
|
+
"Topic :: Software Development :: Libraries",
|
|
24
|
+
]
|
|
25
|
+
dependencies = [
|
|
26
|
+
"cryptography>=41.0",
|
|
27
|
+
"pyyaml>=6.0",
|
|
28
|
+
"click>=8.1",
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
[project.optional-dependencies]
|
|
32
|
+
web = ["fastapi>=0.110", "uvicorn[standard]>=0.29", "jinja2>=3.1"]
|
|
33
|
+
otel = ["opentelemetry-api>=1.24", "opentelemetry-sdk>=1.24"]
|
|
34
|
+
cedar = ["cedarpy>=4.0"]
|
|
35
|
+
dev = ["pytest>=8", "pytest-asyncio>=0.23", "httpx>=0.27", "ruff>=0.5"]
|
|
36
|
+
all = ["custos-mcp[web,otel,cedar,dev]"]
|
|
37
|
+
|
|
38
|
+
[project.scripts]
|
|
39
|
+
custos = "custos.cli:main"
|
|
40
|
+
|
|
41
|
+
[project.urls]
|
|
42
|
+
Homepage = "https://github.com/sanjaynandanj/custos"
|
|
43
|
+
Documentation = "https://github.com/sanjaynandanj/custos"
|
|
44
|
+
Repository = "https://github.com/sanjaynandanj/custos"
|
|
45
|
+
|
|
46
|
+
[tool.hatch.build.targets.wheel]
|
|
47
|
+
packages = ["src/custos"]
|
|
48
|
+
|
|
49
|
+
[tool.pytest.ini_options]
|
|
50
|
+
testpaths = ["tests"]
|
|
51
|
+
asyncio_mode = "auto"
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""Custos: runtime governance for MCP tool calls."""
|
|
2
|
+
|
|
3
|
+
from custos.canonical import dumps as canonical_dumps, loads as canonical_loads
|
|
4
|
+
from custos.keys import KeyPair, generate_keypair, load_keypair, load_public_key
|
|
5
|
+
from custos.ledger import Ledger, LedgerError
|
|
6
|
+
from custos.policy import Policy, PolicyDecision, Rule, load_policy
|
|
7
|
+
from custos.record import Decision, DecisionRecord, Actor, Server
|
|
8
|
+
from custos.sdk import Gate, GateResult
|
|
9
|
+
from custos.verify import verify_ledger, VerifyResult
|
|
10
|
+
|
|
11
|
+
__version__ = "0.1.0"
|
|
12
|
+
|
|
13
|
+
__all__ = [
|
|
14
|
+
"Actor",
|
|
15
|
+
"canonical_dumps",
|
|
16
|
+
"canonical_loads",
|
|
17
|
+
"Decision",
|
|
18
|
+
"DecisionRecord",
|
|
19
|
+
"Gate",
|
|
20
|
+
"GateResult",
|
|
21
|
+
"generate_keypair",
|
|
22
|
+
"KeyPair",
|
|
23
|
+
"Ledger",
|
|
24
|
+
"LedgerError",
|
|
25
|
+
"load_keypair",
|
|
26
|
+
"load_policy",
|
|
27
|
+
"load_public_key",
|
|
28
|
+
"Policy",
|
|
29
|
+
"PolicyDecision",
|
|
30
|
+
"Rule",
|
|
31
|
+
"Server",
|
|
32
|
+
"verify_ledger",
|
|
33
|
+
"VerifyResult",
|
|
34
|
+
"__version__",
|
|
35
|
+
]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Optional policy engine adapters (Cedar, OPA)."""
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""Cedar adapter (optional): requires `pip install custos-mcp[cedar]`."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
from typing import List
|
|
6
|
+
|
|
7
|
+
from custos.policy import PolicyDecision
|
|
8
|
+
from custos.record import Decision
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass
|
|
12
|
+
class CedarPolicy:
|
|
13
|
+
id: str
|
|
14
|
+
policy_text: str
|
|
15
|
+
default: Decision = Decision.DENY
|
|
16
|
+
version: int = 1
|
|
17
|
+
engine: str = "cedar"
|
|
18
|
+
rules: List = field(default_factory=list)
|
|
19
|
+
|
|
20
|
+
def __post_init__(self):
|
|
21
|
+
try:
|
|
22
|
+
import cedarpy # noqa: F401
|
|
23
|
+
except ImportError as e:
|
|
24
|
+
raise ImportError(
|
|
25
|
+
"Cedar support requires `pip install custos-mcp[cedar]`"
|
|
26
|
+
) from e
|
|
27
|
+
|
|
28
|
+
def evaluate(self, ctx: dict) -> PolicyDecision:
|
|
29
|
+
import cedarpy
|
|
30
|
+
# Build principal/action/resource from context
|
|
31
|
+
principal = f'Actor::"{ctx.get("actor", {}).get("id", "unknown")}"'
|
|
32
|
+
action = f'Action::"{ctx.get("tool", "unknown")}"'
|
|
33
|
+
resource = f'Server::"{ctx.get("server", {}).get("id", "unknown")}"'
|
|
34
|
+
try:
|
|
35
|
+
result = cedarpy.is_authorized(
|
|
36
|
+
self.policy_text,
|
|
37
|
+
{"schema": {}},
|
|
38
|
+
{
|
|
39
|
+
"principal": principal,
|
|
40
|
+
"action": action,
|
|
41
|
+
"resource": resource,
|
|
42
|
+
"context": ctx,
|
|
43
|
+
},
|
|
44
|
+
[],
|
|
45
|
+
)
|
|
46
|
+
allowed = bool(result.get("allowed", False))
|
|
47
|
+
reasons = result.get("reasons", [])
|
|
48
|
+
reason = ", ".join(reasons) if reasons else ("cedar allow" if allowed else "cedar deny")
|
|
49
|
+
except Exception as e:
|
|
50
|
+
return PolicyDecision(decision=Decision.ERROR, rule_id="", reason=f"cedar error: {e}")
|
|
51
|
+
return PolicyDecision(
|
|
52
|
+
decision=Decision.ALLOW if allowed else self.default,
|
|
53
|
+
rule_id="cedar",
|
|
54
|
+
reason=reason,
|
|
55
|
+
)
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""OPA adapter: evaluate policies via a locally running OPA HTTP sidecar.
|
|
2
|
+
|
|
3
|
+
Presents the same interface as `custos.policy.Policy` — the ledger records
|
|
4
|
+
`engine="opa"` and captures the OPA rule/reason.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
import urllib.request
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
from typing import Any, Dict, List
|
|
12
|
+
|
|
13
|
+
from custos.policy import PolicyDecision
|
|
14
|
+
from custos.record import Decision
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass
|
|
18
|
+
class OpaPolicy:
|
|
19
|
+
id: str
|
|
20
|
+
url: str # e.g. http://localhost:8181/v1/data/custos/authz
|
|
21
|
+
default: Decision = Decision.DENY
|
|
22
|
+
version: int = 1
|
|
23
|
+
engine: str = "opa"
|
|
24
|
+
rules: List = field(default_factory=list)
|
|
25
|
+
|
|
26
|
+
def evaluate(self, ctx: dict) -> PolicyDecision:
|
|
27
|
+
req = urllib.request.Request(
|
|
28
|
+
self.url,
|
|
29
|
+
data=json.dumps({"input": ctx}).encode("utf-8"),
|
|
30
|
+
headers={"Content-Type": "application/json"},
|
|
31
|
+
method="POST",
|
|
32
|
+
)
|
|
33
|
+
try:
|
|
34
|
+
with urllib.request.urlopen(req, timeout=2.0) as resp:
|
|
35
|
+
body = json.loads(resp.read())
|
|
36
|
+
except Exception as e:
|
|
37
|
+
return PolicyDecision(
|
|
38
|
+
decision=Decision.ERROR,
|
|
39
|
+
rule_id="",
|
|
40
|
+
reason=f"opa unreachable: {e}",
|
|
41
|
+
)
|
|
42
|
+
result = body.get("result") or {}
|
|
43
|
+
if isinstance(result, dict):
|
|
44
|
+
allow = result.get("allow", False)
|
|
45
|
+
rule = result.get("rule", "")
|
|
46
|
+
reason = result.get("reason", "")
|
|
47
|
+
else:
|
|
48
|
+
allow = bool(result)
|
|
49
|
+
rule = ""
|
|
50
|
+
reason = ""
|
|
51
|
+
return PolicyDecision(
|
|
52
|
+
decision=Decision.ALLOW if allow else self.default,
|
|
53
|
+
rule_id=rule,
|
|
54
|
+
reason=reason or ("opa allow" if allow else "opa deny"),
|
|
55
|
+
)
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"""Portable evidence bundle: tar.gz containing ledger + pubkey + policy snapshot + signed manifest."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import base64
|
|
5
|
+
import hashlib
|
|
6
|
+
import io
|
|
7
|
+
import json
|
|
8
|
+
import tarfile
|
|
9
|
+
import time
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Optional
|
|
12
|
+
|
|
13
|
+
from cryptography.exceptions import InvalidSignature
|
|
14
|
+
|
|
15
|
+
from custos.canonical import dumps as canonical_dumps
|
|
16
|
+
from custos.keys import KeyPair, public_key_from_b64
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def create_bundle(
|
|
20
|
+
ledger_path: str | Path,
|
|
21
|
+
pubkey_path: str | Path,
|
|
22
|
+
output_path: str | Path,
|
|
23
|
+
keypair: KeyPair,
|
|
24
|
+
policies_dir: Optional[str | Path] = None,
|
|
25
|
+
) -> Path:
|
|
26
|
+
ledger_path = Path(ledger_path)
|
|
27
|
+
pubkey_path = Path(pubkey_path)
|
|
28
|
+
output_path = Path(output_path)
|
|
29
|
+
|
|
30
|
+
records = 0
|
|
31
|
+
with ledger_path.open("rb") as f:
|
|
32
|
+
for line in f:
|
|
33
|
+
if line.strip():
|
|
34
|
+
records += 1
|
|
35
|
+
|
|
36
|
+
manifest = {
|
|
37
|
+
"v": 1,
|
|
38
|
+
"created": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
|
39
|
+
"records": records,
|
|
40
|
+
"pubkey": Path(pubkey_path).read_bytes().decode("ascii").strip(),
|
|
41
|
+
}
|
|
42
|
+
manifest_bytes = canonical_dumps(manifest)
|
|
43
|
+
digest = hashlib.sha256(manifest_bytes).digest()
|
|
44
|
+
sig = keypair.sign(digest)
|
|
45
|
+
sig_line = "ed25519:" + base64.b64encode(sig).decode("ascii")
|
|
46
|
+
|
|
47
|
+
with tarfile.open(output_path, "w:gz") as tar:
|
|
48
|
+
_add_file(tar, "bundle/manifest.json", manifest_bytes)
|
|
49
|
+
_add_file(tar, "bundle/manifest.sig", sig_line.encode("ascii"))
|
|
50
|
+
_add_file(tar, "bundle/ledger.jsonl", ledger_path.read_bytes())
|
|
51
|
+
_add_file(tar, "bundle/ledger.pub", pubkey_path.read_bytes())
|
|
52
|
+
if policies_dir:
|
|
53
|
+
pd = Path(policies_dir)
|
|
54
|
+
for p in pd.rglob("*"):
|
|
55
|
+
if p.is_file():
|
|
56
|
+
rel = p.relative_to(pd)
|
|
57
|
+
_add_file(tar, f"bundle/policies/{rel.as_posix()}", p.read_bytes())
|
|
58
|
+
return output_path
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _add_file(tar: tarfile.TarFile, name: str, data: bytes) -> None:
|
|
62
|
+
info = tarfile.TarInfo(name=name)
|
|
63
|
+
info.size = len(data)
|
|
64
|
+
info.mtime = int(time.time())
|
|
65
|
+
tar.addfile(info, io.BytesIO(data))
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def verify_bundle(path: str | Path) -> dict:
|
|
69
|
+
"""Return {ok, records, errors} after verifying manifest signature and ledger chain."""
|
|
70
|
+
from custos.verify import verify_ledger
|
|
71
|
+
|
|
72
|
+
path = Path(path)
|
|
73
|
+
with tarfile.open(path, "r:gz") as tar:
|
|
74
|
+
manifest_b = _read_member(tar, "bundle/manifest.json")
|
|
75
|
+
sig_b = _read_member(tar, "bundle/manifest.sig").decode("ascii").strip()
|
|
76
|
+
ledger_b = _read_member(tar, "bundle/ledger.jsonl")
|
|
77
|
+
pub_b = _read_member(tar, "bundle/ledger.pub").decode("ascii").strip()
|
|
78
|
+
|
|
79
|
+
manifest = json.loads(manifest_b)
|
|
80
|
+
pub = public_key_from_b64(pub_b)
|
|
81
|
+
digest = hashlib.sha256(canonical_dumps(manifest)).digest()
|
|
82
|
+
if not sig_b.startswith("ed25519:"):
|
|
83
|
+
return {"ok": False, "records": 0, "errors": ["manifest sig format invalid"]}
|
|
84
|
+
try:
|
|
85
|
+
pub.verify(base64.b64decode(sig_b.split(":", 1)[1]), digest)
|
|
86
|
+
except InvalidSignature:
|
|
87
|
+
return {"ok": False, "records": 0, "errors": ["manifest signature invalid"]}
|
|
88
|
+
|
|
89
|
+
# Extract to temp files and reuse verifier
|
|
90
|
+
import tempfile
|
|
91
|
+
with tempfile.TemporaryDirectory() as td:
|
|
92
|
+
tdp = Path(td)
|
|
93
|
+
(tdp / "ledger.jsonl").write_bytes(ledger_b)
|
|
94
|
+
(tdp / "ledger.pub").write_bytes(pub_b.encode("ascii"))
|
|
95
|
+
r = verify_ledger(tdp / "ledger.jsonl", tdp / "ledger.pub")
|
|
96
|
+
return {"ok": r.ok, "records": r.records, "errors": r.errors, "manifest": manifest}
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _read_member(tar: tarfile.TarFile, name: str) -> bytes:
|
|
100
|
+
m = tar.getmember(name)
|
|
101
|
+
f = tar.extractfile(m)
|
|
102
|
+
assert f is not None
|
|
103
|
+
return f.read()
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""Canonical JSON serialization compatible with the Custos wire spec.
|
|
2
|
+
|
|
3
|
+
Rules (see spec/WIRE.md §1):
|
|
4
|
+
- UTF-8, no BOM
|
|
5
|
+
- Keys sorted lexicographically at every depth
|
|
6
|
+
- No insignificant whitespace
|
|
7
|
+
- ensure_ascii=False (raw UTF-8)
|
|
8
|
+
- allow_nan=False (strict JSON)
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def dumps(value: Any) -> bytes:
|
|
17
|
+
"""Serialize to Canonical JSON bytes."""
|
|
18
|
+
return json.dumps(
|
|
19
|
+
value,
|
|
20
|
+
sort_keys=True,
|
|
21
|
+
separators=(",", ":"),
|
|
22
|
+
ensure_ascii=False,
|
|
23
|
+
allow_nan=False,
|
|
24
|
+
).encode("utf-8")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def loads(data: bytes | str) -> Any:
|
|
28
|
+
"""Parse JSON (canonical or not) into Python."""
|
|
29
|
+
if isinstance(data, bytes):
|
|
30
|
+
data = data.decode("utf-8")
|
|
31
|
+
return json.loads(data)
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
"""Custos CLI: `custos <command>`."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import asyncio
|
|
5
|
+
import json
|
|
6
|
+
import sys
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
import click
|
|
10
|
+
|
|
11
|
+
from custos import __version__
|
|
12
|
+
from custos.bundle import create_bundle, verify_bundle
|
|
13
|
+
from custos.keys import generate_keypair, load_keypair
|
|
14
|
+
from custos.ledger import Ledger
|
|
15
|
+
from custos.policy import load_policy
|
|
16
|
+
from custos.record import Actor, Server
|
|
17
|
+
from custos.verify import verify_ledger
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@click.group()
|
|
21
|
+
@click.version_option(__version__, prog_name="custos")
|
|
22
|
+
def main():
|
|
23
|
+
"""Custos: runtime governance for MCP tool calls."""
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@main.command()
|
|
27
|
+
@click.option("--dir", "dir_", default="./.custos", show_default=True, help="Directory to write ledger.key + ledger.pub")
|
|
28
|
+
def keygen(dir_: str):
|
|
29
|
+
"""Generate a new Ed25519 signing keypair."""
|
|
30
|
+
kp = generate_keypair()
|
|
31
|
+
kp.save(dir_)
|
|
32
|
+
click.echo(f"wrote {dir_}/ledger.key + ledger.pub")
|
|
33
|
+
click.echo(f"pubkey (base64): {kp.public_b64()}")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@main.command()
|
|
37
|
+
@click.option("--ledger", "ledger_path", default="./.custos/ledger.jsonl", show_default=True)
|
|
38
|
+
@click.option("--pub", "pub_path", default=None, help="Public key (defaults to ledger.pub sidecar)")
|
|
39
|
+
def verify(ledger_path: str, pub_path: str | None):
|
|
40
|
+
"""Verify a signed ledger."""
|
|
41
|
+
r = verify_ledger(ledger_path, pub_path)
|
|
42
|
+
if r.ok:
|
|
43
|
+
click.secho(f"OK {r.records} records verified", fg="green")
|
|
44
|
+
sys.exit(0)
|
|
45
|
+
else:
|
|
46
|
+
for e in r.errors:
|
|
47
|
+
click.secho(f"ERR {e}", fg="red")
|
|
48
|
+
sys.exit(1)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@main.command()
|
|
52
|
+
@click.option("--policy", "policy_path", required=True, help="Policy YAML/JSON")
|
|
53
|
+
@click.option("--ledger", "ledger_path", default="./.custos/ledger.jsonl", show_default=True)
|
|
54
|
+
@click.option("--keys", "keys_dir", default="./.custos", show_default=True)
|
|
55
|
+
@click.option("--actor-id", default="agent")
|
|
56
|
+
@click.option("--server-id", default="upstream")
|
|
57
|
+
@click.argument("upstream", nargs=-1, required=True)
|
|
58
|
+
def proxy(policy_path: str, ledger_path: str, keys_dir: str, actor_id: str, server_id: str, upstream: tuple):
|
|
59
|
+
"""Run stdio MCP proxy: custos proxy --policy p.yaml -- python -m server."""
|
|
60
|
+
from custos.proxy import ProxyConfig, run_stdio_proxy
|
|
61
|
+
|
|
62
|
+
policy = load_policy(policy_path)
|
|
63
|
+
kp = load_keypair(keys_dir)
|
|
64
|
+
ledger = Ledger(ledger_path, kp)
|
|
65
|
+
cfg = ProxyConfig(
|
|
66
|
+
upstream_cmd=list(upstream),
|
|
67
|
+
policy=policy,
|
|
68
|
+
ledger=ledger,
|
|
69
|
+
actor=Actor(id=actor_id),
|
|
70
|
+
server=Server(id=server_id, pubkey=kp.public_b64()),
|
|
71
|
+
)
|
|
72
|
+
code = asyncio.run(run_stdio_proxy(cfg))
|
|
73
|
+
sys.exit(code)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@main.command()
|
|
77
|
+
@click.option("--ledger", "ledger_path", default="./.custos/ledger.jsonl", show_default=True)
|
|
78
|
+
@click.option("--host", default="127.0.0.1", show_default=True)
|
|
79
|
+
@click.option("--port", default=8787, show_default=True, type=int)
|
|
80
|
+
def serve(ledger_path: str, host: str, port: int):
|
|
81
|
+
"""Launch the FastAPI dashboard (requires `pip install custos-mcp[web]`)."""
|
|
82
|
+
from custos.dashboard import create_app
|
|
83
|
+
import uvicorn
|
|
84
|
+
|
|
85
|
+
uvicorn.run(create_app(ledger_path), host=host, port=port, log_level="warning")
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@main.command()
|
|
89
|
+
@click.option("--ledger", "ledger_path", default="./.custos/ledger.jsonl", show_default=True)
|
|
90
|
+
@click.option("--keys", "keys_dir", default="./.custos", show_default=True)
|
|
91
|
+
@click.option("--policies", default=None, help="Optional policies dir to snapshot")
|
|
92
|
+
@click.argument("output")
|
|
93
|
+
def bundle(ledger_path: str, keys_dir: str, policies: str | None, output: str):
|
|
94
|
+
"""Export a signed evidence bundle."""
|
|
95
|
+
kp = load_keypair(keys_dir)
|
|
96
|
+
ledger = Path(ledger_path)
|
|
97
|
+
pub = ledger.parent / (ledger.stem + ".pub")
|
|
98
|
+
out = create_bundle(ledger, pub, output, kp, policies_dir=policies)
|
|
99
|
+
click.echo(f"wrote {out}")
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
@main.command("verify-bundle")
|
|
103
|
+
@click.argument("bundle_path")
|
|
104
|
+
def verify_bundle_cmd(bundle_path: str):
|
|
105
|
+
"""Verify a portable evidence bundle."""
|
|
106
|
+
r = verify_bundle(bundle_path)
|
|
107
|
+
if r["ok"]:
|
|
108
|
+
click.secho(f"OK {r['records']} records verified", fg="green")
|
|
109
|
+
click.echo(json.dumps(r.get("manifest"), indent=2))
|
|
110
|
+
sys.exit(0)
|
|
111
|
+
click.secho(f"FAIL {r['errors']}", fg="red")
|
|
112
|
+
sys.exit(1)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
@main.command("show-policy")
|
|
116
|
+
@click.argument("policy_path")
|
|
117
|
+
def show_policy(policy_path: str):
|
|
118
|
+
"""Load and print a normalized policy."""
|
|
119
|
+
p = load_policy(policy_path)
|
|
120
|
+
click.echo(f"id={p.id} version={p.version} default={p.default.value} rules={len(p.rules)}")
|
|
121
|
+
for r in p.rules:
|
|
122
|
+
click.echo(f" {r.id}: when={r.when} decision={r.decision.value} reason={r.reason!r}")
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
if __name__ == "__main__":
|
|
126
|
+
main()
|