gaas-spec 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.
- gaas_spec-0.1.0/.gitignore +71 -0
- gaas_spec-0.1.0/PKG-INFO +66 -0
- gaas_spec-0.1.0/README.md +42 -0
- gaas_spec-0.1.0/gaas_spec/__init__.py +30 -0
- gaas_spec-0.1.0/gaas_spec/bundle.py +220 -0
- gaas_spec-0.1.0/gaas_spec/cli.py +109 -0
- gaas_spec-0.1.0/gaas_spec/schemas/attestation.schema.json +77 -0
- gaas_spec-0.1.0/gaas_spec/schemas/audit.schema.json +108 -0
- gaas_spec-0.1.0/gaas_spec/schemas/auth.schema.json +232 -0
- gaas_spec-0.1.0/gaas_spec/schemas/escalation.schema.json +238 -0
- gaas_spec-0.1.0/gaas_spec/schemas/fieldpaths-0.1.json +25 -0
- gaas_spec-0.1.0/gaas_spec/schemas/policy.schema.json +241 -0
- gaas_spec-0.1.0/gaas_spec/signing.py +126 -0
- gaas_spec-0.1.0/pyproject.toml +43 -0
- gaas_spec-0.1.0/tests/fixtures/README.md +21 -0
- gaas_spec-0.1.0/tests/fixtures/expected.json +40 -0
- gaas_spec-0.1.0/tests/fixtures/invalid/bad-field-path/.gaas/policy.md +31 -0
- gaas_spec-0.1.0/tests/fixtures/invalid/bad-op/.gaas/policy.md +31 -0
- gaas_spec-0.1.0/tests/fixtures/invalid/missing-default-route/.gaas/escalation.md +13 -0
- gaas_spec-0.1.0/tests/fixtures/invalid/record-partial/.gaas/audit.md +17 -0
- gaas_spec-0.1.0/tests/fixtures/invalid/timeout-approve/.gaas/escalation.md +24 -0
- gaas_spec-0.1.0/tests/fixtures/invalid/unknown-top-key/.gaas/policy.md +32 -0
- gaas_spec-0.1.0/tests/fixtures/invalid/wrong-spec-version/.gaas/policy.md +31 -0
- gaas_spec-0.1.0/tests/fixtures/valid/full-bundle/.gaas/audit.md +17 -0
- gaas_spec-0.1.0/tests/fixtures/valid/full-bundle/.gaas/auth.md +21 -0
- gaas_spec-0.1.0/tests/fixtures/valid/full-bundle/.gaas/escalation.md +24 -0
- gaas_spec-0.1.0/tests/fixtures/valid/full-bundle/.gaas/policy.md +31 -0
- gaas_spec-0.1.0/tests/fixtures/valid/policy-only/.gaas/policy.md +31 -0
- gaas_spec-0.1.0/tests/fixtures/valid/when-always/.gaas/policy.md +13 -0
- gaas_spec-0.1.0/tests/test_conformance.py +38 -0
- gaas_spec-0.1.0/tests/test_signing.py +131 -0
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# Python
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[cod]
|
|
4
|
+
*.egg-info/
|
|
5
|
+
dist/
|
|
6
|
+
build/
|
|
7
|
+
.venv/
|
|
8
|
+
venv/
|
|
9
|
+
.env
|
|
10
|
+
.env.*
|
|
11
|
+
!.env.example
|
|
12
|
+
|
|
13
|
+
# IDE
|
|
14
|
+
.vscode/
|
|
15
|
+
.idea/
|
|
16
|
+
*.swp
|
|
17
|
+
|
|
18
|
+
# OS
|
|
19
|
+
.DS_Store
|
|
20
|
+
Thumbs.db
|
|
21
|
+
nul
|
|
22
|
+
|
|
23
|
+
# Claude Code (keep shared configs, ignore local/transient)
|
|
24
|
+
.claude/settings.local.json
|
|
25
|
+
.claude/history/
|
|
26
|
+
.claude/plans/
|
|
27
|
+
.claude/projects/
|
|
28
|
+
.claude/todos/
|
|
29
|
+
|
|
30
|
+
# Node / TypeScript
|
|
31
|
+
node_modules/
|
|
32
|
+
.vite/
|
|
33
|
+
|
|
34
|
+
# Project
|
|
35
|
+
*.log
|
|
36
|
+
|
|
37
|
+
# Generated reports
|
|
38
|
+
gaas-cost-audit.html
|
|
39
|
+
|
|
40
|
+
# Super admin credentials (contains passwords)
|
|
41
|
+
SUPER_ADMIN_CREDENTIALS.txt
|
|
42
|
+
|
|
43
|
+
# Local development scripts (not part of tracked codebase)
|
|
44
|
+
scripts/test-*.py
|
|
45
|
+
scripts/diagnose-*.py
|
|
46
|
+
scripts/send-*.py
|
|
47
|
+
scripts/trigger-*.py
|
|
48
|
+
scripts/uat-*.py
|
|
49
|
+
test_results_*.json
|
|
50
|
+
tests/e2e/
|
|
51
|
+
|
|
52
|
+
# Local trash can (never committed)
|
|
53
|
+
gaascan/
|
|
54
|
+
|
|
55
|
+
# Local planning docs (never committed)
|
|
56
|
+
.planning/
|
|
57
|
+
|
|
58
|
+
# Confidential business & sales documents
|
|
59
|
+
docs/business/
|
|
60
|
+
docs/sales/
|
|
61
|
+
|
|
62
|
+
# Session artifacts (Claude Code working docs)
|
|
63
|
+
/DAY_*.md
|
|
64
|
+
/DAYS_*.md
|
|
65
|
+
/HANDOFF_*.md
|
|
66
|
+
/think_tank.md
|
|
67
|
+
|
|
68
|
+
# Auto-generated test reports
|
|
69
|
+
apps/dashboard/playwright-report/
|
|
70
|
+
apps/dashboard/test-results/
|
|
71
|
+
apps/dashboard/e2e/screenshots/
|
gaas_spec-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: gaas-spec
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Reference validator, signer, and verifier for the auth.md governance file convention (spec 0.1)
|
|
5
|
+
Project-URL: Homepage, https://github.com/H2OmAI/authmd
|
|
6
|
+
Project-URL: Specification, https://github.com/H2OmAI/authmd
|
|
7
|
+
Project-URL: Repository, https://github.com/H2OmAI/gaas
|
|
8
|
+
Author-email: H2Om <sdk@gaas.is>
|
|
9
|
+
License-Expression: Apache-2.0
|
|
10
|
+
Keywords: agents,ai,auth.md,gaas,governance,policy-as-code
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: License :: OSI Approved :: Apache Software License
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
16
|
+
Classifier: Typing :: Typed
|
|
17
|
+
Requires-Python: >=3.11
|
|
18
|
+
Requires-Dist: cryptography>=43.0.0
|
|
19
|
+
Requires-Dist: jsonschema>=4.21.0
|
|
20
|
+
Requires-Dist: pyyaml>=6.0.0
|
|
21
|
+
Provides-Extra: dev
|
|
22
|
+
Requires-Dist: pytest>=8.3.0; extra == 'dev'
|
|
23
|
+
Description-Content-Type: text/markdown
|
|
24
|
+
|
|
25
|
+
# gaas-spec
|
|
26
|
+
|
|
27
|
+
Reference validator, signer, and verifier for the **auth.md governance file convention** — spec 0.1.
|
|
28
|
+
|
|
29
|
+
The convention: a `.gaas/` directory (`auth.md`, `policy.md`, `audit.md`, `escalation.md`, `attestation.sig`) declaring how an AI agent is governed — readable by people, validated and enforced by machines, signed like a release. Spec, schemas, and conformance fixtures: **[github.com/H2OmAI/authmd](https://github.com/H2OmAI/authmd)**.
|
|
30
|
+
|
|
31
|
+
Local-only: no network, no API key. Suitable for CI.
|
|
32
|
+
|
|
33
|
+
## Install
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
pip install gaas-spec
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
## CLI
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
gaas validate # validate ./.gaas (or: gaas validate path/to/repo)
|
|
43
|
+
gaas sign --key org-key.pem --key-id acme-2026
|
|
44
|
+
gaas verify --pub acme-2026=org-pub.pem
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
`validate` exits non-zero with findings when the bundle doesn't conform — wire it straight into CI. `sign` refuses non-conforming bundles. `verify` checks every file hash against the attestation manifest before checking signatures, so a tampered bundle fails loudly.
|
|
48
|
+
|
|
49
|
+
## Library
|
|
50
|
+
|
|
51
|
+
```python
|
|
52
|
+
from gaas_spec import parse_bundle, validate, sign, verify
|
|
53
|
+
|
|
54
|
+
bundle = parse_bundle(".") # finds ./.gaas
|
|
55
|
+
findings = validate(bundle) # [] means conforming
|
|
56
|
+
attestation = sign(bundle, private_key_pem, key_id="acme-2026", signer="org")
|
|
57
|
+
results = verify(bundle, attestation, {"acme-2026": public_key_pem})
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
## Conformance
|
|
61
|
+
|
|
62
|
+
This package vendors the spec repo's fixtures and runs them in its test suite — it is the reference implementation and must agree with the fixtures at all times. Signing is ES256 (ECDSA P-256 + SHA-256) over an RFC 8785 canonical-JSON manifest hash, per SPEC-attestation.md.
|
|
63
|
+
|
|
64
|
+
## License
|
|
65
|
+
|
|
66
|
+
Apache-2.0. The spec text itself is CC BY 4.0 at the spec repo.
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# gaas-spec
|
|
2
|
+
|
|
3
|
+
Reference validator, signer, and verifier for the **auth.md governance file convention** — spec 0.1.
|
|
4
|
+
|
|
5
|
+
The convention: a `.gaas/` directory (`auth.md`, `policy.md`, `audit.md`, `escalation.md`, `attestation.sig`) declaring how an AI agent is governed — readable by people, validated and enforced by machines, signed like a release. Spec, schemas, and conformance fixtures: **[github.com/H2OmAI/authmd](https://github.com/H2OmAI/authmd)**.
|
|
6
|
+
|
|
7
|
+
Local-only: no network, no API key. Suitable for CI.
|
|
8
|
+
|
|
9
|
+
## Install
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
pip install gaas-spec
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
## CLI
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
gaas validate # validate ./.gaas (or: gaas validate path/to/repo)
|
|
19
|
+
gaas sign --key org-key.pem --key-id acme-2026
|
|
20
|
+
gaas verify --pub acme-2026=org-pub.pem
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
`validate` exits non-zero with findings when the bundle doesn't conform — wire it straight into CI. `sign` refuses non-conforming bundles. `verify` checks every file hash against the attestation manifest before checking signatures, so a tampered bundle fails loudly.
|
|
24
|
+
|
|
25
|
+
## Library
|
|
26
|
+
|
|
27
|
+
```python
|
|
28
|
+
from gaas_spec import parse_bundle, validate, sign, verify
|
|
29
|
+
|
|
30
|
+
bundle = parse_bundle(".") # finds ./.gaas
|
|
31
|
+
findings = validate(bundle) # [] means conforming
|
|
32
|
+
attestation = sign(bundle, private_key_pem, key_id="acme-2026", signer="org")
|
|
33
|
+
results = verify(bundle, attestation, {"acme-2026": public_key_pem})
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
## Conformance
|
|
37
|
+
|
|
38
|
+
This package vendors the spec repo's fixtures and runs them in its test suite — it is the reference implementation and must agree with the fixtures at all times. Signing is ES256 (ECDSA P-256 + SHA-256) over an RFC 8785 canonical-JSON manifest hash, per SPEC-attestation.md.
|
|
39
|
+
|
|
40
|
+
## License
|
|
41
|
+
|
|
42
|
+
Apache-2.0. The spec text itself is CC BY 4.0 at the spec repo.
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""gaas-spec — reference validator/signer/verifier for the auth.md convention.
|
|
2
|
+
|
|
3
|
+
Spec: https://github.com/H2OmAI/authmd (version 0.1)
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from .bundle import (
|
|
7
|
+
BUNDLE_FILES,
|
|
8
|
+
SPEC_VERSIONS,
|
|
9
|
+
Finding,
|
|
10
|
+
GovernanceBundle,
|
|
11
|
+
parse_bundle,
|
|
12
|
+
validate,
|
|
13
|
+
)
|
|
14
|
+
from .signing import canonical_manifest, manifest_hash, sign, verify
|
|
15
|
+
|
|
16
|
+
__version__ = "0.1.0"
|
|
17
|
+
|
|
18
|
+
__all__ = [
|
|
19
|
+
"BUNDLE_FILES",
|
|
20
|
+
"SPEC_VERSIONS",
|
|
21
|
+
"Finding",
|
|
22
|
+
"GovernanceBundle",
|
|
23
|
+
"parse_bundle",
|
|
24
|
+
"validate",
|
|
25
|
+
"canonical_manifest",
|
|
26
|
+
"manifest_hash",
|
|
27
|
+
"sign",
|
|
28
|
+
"verify",
|
|
29
|
+
"__version__",
|
|
30
|
+
]
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
"""Parse and validate .gaas/ governance bundles (spec 0.1).
|
|
2
|
+
|
|
3
|
+
The validator is deliberately strict: schema validation plus the semantic
|
|
4
|
+
checks the spec requires beyond JSON Schema (default escalation route,
|
|
5
|
+
timeout-chain termination, matcher depth/leaf limits, spec version).
|
|
6
|
+
Fixtures in the spec repo are ground truth.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import hashlib
|
|
12
|
+
import json
|
|
13
|
+
from dataclasses import dataclass, field
|
|
14
|
+
from importlib import resources
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
import yaml
|
|
18
|
+
from jsonschema import Draft202012Validator
|
|
19
|
+
|
|
20
|
+
SPEC_VERSIONS = ("0.1",)
|
|
21
|
+
BUNDLE_FILES = ("auth.md", "policy.md", "audit.md", "escalation.md")
|
|
22
|
+
MAX_MATCHER_DEPTH = 8
|
|
23
|
+
MAX_MATCHER_LEAVES = 64
|
|
24
|
+
|
|
25
|
+
_SCHEMAS: dict[str, Draft202012Validator] = {}
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _schema(fname: str) -> Draft202012Validator:
|
|
29
|
+
if fname not in _SCHEMAS:
|
|
30
|
+
ref = resources.files("gaas_spec.schemas") / f"{fname.removesuffix('.md')}.schema.json"
|
|
31
|
+
_SCHEMAS[fname] = Draft202012Validator(json.loads(ref.read_text()))
|
|
32
|
+
return _SCHEMAS[fname]
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass(frozen=True)
|
|
36
|
+
class Finding:
|
|
37
|
+
"""A single validation finding."""
|
|
38
|
+
|
|
39
|
+
file: str
|
|
40
|
+
code: str
|
|
41
|
+
message: str
|
|
42
|
+
|
|
43
|
+
def __str__(self) -> str: # pragma: no cover - cosmetic
|
|
44
|
+
return f"{self.file}: [{self.code}] {self.message}"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass
|
|
48
|
+
class GovernanceBundle:
|
|
49
|
+
"""A parsed .gaas/ bundle: raw bytes, frontmatter, and file hashes."""
|
|
50
|
+
|
|
51
|
+
root: Path
|
|
52
|
+
files: dict[str, bytes] = field(default_factory=dict)
|
|
53
|
+
frontmatter: dict[str, dict] = field(default_factory=dict)
|
|
54
|
+
parse_findings: list[Finding] = field(default_factory=list)
|
|
55
|
+
|
|
56
|
+
@property
|
|
57
|
+
def hashes(self) -> dict[str, str]:
|
|
58
|
+
return {
|
|
59
|
+
name: f"sha256:{hashlib.sha256(raw).hexdigest()}"
|
|
60
|
+
for name, raw in sorted(self.files.items())
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def parse_bundle(path: str | Path) -> GovernanceBundle:
|
|
65
|
+
"""Parse a bundle directory (either the .gaas/ dir or its parent)."""
|
|
66
|
+
root = Path(path)
|
|
67
|
+
if (root / ".gaas").is_dir():
|
|
68
|
+
root = root / ".gaas"
|
|
69
|
+
bundle = GovernanceBundle(root=root)
|
|
70
|
+
if not root.is_dir():
|
|
71
|
+
bundle.parse_findings.append(
|
|
72
|
+
Finding("<bundle>", "bundle-not-found", f"no such directory: {root}")
|
|
73
|
+
)
|
|
74
|
+
return bundle
|
|
75
|
+
|
|
76
|
+
for name in BUNDLE_FILES:
|
|
77
|
+
f = root / name
|
|
78
|
+
if not f.is_file():
|
|
79
|
+
continue
|
|
80
|
+
raw = f.read_bytes()
|
|
81
|
+
bundle.files[name] = raw
|
|
82
|
+
text = raw.decode("utf-8", errors="strict")
|
|
83
|
+
if not text.startswith("---\n") or "\n---" not in text[4:]:
|
|
84
|
+
bundle.parse_findings.append(
|
|
85
|
+
Finding(name, "missing-frontmatter", "file must start with a YAML frontmatter block")
|
|
86
|
+
)
|
|
87
|
+
continue
|
|
88
|
+
end = text.index("\n---", 4)
|
|
89
|
+
try:
|
|
90
|
+
fm = yaml.safe_load(text[4:end])
|
|
91
|
+
except yaml.YAMLError as exc:
|
|
92
|
+
bundle.parse_findings.append(Finding(name, "invalid-yaml", str(exc)[:200]))
|
|
93
|
+
continue
|
|
94
|
+
if not isinstance(fm, dict):
|
|
95
|
+
bundle.parse_findings.append(
|
|
96
|
+
Finding(name, "invalid-frontmatter", "frontmatter must be a mapping")
|
|
97
|
+
)
|
|
98
|
+
continue
|
|
99
|
+
bundle.frontmatter[name] = fm
|
|
100
|
+
|
|
101
|
+
if not bundle.files:
|
|
102
|
+
bundle.parse_findings.append(
|
|
103
|
+
Finding("<bundle>", "empty-bundle", f"no governance files found in {root}")
|
|
104
|
+
)
|
|
105
|
+
return bundle
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
# ---------------------------------------------------------------------------
|
|
109
|
+
# Validation
|
|
110
|
+
# ---------------------------------------------------------------------------
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _matcher_limits(node: object, depth: int = 1) -> tuple[int, int]:
|
|
114
|
+
"""Return (max_depth, leaf_count) of a matcher tree."""
|
|
115
|
+
if not isinstance(node, dict):
|
|
116
|
+
return depth, 0
|
|
117
|
+
if "field" in node:
|
|
118
|
+
return depth, 1
|
|
119
|
+
children: list[object] = []
|
|
120
|
+
for key in ("all", "any"):
|
|
121
|
+
if key in node and isinstance(node[key], list):
|
|
122
|
+
children.extend(node[key])
|
|
123
|
+
if "not" in node:
|
|
124
|
+
children.append(node["not"])
|
|
125
|
+
max_d, leaves = depth, 0
|
|
126
|
+
for child in children:
|
|
127
|
+
d, n = _matcher_limits(child, depth + 1)
|
|
128
|
+
max_d = max(max_d, d)
|
|
129
|
+
leaves += n
|
|
130
|
+
return max_d, leaves
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _iter_matchers(fname: str, fm: dict):
|
|
134
|
+
if fname == "policy.md":
|
|
135
|
+
for pol in fm.get("policies", []):
|
|
136
|
+
when = pol.get("when")
|
|
137
|
+
if isinstance(when, dict):
|
|
138
|
+
yield when
|
|
139
|
+
elif fname == "auth.md":
|
|
140
|
+
for entry in fm.get("requires_human", []):
|
|
141
|
+
when = entry.get("when")
|
|
142
|
+
if isinstance(when, dict):
|
|
143
|
+
yield when
|
|
144
|
+
elif fname == "escalation.md":
|
|
145
|
+
for route in fm.get("routes", []):
|
|
146
|
+
match = route.get("match")
|
|
147
|
+
if isinstance(match, dict):
|
|
148
|
+
yield match
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _semantic_findings(fname: str, fm: dict) -> list[Finding]:
|
|
152
|
+
findings: list[Finding] = []
|
|
153
|
+
|
|
154
|
+
spec = fm.get("gaas_spec")
|
|
155
|
+
if spec not in SPEC_VERSIONS:
|
|
156
|
+
findings.append(
|
|
157
|
+
Finding(
|
|
158
|
+
fname,
|
|
159
|
+
"unsupported-spec-version",
|
|
160
|
+
f"gaas_spec {spec!r} not implemented (supported: {', '.join(SPEC_VERSIONS)})",
|
|
161
|
+
)
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
for tree in _iter_matchers(fname, fm):
|
|
165
|
+
depth, leaves = _matcher_limits(tree)
|
|
166
|
+
if depth > MAX_MATCHER_DEPTH:
|
|
167
|
+
findings.append(
|
|
168
|
+
Finding(fname, "matcher-too-deep", f"depth {depth} > {MAX_MATCHER_DEPTH}")
|
|
169
|
+
)
|
|
170
|
+
if leaves > MAX_MATCHER_LEAVES:
|
|
171
|
+
findings.append(
|
|
172
|
+
Finding(fname, "matcher-too-large", f"{leaves} leaves > {MAX_MATCHER_LEAVES}")
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
if fname == "escalation.md":
|
|
176
|
+
routes = {r.get("id"): r for r in fm.get("routes", []) if isinstance(r, dict)}
|
|
177
|
+
if "default" not in routes:
|
|
178
|
+
findings.append(
|
|
179
|
+
Finding(fname, "missing-default-route", "a route with id 'default' is required")
|
|
180
|
+
)
|
|
181
|
+
for rid, route in routes.items():
|
|
182
|
+
seen: set[str] = set()
|
|
183
|
+
cur: str | None = rid
|
|
184
|
+
while cur is not None:
|
|
185
|
+
if cur in seen:
|
|
186
|
+
findings.append(
|
|
187
|
+
Finding(fname, "escalation-cycle", f"timeout chain from {rid!r} cycles")
|
|
188
|
+
)
|
|
189
|
+
break
|
|
190
|
+
seen.add(cur)
|
|
191
|
+
r = routes.get(cur)
|
|
192
|
+
if r is None:
|
|
193
|
+
findings.append(
|
|
194
|
+
Finding(fname, "dangling-escalate-to", f"route {cur!r} does not exist")
|
|
195
|
+
)
|
|
196
|
+
break
|
|
197
|
+
cur = r.get("escalate_to") if r.get("on_timeout") == "escalate_up" else None
|
|
198
|
+
|
|
199
|
+
return findings
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def validate(bundle: GovernanceBundle) -> list[Finding]:
|
|
203
|
+
"""Validate a parsed bundle. Empty list = conforming."""
|
|
204
|
+
findings = list(bundle.parse_findings)
|
|
205
|
+
|
|
206
|
+
orgs = set()
|
|
207
|
+
for fname, fm in bundle.frontmatter.items():
|
|
208
|
+
for err in _schema(fname).iter_errors(fm):
|
|
209
|
+
path = ".".join(str(p) for p in err.absolute_path) or "<root>"
|
|
210
|
+
findings.append(Finding(fname, "schema", f"{path}: {err.message[:160]}"))
|
|
211
|
+
findings.extend(_semantic_findings(fname, fm))
|
|
212
|
+
if isinstance(fm.get("org"), str):
|
|
213
|
+
orgs.add(fm["org"])
|
|
214
|
+
|
|
215
|
+
if len(orgs) > 1:
|
|
216
|
+
findings.append(
|
|
217
|
+
Finding("<bundle>", "org-mismatch", f"files declare different orgs: {sorted(orgs)}")
|
|
218
|
+
)
|
|
219
|
+
|
|
220
|
+
return findings
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"""gaas — CLI for the auth.md governance file convention (spec 0.1).
|
|
2
|
+
|
|
3
|
+
Local-only: no network, no API key. Suitable for CI.
|
|
4
|
+
|
|
5
|
+
gaas validate [path] # validate a .gaas/ bundle (default: cwd)
|
|
6
|
+
gaas sign [path] --key k.pem --key-id my-key [--signer org]
|
|
7
|
+
gaas verify [path] --pub key_id=pub.pem [--pub ...]
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import argparse
|
|
13
|
+
import json
|
|
14
|
+
import sys
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
from .bundle import parse_bundle, validate
|
|
18
|
+
from .signing import sign, verify
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _cmd_validate(args: argparse.Namespace) -> int:
|
|
22
|
+
bundle = parse_bundle(args.path)
|
|
23
|
+
findings = validate(bundle)
|
|
24
|
+
for f in findings:
|
|
25
|
+
print(f" ✗ {f}", file=sys.stderr)
|
|
26
|
+
if findings:
|
|
27
|
+
print(f"\n{len(findings)} finding(s) — bundle does NOT conform to spec 0.1", file=sys.stderr)
|
|
28
|
+
return 1
|
|
29
|
+
print(f"✓ {len(bundle.files)} file(s) conform to spec 0.1: {', '.join(sorted(bundle.files))}")
|
|
30
|
+
return 0
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _cmd_sign(args: argparse.Namespace) -> int:
|
|
34
|
+
bundle = parse_bundle(args.path)
|
|
35
|
+
findings = validate(bundle)
|
|
36
|
+
if findings:
|
|
37
|
+
print("refusing to sign a non-conforming bundle:", file=sys.stderr)
|
|
38
|
+
for f in findings:
|
|
39
|
+
print(f" ✗ {f}", file=sys.stderr)
|
|
40
|
+
return 1
|
|
41
|
+
attestation = sign(
|
|
42
|
+
bundle,
|
|
43
|
+
Path(args.key).read_bytes(),
|
|
44
|
+
key_id=args.key_id,
|
|
45
|
+
signer=args.signer,
|
|
46
|
+
)
|
|
47
|
+
out = bundle.root / "attestation.sig"
|
|
48
|
+
out.write_text(json.dumps(attestation, indent=2) + "\n")
|
|
49
|
+
print(f"✓ wrote {out} (manifest_hash {attestation['manifest_hash'][:23]}…)")
|
|
50
|
+
return 0
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _cmd_verify(args: argparse.Namespace) -> int:
|
|
54
|
+
bundle = parse_bundle(args.path)
|
|
55
|
+
att_path = bundle.root / "attestation.sig"
|
|
56
|
+
if not att_path.is_file():
|
|
57
|
+
print(f"✗ no attestation.sig in {bundle.root}", file=sys.stderr)
|
|
58
|
+
return 1
|
|
59
|
+
attestation = json.loads(att_path.read_text())
|
|
60
|
+
keys: dict[str, bytes] = {}
|
|
61
|
+
for spec in args.pub or []:
|
|
62
|
+
key_id, _, pem_path = spec.partition("=")
|
|
63
|
+
if not pem_path:
|
|
64
|
+
print(f"✗ --pub must be key_id=path.pem (got {spec!r})", file=sys.stderr)
|
|
65
|
+
return 2
|
|
66
|
+
keys[key_id] = Path(pem_path).read_bytes()
|
|
67
|
+
try:
|
|
68
|
+
results = verify(bundle, attestation, keys)
|
|
69
|
+
except ValueError as exc:
|
|
70
|
+
print(f"✗ {exc}", file=sys.stderr)
|
|
71
|
+
return 1
|
|
72
|
+
any_valid = False
|
|
73
|
+
for key_id, status in results.items():
|
|
74
|
+
mark = "✓" if status == "valid" else "✗"
|
|
75
|
+
print(f" {mark} {key_id}: {status}")
|
|
76
|
+
any_valid |= status == "valid"
|
|
77
|
+
if not any_valid:
|
|
78
|
+
print("✗ no valid signature", file=sys.stderr)
|
|
79
|
+
return 1
|
|
80
|
+
print("✓ bundle verified")
|
|
81
|
+
return 0
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def main(argv: list[str] | None = None) -> int:
|
|
85
|
+
parser = argparse.ArgumentParser(prog="gaas", description=__doc__)
|
|
86
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
87
|
+
|
|
88
|
+
p = sub.add_parser("validate", help="validate a .gaas/ bundle")
|
|
89
|
+
p.add_argument("path", nargs="?", default=".")
|
|
90
|
+
p.set_defaults(func=_cmd_validate)
|
|
91
|
+
|
|
92
|
+
p = sub.add_parser("sign", help="sign a bundle -> attestation.sig")
|
|
93
|
+
p.add_argument("path", nargs="?", default=".")
|
|
94
|
+
p.add_argument("--key", required=True, help="ECDSA P-256 private key (PEM)")
|
|
95
|
+
p.add_argument("--key-id", required=True)
|
|
96
|
+
p.add_argument("--signer", choices=["org", "runtime"], default="org")
|
|
97
|
+
p.set_defaults(func=_cmd_sign)
|
|
98
|
+
|
|
99
|
+
p = sub.add_parser("verify", help="verify attestation.sig against the bundle")
|
|
100
|
+
p.add_argument("path", nargs="?", default=".")
|
|
101
|
+
p.add_argument("--pub", action="append", help="key_id=public.pem (repeatable)")
|
|
102
|
+
p.set_defaults(func=_cmd_verify)
|
|
103
|
+
|
|
104
|
+
args = parser.parse_args(argv)
|
|
105
|
+
return args.func(args)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
if __name__ == "__main__": # pragma: no cover
|
|
109
|
+
sys.exit(main())
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
"$id": "https://gaas.to/spec/0.1/attestation.schema.json",
|
|
4
|
+
"title": "attestation.sig \u2014 spec 0.1",
|
|
5
|
+
"type": "object",
|
|
6
|
+
"$defs": {},
|
|
7
|
+
"properties": {
|
|
8
|
+
"gaas_spec": {
|
|
9
|
+
"const": "0.1"
|
|
10
|
+
},
|
|
11
|
+
"org": {
|
|
12
|
+
"type": "string",
|
|
13
|
+
"pattern": "^[a-z0-9_-]{1,64}$"
|
|
14
|
+
},
|
|
15
|
+
"manifest": {
|
|
16
|
+
"type": "object",
|
|
17
|
+
"patternProperties": {
|
|
18
|
+
"^(auth|policy|audit|escalation)\\.md$": {
|
|
19
|
+
"type": "string",
|
|
20
|
+
"pattern": "^sha256:[0-9a-f]{64}$"
|
|
21
|
+
}
|
|
22
|
+
},
|
|
23
|
+
"minProperties": 1,
|
|
24
|
+
"additionalProperties": false
|
|
25
|
+
},
|
|
26
|
+
"manifest_hash": {
|
|
27
|
+
"type": "string",
|
|
28
|
+
"pattern": "^sha256:[0-9a-f]{64}$"
|
|
29
|
+
},
|
|
30
|
+
"algo": {
|
|
31
|
+
"const": "ES256"
|
|
32
|
+
},
|
|
33
|
+
"signatures": {
|
|
34
|
+
"type": "array",
|
|
35
|
+
"minItems": 1,
|
|
36
|
+
"items": {
|
|
37
|
+
"type": "object",
|
|
38
|
+
"properties": {
|
|
39
|
+
"key_id": {
|
|
40
|
+
"type": "string",
|
|
41
|
+
"minLength": 1
|
|
42
|
+
},
|
|
43
|
+
"signer": {
|
|
44
|
+
"enum": [
|
|
45
|
+
"org",
|
|
46
|
+
"runtime"
|
|
47
|
+
]
|
|
48
|
+
},
|
|
49
|
+
"sig": {
|
|
50
|
+
"type": "string",
|
|
51
|
+
"minLength": 1
|
|
52
|
+
},
|
|
53
|
+
"signed_at": {
|
|
54
|
+
"type": "string",
|
|
55
|
+
"format": "date-time"
|
|
56
|
+
}
|
|
57
|
+
},
|
|
58
|
+
"required": [
|
|
59
|
+
"key_id",
|
|
60
|
+
"signer",
|
|
61
|
+
"sig",
|
|
62
|
+
"signed_at"
|
|
63
|
+
],
|
|
64
|
+
"additionalProperties": false
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
},
|
|
68
|
+
"required": [
|
|
69
|
+
"gaas_spec",
|
|
70
|
+
"org",
|
|
71
|
+
"manifest",
|
|
72
|
+
"manifest_hash",
|
|
73
|
+
"algo",
|
|
74
|
+
"signatures"
|
|
75
|
+
],
|
|
76
|
+
"additionalProperties": false
|
|
77
|
+
}
|