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.
@@ -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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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,2 @@
1
+ [console_scripts]
2
+ gitvow-provider-facts = gitvow_provider_facts.cli:main
@@ -0,0 +1,8 @@
1
+
2
+ [dev]
3
+ pytest>=8
4
+ pytest-cov>=5
5
+ ruff>=0.6
6
+ bandit[toml]>=1.7
7
+ pip-audit>=2.7
8
+ mypy>=1.10
@@ -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"