csoai 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.
- csoai-0.1.0/PKG-INFO +30 -0
- csoai-0.1.0/README.md +16 -0
- csoai-0.1.0/pyproject.toml +26 -0
- csoai-0.1.0/setup.cfg +4 -0
- csoai-0.1.0/src/csoai/__init__.py +6 -0
- csoai-0.1.0/src/csoai/cli.py +88 -0
- csoai-0.1.0/src/csoai/council_signal.py +247 -0
- csoai-0.1.0/src/csoai/mcp_server.py +95 -0
- csoai-0.1.0/src/csoai/sign.py +112 -0
- csoai-0.1.0/src/csoai.egg-info/PKG-INFO +30 -0
- csoai-0.1.0/src/csoai.egg-info/SOURCES.txt +13 -0
- csoai-0.1.0/src/csoai.egg-info/dependency_links.txt +1 -0
- csoai-0.1.0/src/csoai.egg-info/entry_points.txt +2 -0
- csoai-0.1.0/src/csoai.egg-info/requires.txt +4 -0
- csoai-0.1.0/src/csoai.egg-info/top_level.txt +1 -0
csoai-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: csoai
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Council of AI — signed, deterministic AI-governance measurement: CLI + MCP server + agent-callable verifier
|
|
5
|
+
Author: Council of AI (CSOAI LTD, UK 16939677)
|
|
6
|
+
License: Apache-2.0
|
|
7
|
+
Project-URL: Homepage, https://councilof.ai
|
|
8
|
+
Keywords: ai-governance,eu-ai-act,measurement,signed-evidence,mcp,article-50
|
|
9
|
+
Requires-Python: >=3.9
|
|
10
|
+
Description-Content-Type: text/markdown
|
|
11
|
+
Requires-Dist: cryptography>=41
|
|
12
|
+
Provides-Extra: mcp
|
|
13
|
+
Requires-Dist: fastmcp>=2.0; extra == "mcp"
|
|
14
|
+
|
|
15
|
+
# csoai
|
|
16
|
+
|
|
17
|
+
Council of AI — signed, deterministic AI-governance measurement.
|
|
18
|
+
|
|
19
|
+
The boring CLI is the primary agent rail; the MCP server wraps it; both emit the
|
|
20
|
+
same signed atom. Public artifacts only (hiQ/Van Buren footing); measurement, not
|
|
21
|
+
certification.
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
pip install csoai
|
|
25
|
+
csoai check --entity gpt2 --pack art50 --json # measure a public artifact -> signed card
|
|
26
|
+
csoai verify --record card.json # verify a signed record offline
|
|
27
|
+
pip install "csoai[mcp]" && python -m csoai.mcp_server # agent-callable MCP tools
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
CI gate: `csoai check` exits 3 on a missing transparency predicate — fail the build on it.
|
csoai-0.1.0/README.md
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# csoai
|
|
2
|
+
|
|
3
|
+
Council of AI — signed, deterministic AI-governance measurement.
|
|
4
|
+
|
|
5
|
+
The boring CLI is the primary agent rail; the MCP server wraps it; both emit the
|
|
6
|
+
same signed atom. Public artifacts only (hiQ/Van Buren footing); measurement, not
|
|
7
|
+
certification.
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
pip install csoai
|
|
11
|
+
csoai check --entity gpt2 --pack art50 --json # measure a public artifact -> signed card
|
|
12
|
+
csoai verify --record card.json # verify a signed record offline
|
|
13
|
+
pip install "csoai[mcp]" && python -m csoai.mcp_server # agent-callable MCP tools
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
CI gate: `csoai check` exits 3 on a missing transparency predicate — fail the build on it.
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "csoai"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Council of AI — signed, deterministic AI-governance measurement: CLI + MCP server + agent-callable verifier"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.9"
|
|
11
|
+
license = {text = "Apache-2.0"}
|
|
12
|
+
authors = [{name = "Council of AI (CSOAI LTD, UK 16939677)"}]
|
|
13
|
+
keywords = ["ai-governance", "eu-ai-act", "measurement", "signed-evidence", "mcp", "article-50"]
|
|
14
|
+
dependencies = ["cryptography>=41"]
|
|
15
|
+
|
|
16
|
+
[project.optional-dependencies]
|
|
17
|
+
mcp = ["fastmcp>=2.0"]
|
|
18
|
+
|
|
19
|
+
[project.urls]
|
|
20
|
+
Homepage = "https://councilof.ai"
|
|
21
|
+
|
|
22
|
+
[project.scripts]
|
|
23
|
+
csoai = "csoai.cli:main"
|
|
24
|
+
|
|
25
|
+
[tool.setuptools.packages.find]
|
|
26
|
+
where = ["src"]
|
csoai-0.1.0/setup.cfg
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""csoai — the boring CLI. Universal agent rail + CI forcing-function + atom-emitter
|
|
2
|
+
+ the first agent-callable signed-record verifier.
|
|
3
|
+
|
|
4
|
+
csoai check --entity <hf-repo> --pack art50 [--sign] [--json]
|
|
5
|
+
csoai verify --record <file.json>
|
|
6
|
+
|
|
7
|
+
Deterministic output + exit codes so an agent (or a CI gate) acts on the result
|
|
8
|
+
without reasoning about the statute from memory. Guardrails: PUBLIC artifacts only
|
|
9
|
+
(hiQ/Van Buren); measurement, not certification; signed only with a real key.
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import argparse
|
|
14
|
+
import json
|
|
15
|
+
import sys
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
from csoai import council_signal
|
|
19
|
+
|
|
20
|
+
PACKS = {
|
|
21
|
+
"art50": {
|
|
22
|
+
"title": "EU AI Act Article 50 transparency (public-artifact signals)",
|
|
23
|
+
"required": ["license_declared", "task_declared", "model_card_present"],
|
|
24
|
+
},
|
|
25
|
+
"transparency": {
|
|
26
|
+
"title": "Baseline transparency",
|
|
27
|
+
"required": ["license_declared", "model_card_present"],
|
|
28
|
+
},
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def cmd_check(a) -> int:
|
|
33
|
+
pack = PACKS.get(a.pack)
|
|
34
|
+
if not pack:
|
|
35
|
+
print(f"unknown pack '{a.pack}'; known: {', '.join(PACKS)}", file=sys.stderr)
|
|
36
|
+
return 64
|
|
37
|
+
try:
|
|
38
|
+
rec = council_signal.state_record(a.entity)
|
|
39
|
+
except Exception as e: # noqa: BLE001
|
|
40
|
+
print(f"FETCH FAILED for {a.entity}: {e}. No card emitted (never fabricated).", file=sys.stderr)
|
|
41
|
+
return 2
|
|
42
|
+
rec["pack"] = a.pack
|
|
43
|
+
missing = [k for k in pack["required"] if not rec["predicates"].get(k)]
|
|
44
|
+
rec["pack_result"] = {"pack": a.pack, "required": pack["required"], "missing": missing, "pass": not missing}
|
|
45
|
+
if a.sign:
|
|
46
|
+
out = a.out or f"csoai_check_{a.entity.replace('/', '_')}.json"
|
|
47
|
+
rec = council_signal.sign_record(rec, out)
|
|
48
|
+
if a.json:
|
|
49
|
+
print(json.dumps(rec, indent=2))
|
|
50
|
+
else:
|
|
51
|
+
print(f" {a.entity} pack={a.pack} pass={rec['pack_result']['pass']}"
|
|
52
|
+
f" missing={missing or 'none'} signed={rec.get('signed', bool(a.sign))}")
|
|
53
|
+
return 0 if not missing else 3
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def cmd_verify(a) -> int:
|
|
57
|
+
from csoai import sign
|
|
58
|
+
obj = json.loads(Path(a.record).read_text())
|
|
59
|
+
s = obj.get("signature")
|
|
60
|
+
if not s or s.get("kind") != "ed25519":
|
|
61
|
+
print("UNSIGNED — no Ed25519 signature on this record.")
|
|
62
|
+
return 1
|
|
63
|
+
try:
|
|
64
|
+
sign.verify(a.record)
|
|
65
|
+
return 0
|
|
66
|
+
except SystemExit as e:
|
|
67
|
+
return int(e.code) if isinstance(e.code, int) else 1
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def main(argv=None) -> int:
|
|
71
|
+
ap = argparse.ArgumentParser(prog="csoai", description=__doc__)
|
|
72
|
+
sub = ap.add_subparsers(dest="cmd", required=True)
|
|
73
|
+
c = sub.add_parser("check", help="measure a public artifact → signed card")
|
|
74
|
+
c.add_argument("--entity", required=True)
|
|
75
|
+
c.add_argument("--pack", default="art50", help=f"one of: {', '.join(PACKS)}")
|
|
76
|
+
c.add_argument("--sign", action="store_true")
|
|
77
|
+
c.add_argument("--json", action="store_true")
|
|
78
|
+
c.add_argument("--out")
|
|
79
|
+
c.set_defaults(fn=cmd_check)
|
|
80
|
+
v = sub.add_parser("verify", help="verify a signed record offline")
|
|
81
|
+
v.add_argument("--record", required=True)
|
|
82
|
+
v.set_defaults(fn=cmd_verify)
|
|
83
|
+
a = ap.parse_args(argv)
|
|
84
|
+
return a.fn(a)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
if __name__ == "__main__":
|
|
88
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
"""council_signal.py — continuous, LAWFUL compliance signal over PUBLISHED AI artifacts.
|
|
2
|
+
|
|
3
|
+
The play (build-map, compass 237202a7 #A): scan an entity's PUBLIC artifacts, emit a
|
|
4
|
+
compact Ed25519-signed state record, and notify on drift. This is the first brick:
|
|
5
|
+
one public artifact source (a Hugging Face model repo's PUBLIC metadata) → a
|
|
6
|
+
deterministic transparency state → a signed record → a drift diff against the prior.
|
|
7
|
+
|
|
8
|
+
LEGAL GUARDRAILS, baked in (not optional):
|
|
9
|
+
* PUBLIC ARTIFACTS ONLY. This reads the public model-info endpoint (no auth, no
|
|
10
|
+
gated content, no private API). Footing: hiQ v. LinkedIn (9th Cir. 2022) +
|
|
11
|
+
Van Buren (SCOTUS 2021) — scanning public pages is not CFAA "without
|
|
12
|
+
authorization." (Still respect ToS/copyright; never scan a private API.)
|
|
13
|
+
* MEASUREMENT, NOT CERTIFICATION. Every field is an observed public fact or a
|
|
14
|
+
deterministic predicate over one. We do not certify compliance or conformity.
|
|
15
|
+
* SIGN ONLY WITH A REAL KEY. Signed on the signing node; UNSIGNED and labelled so
|
|
16
|
+
everywhere else. Never a fake signature.
|
|
17
|
+
|
|
18
|
+
Checks are deterministic functions of the fetched public metadata — recomputable by
|
|
19
|
+
anyone from the same public source. Nothing here is a judgement call.
|
|
20
|
+
"""
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import argparse
|
|
24
|
+
import json
|
|
25
|
+
import hashlib
|
|
26
|
+
import sys
|
|
27
|
+
import urllib.request
|
|
28
|
+
import urllib.error
|
|
29
|
+
import urllib.parse
|
|
30
|
+
from datetime import datetime, timezone
|
|
31
|
+
from pathlib import Path
|
|
32
|
+
from typing import Any, Dict, Optional
|
|
33
|
+
|
|
34
|
+
VERSION = "0.1.0"
|
|
35
|
+
HF_MODEL_API = "https://huggingface.co/api/models/{entity}"
|
|
36
|
+
# Art-50 / transparency signal terms we look for in public tags/cardData.
|
|
37
|
+
_MARKING_TERMS = ("c2pa", "content-credential", "content credentials", "watermark",
|
|
38
|
+
"synthid", "ai-generated", "provenance")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def fetch_public_metadata(entity: str, timeout: int = 20) -> Dict[str, Any]:
|
|
42
|
+
"""GET the PUBLIC model-info JSON. Raises on network/HTTP error (never fakes data)."""
|
|
43
|
+
url = HF_MODEL_API.format(entity=urllib.parse.quote(entity, safe="/"))
|
|
44
|
+
req = urllib.request.Request(url, headers={"User-Agent": f"council-signal/{VERSION}"})
|
|
45
|
+
with urllib.request.urlopen(req, timeout=timeout) as r:
|
|
46
|
+
return json.loads(r.read().decode("utf-8"))
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def compute_checks(meta: Dict[str, Any]) -> Dict[str, Any]:
|
|
50
|
+
"""Deterministic transparency predicates over the PUBLIC metadata."""
|
|
51
|
+
card = meta.get("cardData") or {}
|
|
52
|
+
tags = [str(t).lower() for t in (meta.get("tags") or [])]
|
|
53
|
+
blob = (json.dumps(card) + " " + " ".join(tags)).lower()
|
|
54
|
+
return {
|
|
55
|
+
# transparency basics an Art-50/AI-Act reader looks for
|
|
56
|
+
"license_declared": bool(card.get("license") or any(t.startswith("license:") for t in tags)),
|
|
57
|
+
"task_declared": bool(meta.get("pipeline_tag")),
|
|
58
|
+
"model_card_present": bool(card) and card != {},
|
|
59
|
+
"gated": bool(meta.get("gated")), # a transparency/access signal (not good/bad by itself)
|
|
60
|
+
# generative-marking signal (Art 50) — does the card/tags mention any marking scheme
|
|
61
|
+
"generative_marking_declared": any(term in blob for term in _MARKING_TERMS),
|
|
62
|
+
# observed facts (not predicates) kept for the record
|
|
63
|
+
"license_value": card.get("license"),
|
|
64
|
+
"pipeline_tag": meta.get("pipeline_tag"),
|
|
65
|
+
"downloads": meta.get("downloads"),
|
|
66
|
+
"lastModified": meta.get("lastModified"),
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def state_record(entity: str) -> Dict[str, Any]:
|
|
71
|
+
meta = fetch_public_metadata(entity)
|
|
72
|
+
checks = compute_checks(meta)
|
|
73
|
+
# the state hash is over the PREDICATES only (facts like downloads churn constantly
|
|
74
|
+
# and are not compliance drift) — so drift means a transparency signal changed.
|
|
75
|
+
predicate_keys = ("license_declared", "task_declared", "model_card_present",
|
|
76
|
+
"gated", "generative_marking_declared")
|
|
77
|
+
predicates = {k: checks[k] for k in predicate_keys}
|
|
78
|
+
state_hash = hashlib.sha256(json.dumps(predicates, sort_keys=True).encode()).hexdigest()
|
|
79
|
+
return {
|
|
80
|
+
"kind": "council_signal.state",
|
|
81
|
+
"version": VERSION,
|
|
82
|
+
"entity": entity,
|
|
83
|
+
"source": "huggingface public model-info (no auth)",
|
|
84
|
+
"fetched_at": datetime.now(timezone.utc).isoformat(),
|
|
85
|
+
"predicates": predicates,
|
|
86
|
+
"observed": {k: checks[k] for k in checks if k not in predicate_keys},
|
|
87
|
+
"state_hash": state_hash,
|
|
88
|
+
"frame": ("Deterministic transparency predicates over PUBLIC artifacts only. "
|
|
89
|
+
"Measurement, not certification. Drift = a transparency predicate changed."),
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def diff(prev: Dict[str, Any], curr: Dict[str, Any]) -> Dict[str, Any]:
|
|
94
|
+
"""Drift = which transparency predicates changed since the prior signed state."""
|
|
95
|
+
pp, cp = prev.get("predicates", {}), curr.get("predicates", {})
|
|
96
|
+
changed = {k: {"was": pp.get(k), "now": cp.get(k)} for k in cp if pp.get(k) != cp.get(k)}
|
|
97
|
+
return {
|
|
98
|
+
"entity": curr["entity"],
|
|
99
|
+
"drifted": bool(changed) or prev.get("state_hash") != curr["state_hash"],
|
|
100
|
+
"changes": changed,
|
|
101
|
+
"prev_fetched_at": prev.get("fetched_at"),
|
|
102
|
+
"curr_fetched_at": curr["fetched_at"],
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def scan_list(entities: list[str]) -> Dict[str, Any]:
|
|
107
|
+
"""Scan several public entities in one pass → one batch report.
|
|
108
|
+
|
|
109
|
+
Each entity gets its own deterministic state; a fetch that fails is recorded as
|
|
110
|
+
an error entry (never fabricated), so a broken source is visible, not silent.
|
|
111
|
+
The batch hash is over the per-entity (entity, state_hash) pairs, so the batch
|
|
112
|
+
itself drifts iff any member's transparency state changed.
|
|
113
|
+
"""
|
|
114
|
+
states, errors = [], []
|
|
115
|
+
for e in entities:
|
|
116
|
+
try:
|
|
117
|
+
states.append(state_record(e))
|
|
118
|
+
except (urllib.error.URLError, urllib.error.HTTPError, TimeoutError, json.JSONDecodeError) as exc:
|
|
119
|
+
errors.append({"entity": e, "error": str(exc)})
|
|
120
|
+
members = sorted((s["entity"], s["state_hash"]) for s in states)
|
|
121
|
+
batch_hash = hashlib.sha256(json.dumps(members).encode()).hexdigest()
|
|
122
|
+
return {
|
|
123
|
+
"kind": "council_signal.batch",
|
|
124
|
+
"version": VERSION,
|
|
125
|
+
"scanned_at": datetime.now(timezone.utc).isoformat(),
|
|
126
|
+
"n_requested": len(entities),
|
|
127
|
+
"n_scanned": len(states),
|
|
128
|
+
"n_errors": len(errors),
|
|
129
|
+
"batch_hash": batch_hash,
|
|
130
|
+
"states": states,
|
|
131
|
+
"errors": errors,
|
|
132
|
+
"frame": ("Batch of deterministic transparency states over PUBLIC artifacts. "
|
|
133
|
+
"Fetch failures are recorded, not hidden. Measurement, not certification."),
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def batch_diff(prev: Dict[str, Any], curr: Dict[str, Any]) -> Dict[str, Any]:
|
|
138
|
+
"""Drift-report: which entities changed transparency state since the prior batch."""
|
|
139
|
+
pstate = {s["entity"]: s for s in prev.get("states", [])}
|
|
140
|
+
cstate = {s["entity"]: s for s in curr.get("states", [])}
|
|
141
|
+
drifted, added, removed = [], [], []
|
|
142
|
+
for e, cs in cstate.items():
|
|
143
|
+
if e not in pstate:
|
|
144
|
+
added.append(e)
|
|
145
|
+
elif pstate[e]["state_hash"] != cs["state_hash"]:
|
|
146
|
+
drifted.append({"entity": e, "changes": diff(pstate[e], cs)["changes"]})
|
|
147
|
+
removed = [e for e in pstate if e not in cstate]
|
|
148
|
+
return {
|
|
149
|
+
"kind": "council_signal.drift_report",
|
|
150
|
+
"prev_scanned_at": prev.get("scanned_at"),
|
|
151
|
+
"curr_scanned_at": curr.get("scanned_at"),
|
|
152
|
+
"batch_drifted": prev.get("batch_hash") != curr.get("batch_hash"),
|
|
153
|
+
"drifted": drifted, # entities whose transparency predicates changed
|
|
154
|
+
"added": added, # newly-scanned entities
|
|
155
|
+
"removed": removed, # entities no longer in the batch
|
|
156
|
+
"notify": bool(drifted or added or removed), # the hook the notify channel fires on
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def sign_record(record: Dict[str, Any], out_path: str | Path) -> Dict[str, Any]:
|
|
161
|
+
"""Ed25519-sign iff the key exists on this node; else emit UNSIGNED, labelled."""
|
|
162
|
+
Path(out_path).write_text(json.dumps(record, indent=2), encoding="utf-8")
|
|
163
|
+
try:
|
|
164
|
+
import os
|
|
165
|
+
try:
|
|
166
|
+
import sign # repo-root context
|
|
167
|
+
except ModuleNotFoundError:
|
|
168
|
+
from csoai import sign # installed-package context
|
|
169
|
+
if os.path.exists(sign.PRIV):
|
|
170
|
+
sign.sign(str(out_path))
|
|
171
|
+
return json.loads(Path(out_path).read_text())
|
|
172
|
+
except Exception as e:
|
|
173
|
+
record["_sign_error"] = str(e)
|
|
174
|
+
record["signature"] = None
|
|
175
|
+
record["signed"] = False
|
|
176
|
+
record["_unsigned_note"] = "no signing key on this node — sign on the signing node: python3 sign.py --sign " + str(out_path)
|
|
177
|
+
Path(out_path).write_text(json.dumps(record, indent=2), encoding="utf-8")
|
|
178
|
+
return record
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def selftest() -> int:
|
|
182
|
+
entity = "bert-base-uncased" # a stable, public, non-gated repo
|
|
183
|
+
try:
|
|
184
|
+
rec = state_record(entity)
|
|
185
|
+
except (urllib.error.URLError, urllib.error.HTTPError, TimeoutError) as e:
|
|
186
|
+
print(f" selftest SKIPPED — no network to the public endpoint ({e})")
|
|
187
|
+
return 0 # honest: cannot test offline, do not fake a pass
|
|
188
|
+
print(f" scanned {entity}: predicates = {rec['predicates']}")
|
|
189
|
+
print(f" state_hash = {rec['state_hash'][:16]}…")
|
|
190
|
+
# drift self-check: same scan → no drift; mutated predicate → drift
|
|
191
|
+
d0 = diff(rec, rec)
|
|
192
|
+
mutated = json.loads(json.dumps(rec)); mutated["predicates"]["license_declared"] = not mutated["predicates"]["license_declared"]
|
|
193
|
+
mutated["state_hash"] = "x"
|
|
194
|
+
d1 = diff(rec, mutated)
|
|
195
|
+
ok = (d0["drifted"] is False) and (d1["drifted"] is True) and ("license_declared" in d1["changes"])
|
|
196
|
+
print(f" drift(no-change)={d0['drifted']} drift(mutated)={d1['drifted']} changes={list(d1['changes'])}")
|
|
197
|
+
print(" selftest:", "PASS" if ok else "FAIL")
|
|
198
|
+
return 0 if ok else 1
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def main() -> int:
|
|
202
|
+
ap = argparse.ArgumentParser(description=__doc__)
|
|
203
|
+
ap.add_argument("--scan", metavar="ENTITY", help="public HF model repo id, e.g. meta-llama/Llama-3.1-8B-Instruct")
|
|
204
|
+
ap.add_argument("--scan-list", metavar="FILE", help="file of public entity ids (one per line) to scan as a batch")
|
|
205
|
+
ap.add_argument("--against", metavar="PRIOR.json", help="prior signed state to diff for drift")
|
|
206
|
+
ap.add_argument("--against-batch", metavar="PRIOR_BATCH.json", help="prior batch to diff for a drift-report")
|
|
207
|
+
ap.add_argument("--out", default="benchmark-results/council_signal_state.json")
|
|
208
|
+
ap.add_argument("--selftest", action="store_true")
|
|
209
|
+
a = ap.parse_args()
|
|
210
|
+
if a.selftest:
|
|
211
|
+
return selftest()
|
|
212
|
+
if a.scan_list:
|
|
213
|
+
entities = [l.strip() for l in Path(a.scan_list).read_text().splitlines() if l.strip() and not l.startswith("#")]
|
|
214
|
+
batch = sign_record(scan_list(entities), a.out)
|
|
215
|
+
print(f" batch: {batch['n_scanned']}/{batch['n_requested']} scanned, "
|
|
216
|
+
f"{batch['n_errors']} errors, hash {batch['batch_hash'][:16]}…, signed={batch.get('signed')}")
|
|
217
|
+
for s in batch["states"]:
|
|
218
|
+
# `gated` is a neutral observed attribute (False = not gated = normal), not a
|
|
219
|
+
# deficiency — exclude it. Flag only absent transparency signals.
|
|
220
|
+
flags = [k for k, v in s["predicates"].items() if not v and k != "gated"]
|
|
221
|
+
print(f" {s['entity']:40} missing: {flags or 'none'}")
|
|
222
|
+
for e in batch["errors"]:
|
|
223
|
+
print(f" {e['entity']:40} FETCH ERROR: {e['error']}")
|
|
224
|
+
if a.against_batch and Path(a.against_batch).exists():
|
|
225
|
+
rep = batch_diff(json.loads(Path(a.against_batch).read_text()), batch)
|
|
226
|
+
print("\n--- DRIFT REPORT ---")
|
|
227
|
+
print(f" batch_drifted={rep['batch_drifted']} · notify={rep['notify']}")
|
|
228
|
+
print(f" drifted={[d['entity'] for d in rep['drifted']]} added={rep['added']} removed={rep['removed']}")
|
|
229
|
+
return 0
|
|
230
|
+
if a.scan:
|
|
231
|
+
try:
|
|
232
|
+
rec = sign_record(state_record(a.scan), a.out)
|
|
233
|
+
except (urllib.error.URLError, urllib.error.HTTPError, TimeoutError, json.JSONDecodeError) as e:
|
|
234
|
+
print(f"FETCH FAILED for {a.scan}: {e}. Public artifact unreadable — no state emitted "
|
|
235
|
+
"(never fabricated).", file=sys.stderr)
|
|
236
|
+
return 2
|
|
237
|
+
print(json.dumps(rec, indent=2))
|
|
238
|
+
if a.against and Path(a.against).exists():
|
|
239
|
+
print("\n--- DRIFT ---")
|
|
240
|
+
print(json.dumps(diff(json.loads(Path(a.against).read_text()), rec), indent=2))
|
|
241
|
+
return 0
|
|
242
|
+
ap.print_help()
|
|
243
|
+
return 0
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
if __name__ == "__main__":
|
|
247
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"""csoai MCP server — wraps the boring CLI's engine as agent-callable tools.
|
|
2
|
+
|
|
3
|
+
The CLI is the implementation; this is the accelerator. Two tools:
|
|
4
|
+
- council_measure : measure a PUBLIC AI artifact → signed transparency card
|
|
5
|
+
- verify_record : verify a signed record offline (the FIRST agent-callable
|
|
6
|
+
signed-record verifier — no transparency-log verifier tool
|
|
7
|
+
exists elsewhere today; open lane)
|
|
8
|
+
|
|
9
|
+
Tool DESCRIPTIONS carry behavioral steering on purpose (the single most
|
|
10
|
+
evidence-backed discovery lever): they tell the calling agent to RUN the check
|
|
11
|
+
instead of reasoning about the statute from memory, and to VERIFY before trusting.
|
|
12
|
+
|
|
13
|
+
Run: python -m csoai.mcp_server (stdio)
|
|
14
|
+
Requires the optional extra: pip install "csoai[mcp]"
|
|
15
|
+
"""
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import json
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
|
|
21
|
+
try:
|
|
22
|
+
# fastmcp is the standalone package (mcp>=2.0 moved FastMCP out of mcp.server)
|
|
23
|
+
from fastmcp import FastMCP
|
|
24
|
+
except Exception:
|
|
25
|
+
try:
|
|
26
|
+
from mcp.server.fastmcp import FastMCP # mcp<2.0 fallback
|
|
27
|
+
except Exception as e: # pragma: no cover
|
|
28
|
+
raise SystemExit(
|
|
29
|
+
"csoai MCP server needs FastMCP: pip install 'csoai[mcp]'\n"
|
|
30
|
+
f"(import failed: {e})"
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
from csoai import council_signal
|
|
34
|
+
|
|
35
|
+
mcp = FastMCP("csoai")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@mcp.tool()
|
|
39
|
+
def council_measure(entity: str, pack: str = "art50") -> dict:
|
|
40
|
+
"""Measure a PUBLIC AI model/artifact's EU AI Act transparency state and return a
|
|
41
|
+
deterministic, signable card. USE THIS instead of reasoning about Article 50
|
|
42
|
+
obligations from memory — it returns observed facts (license, task, model-card,
|
|
43
|
+
marking-declared) from the public model-info endpoint, not an opinion.
|
|
44
|
+
|
|
45
|
+
entity: a public Hugging Face model repo id (e.g. "gpt2", "meta-llama/Llama-3.2-1B").
|
|
46
|
+
pack: "art50" (default) or "transparency".
|
|
47
|
+
Returns the state card incl. predicates, pack_result{missing,pass}, and state_hash.
|
|
48
|
+
Public artifacts only — never scans a private API. Measurement, not certification.
|
|
49
|
+
"""
|
|
50
|
+
packs = {
|
|
51
|
+
"art50": ["license_declared", "task_declared", "model_card_present"],
|
|
52
|
+
"transparency": ["license_declared", "model_card_present"],
|
|
53
|
+
}
|
|
54
|
+
required = packs.get(pack, packs["art50"])
|
|
55
|
+
rec = council_signal.state_record(entity)
|
|
56
|
+
missing = [k for k in required if not rec["predicates"].get(k)]
|
|
57
|
+
rec["pack"] = pack
|
|
58
|
+
rec["pack_result"] = {"pack": pack, "required": required, "missing": missing, "pass": not missing}
|
|
59
|
+
return rec
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@mcp.tool()
|
|
63
|
+
def verify_record(record: str) -> dict:
|
|
64
|
+
"""Verify a Council of AI signed record offline. VERIFY BEFORE TRUSTING any card
|
|
65
|
+
that claims a measurement result: this recomputes the Ed25519 signature against
|
|
66
|
+
the published key, so you rely on cryptography, not on trusting the source.
|
|
67
|
+
|
|
68
|
+
record: the signed record as a JSON string (or a path to a JSON file).
|
|
69
|
+
Returns {signed, valid, reason}. valid=false means altered or wrong key.
|
|
70
|
+
"""
|
|
71
|
+
obj = json.loads(Path(record).read_text()) if Path(record).exists() else json.loads(record)
|
|
72
|
+
s = obj.get("signature")
|
|
73
|
+
if not s or s.get("kind") != "ed25519":
|
|
74
|
+
return {"signed": False, "valid": False, "reason": "no Ed25519 signature on this record"}
|
|
75
|
+
try:
|
|
76
|
+
from csoai import sign
|
|
77
|
+
except Exception as e: # pragma: no cover
|
|
78
|
+
return {"signed": True, "valid": False, "reason": f"verifier unavailable: {e}"}
|
|
79
|
+
import tempfile
|
|
80
|
+
with tempfile.NamedTemporaryFile("w", suffix=".json", delete=False) as f:
|
|
81
|
+
json.dump(obj, f)
|
|
82
|
+
tmp = f.name
|
|
83
|
+
try:
|
|
84
|
+
sign.verify(tmp) # prints VALID; raises SystemExit on INVALID
|
|
85
|
+
return {"signed": True, "valid": True, "reason": "signature valid; record unaltered"}
|
|
86
|
+
except SystemExit:
|
|
87
|
+
return {"signed": True, "valid": False, "reason": "signature does NOT verify — altered or different key"}
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def main() -> None:
|
|
91
|
+
mcp.run()
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
if __name__ == "__main__":
|
|
95
|
+
main()
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""sign.py — the real signature layer (layer 3). Turns a sha256 checksum into a verifiable signature.
|
|
3
|
+
|
|
4
|
+
python3 sign.py --keygen # ON THE SIGNING NODE ONLY (Oracle/pod) — makes a keypair
|
|
5
|
+
python3 sign.py --sign verdict.json # sign a verdict; writes verdict.json.sig
|
|
6
|
+
python3 sign.py --verify verdict.json # verify against the published public key (no private key needed)
|
|
7
|
+
|
|
8
|
+
WHY
|
|
9
|
+
Council OS's whole claim is verifiable measurement. Until now a verdict carried a sha256 of its body — a
|
|
10
|
+
checksum, honestly labelled as "not signed on this host". A checksum proves integrity, not authorship.
|
|
11
|
+
This module signs the body with Ed25519 (a real signature: anyone with the PUBLIC key can verify a
|
|
12
|
+
verdict was issued by the holder of the private key, and was not altered). The path to post-quantum is
|
|
13
|
+
one swap: ML-DSA-65 via liboqs (Open Quantum Safe) — the ASI axis, applied to our own signatures.
|
|
14
|
+
|
|
15
|
+
THE ONE DISCIPLINE
|
|
16
|
+
The private key lives on the SIGNING NODE and never on a developer laptop. --keygen refuses to write a
|
|
17
|
+
key on a host it does not recognise as a signing node (CSOAI_SIGNING_NODE=1 must be set), and --sign
|
|
18
|
+
refuses if the key is absent (it will NOT silently fall back to a fake signature — an unsigned verdict
|
|
19
|
+
is labelled unsigned, never dressed as signed). The public key is published for verification.
|
|
20
|
+
|
|
21
|
+
Requires `cryptography` (pure-python-friendly, no GPU). ML-DSA path noted where it plugs in.
|
|
22
|
+
"""
|
|
23
|
+
import argparse, json, os, sys, base64, hashlib
|
|
24
|
+
|
|
25
|
+
KEY_DIR = os.path.expanduser(os.environ.get("CSOAI_KEY_DIR", "~/.csoai_keys"))
|
|
26
|
+
PRIV = os.path.join(KEY_DIR, "csoai_ed25519.key")
|
|
27
|
+
PUB = os.path.join(KEY_DIR, "csoai_ed25519.pub")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _lib():
|
|
31
|
+
try:
|
|
32
|
+
from cryptography.hazmat.primitives.asymmetric.ed25519 import (
|
|
33
|
+
Ed25519PrivateKey, Ed25519PublicKey)
|
|
34
|
+
from cryptography.hazmat.primitives import serialization
|
|
35
|
+
return Ed25519PrivateKey, Ed25519PublicKey, serialization
|
|
36
|
+
except ImportError:
|
|
37
|
+
sys.exit("needs `cryptography` — pip install cryptography. (ML-DSA path: pip install liboqs-python.)")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def keygen():
|
|
41
|
+
Ed25519PrivateKey, _, serialization = _lib()
|
|
42
|
+
if os.environ.get("CSOAI_SIGNING_NODE") != "1":
|
|
43
|
+
sys.exit("REFUSING to generate a key here: set CSOAI_SIGNING_NODE=1 only on the real signing "
|
|
44
|
+
"node (Oracle/pod), never on a developer laptop. The private key must never touch the Mac.")
|
|
45
|
+
os.makedirs(KEY_DIR, exist_ok=True)
|
|
46
|
+
if os.path.exists(PRIV):
|
|
47
|
+
sys.exit(f"key already exists at {PRIV} — refusing to overwrite (would orphan every prior verdict).")
|
|
48
|
+
sk = Ed25519PrivateKey.generate()
|
|
49
|
+
with open(PRIV, "wb") as f:
|
|
50
|
+
f.write(sk.private_bytes(serialization.Encoding.PEM,
|
|
51
|
+
serialization.PrivateFormat.PKCS8,
|
|
52
|
+
serialization.NoEncryption()))
|
|
53
|
+
os.chmod(PRIV, 0o600)
|
|
54
|
+
pub = sk.public_key().public_bytes(serialization.Encoding.Raw, serialization.PublicFormat.Raw)
|
|
55
|
+
open(PUB, "w").write(base64.b64encode(pub).decode())
|
|
56
|
+
print(f"signing key written → {PRIV} (0600)\npublic key (publish this):\n {base64.b64encode(pub).decode()}")
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _body_bytes(obj):
|
|
60
|
+
"""Sign the canonical body WITHOUT any existing signature/sha fields, so verification is stable."""
|
|
61
|
+
clean = {k: v for k, v in obj.items() if k not in ("signature", "sha256", "sig")}
|
|
62
|
+
return json.dumps(clean, sort_keys=True, separators=(",", ":")).encode()
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def sign(path):
|
|
66
|
+
Ed25519PrivateKey, _, serialization = _lib()
|
|
67
|
+
if not os.path.exists(PRIV):
|
|
68
|
+
sys.exit(f"NO PRIVATE KEY at {PRIV}. This host cannot sign — run --keygen on the signing node. "
|
|
69
|
+
"Refusing to emit a fake signature; an unsigned verdict stays labelled unsigned.")
|
|
70
|
+
obj = json.load(open(path))
|
|
71
|
+
sk = serialization.load_pem_private_key(open(PRIV, "rb").read(), password=None)
|
|
72
|
+
sig = sk.sign(_body_bytes(obj))
|
|
73
|
+
obj["signature"] = {"kind": "ed25519", "sig": base64.b64encode(sig).decode(),
|
|
74
|
+
"body_sha256": hashlib.sha256(_body_bytes(obj)).hexdigest(),
|
|
75
|
+
"pubkey": open(PUB).read().strip() if os.path.exists(PUB) else None,
|
|
76
|
+
"note": "Ed25519 over the canonical body; verify with sign.py --verify. "
|
|
77
|
+
"PQC upgrade: ML-DSA-65 via liboqs — same body, swap the primitive."}
|
|
78
|
+
json.dump(obj, open(path, "w"), indent=2)
|
|
79
|
+
print(f"signed {path} · ed25519 · sig={obj['signature']['sig'][:24]}…")
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def verify(path):
|
|
83
|
+
_, Ed25519PublicKey, _ = _lib()
|
|
84
|
+
obj = json.load(open(path))
|
|
85
|
+
s = obj.get("signature")
|
|
86
|
+
if not s or s.get("kind") != "ed25519":
|
|
87
|
+
sys.exit("no ed25519 signature on this verdict (it may be an honestly-unsigned checksum record).")
|
|
88
|
+
pub_b64 = s.get("pubkey") or (open(PUB).read() if os.path.exists(PUB) else None)
|
|
89
|
+
if not pub_b64:
|
|
90
|
+
sys.exit("no public key available to verify against.")
|
|
91
|
+
pk = Ed25519PublicKey.from_public_bytes(base64.b64decode(pub_b64))
|
|
92
|
+
try:
|
|
93
|
+
pk.verify(base64.b64decode(s["sig"]), _body_bytes(obj))
|
|
94
|
+
print(f"✅ VALID — {path} was signed by the holder of the published key and is unaltered.")
|
|
95
|
+
except Exception:
|
|
96
|
+
sys.exit(f"❌ INVALID — signature does not verify. The verdict was altered or signed by another key.")
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def main():
|
|
100
|
+
ap = argparse.ArgumentParser(description="Council OS real signature layer (Ed25519; ML-DSA path)")
|
|
101
|
+
ap.add_argument("--keygen", action="store_true")
|
|
102
|
+
ap.add_argument("--sign")
|
|
103
|
+
ap.add_argument("--verify")
|
|
104
|
+
a = ap.parse_args()
|
|
105
|
+
if a.keygen: keygen()
|
|
106
|
+
elif a.sign: sign(a.sign)
|
|
107
|
+
elif a.verify: verify(a.verify)
|
|
108
|
+
else: ap.print_help()
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
if __name__ == "__main__":
|
|
112
|
+
main()
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: csoai
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Council of AI — signed, deterministic AI-governance measurement: CLI + MCP server + agent-callable verifier
|
|
5
|
+
Author: Council of AI (CSOAI LTD, UK 16939677)
|
|
6
|
+
License: Apache-2.0
|
|
7
|
+
Project-URL: Homepage, https://councilof.ai
|
|
8
|
+
Keywords: ai-governance,eu-ai-act,measurement,signed-evidence,mcp,article-50
|
|
9
|
+
Requires-Python: >=3.9
|
|
10
|
+
Description-Content-Type: text/markdown
|
|
11
|
+
Requires-Dist: cryptography>=41
|
|
12
|
+
Provides-Extra: mcp
|
|
13
|
+
Requires-Dist: fastmcp>=2.0; extra == "mcp"
|
|
14
|
+
|
|
15
|
+
# csoai
|
|
16
|
+
|
|
17
|
+
Council of AI — signed, deterministic AI-governance measurement.
|
|
18
|
+
|
|
19
|
+
The boring CLI is the primary agent rail; the MCP server wraps it; both emit the
|
|
20
|
+
same signed atom. Public artifacts only (hiQ/Van Buren footing); measurement, not
|
|
21
|
+
certification.
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
pip install csoai
|
|
25
|
+
csoai check --entity gpt2 --pack art50 --json # measure a public artifact -> signed card
|
|
26
|
+
csoai verify --record card.json # verify a signed record offline
|
|
27
|
+
pip install "csoai[mcp]" && python -m csoai.mcp_server # agent-callable MCP tools
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
CI gate: `csoai check` exits 3 on a missing transparency predicate — fail the build on it.
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
src/csoai/__init__.py
|
|
4
|
+
src/csoai/cli.py
|
|
5
|
+
src/csoai/council_signal.py
|
|
6
|
+
src/csoai/mcp_server.py
|
|
7
|
+
src/csoai/sign.py
|
|
8
|
+
src/csoai.egg-info/PKG-INFO
|
|
9
|
+
src/csoai.egg-info/SOURCES.txt
|
|
10
|
+
src/csoai.egg-info/dependency_links.txt
|
|
11
|
+
src/csoai.egg-info/entry_points.txt
|
|
12
|
+
src/csoai.egg-info/requires.txt
|
|
13
|
+
src/csoai.egg-info/top_level.txt
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
csoai
|