gitvow 0.3.0__tar.gz → 0.4.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.
Files changed (33) hide show
  1. {gitvow-0.3.0/src/gitvow.egg-info → gitvow-0.4.0}/PKG-INFO +1 -1
  2. {gitvow-0.3.0 → gitvow-0.4.0}/pyproject.toml +1 -1
  3. {gitvow-0.3.0 → gitvow-0.4.0}/src/gitvow/cli.py +26 -0
  4. {gitvow-0.3.0 → gitvow-0.4.0}/src/gitvow/hooks/__init__.py +1 -1
  5. {gitvow-0.3.0 → gitvow-0.4.0}/src/gitvow/policy.py +18 -3
  6. gitvow-0.4.0/src/gitvow/providers.py +126 -0
  7. {gitvow-0.3.0 → gitvow-0.4.0}/src/gitvow/selftest.py +19 -0
  8. {gitvow-0.3.0 → gitvow-0.4.0/src/gitvow.egg-info}/PKG-INFO +1 -1
  9. {gitvow-0.3.0 → gitvow-0.4.0}/src/gitvow.egg-info/SOURCES.txt +2 -0
  10. gitvow-0.4.0/tests/test_providers.py +160 -0
  11. {gitvow-0.3.0 → gitvow-0.4.0}/LICENSE +0 -0
  12. {gitvow-0.3.0 → gitvow-0.4.0}/README.md +0 -0
  13. {gitvow-0.3.0 → gitvow-0.4.0}/setup.cfg +0 -0
  14. {gitvow-0.3.0 → gitvow-0.4.0}/src/gitvow/__init__.py +0 -0
  15. {gitvow-0.3.0 → gitvow-0.4.0}/src/gitvow/__main__.py +0 -0
  16. {gitvow-0.3.0 → gitvow-0.4.0}/src/gitvow/collect.py +0 -0
  17. {gitvow-0.3.0 → gitvow-0.4.0}/src/gitvow/default_policy.json +0 -0
  18. {gitvow-0.3.0 → gitvow-0.4.0}/src/gitvow/install.py +0 -0
  19. {gitvow-0.3.0 → gitvow-0.4.0}/src/gitvow/redact.py +0 -0
  20. {gitvow-0.3.0 → gitvow-0.4.0}/src/gitvow/report.py +0 -0
  21. {gitvow-0.3.0 → gitvow-0.4.0}/src/gitvow/state.py +0 -0
  22. {gitvow-0.3.0 → gitvow-0.4.0}/src/gitvow/transcript.py +0 -0
  23. {gitvow-0.3.0 → gitvow-0.4.0}/src/gitvow.egg-info/dependency_links.txt +0 -0
  24. {gitvow-0.3.0 → gitvow-0.4.0}/src/gitvow.egg-info/entry_points.txt +0 -0
  25. {gitvow-0.3.0 → gitvow-0.4.0}/src/gitvow.egg-info/requires.txt +0 -0
  26. {gitvow-0.3.0 → gitvow-0.4.0}/src/gitvow.egg-info/top_level.txt +0 -0
  27. {gitvow-0.3.0 → gitvow-0.4.0}/tests/test_collect.py +0 -0
  28. {gitvow-0.3.0 → gitvow-0.4.0}/tests/test_hooks.py +0 -0
  29. {gitvow-0.3.0 → gitvow-0.4.0}/tests/test_install_cli.py +0 -0
  30. {gitvow-0.3.0 → gitvow-0.4.0}/tests/test_packaging.py +0 -0
  31. {gitvow-0.3.0 → gitvow-0.4.0}/tests/test_policy.py +0 -0
  32. {gitvow-0.3.0 → gitvow-0.4.0}/tests/test_redact.py +0 -0
  33. {gitvow-0.3.0 → gitvow-0.4.0}/tests/test_report.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: gitvow
3
- Version: 0.3.0
3
+ Version: 0.4.0
4
4
  Summary: Provenance and policy gate for AI-agent coding sessions: session trailers on commits, redacted session notes in git, a tool-call gate, and a local ledger. No runtime dependencies.
5
5
  Author-email: Nikhil Bora <nikhil@wirevow.com>
6
6
  License: Apache-2.0
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "gitvow"
7
- version = "0.3.0"
7
+ version = "0.4.0"
8
8
  description = "Provenance and policy gate for AI-agent coding sessions: session trailers on commits, redacted session notes in git, a tool-call gate, and a local ledger. No runtime dependencies."
9
9
  readme = "README.md"
10
10
  license = {text = "Apache-2.0"}
@@ -13,6 +13,7 @@ from .collect import collect, summarize_text
13
13
  from .hooks import HANDLERS, LEGACY_NOTES_REF, notes_ref
14
14
  from .install import install_repo, install_user, uninstall_repo, uninstall_user
15
15
  from .policy import PolicyError, evaluate, load_policy
16
+ from .providers import QUESTIONS, ask
16
17
  from .redact import RedactionError, load_rules, redact
17
18
  from .report import build, render_markdown
18
19
  from .state import git
@@ -66,6 +67,26 @@ def cmd_check(a: argparse.Namespace) -> int:
66
67
  return 2 if d.blocks else 0
67
68
 
68
69
 
70
+ def cmd_ask(a: argparse.Namespace) -> int:
71
+ """Ask every configured provider one question and show the gate's decision."""
72
+ try:
73
+ pol = load_policy(os.getcwd())
74
+ except PolicyError as e:
75
+ print(f"policy error: {e}", file=sys.stderr)
76
+ return 2
77
+ if not pol.get("providers"):
78
+ print("no providers configured in the active policy", file=sys.stderr)
79
+ return 1
80
+ path = a.path or (a.subject if a.question == "gate_bearing" else "")
81
+ answers = ask(pol, "Edit", {"file_path": path}, os.getcwd(), only=[(a.question, a.subject)])
82
+ for ans in answers:
83
+ ev = " — " + "; ".join(ans.evidence) if ans.evidence else ""
84
+ print(f"{ans.provider}: {ans.answer}{ev}")
85
+ blocked = any(x.blocks for x in answers)
86
+ print("decision: " + ("CONFIRM" if blocked else "ALLOW"))
87
+ return 2 if blocked else 0
88
+
89
+
69
90
  def cmd_show(a: argparse.Namespace) -> int:
70
91
  """Print a commit's trailers and session note."""
71
92
  rc, msg, _ = git(["log", "-1", "--format=%H%n%s%n%b", a.commit], os.getcwd())
@@ -164,6 +185,11 @@ def main(argv: list[str] | None = None) -> int:
164
185
  s = sub.add_parser("show", help="print a commit's trailers and session note")
165
186
  s.add_argument("commit", nargs="?", default="HEAD")
166
187
  s.set_defaults(f=cmd_show)
188
+ s = sub.add_parser("ask", help="ask configured providers a question: gitvow ask route_gate /v1/x --path src/api.py")
189
+ s.add_argument("question", choices=QUESTIONS)
190
+ s.add_argument("subject")
191
+ s.add_argument("--path", default="")
192
+ s.set_defaults(f=cmd_ask)
167
193
  s = sub.add_parser("redact", help="apply built-in redaction plus your rules files to text")
168
194
  s.add_argument("text", nargs="+")
169
195
  s.set_defaults(f=cmd_redact)
@@ -64,7 +64,7 @@ def pre_tool_use(h: dict[str, Any], home: str | None = None) -> tuple[int, str]:
64
64
  2,
65
65
  f"BLOCKED: tool policy could not be loaded ({e}); refusing all tool calls until the policy is restored.",
66
66
  )
67
- d = evaluate(pol, tool, inp)
67
+ d = evaluate(pol, tool, inp, cwd)
68
68
  rules, warn = _rules_or_none(cwd, home)
69
69
  if d.blocks:
70
70
  payload: dict[str, Any] = {"tool": tool, "reason": d.reason, "session_id": h.get("session_id")}
@@ -10,6 +10,8 @@ import subprocess
10
10
  from dataclasses import dataclass
11
11
  from typing import Any
12
12
 
13
+ from . import providers as _providers
14
+
13
15
  DEFAULT_POLICY_PATH = os.path.join(os.path.dirname(__file__), "default_policy.json")
14
16
 
15
17
 
@@ -63,9 +65,18 @@ def _validate(pol: dict[str, Any], path: str) -> None:
63
65
  re.compile(pat)
64
66
  except re.error as e:
65
67
  raise PolicyError(f"{path}: bad regex in {key}: {pat} ({e})") from e
66
-
67
-
68
- def evaluate(pol: dict[str, Any], tool: str, tool_input: dict[str, Any]) -> Decision:
68
+ provs = pol.get("providers", [])
69
+ if not isinstance(provs, list):
70
+ raise PolicyError(f"{path}: providers must be a list")
71
+ for prov in provs:
72
+ if not isinstance(prov, dict) or not prov.get("name") or not prov.get("command"):
73
+ raise PolicyError(f"{path}: each provider needs 'name' and 'command'")
74
+ bad = [q for q in prov.get("questions", []) if q not in _providers.QUESTIONS]
75
+ if bad:
76
+ raise PolicyError(f"{path}: provider {prov['name']} lists unknown questions {bad}")
77
+
78
+
79
+ def evaluate(pol: dict[str, Any], tool: str, tool_input: dict[str, Any], cwd: str | None = None) -> Decision:
69
80
  text = tool_input.get("command", "") if tool == "Bash" else ""
70
81
  path = tool_input.get("file_path", "") or tool_input.get("notebook_path", "")
71
82
  if tool == "Bash":
@@ -85,6 +96,10 @@ def evaluate(pol: dict[str, Any], tool: str, tool_input: dict[str, Any]) -> Deci
85
96
  allow = pol.get("mcp_allow", [])
86
97
  if allow and not any(re.fullmatch(p, tool) for p in allow):
87
98
  return Decision("confirm", "MCP tool not on allow list", tool)
99
+ if pol.get("providers") and tool in ("Edit", "Write", "MultiEdit", "NotebookEdit"):
100
+ hits = [a for a in _providers.ask(pol, tool, tool_input, cwd) if a.blocks]
101
+ if hits:
102
+ return Decision("confirm", "; ".join(h.text() for h in hits[:3]), path)
88
103
  clf = pol.get("llm_classifier") or {}
89
104
  if clf.get("enabled") and clf.get("command"):
90
105
  return _classify(clf["command"], tool, tool_input, text or path or tool)
@@ -0,0 +1,126 @@
1
+ """Providers: external programs the gate asks factual questions (gate_bearing, route_gate, route_callers)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import re
8
+ import shlex
9
+ import subprocess
10
+ from dataclasses import dataclass, field
11
+ from typing import Any
12
+
13
+ PROTOCOL = 1
14
+ QUESTIONS = ("gate_bearing", "route_gate", "route_callers")
15
+ EDIT_TOOLS = ("Edit", "Write", "MultiEdit", "NotebookEdit")
16
+ ROUTE_RE = re.compile(r"""["'`](/[A-Za-z0-9_\-./{}:*$]*)["'`]""")
17
+
18
+
19
+ @dataclass
20
+ class Answer:
21
+ provider: str
22
+ question: str
23
+ subject: str
24
+ answer: str # yes | no | unknown | failed
25
+ evidence: list[str] = field(default_factory=list)
26
+
27
+ @property
28
+ def blocks(self) -> bool:
29
+ return self.answer in ("yes", "failed")
30
+
31
+ def text(self) -> str:
32
+ why = "; ".join(self.evidence[:4]) if self.evidence else ""
33
+ if self.answer == "failed":
34
+ return f"provider {self.provider} unavailable for {self.question} {self.subject}"
35
+ return f"provider {self.provider}: {self.question} {self.subject}" + (f": {why}" if why else "")
36
+
37
+
38
+ def route_literals(text: str) -> list[str]:
39
+ """Quoted strings that look like routes: begin with '/', no spaces, at least one more character."""
40
+ out: list[str] = []
41
+ for m in ROUTE_RE.finditer(text or ""):
42
+ r = m.group(1)
43
+ if len(r) > 1 and not r.startswith("//") and "." not in os.path.basename(r) and r not in out:
44
+ out.append(r)
45
+ return out
46
+
47
+
48
+ def questions_for(tool: str, tool_input: dict[str, Any]) -> list[tuple[str, str]]:
49
+ """(question, subject) pairs the gate should ask for this tool call."""
50
+ if tool not in EDIT_TOOLS:
51
+ return []
52
+ path = tool_input.get("file_path") or tool_input.get("notebook_path") or ""
53
+ qs: list[tuple[str, str]] = []
54
+ if path:
55
+ qs.append(("gate_bearing", path))
56
+ old, new = "", ""
57
+ if tool == "Edit":
58
+ old, new = str(tool_input.get("old_string") or ""), str(tool_input.get("new_string") or "")
59
+ elif tool == "MultiEdit":
60
+ for e in tool_input.get("edits") or []:
61
+ old += "\n" + str(e.get("old_string") or "")
62
+ new += "\n" + str(e.get("new_string") or "")
63
+ elif tool == "Write":
64
+ new = str(tool_input.get("content") or "")
65
+ before, after = set(route_literals(old)), set(route_literals(new))
66
+ qs += [("route_gate", r) for r in sorted(after - before)]
67
+ qs += [("route_callers", r) for r in sorted(before - after)]
68
+ return qs
69
+
70
+
71
+ def _run(prov: dict[str, Any], question: str, subject: str, repo: str, path: str, tool: str) -> Answer:
72
+ name = str(prov.get("name") or "provider")
73
+ req = {
74
+ "protocol": PROTOCOL,
75
+ "question": question,
76
+ "subject": subject,
77
+ "repo": repo,
78
+ "path": path,
79
+ "tool_name": tool,
80
+ }
81
+ try:
82
+ argv = shlex.split(str(prov.get("command") or ""))
83
+ if not argv:
84
+ return Answer(name, question, subject, "failed", ["empty command"])
85
+ r = subprocess.run(
86
+ argv,
87
+ input=json.dumps(req),
88
+ capture_output=True,
89
+ text=True,
90
+ timeout=float(prov.get("timeout") or 10),
91
+ check=False,
92
+ cwd=repo or None,
93
+ )
94
+ if r.returncode != 0:
95
+ return Answer(name, question, subject, "failed", [f"exit {r.returncode}"])
96
+ data = json.loads(r.stdout)
97
+ ans = str(data.get("answer", "unknown")).lower()
98
+ if ans not in ("yes", "no", "unknown"):
99
+ return Answer(name, question, subject, "failed", ["bad answer"])
100
+ ev = [str(e)[:200] for e in (data.get("evidence") or [])[:8]]
101
+ return Answer(name, question, subject, ans, ev)
102
+ except (OSError, subprocess.TimeoutExpired, ValueError, AttributeError) as e:
103
+ return Answer(name, question, subject, "failed", [type(e).__name__])
104
+
105
+
106
+ def ask(
107
+ pol: dict[str, Any],
108
+ tool: str,
109
+ tool_input: dict[str, Any],
110
+ cwd: str | None = None,
111
+ only: list[tuple[str, str]] | None = None,
112
+ ) -> list[Answer]:
113
+ provs = pol.get("providers") or []
114
+ if not provs:
115
+ return []
116
+ repo = cwd or os.getcwd()
117
+ path = str(tool_input.get("file_path") or tool_input.get("notebook_path") or "")
118
+ if path and os.path.isabs(path):
119
+ rel = os.path.relpath(path, repo)
120
+ path = rel if not rel.startswith("..") else path
121
+ answers: list[Answer] = []
122
+ for q, subj in only if only is not None else questions_for(tool, {**tool_input, "file_path": path}):
123
+ for prov in provs:
124
+ if q in (prov.get("questions") or QUESTIONS):
125
+ answers.append(_run(prov, q, subj, repo, path, tool))
126
+ return answers
@@ -6,6 +6,7 @@ import json
6
6
  import os
7
7
  import shutil
8
8
  import subprocess
9
+ import sys
9
10
  import tempfile
10
11
 
11
12
  from .hooks import post_tool_use, pre_tool_use, session_start, stop
@@ -66,6 +67,24 @@ def run() -> int:
66
67
  "deny: destructive MCP tool",
67
68
  )
68
69
  )
70
+ prov = (
71
+ f'{sys.executable} -c "import sys,json;q=json.load(sys.stdin);'
72
+ "print(json.dumps({'answer':'yes' if q['question']=='route_gate' else 'no',"
73
+ "'evidence':['route not covered by authorization (selftest provider)']}))\""
74
+ )
75
+ os.makedirs(os.path.join(repo, ".gitvow"), exist_ok=True)
76
+ with open(os.path.join(repo, ".gitvow", "policy.json"), "w") as fh:
77
+ json.dump({"providers": [{"name": "selftest", "command": prov}]}, fh)
78
+ code, msg = pre_tool_use(
79
+ {
80
+ **base,
81
+ "tool_name": "Edit",
82
+ "tool_input": {"file_path": "api.py", "old_string": "", "new_string": '"/v1/new"'},
83
+ },
84
+ home,
85
+ )
86
+ results.append((code == 2 and "route not covered" in msg, "confirm: provider says new route is unauthorised"))
87
+ os.remove(os.path.join(repo, ".gitvow", "policy.json"))
69
88
  with open(os.path.join(repo, "a.txt"), "a") as fh:
70
89
  fh.write("b\n")
71
90
  post_tool_use({**base, "tool_name": "Edit", "tool_input": {"file_path": "a.txt"}}, home)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: gitvow
3
- Version: 0.3.0
3
+ Version: 0.4.0
4
4
  Summary: Provenance and policy gate for AI-agent coding sessions: session trailers on commits, redacted session notes in git, a tool-call gate, and a local ledger. No runtime dependencies.
5
5
  Author-email: Nikhil Bora <nikhil@wirevow.com>
6
6
  License: Apache-2.0
@@ -8,6 +8,7 @@ src/gitvow/collect.py
8
8
  src/gitvow/default_policy.json
9
9
  src/gitvow/install.py
10
10
  src/gitvow/policy.py
11
+ src/gitvow/providers.py
11
12
  src/gitvow/redact.py
12
13
  src/gitvow/report.py
13
14
  src/gitvow/selftest.py
@@ -25,5 +26,6 @@ tests/test_hooks.py
25
26
  tests/test_install_cli.py
26
27
  tests/test_packaging.py
27
28
  tests/test_policy.py
29
+ tests/test_providers.py
28
30
  tests/test_redact.py
29
31
  tests/test_report.py
@@ -0,0 +1,160 @@
1
+ import json
2
+ import sys
3
+ from pathlib import Path
4
+
5
+ from gitvow import cli
6
+ from gitvow.hooks import pre_tool_use
7
+ from gitvow.policy import evaluate
8
+ from gitvow.providers import questions_for, route_literals
9
+
10
+ ROOT = Path(__file__).resolve().parents[1]
11
+ EXAMPLE = ROOT / "examples" / "providers" / "static_facts.py"
12
+
13
+
14
+ def test_route_literals_and_questions():
15
+ txt = 'a = "/v1/orders"; b = \'/v1/orders/{id}\'; c = "/static/app.js"; d = "//comment"; e = "/"; f = "/v1/orders"'
16
+ assert route_literals(txt) == ["/v1/orders", "/v1/orders/{id}"]
17
+ qs = questions_for(
18
+ "Edit",
19
+ {"file_path": "api.py", "old_string": '@app.get("/old")', "new_string": '@app.get("/new")\n@app.get("/old2")'},
20
+ )
21
+ assert qs == [
22
+ ("gate_bearing", "api.py"),
23
+ ("route_gate", "/new"),
24
+ ("route_gate", "/old2"),
25
+ ("route_callers", "/old"),
26
+ ]
27
+ assert questions_for("Write", {"file_path": "x.py", "content": 'r("/w")'}) == [
28
+ ("gate_bearing", "x.py"),
29
+ ("route_gate", "/w"),
30
+ ]
31
+ assert questions_for("Bash", {"command": 'echo "/v1/x"'}) == []
32
+
33
+
34
+ def _facts(tmp_path):
35
+ f = tmp_path / "facts.json"
36
+ f.write_text(
37
+ json.dumps(
38
+ {
39
+ "gate_bearing": ["auth/AuthorizeWhitelistedPaths.java", "deploy/values/production-*.yaml"],
40
+ "whitelist_file": "auth/AuthorizeWhitelistedPaths.java",
41
+ "authorized_routes": ["/v1/orders", "/v1/orders/{id}"],
42
+ "callers": {"/v1/orders": ["client-orch (OrdersClient.java:88)"]},
43
+ }
44
+ )
45
+ )
46
+ return f
47
+
48
+
49
+ def _policy(tmp_path, command):
50
+ return {"providers": [{"name": "facts", "command": command}]}
51
+
52
+
53
+ def test_provider_confirms_with_evidence(tmp_path):
54
+ pol = _policy(tmp_path, f"{sys.executable} {EXAMPLE} --facts {_facts(tmp_path)}")
55
+ d = evaluate(
56
+ pol,
57
+ "Edit",
58
+ {"file_path": "src/api/Orders.java", "old_string": "x", "new_string": '@Path("/v1/orders/export")'},
59
+ str(tmp_path),
60
+ )
61
+ assert d.outcome == "confirm" and "not covered by any authorization rule" in d.reason
62
+ assert "AuthorizeWhitelistedPaths.java" in d.reason
63
+ d = evaluate(
64
+ pol,
65
+ "Edit",
66
+ {"file_path": "src/api/Orders.java", "old_string": "x", "new_string": '@Path("/v1/orders/{id}")'},
67
+ str(tmp_path),
68
+ )
69
+ assert d.outcome == "allow" # listed as authorized
70
+ d = evaluate(
71
+ pol,
72
+ "Edit",
73
+ {"file_path": "src/api/Orders.java", "old_string": '@Path("/v1/orders")', "new_string": ""},
74
+ str(tmp_path),
75
+ )
76
+ assert d.outcome == "confirm" and "client-orch" in d.reason
77
+ d = evaluate(
78
+ pol,
79
+ "Write",
80
+ {"file_path": str(tmp_path / "deploy/values/production-in.yaml"), "content": "a: 1"},
81
+ str(tmp_path),
82
+ )
83
+ assert d.outcome == "confirm" and "gate-bearing" in d.reason
84
+ assert (
85
+ evaluate(pol, "Edit", {"file_path": "README.md", "old_string": "a", "new_string": "b"}, str(tmp_path)).outcome
86
+ == "allow"
87
+ )
88
+ assert evaluate(pol, "Bash", {"command": 'echo "/v1/new"'}, str(tmp_path)).outcome == "allow"
89
+
90
+
91
+ def test_provider_failure_means_confirm(tmp_path):
92
+ for cmd in (
93
+ "/nonexistent/provider",
94
+ f"{sys.executable} -c \"print('not json')\"",
95
+ f"{sys.executable} -c 'import sys; sys.exit(3)'",
96
+ "",
97
+ ):
98
+ pol = _policy(tmp_path, cmd)
99
+ d = evaluate(pol, "Edit", {"file_path": "a.py", "old_string": "", "new_string": ""}, str(tmp_path))
100
+ assert d.outcome == "confirm" and "unavailable" in d.reason, cmd
101
+ pol = _policy(tmp_path, f"{sys.executable} -c 'import time; time.sleep(5)'")
102
+ pol["providers"][0]["timeout"] = 0.5
103
+ assert evaluate(pol, "Edit", {"file_path": "a.py"}, str(tmp_path)).outcome == "confirm"
104
+
105
+
106
+ def test_unknown_answer_does_not_block(tmp_path):
107
+ pol = _policy(tmp_path, f'{sys.executable} -c "print(\'{{\\"answer\\": \\"unknown\\"}}\')"')
108
+ assert evaluate(pol, "Edit", {"file_path": "a.py"}, str(tmp_path)).outcome == "allow"
109
+
110
+
111
+ def test_provider_questions_filter_and_validation(tmp_path):
112
+ from gitvow.policy import PolicyError, load_policy
113
+
114
+ (tmp_path / ".gitvow").mkdir()
115
+ (tmp_path / ".gitvow" / "policy.json").write_text(
116
+ json.dumps({"providers": [{"name": "x", "command": "y", "questions": ["nope"]}]})
117
+ )
118
+ try:
119
+ load_policy(str(tmp_path), str(tmp_path / "h"))
120
+ raise AssertionError("expected PolicyError")
121
+ except PolicyError as e:
122
+ assert "unknown questions" in str(e)
123
+ (tmp_path / ".gitvow" / "policy.json").write_text(json.dumps({"providers": [{"name": "x"}]}))
124
+ try:
125
+ load_policy(str(tmp_path), str(tmp_path / "h"))
126
+ raise AssertionError("expected PolicyError")
127
+ except PolicyError:
128
+ pass
129
+ pol = _policy(tmp_path, f"{sys.executable} {EXAMPLE} --facts {_facts(tmp_path)}")
130
+ pol["providers"][0]["questions"] = ["gate_bearing"] # route questions not asked of this provider
131
+ d = evaluate(pol, "Edit", {"file_path": "a.py", "old_string": "", "new_string": '"/v1/zzz"'}, str(tmp_path))
132
+ assert d.outcome == "allow"
133
+
134
+
135
+ def test_hook_and_cli_ask_end_to_end(repo, home, payload, tmp_path, monkeypatch, capsys):
136
+ facts = _facts(tmp_path)
137
+ (repo / ".gitvow").mkdir()
138
+ (repo / ".gitvow" / "policy.json").write_text(
139
+ json.dumps({"providers": [{"name": "facts", "command": f"{sys.executable} {EXAMPLE} --facts {facts}"}]})
140
+ )
141
+ code, msg = pre_tool_use(
142
+ payload(
143
+ "PreToolUse",
144
+ "Edit",
145
+ {"file_path": str(repo / "src/Orders.java"), "old_string": "", "new_string": '@Path("/v1/orders/export")'},
146
+ ),
147
+ str(home),
148
+ )
149
+ assert code == 2 and "CONFIRMATION REQUIRED" in msg and "AuthorizeWhitelistedPaths.java" in msg
150
+ log = (repo / ".git" / "gitvow-hooks.log").read_text()
151
+ assert '"kind": "confirm_required"' in log and "provider facts" in log
152
+ monkeypatch.chdir(repo)
153
+ assert cli.main(["ask", "route_gate", "/v1/orders/export", "--path", "src/Orders.java"]) == 2
154
+ out = capsys.readouterr().out
155
+ assert "facts: yes" in out and "decision: CONFIRM" in out
156
+ assert cli.main(["ask", "route_gate", "/v1/orders"]) == 0
157
+ assert "decision: ALLOW" in capsys.readouterr().out
158
+ assert cli.main(["ask", "gate_bearing", "auth/AuthorizeWhitelistedPaths.java"]) == 2
159
+ (repo / ".gitvow" / "policy.json").write_text("{}")
160
+ assert cli.main(["ask", "gate_bearing", "x"]) == 1
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes