gitvow-provider-facts 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.
- gitvow_provider_facts-0.1.0/LICENSE +17 -0
- gitvow_provider_facts-0.1.0/PKG-INFO +53 -0
- gitvow_provider_facts-0.1.0/README.md +29 -0
- gitvow_provider_facts-0.1.0/pyproject.toml +47 -0
- gitvow_provider_facts-0.1.0/setup.cfg +4 -0
- gitvow_provider_facts-0.1.0/src/gitvow_provider_facts/__init__.py +8 -0
- gitvow_provider_facts-0.1.0/src/gitvow_provider_facts/cli.py +53 -0
- gitvow_provider_facts-0.1.0/src/gitvow_provider_facts/provider.py +124 -0
- gitvow_provider_facts-0.1.0/src/gitvow_provider_facts/store.py +137 -0
- gitvow_provider_facts-0.1.0/src/gitvow_provider_facts.egg-info/PKG-INFO +53 -0
- gitvow_provider_facts-0.1.0/src/gitvow_provider_facts.egg-info/SOURCES.txt +14 -0
- gitvow_provider_facts-0.1.0/src/gitvow_provider_facts.egg-info/dependency_links.txt +1 -0
- gitvow_provider_facts-0.1.0/src/gitvow_provider_facts.egg-info/entry_points.txt +2 -0
- gitvow_provider_facts-0.1.0/src/gitvow_provider_facts.egg-info/requires.txt +8 -0
- gitvow_provider_facts-0.1.0/src/gitvow_provider_facts.egg-info/top_level.txt +1 -0
- gitvow_provider_facts-0.1.0/tests/test_provider.py +154 -0
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
Apache License
|
|
2
|
+
Version 2.0, January 2004
|
|
3
|
+
http://www.apache.org/licenses/
|
|
4
|
+
|
|
5
|
+
Copyright 2026 gitvow contributors
|
|
6
|
+
|
|
7
|
+
Licensed under the Apache License, Version 2.0 (the "License");
|
|
8
|
+
you may not use this file except in compliance with the License.
|
|
9
|
+
You may obtain a copy of the License at
|
|
10
|
+
|
|
11
|
+
http://www.apache.org/licenses/LICENSE-2.0
|
|
12
|
+
|
|
13
|
+
Unless required by applicable law or agreed to in writing, software
|
|
14
|
+
distributed under the License is distributed on an "AS IS" BASIS,
|
|
15
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
16
|
+
See the License for the specific language governing permissions and
|
|
17
|
+
limitations under the License.
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: gitvow-provider-facts
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: gitvow provider answering gate questions from a derived fact store of routes, gates and callers.
|
|
5
|
+
Author-email: Nikhil Bora <nikhil@wirevow.com>
|
|
6
|
+
License: Apache-2.0
|
|
7
|
+
Project-URL: Homepage, https://wirevow.dev/gitvow-provider-facts
|
|
8
|
+
Project-URL: Repository, https://github.com/wirevow/gitvow-provider-facts
|
|
9
|
+
Keywords: gitvow,provenance,policy,ai-agents,git
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Classifier: License :: OSI Approved :: Apache Software License
|
|
12
|
+
Classifier: Operating System :: OS Independent
|
|
13
|
+
Requires-Python: >=3.9
|
|
14
|
+
Description-Content-Type: text/markdown
|
|
15
|
+
License-File: LICENSE
|
|
16
|
+
Provides-Extra: dev
|
|
17
|
+
Requires-Dist: pytest>=8; extra == "dev"
|
|
18
|
+
Requires-Dist: pytest-cov>=5; extra == "dev"
|
|
19
|
+
Requires-Dist: ruff>=0.6; extra == "dev"
|
|
20
|
+
Requires-Dist: bandit[toml]>=1.7; extra == "dev"
|
|
21
|
+
Requires-Dist: pip-audit>=2.7; extra == "dev"
|
|
22
|
+
Requires-Dist: mypy>=1.10; extra == "dev"
|
|
23
|
+
Dynamic: license-file
|
|
24
|
+
|
|
25
|
+
# gitvow-provider-facts
|
|
26
|
+
|
|
27
|
+
A [gitvow](https://wirevow.dev/gitvow/) provider that answers the gate's questions from a derived fact store of your services: which routes exist, how each is gated, and who calls it.
|
|
28
|
+
|
|
29
|
+
With it, an agent that adds an endpoint the authorization layer does not cover, or removes one that other services call, is stopped before the file changes, and the message names the whitelist pattern or the calling services.
|
|
30
|
+
|
|
31
|
+
```sh
|
|
32
|
+
pip install gitvow-provider-facts
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
```json
|
|
36
|
+
"providers": [{
|
|
37
|
+
"name": "topology",
|
|
38
|
+
"command": "gitvow-provider-facts --store /var/lib/topology/facts.db --service orders",
|
|
39
|
+
"questions": ["route_gate", "route_callers", "gate_bearing"]
|
|
40
|
+
}]
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
```
|
|
44
|
+
$ gitvow ask route_callers /v1/orders --path src/api/OrdersResource.java
|
|
45
|
+
topology: yes — called by client-orch (OrdersClient.java:88) POST; called by billing-worker (sync.py:41) GET; store built 2026-09-08 (1 day old)
|
|
46
|
+
decision: CONFIRM
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
No runtime dependencies: Python 3.9+ and sqlite3 from the standard library. The provider holds no knowledge of its own; it reads a store you build. It never writes to the store and never contacts the network.
|
|
50
|
+
|
|
51
|
+
Documentation: **https://wirevow.dev/gitvow-provider-facts/** · Protocol it implements: [gitvow provider protocol](https://wirevow.dev/gitvow/reference/provider-protocol/)
|
|
52
|
+
|
|
53
|
+
Apache-2.0.
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# gitvow-provider-facts
|
|
2
|
+
|
|
3
|
+
A [gitvow](https://wirevow.dev/gitvow/) provider that answers the gate's questions from a derived fact store of your services: which routes exist, how each is gated, and who calls it.
|
|
4
|
+
|
|
5
|
+
With it, an agent that adds an endpoint the authorization layer does not cover, or removes one that other services call, is stopped before the file changes, and the message names the whitelist pattern or the calling services.
|
|
6
|
+
|
|
7
|
+
```sh
|
|
8
|
+
pip install gitvow-provider-facts
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
```json
|
|
12
|
+
"providers": [{
|
|
13
|
+
"name": "topology",
|
|
14
|
+
"command": "gitvow-provider-facts --store /var/lib/topology/facts.db --service orders",
|
|
15
|
+
"questions": ["route_gate", "route_callers", "gate_bearing"]
|
|
16
|
+
}]
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
```
|
|
20
|
+
$ gitvow ask route_callers /v1/orders --path src/api/OrdersResource.java
|
|
21
|
+
topology: yes — called by client-orch (OrdersClient.java:88) POST; called by billing-worker (sync.py:41) GET; store built 2026-09-08 (1 day old)
|
|
22
|
+
decision: CONFIRM
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
No runtime dependencies: Python 3.9+ and sqlite3 from the standard library. The provider holds no knowledge of its own; it reads a store you build. It never writes to the store and never contacts the network.
|
|
26
|
+
|
|
27
|
+
Documentation: **https://wirevow.dev/gitvow-provider-facts/** · Protocol it implements: [gitvow provider protocol](https://wirevow.dev/gitvow/reference/provider-protocol/)
|
|
28
|
+
|
|
29
|
+
Apache-2.0.
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=69", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "gitvow-provider-facts"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "gitvow provider answering gate questions from a derived fact store of routes, gates and callers."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.9"
|
|
11
|
+
license = {text = "Apache-2.0"}
|
|
12
|
+
authors = [{name = "Nikhil Bora", email = "nikhil@wirevow.com"}]
|
|
13
|
+
keywords = ["gitvow", "provenance", "policy", "ai-agents", "git"]
|
|
14
|
+
classifiers = ["Programming Language :: Python :: 3", "License :: OSI Approved :: Apache Software License", "Operating System :: OS Independent"]
|
|
15
|
+
dependencies = []
|
|
16
|
+
|
|
17
|
+
[project.optional-dependencies]
|
|
18
|
+
dev = ["pytest>=8", "pytest-cov>=5", "ruff>=0.6", "bandit[toml]>=1.7", "pip-audit>=2.7", "mypy>=1.10"]
|
|
19
|
+
|
|
20
|
+
[project.scripts]
|
|
21
|
+
gitvow-provider-facts = "gitvow_provider_facts.cli:main"
|
|
22
|
+
|
|
23
|
+
[project.urls]
|
|
24
|
+
Homepage = "https://wirevow.dev/gitvow-provider-facts"
|
|
25
|
+
Repository = "https://github.com/wirevow/gitvow-provider-facts"
|
|
26
|
+
|
|
27
|
+
[tool.setuptools.packages.find]
|
|
28
|
+
where = ["src"]
|
|
29
|
+
|
|
30
|
+
[tool.ruff]
|
|
31
|
+
line-length = 120
|
|
32
|
+
target-version = "py39"
|
|
33
|
+
|
|
34
|
+
[tool.ruff.lint]
|
|
35
|
+
select = ["E", "F", "W", "I", "B", "UP", "S", "N", "C4", "SIM", "RUF"]
|
|
36
|
+
ignore = ["S101", "S603", "S607", "E501"]
|
|
37
|
+
|
|
38
|
+
[tool.pytest.ini_options]
|
|
39
|
+
addopts = "-q --cov=gitvow_provider_facts --cov-report=term-missing --cov-fail-under=85"
|
|
40
|
+
testpaths = ["tests"]
|
|
41
|
+
|
|
42
|
+
[tool.bandit]
|
|
43
|
+
skips = ["B404", "B603", "B607"]
|
|
44
|
+
|
|
45
|
+
[tool.mypy]
|
|
46
|
+
python_version = "3.10"
|
|
47
|
+
strict = true
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
"""gitvow provider over a derived fact store (routes, gates, callers)."""
|
|
2
|
+
|
|
3
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
4
|
+
|
|
5
|
+
try:
|
|
6
|
+
__version__ = version("gitvow-provider-facts")
|
|
7
|
+
except PackageNotFoundError: # source tree not installed
|
|
8
|
+
__version__ = "0.0.0+unknown"
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""Command line: read a gitvow request on stdin, or answer one question from arguments with `ask`."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import json
|
|
7
|
+
import sys
|
|
8
|
+
|
|
9
|
+
from . import __version__
|
|
10
|
+
from .provider import Config, answer
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def main(argv: list[str] | None = None) -> int:
|
|
14
|
+
p = argparse.ArgumentParser(prog="gitvow-provider-facts", description="gitvow provider over a fact store.")
|
|
15
|
+
p.add_argument("--version", action="version", version=f"gitvow-provider-facts {__version__}")
|
|
16
|
+
p.add_argument("--store", required=True)
|
|
17
|
+
p.add_argument("--repo", default="")
|
|
18
|
+
p.add_argument("--service", default="")
|
|
19
|
+
p.add_argument("--gate-file", action="append", default=[])
|
|
20
|
+
p.add_argument("--max-age-days", type=float, default=14)
|
|
21
|
+
sub = p.add_subparsers(dest="cmd")
|
|
22
|
+
s = sub.add_parser("ask", help="answer one question from arguments (for testing)")
|
|
23
|
+
s.add_argument("question", choices=["gate_bearing", "route_gate", "route_callers"])
|
|
24
|
+
s.add_argument("subject")
|
|
25
|
+
s.add_argument("--path", default="")
|
|
26
|
+
a = p.parse_args(argv)
|
|
27
|
+
cfg = Config(a.store, a.repo, a.service, a.gate_file, a.max_age_days)
|
|
28
|
+
if a.cmd == "ask":
|
|
29
|
+
req = {
|
|
30
|
+
"protocol": 1,
|
|
31
|
+
"question": a.question,
|
|
32
|
+
"subject": a.subject,
|
|
33
|
+
"path": a.path or (a.subject if a.question == "gate_bearing" else ""),
|
|
34
|
+
"repo": "",
|
|
35
|
+
}
|
|
36
|
+
else:
|
|
37
|
+
try:
|
|
38
|
+
req = json.load(sys.stdin)
|
|
39
|
+
except json.JSONDecodeError as e:
|
|
40
|
+
print(f"bad request: {e}", file=sys.stderr)
|
|
41
|
+
return 2
|
|
42
|
+
try:
|
|
43
|
+
out = answer(cfg, req)
|
|
44
|
+
except FileNotFoundError as e:
|
|
45
|
+
print(f"store not found: {e}", file=sys.stderr)
|
|
46
|
+
return 3
|
|
47
|
+
json.dump(out, sys.stdout)
|
|
48
|
+
print()
|
|
49
|
+
return 0
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
if __name__ == "__main__":
|
|
53
|
+
sys.exit(main())
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
"""Answer gitvow provider questions from a Store."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import fnmatch
|
|
6
|
+
import os
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from .store import Store, normalize, route_matches
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass
|
|
14
|
+
class Config:
|
|
15
|
+
store: str
|
|
16
|
+
repo: str = ""
|
|
17
|
+
service: str = ""
|
|
18
|
+
gate_files: list[str] = field(default_factory=list)
|
|
19
|
+
max_age_days: float = 14
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _age_evidence(st: Store, cfg: Config) -> str:
|
|
23
|
+
days = st.age_days()
|
|
24
|
+
warn = " (STALE: rebuild the store)" if days > cfg.max_age_days else ""
|
|
25
|
+
return f"store built {int(days)} day{'s' if int(days) != 1 else ''} ago{warn}"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def resolve_route(st: Store, repo: str, literal: str, path: str) -> tuple[str, str]:
|
|
29
|
+
"""Return (full route, how). Combine a method-level literal with the class-level prefix implied by the file."""
|
|
30
|
+
lit = normalize(literal)
|
|
31
|
+
known = [normalize(r) for r in st.routes_in_file(repo, path)] if path else []
|
|
32
|
+
if lit in known:
|
|
33
|
+
return lit, "exact"
|
|
34
|
+
# longest common prefix of the file's known routes is the class-level @Path
|
|
35
|
+
if known:
|
|
36
|
+
segs = [r.split("/") for r in known]
|
|
37
|
+
prefix: list[str] = []
|
|
38
|
+
for parts in zip(*segs):
|
|
39
|
+
if all(p == parts[0] for p in parts):
|
|
40
|
+
prefix.append(parts[0])
|
|
41
|
+
else:
|
|
42
|
+
break
|
|
43
|
+
pre = "/".join(prefix)
|
|
44
|
+
if pre and pre != "/" and not lit.startswith(pre + "/") and lit != pre:
|
|
45
|
+
return normalize(pre + "/" + lit.lstrip("/")), f"prefix {pre} from {os.path.basename(path)}"
|
|
46
|
+
return lit, "as written"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def answer(cfg: Config, req: dict[str, Any]) -> dict[str, Any]:
|
|
50
|
+
st = Store(cfg.store)
|
|
51
|
+
q = req.get("question")
|
|
52
|
+
subject = str(req.get("subject") or "")
|
|
53
|
+
path = str(req.get("path") or "")
|
|
54
|
+
repo = cfg.repo or os.path.basename(str(req.get("repo") or "").rstrip("/"))
|
|
55
|
+
service = cfg.service or repo
|
|
56
|
+
age = _age_evidence(st, cfg)
|
|
57
|
+
if q == "gate_bearing":
|
|
58
|
+
hit = [g for g in cfg.gate_files if fnmatch.fnmatch(path, g) or path.endswith(g)]
|
|
59
|
+
if hit:
|
|
60
|
+
return {"answer": "yes", "evidence": [f"gate-bearing file ({hit[0]})", age], "confidence": 1.0}
|
|
61
|
+
return {"answer": "no" if cfg.gate_files else "unknown", "evidence": [age], "confidence": 0.5}
|
|
62
|
+
if q not in ("route_gate", "route_callers"):
|
|
63
|
+
return {"answer": "unknown", "evidence": ["unsupported question"], "confidence": 0.0}
|
|
64
|
+
route, how = resolve_route(st, repo, subject, path)
|
|
65
|
+
resolved = f"resolved to {route} ({how})" if route != normalize(subject) else f"route {route}"
|
|
66
|
+
if q == "route_callers":
|
|
67
|
+
callers = st.callers(service, route)
|
|
68
|
+
if callers:
|
|
69
|
+
ev = [resolved] + [f"called by {c.text()}" for c in callers[:6]]
|
|
70
|
+
if len(callers) > 6:
|
|
71
|
+
ev.append(f"and {len(callers) - 6} more callers")
|
|
72
|
+
return {"answer": "yes", "evidence": [*ev, age], "confidence": 0.9}
|
|
73
|
+
return {
|
|
74
|
+
"answer": "unknown",
|
|
75
|
+
"evidence": [resolved, f"no recorded callers into service {service}", age],
|
|
76
|
+
"confidence": 0.5,
|
|
77
|
+
}
|
|
78
|
+
gates = st.gates(repo, route)
|
|
79
|
+
if gates:
|
|
80
|
+
exposed = [g for g in gates if g.exposed]
|
|
81
|
+
if exposed:
|
|
82
|
+
g = exposed[0]
|
|
83
|
+
return {
|
|
84
|
+
"answer": "yes",
|
|
85
|
+
"evidence": [resolved, f"recorded gate: {g.classification}", f"declared in {g.file}", age],
|
|
86
|
+
"confidence": 0.9,
|
|
87
|
+
}
|
|
88
|
+
return {
|
|
89
|
+
"answer": "no",
|
|
90
|
+
"evidence": [resolved, f"recorded gate: {gates[0].classification}", age],
|
|
91
|
+
"confidence": 0.9,
|
|
92
|
+
}
|
|
93
|
+
if not st.has_gate_data(repo):
|
|
94
|
+
return {
|
|
95
|
+
"answer": "unknown",
|
|
96
|
+
"evidence": [resolved, f"no gate classification recorded for repo {repo}", age],
|
|
97
|
+
"confidence": 0.3,
|
|
98
|
+
}
|
|
99
|
+
for pattern, authenticated in st.whitelist_patterns(repo):
|
|
100
|
+
if route_matches(pattern, route):
|
|
101
|
+
if authenticated:
|
|
102
|
+
return {
|
|
103
|
+
"answer": "no",
|
|
104
|
+
"evidence": [resolved, f"new route; covered by authenticated whitelist pattern {pattern}", age],
|
|
105
|
+
"confidence": 0.8,
|
|
106
|
+
}
|
|
107
|
+
return {
|
|
108
|
+
"answer": "yes",
|
|
109
|
+
"evidence": [
|
|
110
|
+
resolved,
|
|
111
|
+
f"new route; whitelist pattern {pattern} makes it OPEN without authentication",
|
|
112
|
+
age,
|
|
113
|
+
],
|
|
114
|
+
"confidence": 0.8,
|
|
115
|
+
}
|
|
116
|
+
return {
|
|
117
|
+
"answer": "yes",
|
|
118
|
+
"evidence": [
|
|
119
|
+
resolved,
|
|
120
|
+
"new route; no whitelist pattern covers it, so the gate would return 403 until it is whitelisted",
|
|
121
|
+
age,
|
|
122
|
+
],
|
|
123
|
+
"confidence": 0.7,
|
|
124
|
+
}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
"""Read-only queries over the fact store."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import fnmatch
|
|
6
|
+
import os
|
|
7
|
+
import re
|
|
8
|
+
import sqlite3
|
|
9
|
+
import time
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
|
|
12
|
+
ROUTE_SUBJECT = re.compile(r"^(\w+)\s+(/\S*)$")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def normalize(path: str) -> str:
|
|
16
|
+
p = re.sub(r"\{[^}]*\}", "{}", path.strip())
|
|
17
|
+
p = re.sub(r"/{2,}", "/", p)
|
|
18
|
+
if len(p) > 1 and p.endswith("/"):
|
|
19
|
+
p = p[:-1]
|
|
20
|
+
return p or "/"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def route_matches(pattern: str, route: str) -> bool:
|
|
24
|
+
"""Whitelist-style match: leading slash optional, trailing '*' matches any depth, {} matches one segment."""
|
|
25
|
+
pat = normalize("/" + pattern.lstrip("/"))
|
|
26
|
+
rt = normalize(route)
|
|
27
|
+
if pat.endswith("/*"):
|
|
28
|
+
base = pat[:-2]
|
|
29
|
+
return rt == base or rt.startswith(base + "/")
|
|
30
|
+
if pat.endswith("*"):
|
|
31
|
+
return rt.startswith(pat[:-1])
|
|
32
|
+
ps, rs = pat.split("/"), rt.split("/")
|
|
33
|
+
if len(ps) != len(rs):
|
|
34
|
+
return False
|
|
35
|
+
return all(a == b or a == "{}" or b == "{}" or fnmatch.fnmatchcase(b, a) for a, b in zip(ps, rs))
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def edge_hits_route(edge_path: str, route: str) -> bool:
|
|
39
|
+
"""Does a recorded call to edge_path reach route? A route parameter accepts any caller segment;
|
|
40
|
+
a caller parameter only matches a route parameter, never a literal segment."""
|
|
41
|
+
es, rs = normalize(edge_path).split("/"), normalize(route).split("/")
|
|
42
|
+
if len(es) != len(rs):
|
|
43
|
+
return False
|
|
44
|
+
for e, r in zip(es, rs):
|
|
45
|
+
if r == "{}":
|
|
46
|
+
continue
|
|
47
|
+
if e == "{}" or e != r:
|
|
48
|
+
return False
|
|
49
|
+
return True
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@dataclass
|
|
53
|
+
class Gate:
|
|
54
|
+
verb: str
|
|
55
|
+
route: str
|
|
56
|
+
classification: str
|
|
57
|
+
file: str
|
|
58
|
+
|
|
59
|
+
@property
|
|
60
|
+
def exposed(self) -> bool:
|
|
61
|
+
return self.classification.startswith(("OPEN", "BLOCKED"))
|
|
62
|
+
|
|
63
|
+
@property
|
|
64
|
+
def pattern(self) -> str | None:
|
|
65
|
+
m = re.search(r"\bvia\s+(\S+)", self.classification)
|
|
66
|
+
return m.group(1) if m else None
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@dataclass
|
|
70
|
+
class Caller:
|
|
71
|
+
repo: str
|
|
72
|
+
site: str
|
|
73
|
+
verb: str
|
|
74
|
+
path: str
|
|
75
|
+
|
|
76
|
+
def text(self) -> str:
|
|
77
|
+
return f"{self.repo} ({self.site}) {self.verb}".rstrip()
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class Store:
|
|
81
|
+
def __init__(self, path: str):
|
|
82
|
+
if not os.path.exists(path):
|
|
83
|
+
raise FileNotFoundError(path)
|
|
84
|
+
self.path = path
|
|
85
|
+
uri = f"file:{os.path.abspath(path)}?mode=ro"
|
|
86
|
+
self.db = sqlite3.connect(uri, uri=True)
|
|
87
|
+
|
|
88
|
+
def age_days(self) -> float:
|
|
89
|
+
return (time.time() - os.path.getmtime(self.path)) / 86400
|
|
90
|
+
|
|
91
|
+
def routes_in_file(self, repo: str, file: str) -> list[str]:
|
|
92
|
+
rows = self.db.execute(
|
|
93
|
+
"select subject from fact where repo=? and kind in ('route','route_gate') and (file=? or file like ?)",
|
|
94
|
+
(repo, file, f"%/{os.path.basename(file)}"),
|
|
95
|
+
).fetchall()
|
|
96
|
+
out: list[str] = []
|
|
97
|
+
for (subj,) in rows:
|
|
98
|
+
m = ROUTE_SUBJECT.match(subj or "")
|
|
99
|
+
if m and m.group(2) not in out:
|
|
100
|
+
out.append(m.group(2))
|
|
101
|
+
return out
|
|
102
|
+
|
|
103
|
+
def gates(self, repo: str, route: str) -> list[Gate]:
|
|
104
|
+
rt = normalize(route)
|
|
105
|
+
out: list[Gate] = []
|
|
106
|
+
for subj, obj, file in self.db.execute(
|
|
107
|
+
"select subject, object, file from fact where repo=? and kind='route_gate'", (repo,)
|
|
108
|
+
):
|
|
109
|
+
m = ROUTE_SUBJECT.match(subj or "")
|
|
110
|
+
if m and normalize(m.group(2)) == rt:
|
|
111
|
+
out.append(Gate(m.group(1), m.group(2), obj or "", file or ""))
|
|
112
|
+
return out
|
|
113
|
+
|
|
114
|
+
def whitelist_patterns(self, repo: str) -> list[tuple[str, bool]]:
|
|
115
|
+
"""(pattern, authenticated) pairs seen in this repo's gate classifications."""
|
|
116
|
+
seen: dict[str, bool] = {}
|
|
117
|
+
for (obj,) in self.db.execute("select distinct object from fact where repo=? and kind='route_gate'", (repo,)):
|
|
118
|
+
m = re.search(r"\bvia\s+(\S+)", obj or "")
|
|
119
|
+
if m:
|
|
120
|
+
seen[m.group(1)] = not (obj or "").startswith("OPEN")
|
|
121
|
+
return sorted(seen.items())
|
|
122
|
+
|
|
123
|
+
def has_gate_data(self, repo: str) -> bool:
|
|
124
|
+
return (
|
|
125
|
+
self.db.execute("select 1 from fact where repo=? and kind='route_gate' limit 1", (repo,)).fetchone()
|
|
126
|
+
is not None
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
def callers(self, service: str, route: str) -> list[Caller]:
|
|
130
|
+
rt = normalize(route)
|
|
131
|
+
out: list[Caller] = []
|
|
132
|
+
for src_repo, src_site, dst_path, verb in self.db.execute(
|
|
133
|
+
"select src_repo, src_site, dst_path, coalesce(verb,'') from edge where dst_service=?", (service,)
|
|
134
|
+
):
|
|
135
|
+
if dst_path and edge_hits_route(dst_path, rt):
|
|
136
|
+
out.append(Caller(src_repo or "?", src_site or "?", verb, dst_path or ""))
|
|
137
|
+
return out
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: gitvow-provider-facts
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: gitvow provider answering gate questions from a derived fact store of routes, gates and callers.
|
|
5
|
+
Author-email: Nikhil Bora <nikhil@wirevow.com>
|
|
6
|
+
License: Apache-2.0
|
|
7
|
+
Project-URL: Homepage, https://wirevow.dev/gitvow-provider-facts
|
|
8
|
+
Project-URL: Repository, https://github.com/wirevow/gitvow-provider-facts
|
|
9
|
+
Keywords: gitvow,provenance,policy,ai-agents,git
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Classifier: License :: OSI Approved :: Apache Software License
|
|
12
|
+
Classifier: Operating System :: OS Independent
|
|
13
|
+
Requires-Python: >=3.9
|
|
14
|
+
Description-Content-Type: text/markdown
|
|
15
|
+
License-File: LICENSE
|
|
16
|
+
Provides-Extra: dev
|
|
17
|
+
Requires-Dist: pytest>=8; extra == "dev"
|
|
18
|
+
Requires-Dist: pytest-cov>=5; extra == "dev"
|
|
19
|
+
Requires-Dist: ruff>=0.6; extra == "dev"
|
|
20
|
+
Requires-Dist: bandit[toml]>=1.7; extra == "dev"
|
|
21
|
+
Requires-Dist: pip-audit>=2.7; extra == "dev"
|
|
22
|
+
Requires-Dist: mypy>=1.10; extra == "dev"
|
|
23
|
+
Dynamic: license-file
|
|
24
|
+
|
|
25
|
+
# gitvow-provider-facts
|
|
26
|
+
|
|
27
|
+
A [gitvow](https://wirevow.dev/gitvow/) provider that answers the gate's questions from a derived fact store of your services: which routes exist, how each is gated, and who calls it.
|
|
28
|
+
|
|
29
|
+
With it, an agent that adds an endpoint the authorization layer does not cover, or removes one that other services call, is stopped before the file changes, and the message names the whitelist pattern or the calling services.
|
|
30
|
+
|
|
31
|
+
```sh
|
|
32
|
+
pip install gitvow-provider-facts
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
```json
|
|
36
|
+
"providers": [{
|
|
37
|
+
"name": "topology",
|
|
38
|
+
"command": "gitvow-provider-facts --store /var/lib/topology/facts.db --service orders",
|
|
39
|
+
"questions": ["route_gate", "route_callers", "gate_bearing"]
|
|
40
|
+
}]
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
```
|
|
44
|
+
$ gitvow ask route_callers /v1/orders --path src/api/OrdersResource.java
|
|
45
|
+
topology: yes — called by client-orch (OrdersClient.java:88) POST; called by billing-worker (sync.py:41) GET; store built 2026-09-08 (1 day old)
|
|
46
|
+
decision: CONFIRM
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
No runtime dependencies: Python 3.9+ and sqlite3 from the standard library. The provider holds no knowledge of its own; it reads a store you build. It never writes to the store and never contacts the network.
|
|
50
|
+
|
|
51
|
+
Documentation: **https://wirevow.dev/gitvow-provider-facts/** · Protocol it implements: [gitvow provider protocol](https://wirevow.dev/gitvow/reference/provider-protocol/)
|
|
52
|
+
|
|
53
|
+
Apache-2.0.
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
src/gitvow_provider_facts/__init__.py
|
|
5
|
+
src/gitvow_provider_facts/cli.py
|
|
6
|
+
src/gitvow_provider_facts/provider.py
|
|
7
|
+
src/gitvow_provider_facts/store.py
|
|
8
|
+
src/gitvow_provider_facts.egg-info/PKG-INFO
|
|
9
|
+
src/gitvow_provider_facts.egg-info/SOURCES.txt
|
|
10
|
+
src/gitvow_provider_facts.egg-info/dependency_links.txt
|
|
11
|
+
src/gitvow_provider_facts.egg-info/entry_points.txt
|
|
12
|
+
src/gitvow_provider_facts.egg-info/requires.txt
|
|
13
|
+
src/gitvow_provider_facts.egg-info/top_level.txt
|
|
14
|
+
tests/test_provider.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
gitvow_provider_facts
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import os
|
|
3
|
+
import subprocess
|
|
4
|
+
import sys
|
|
5
|
+
import time
|
|
6
|
+
|
|
7
|
+
from gitvow_provider_facts.cli import main
|
|
8
|
+
from gitvow_provider_facts.provider import Config, answer, resolve_route
|
|
9
|
+
from gitvow_provider_facts.store import Store, edge_hits_route, normalize, route_matches
|
|
10
|
+
from tests.conftest import F
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def test_normalize_and_matching():
|
|
14
|
+
assert normalize("/v1/orders/{id}/") == "/v1/orders/{}"
|
|
15
|
+
assert normalize("//a//b") == "/a/b"
|
|
16
|
+
assert route_matches("v1/webhook/*", "/v1/webhook/stripe/x")
|
|
17
|
+
assert route_matches("v1/webhook/*", "/v1/webhook")
|
|
18
|
+
assert not route_matches("v1/webhook/*", "/v1/webhooks")
|
|
19
|
+
assert route_matches("/v1/orders/{orderId}", "/v1/orders/{id}")
|
|
20
|
+
assert route_matches("/v1/orders/{id}", "/v1/orders/42")
|
|
21
|
+
assert not route_matches("/v1/orders", "/v1/orders/42")
|
|
22
|
+
assert edge_hits_route("/v1/orders/42", "/v1/orders/{id}") # concrete call reaches the parameterised route
|
|
23
|
+
assert edge_hits_route("/v1/orders/{orderId}", "/v1/orders/{id}")
|
|
24
|
+
assert not edge_hits_route(
|
|
25
|
+
"/v1/orders/{orderId}", "/v1/orders/export"
|
|
26
|
+
) # a caller of the id route is not a caller of export
|
|
27
|
+
assert not edge_hits_route("/v1/orders", "/v1/orders/{id}")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def test_resolve_prefix_from_file(store):
|
|
31
|
+
st = Store(store)
|
|
32
|
+
assert resolve_route(st, "orders-svc", "/export", F) == (
|
|
33
|
+
"/v1/orders/export",
|
|
34
|
+
"prefix /v1/orders from OrdersResource.java",
|
|
35
|
+
)
|
|
36
|
+
assert resolve_route(st, "orders-svc", "/v1/orders/{id}", F) == ("/v1/orders/{}", "exact")
|
|
37
|
+
assert resolve_route(st, "orders-svc", "/v1/orders/export", F) == ("/v1/orders/export", "as written")
|
|
38
|
+
assert resolve_route(st, "orders-svc", "/export", "unknown.java") == ("/export", "as written")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def cfg(store, **kw):
|
|
42
|
+
return Config(store, repo="orders-svc", service="orders", **kw)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def test_route_gate_answers(store):
|
|
46
|
+
c = cfg(store)
|
|
47
|
+
r = answer(c, {"question": "route_gate", "subject": "/export", "path": F})
|
|
48
|
+
assert (
|
|
49
|
+
r["answer"] == "yes"
|
|
50
|
+
and "403" in " ".join(r["evidence"])
|
|
51
|
+
and "resolved to /v1/orders/export" in r["evidence"][0]
|
|
52
|
+
)
|
|
53
|
+
r = answer(c, {"question": "route_gate", "subject": "/{id}", "path": F})
|
|
54
|
+
assert r["answer"] == "no" and "reachable:AuthorizeV2" in r["evidence"][1]
|
|
55
|
+
r = answer(c, {"question": "route_gate", "subject": "/v1/webhook/new", "path": "controller/WebhookResource.java"})
|
|
56
|
+
assert r["answer"] == "yes" and "OPEN" in r["evidence"][1]
|
|
57
|
+
r = answer(c, {"question": "route_gate", "subject": "/myadmin/reports", "path": "controller/AdminResource.java"})
|
|
58
|
+
assert r["answer"] == "no" and "authenticated whitelist" in r["evidence"][1]
|
|
59
|
+
r = answer(c, {"question": "route_gate", "subject": "/v1/legacy/dump", "path": ""})
|
|
60
|
+
assert r["answer"] == "yes" and "BLOCKED" in r["evidence"][1]
|
|
61
|
+
r = answer(Config(store, repo="other-svc"), {"question": "route_gate", "subject": "/y", "path": "x.py"})
|
|
62
|
+
assert r["answer"] == "unknown" and "no gate classification" in r["evidence"][1]
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def test_route_callers(store):
|
|
66
|
+
c = cfg(store)
|
|
67
|
+
r = answer(c, {"question": "route_callers", "subject": "/v1/orders", "path": F})
|
|
68
|
+
assert r["answer"] == "yes" and any("client-orch (src/OrdersClient.java:88) POST" in e for e in r["evidence"])
|
|
69
|
+
assert not any("someone" in e for e in r["evidence"]) # different service
|
|
70
|
+
r = answer(c, {"question": "route_callers", "subject": "/{id}", "path": F})
|
|
71
|
+
assert r["answer"] == "yes" and any("billing-worker" in e for e in r["evidence"])
|
|
72
|
+
r = answer(c, {"question": "route_callers", "subject": "/v1/orders/export", "path": F})
|
|
73
|
+
assert r["answer"] == "unknown" and "no recorded callers" in r["evidence"][1]
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def test_gate_bearing_and_age(store):
|
|
77
|
+
c = cfg(store, gate_files=["auth/AuthorizeWhitelistedPaths.java", "deploy/values/production-*.yaml"])
|
|
78
|
+
assert (
|
|
79
|
+
answer(c, {"question": "gate_bearing", "subject": "x", "path": "svc/auth/AuthorizeWhitelistedPaths.java"})[
|
|
80
|
+
"answer"
|
|
81
|
+
]
|
|
82
|
+
== "yes"
|
|
83
|
+
)
|
|
84
|
+
assert (
|
|
85
|
+
answer(c, {"question": "gate_bearing", "subject": "x", "path": "deploy/values/production-in.yaml"})["answer"]
|
|
86
|
+
== "yes"
|
|
87
|
+
)
|
|
88
|
+
assert answer(c, {"question": "gate_bearing", "subject": "x", "path": "README.md"})["answer"] == "no"
|
|
89
|
+
assert answer(cfg(store), {"question": "gate_bearing", "subject": "x", "path": "README.md"})["answer"] == "unknown"
|
|
90
|
+
old = time.time() - 40 * 86400
|
|
91
|
+
os.utime(store, (old, old))
|
|
92
|
+
r = answer(cfg(store), {"question": "route_callers", "subject": "/v1/orders", "path": F})
|
|
93
|
+
assert "STALE" in r["evidence"][-1] and "40 days ago" in r["evidence"][-1]
|
|
94
|
+
assert answer(c, {"question": "bogus", "subject": "x"})["answer"] == "unknown"
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def test_cli_stdin_and_ask(store, capsys, monkeypatch, tmp_path):
|
|
98
|
+
import io
|
|
99
|
+
|
|
100
|
+
monkeypatch.setattr(
|
|
101
|
+
"sys.stdin",
|
|
102
|
+
io.StringIO(
|
|
103
|
+
json.dumps(
|
|
104
|
+
{
|
|
105
|
+
"protocol": 1,
|
|
106
|
+
"question": "route_callers",
|
|
107
|
+
"subject": "/v1/orders",
|
|
108
|
+
"path": F,
|
|
109
|
+
"repo": str(tmp_path / "orders-svc"),
|
|
110
|
+
}
|
|
111
|
+
)
|
|
112
|
+
),
|
|
113
|
+
)
|
|
114
|
+
assert main(["--store", store, "--service", "orders"]) == 0
|
|
115
|
+
out = json.loads(capsys.readouterr().out)
|
|
116
|
+
assert out["answer"] == "yes"
|
|
117
|
+
assert main(["--store", store, "--repo", "orders-svc", "ask", "route_gate", "/export", "--path", F]) == 0
|
|
118
|
+
assert json.loads(capsys.readouterr().out)["answer"] == "yes"
|
|
119
|
+
assert main(["--store", "/nonexistent.db", "ask", "route_gate", "/x"]) == 3
|
|
120
|
+
monkeypatch.setattr("sys.stdin", io.StringIO("not json"))
|
|
121
|
+
assert main(["--store", store]) == 2
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def test_store_is_opened_read_only(store):
|
|
125
|
+
st = Store(store)
|
|
126
|
+
try:
|
|
127
|
+
st.db.execute("insert into fact(repo) values ('x')")
|
|
128
|
+
raise AssertionError("write should fail")
|
|
129
|
+
except Exception as e:
|
|
130
|
+
assert "readonly" in str(e).lower() or "read-only" in str(e).lower()
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def test_works_with_gitvow_as_subprocess(store, tmp_path):
|
|
134
|
+
"""The real integration: gitvow's gate calls the console script and reads the answer."""
|
|
135
|
+
r = subprocess.run(
|
|
136
|
+
[
|
|
137
|
+
sys.executable,
|
|
138
|
+
"-m",
|
|
139
|
+
"gitvow_provider_facts.cli",
|
|
140
|
+
"--store",
|
|
141
|
+
store,
|
|
142
|
+
"--service",
|
|
143
|
+
"orders",
|
|
144
|
+
"--repo",
|
|
145
|
+
"orders-svc",
|
|
146
|
+
],
|
|
147
|
+
input=json.dumps(
|
|
148
|
+
{"protocol": 1, "question": "route_callers", "subject": "/v1/orders", "path": F, "repo": str(tmp_path)}
|
|
149
|
+
),
|
|
150
|
+
capture_output=True,
|
|
151
|
+
text=True,
|
|
152
|
+
check=True,
|
|
153
|
+
)
|
|
154
|
+
assert json.loads(r.stdout)["answer"] == "yes"
|