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.
- {gitvow-0.12.3/src/gitvow.egg-info → gitvow-0.12.4}/PKG-INFO +1 -1
- {gitvow-0.12.3 → gitvow-0.12.4}/pyproject.toml +1 -1
- {gitvow-0.12.3 → gitvow-0.12.4}/src/gitvow/adapters.py +35 -3
- {gitvow-0.12.3 → gitvow-0.12.4}/src/gitvow/cli.py +11 -1
- {gitvow-0.12.3 → gitvow-0.12.4}/src/gitvow/decisions.py +2 -1
- {gitvow-0.12.3 → gitvow-0.12.4}/src/gitvow/hooks/__init__.py +3 -4
- {gitvow-0.12.3 → gitvow-0.12.4/src/gitvow.egg-info}/PKG-INFO +1 -1
- {gitvow-0.12.3 → gitvow-0.12.4}/tests/test_adapters.py +89 -0
- {gitvow-0.12.3 → gitvow-0.12.4}/LICENSE +0 -0
- {gitvow-0.12.3 → gitvow-0.12.4}/README.md +0 -0
- {gitvow-0.12.3 → gitvow-0.12.4}/setup.cfg +0 -0
- {gitvow-0.12.3 → gitvow-0.12.4}/src/gitvow/__init__.py +0 -0
- {gitvow-0.12.3 → gitvow-0.12.4}/src/gitvow/__main__.py +0 -0
- {gitvow-0.12.3 → gitvow-0.12.4}/src/gitvow/collect.py +0 -0
- {gitvow-0.12.3 → gitvow-0.12.4}/src/gitvow/default_policy.json +0 -0
- {gitvow-0.12.3 → gitvow-0.12.4}/src/gitvow/digest.py +0 -0
- {gitvow-0.12.3 → gitvow-0.12.4}/src/gitvow/install.py +0 -0
- {gitvow-0.12.3 → gitvow-0.12.4}/src/gitvow/policy.py +0 -0
- {gitvow-0.12.3 → gitvow-0.12.4}/src/gitvow/pricing.py +0 -0
- {gitvow-0.12.3 → gitvow-0.12.4}/src/gitvow/providers.py +0 -0
- {gitvow-0.12.3 → gitvow-0.12.4}/src/gitvow/recall.py +0 -0
- {gitvow-0.12.3 → gitvow-0.12.4}/src/gitvow/redact.py +0 -0
- {gitvow-0.12.3 → gitvow-0.12.4}/src/gitvow/report.py +0 -0
- {gitvow-0.12.3 → gitvow-0.12.4}/src/gitvow/rules.py +0 -0
- {gitvow-0.12.3 → gitvow-0.12.4}/src/gitvow/selftest.py +0 -0
- {gitvow-0.12.3 → gitvow-0.12.4}/src/gitvow/snapshots.py +0 -0
- {gitvow-0.12.3 → gitvow-0.12.4}/src/gitvow/state.py +0 -0
- {gitvow-0.12.3 → gitvow-0.12.4}/src/gitvow/transcript.py +0 -0
- {gitvow-0.12.3 → gitvow-0.12.4}/src/gitvow.egg-info/SOURCES.txt +0 -0
- {gitvow-0.12.3 → gitvow-0.12.4}/src/gitvow.egg-info/dependency_links.txt +0 -0
- {gitvow-0.12.3 → gitvow-0.12.4}/src/gitvow.egg-info/entry_points.txt +0 -0
- {gitvow-0.12.3 → gitvow-0.12.4}/src/gitvow.egg-info/requires.txt +0 -0
- {gitvow-0.12.3 → gitvow-0.12.4}/src/gitvow.egg-info/top_level.txt +0 -0
- {gitvow-0.12.3 → gitvow-0.12.4}/tests/test_collect.py +0 -0
- {gitvow-0.12.3 → gitvow-0.12.4}/tests/test_cost.py +0 -0
- {gitvow-0.12.3 → gitvow-0.12.4}/tests/test_decisions.py +0 -0
- {gitvow-0.12.3 → gitvow-0.12.4}/tests/test_digest.py +0 -0
- {gitvow-0.12.3 → gitvow-0.12.4}/tests/test_external_adapter.py +0 -0
- {gitvow-0.12.3 → gitvow-0.12.4}/tests/test_hooks.py +0 -0
- {gitvow-0.12.3 → gitvow-0.12.4}/tests/test_install_cli.py +0 -0
- {gitvow-0.12.3 → gitvow-0.12.4}/tests/test_packaging.py +0 -0
- {gitvow-0.12.3 → gitvow-0.12.4}/tests/test_policy.py +0 -0
- {gitvow-0.12.3 → gitvow-0.12.4}/tests/test_providers.py +0 -0
- {gitvow-0.12.3 → gitvow-0.12.4}/tests/test_recall.py +0 -0
- {gitvow-0.12.3 → gitvow-0.12.4}/tests/test_redact.py +0 -0
- {gitvow-0.12.3 → gitvow-0.12.4}/tests/test_report.py +0 -0
- {gitvow-0.12.3 → gitvow-0.12.4}/tests/test_rules.py +0 -0
- {gitvow-0.12.3 → gitvow-0.12.4}/tests/test_snapshots.py +0 -0
- {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
|
+
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.
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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"
|
|
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
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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
|
+
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
|
|
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
|