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.
@@ -0,0 +1,10 @@
1
+ .custos/
2
+ .custos-demo/
3
+ __pycache__/
4
+ *.egg-info/
5
+ dist/
6
+ build/
7
+ node_modules/
8
+ *.tsbuildinfo
9
+ .pytest_cache/
10
+ .venv/
@@ -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
+ [![PyPI version](https://img.shields.io/pypi/v/custos-mcp.svg)](https://pypi.org/project/custos-mcp/)
54
+ [![Python versions](https://img.shields.io/pypi/pyversions/custos-mcp.svg)](https://pypi.org/project/custos-mcp/)
55
+ [![License](https://img.shields.io/badge/license-Apache--2.0-blue.svg)](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
+ [![PyPI version](https://img.shields.io/pypi/v/custos-mcp.svg)](https://pypi.org/project/custos-mcp/)
4
+ [![Python versions](https://img.shields.io/pypi/pyversions/custos-mcp.svg)](https://pypi.org/project/custos-mcp/)
5
+ [![License](https://img.shields.io/badge/license-Apache--2.0-blue.svg)](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()