gitvow 0.12.3__tar.gz → 0.12.4__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 (49) hide show
  1. {gitvow-0.12.3/src/gitvow.egg-info → gitvow-0.12.4}/PKG-INFO +1 -1
  2. {gitvow-0.12.3 → gitvow-0.12.4}/pyproject.toml +1 -1
  3. {gitvow-0.12.3 → gitvow-0.12.4}/src/gitvow/adapters.py +35 -3
  4. {gitvow-0.12.3 → gitvow-0.12.4}/src/gitvow/cli.py +11 -1
  5. {gitvow-0.12.3 → gitvow-0.12.4}/src/gitvow/decisions.py +2 -1
  6. {gitvow-0.12.3 → gitvow-0.12.4}/src/gitvow/hooks/__init__.py +3 -4
  7. {gitvow-0.12.3 → gitvow-0.12.4/src/gitvow.egg-info}/PKG-INFO +1 -1
  8. {gitvow-0.12.3 → gitvow-0.12.4}/tests/test_adapters.py +89 -0
  9. {gitvow-0.12.3 → gitvow-0.12.4}/LICENSE +0 -0
  10. {gitvow-0.12.3 → gitvow-0.12.4}/README.md +0 -0
  11. {gitvow-0.12.3 → gitvow-0.12.4}/setup.cfg +0 -0
  12. {gitvow-0.12.3 → gitvow-0.12.4}/src/gitvow/__init__.py +0 -0
  13. {gitvow-0.12.3 → gitvow-0.12.4}/src/gitvow/__main__.py +0 -0
  14. {gitvow-0.12.3 → gitvow-0.12.4}/src/gitvow/collect.py +0 -0
  15. {gitvow-0.12.3 → gitvow-0.12.4}/src/gitvow/default_policy.json +0 -0
  16. {gitvow-0.12.3 → gitvow-0.12.4}/src/gitvow/digest.py +0 -0
  17. {gitvow-0.12.3 → gitvow-0.12.4}/src/gitvow/install.py +0 -0
  18. {gitvow-0.12.3 → gitvow-0.12.4}/src/gitvow/policy.py +0 -0
  19. {gitvow-0.12.3 → gitvow-0.12.4}/src/gitvow/pricing.py +0 -0
  20. {gitvow-0.12.3 → gitvow-0.12.4}/src/gitvow/providers.py +0 -0
  21. {gitvow-0.12.3 → gitvow-0.12.4}/src/gitvow/recall.py +0 -0
  22. {gitvow-0.12.3 → gitvow-0.12.4}/src/gitvow/redact.py +0 -0
  23. {gitvow-0.12.3 → gitvow-0.12.4}/src/gitvow/report.py +0 -0
  24. {gitvow-0.12.3 → gitvow-0.12.4}/src/gitvow/rules.py +0 -0
  25. {gitvow-0.12.3 → gitvow-0.12.4}/src/gitvow/selftest.py +0 -0
  26. {gitvow-0.12.3 → gitvow-0.12.4}/src/gitvow/snapshots.py +0 -0
  27. {gitvow-0.12.3 → gitvow-0.12.4}/src/gitvow/state.py +0 -0
  28. {gitvow-0.12.3 → gitvow-0.12.4}/src/gitvow/transcript.py +0 -0
  29. {gitvow-0.12.3 → gitvow-0.12.4}/src/gitvow.egg-info/SOURCES.txt +0 -0
  30. {gitvow-0.12.3 → gitvow-0.12.4}/src/gitvow.egg-info/dependency_links.txt +0 -0
  31. {gitvow-0.12.3 → gitvow-0.12.4}/src/gitvow.egg-info/entry_points.txt +0 -0
  32. {gitvow-0.12.3 → gitvow-0.12.4}/src/gitvow.egg-info/requires.txt +0 -0
  33. {gitvow-0.12.3 → gitvow-0.12.4}/src/gitvow.egg-info/top_level.txt +0 -0
  34. {gitvow-0.12.3 → gitvow-0.12.4}/tests/test_collect.py +0 -0
  35. {gitvow-0.12.3 → gitvow-0.12.4}/tests/test_cost.py +0 -0
  36. {gitvow-0.12.3 → gitvow-0.12.4}/tests/test_decisions.py +0 -0
  37. {gitvow-0.12.3 → gitvow-0.12.4}/tests/test_digest.py +0 -0
  38. {gitvow-0.12.3 → gitvow-0.12.4}/tests/test_external_adapter.py +0 -0
  39. {gitvow-0.12.3 → gitvow-0.12.4}/tests/test_hooks.py +0 -0
  40. {gitvow-0.12.3 → gitvow-0.12.4}/tests/test_install_cli.py +0 -0
  41. {gitvow-0.12.3 → gitvow-0.12.4}/tests/test_packaging.py +0 -0
  42. {gitvow-0.12.3 → gitvow-0.12.4}/tests/test_policy.py +0 -0
  43. {gitvow-0.12.3 → gitvow-0.12.4}/tests/test_providers.py +0 -0
  44. {gitvow-0.12.3 → gitvow-0.12.4}/tests/test_recall.py +0 -0
  45. {gitvow-0.12.3 → gitvow-0.12.4}/tests/test_redact.py +0 -0
  46. {gitvow-0.12.3 → gitvow-0.12.4}/tests/test_report.py +0 -0
  47. {gitvow-0.12.3 → gitvow-0.12.4}/tests/test_rules.py +0 -0
  48. {gitvow-0.12.3 → gitvow-0.12.4}/tests/test_snapshots.py +0 -0
  49. {gitvow-0.12.3 → gitvow-0.12.4}/tests/test_transcript_formats.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: gitvow
3
- Version: 0.12.3
3
+ Version: 0.12.4
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.12.3"
7
+ version = "0.12.4"
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"}
@@ -10,6 +10,8 @@ import shutil
10
10
  import subprocess
11
11
  from typing import Any
12
12
 
13
+ from .decisions import CARD_HEADER
14
+
13
15
  AGENTS = ("claude", "codex", "gemini", "cursor", "copilot", "factory")
14
16
 
15
17
  # agent event -> gitvow handler
@@ -216,9 +218,15 @@ def normalize(agent: str, event: str, payload: dict[str, Any]) -> list[tuple[str
216
218
  )
217
219
  ]
218
220
  if event == "beforeMCPExecution":
219
- name = tool if tool.startswith("mcp__") else f"mcp__{payload.get('mcp_server_name', 'server')}__{tool}"
221
+ name = _cursor_mcp(tool, payload)
220
222
  return [(gv_event, {**base, "tool_name": name, "tool_input": inp})]
223
+ # generic preToolUse: Cursor fires this alongside the specific hook for shells and MCP calls,
224
+ # which would gate and log the same call twice; the specific hook carries the command, so it wins.
225
+ if tool in ("Shell", "shell", "run_terminal_cmd") and event == "preToolUse":
226
+ return []
221
227
  # generic preToolUse: Cursor's own tool names
228
+ if tool.startswith("MCP:") or tool.startswith("mcp__"):
229
+ return [(gv_event, {**base, "tool_name": _cursor_mcp(tool, payload), "tool_input": inp})]
222
230
  mapped = {
223
231
  "Shell": "Bash",
224
232
  "shell": "Bash",
@@ -227,23 +235,47 @@ def normalize(agent: str, event: str, payload: dict[str, Any]) -> list[tuple[str
227
235
  "write_file": "Write",
228
236
  "Edit": "Edit",
229
237
  "Write": "Write",
238
+ "Delete": "Edit", # deleting an authorization file is as consequential as editing one
230
239
  }.get(tool, tool)
231
240
  if mapped == "Bash" and "command" not in inp and payload.get("command"):
232
241
  inp["command"] = payload["command"]
242
+ if mapped in ("Write", "Edit") and "new_content" in inp:
243
+ # Cursor names the whole new file `new_content`; the gate and providers read `content`
244
+ inp.setdefault("content", inp["new_content"])
233
245
  return [(gv_event, {**base, "tool_name": mapped, "tool_input": inp})]
234
246
  return [(gv_event, {**base, "tool_name": tool, "tool_input": inp})]
235
247
 
236
248
 
249
+ def _cursor_mcp(tool: str, payload: dict[str, Any]) -> str:
250
+ """Cursor names MCP tools `MCP:<tool>` in preToolUse and passes the server separately."""
251
+ if tool.startswith("mcp__"):
252
+ return tool
253
+ name = tool[4:] if tool.startswith("MCP:") else tool
254
+ server = str(payload.get("mcp_server_name") or "server")
255
+ return f"mcp__{server}__{name}"
256
+
257
+
258
+ def _refuse(msg: str) -> bool:
259
+ """Agents with a native permission prompt must refuse, not ask, for these.
260
+
261
+ A denial is never negotiable, and the decision card is not a question about running the command:
262
+ it asks the agent to record the person's answers with `gitvow decide` and run the commit again.
263
+ Delivered as `ask`, a click on the agent's own approve button would run the commit with the
264
+ findings still open and no decision recorded anywhere.
265
+ """
266
+ return msg.startswith("BLOCKED") or msg.startswith(CARD_HEADER)
267
+
268
+
237
269
  def respond(agent: str, code: int, msg: str) -> tuple[int, str, str]:
238
270
  """(exit code, stdout, stderr) in the agent's form. gitvow's code 2 means blocked; the message says DENY or CONFIRM."""
239
271
  if agent == "cursor":
240
272
  if code == 2:
241
- perm = "deny" if msg.startswith("BLOCKED") else "ask"
273
+ perm = "deny" if _refuse(msg) else "ask"
242
274
  return 0, json.dumps({"permission": perm, "user_message": msg, "agent_message": msg}), ""
243
275
  return 0, json.dumps({"permission": "allow"}), msg
244
276
  if agent == "copilot":
245
277
  if code == 2:
246
- perm = "deny" if msg.startswith("BLOCKED") else "ask"
278
+ perm = "deny" if _refuse(msg) else "ask"
247
279
  return 0, json.dumps({"permissionDecision": perm, "permissionDecisionReason": msg}), ""
248
280
  return 0, json.dumps({"permissionDecision": "allow"}), msg
249
281
  return code, "", msg
@@ -44,7 +44,17 @@ def cmd_hook(a: argparse.Namespace) -> int:
44
44
  print(str(e), file=sys.stderr)
45
45
  return 1
46
46
  if not calls:
47
- return 0 # an event this adapter does not use
47
+ # An event this adapter does not use. Agents whose contract is a JSON permission object treat
48
+ # a silent hook as a failed hook, and gitvow installs its hooks fail-closed, so say "allow"
49
+ # explicitly rather than printing nothing.
50
+ try:
51
+ exit_code, out, err = external_respond(exe, 0, "") if exe else respond(agent, 0, "")
52
+ except AdapterError as e:
53
+ print(f"BLOCKED: agent adapter failed ({e}).", file=sys.stderr)
54
+ return 2
55
+ if out:
56
+ print(out)
57
+ return exit_code
48
58
  worst, messages = 0, []
49
59
  for gv_event, p in calls:
50
60
  handler = HANDLERS.get(gv_event)
@@ -15,6 +15,7 @@ from .state import git, load_state, save_state
15
15
 
16
16
  TRAILER_RE = re.compile(r"^Gitvow-(Accepted|Declined|Open):\s*(.+?)(?: by (\S+))?(?: scope=(\S+))?(?:: (.*))?$", re.M)
17
17
  REVISITS_RE = re.compile(r"^Gitvow-Revisits:\s*([0-9a-f]{7,40})\b", re.M)
18
+ CARD_HEADER = "DECISIONS REQUIRED"
18
19
  MAX_EVIDENCE = 8
19
20
  HISTORY_COMMITS = 3000
20
21
 
@@ -218,7 +219,7 @@ def card(
218
219
  lines = []
219
220
  if for_agent:
220
221
  lines += [
221
- f"DECISIONS REQUIRED before this commit: {len(pending)} finding{'s' if len(pending) != 1 else ''} from this session.",
222
+ f"{CARD_HEADER} before this commit: {len(pending)} finding{'s' if len(pending) != 1 else ''} from this session.",
222
223
  "Put this card to the user. Record each answer with",
223
224
  ' gitvow decide <n> accept|decline [--scope <env-or-branch>] [--reason "<phrase>"]',
224
225
  "then run the commit again.",
@@ -125,10 +125,9 @@ def pre_tool_use(h: dict[str, Any], home: str | None = None) -> tuple[int, str]:
125
125
  pending = dec.undecided(cwd)
126
126
  if pending:
127
127
  st = load_state(cwd)
128
- if rules is not None and st.get("card_user_turns") is None:
129
- st["card_user_turns"] = summarize(h.get("transcript_path") or st.get("transcript_path"), rules=rules)[
130
- "user_turns"
131
- ]
128
+ tp = h.get("transcript_path") or st.get("transcript_path")
129
+ if rules is not None and tp and st.get("card_user_turns") is None:
130
+ st["card_user_turns"] = summarize(tp, rules=rules)["user_turns"]
132
131
  st["card_shown_at"] = time.strftime("%Y-%m-%dT%H:%M:%S")
133
132
  save_state(cwd, st)
134
133
  proposed = dec.mark_proposals(cwd, pol)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: gitvow
3
- Version: 0.12.3
3
+ Version: 0.12.4
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
@@ -369,3 +369,92 @@ def test_copilot_factory_cli_and_install(repo, home, monkeypatch, capsys):
369
369
  assert (repo / ".github" / "hooks" / "gitvow.json").exists()
370
370
  uninstall_repo(str(repo), agent="copilot")
371
371
  assert not (repo / ".github" / "hooks" / "gitvow.json").exists()
372
+
373
+
374
+ def test_cursor_pretooluse_matches_the_documented_payload(repo, home):
375
+ """Cursor sends Write with new_content for file edits and MCP:<tool> for MCP calls."""
376
+ from gitvow.adapters import normalize
377
+
378
+ ev, p = normalize(
379
+ "cursor",
380
+ "preToolUse",
381
+ {
382
+ "conversation_id": "cu",
383
+ "workspace_roots": [str(repo)],
384
+ "tool_name": "Write",
385
+ "tool_input": {"file_path": "core/authz_rules.go", "new_content": 'var Public = []string{"/v1/x"}'},
386
+ },
387
+ )[0]
388
+ assert ev == "PreToolUse" and p["tool_name"] == "Write"
389
+ # the gate and the providers read `content`; Cursor's own key is preserved alongside it
390
+ assert p["tool_input"]["content"] == 'var Public = []string{"/v1/x"}'
391
+ assert p["tool_input"]["new_content"] == p["tool_input"]["content"]
392
+ ev, p = normalize(
393
+ "cursor",
394
+ "preToolUse",
395
+ {
396
+ "conversation_id": "cu",
397
+ "workspace_roots": [str(repo)],
398
+ "tool_name": "Delete",
399
+ "tool_input": {"file_path": "core/authz_rules.go"},
400
+ },
401
+ )[0]
402
+ assert p["tool_name"] == "Edit" # a deleted gate-bearing file still reaches the gate
403
+ for payload, expected in (
404
+ ({"tool_name": "MCP:drop_table", "mcp_server_name": "postgres"}, "mcp__postgres__drop_table"),
405
+ ({"tool_name": "MCP:read_rows"}, "mcp__server__read_rows"),
406
+ ({"tool_name": "mcp__already__mapped"}, "mcp__already__mapped"),
407
+ ):
408
+ ev, p = normalize("cursor", "preToolUse", {"conversation_id": "cu", "workspace_roots": [str(repo)], **payload})[
409
+ 0
410
+ ]
411
+ assert p["tool_name"] == expected
412
+
413
+
414
+ def test_the_card_is_refused_not_asked(repo, home):
415
+ """On agents with their own approve button, the card must not be clickable past."""
416
+ from gitvow.adapters import respond
417
+ from gitvow.decisions import CARD_HEADER
418
+
419
+ card = f"{CARD_HEADER} before this commit: 1 finding from this session."
420
+ for agent, key in (("cursor", "permission"), ("copilot", "permissionDecision")):
421
+ assert json.loads(respond(agent, 2, card)[1])[key] == "deny"
422
+ assert json.loads(respond(agent, 2, "BLOCKED by policy (force push).")[1])[key] == "deny"
423
+ # an immediate confirm rule is a real question for the person, so it stays their prompt
424
+ assert json.loads(respond(agent, 2, "CONFIRMATION REQUIRED (pushing to a remote).")[1])[key] == "ask"
425
+
426
+
427
+ def test_cursor_shell_is_gated_once(repo, home):
428
+ """Cursor fires preToolUse and beforeShellExecution for the same command."""
429
+ from gitvow.adapters import normalize
430
+
431
+ base = {"conversation_id": "cu", "workspace_roots": [str(repo)]}
432
+ assert (
433
+ normalize("cursor", "preToolUse", {**base, "tool_name": "Shell", "tool_input": {"command": "git commit -m x"}})
434
+ == []
435
+ )
436
+ calls = normalize("cursor", "beforeShellExecution", {**base, "command": "git commit -m x"})
437
+ assert len(calls) == 1 and calls[0][1]["tool_input"]["command"] == "git commit -m x"
438
+
439
+
440
+ def test_unused_events_still_answer_in_the_agents_form(repo, home, monkeypatch, capsys):
441
+ """gitvow's hooks are installed fail-closed; on Cursor and Copilot a silent hook is a failed hook."""
442
+ import io
443
+ import sys
444
+
445
+ from gitvow import cli
446
+
447
+ monkeypatch.chdir(repo)
448
+ payload = {
449
+ "conversation_id": "cu",
450
+ "workspace_roots": [str(repo)],
451
+ "tool_name": "Shell",
452
+ "tool_input": {"command": "git commit -m x"},
453
+ }
454
+ monkeypatch.setattr(sys, "stdin", io.StringIO(json.dumps(payload)))
455
+ assert cli.main(["hook", "--agent", "cursor", "preToolUse"]) == 0
456
+ assert json.loads(capsys.readouterr().out)["permission"] == "allow"
457
+ monkeypatch.setattr(sys, "stdin", io.StringIO(json.dumps({**payload, "hook_event_name": "beforeReadFile"})))
458
+ assert cli.main(["hook", "--agent", "copilot", "sessionStart"]) == 0
459
+ out = capsys.readouterr().out
460
+ assert not out or json.loads(out).get("permissionDecision") in (None, "allow")
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
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