gitvow 0.5.0__tar.gz → 0.6.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-0.5.0/src/gitvow.egg-info → gitvow-0.6.0}/PKG-INFO +1 -1
- {gitvow-0.5.0 → gitvow-0.6.0}/pyproject.toml +1 -1
- gitvow-0.6.0/src/gitvow/adapters.py +155 -0
- {gitvow-0.5.0 → gitvow-0.6.0}/src/gitvow/cli.py +43 -12
- {gitvow-0.5.0 → gitvow-0.6.0}/src/gitvow/install.py +147 -11
- {gitvow-0.5.0 → gitvow-0.6.0}/src/gitvow/selftest.py +107 -0
- {gitvow-0.5.0 → gitvow-0.6.0}/src/gitvow/snapshots.py +14 -4
- {gitvow-0.5.0 → gitvow-0.6.0/src/gitvow.egg-info}/PKG-INFO +1 -1
- {gitvow-0.5.0 → gitvow-0.6.0}/src/gitvow.egg-info/SOURCES.txt +2 -0
- gitvow-0.6.0/tests/test_adapters.py +238 -0
- {gitvow-0.5.0 → gitvow-0.6.0}/tests/test_snapshots.py +5 -2
- {gitvow-0.5.0 → gitvow-0.6.0}/LICENSE +0 -0
- {gitvow-0.5.0 → gitvow-0.6.0}/README.md +0 -0
- {gitvow-0.5.0 → gitvow-0.6.0}/setup.cfg +0 -0
- {gitvow-0.5.0 → gitvow-0.6.0}/src/gitvow/__init__.py +0 -0
- {gitvow-0.5.0 → gitvow-0.6.0}/src/gitvow/__main__.py +0 -0
- {gitvow-0.5.0 → gitvow-0.6.0}/src/gitvow/collect.py +0 -0
- {gitvow-0.5.0 → gitvow-0.6.0}/src/gitvow/default_policy.json +0 -0
- {gitvow-0.5.0 → gitvow-0.6.0}/src/gitvow/hooks/__init__.py +0 -0
- {gitvow-0.5.0 → gitvow-0.6.0}/src/gitvow/policy.py +0 -0
- {gitvow-0.5.0 → gitvow-0.6.0}/src/gitvow/providers.py +0 -0
- {gitvow-0.5.0 → gitvow-0.6.0}/src/gitvow/redact.py +0 -0
- {gitvow-0.5.0 → gitvow-0.6.0}/src/gitvow/report.py +0 -0
- {gitvow-0.5.0 → gitvow-0.6.0}/src/gitvow/state.py +0 -0
- {gitvow-0.5.0 → gitvow-0.6.0}/src/gitvow/transcript.py +0 -0
- {gitvow-0.5.0 → gitvow-0.6.0}/src/gitvow.egg-info/dependency_links.txt +0 -0
- {gitvow-0.5.0 → gitvow-0.6.0}/src/gitvow.egg-info/entry_points.txt +0 -0
- {gitvow-0.5.0 → gitvow-0.6.0}/src/gitvow.egg-info/requires.txt +0 -0
- {gitvow-0.5.0 → gitvow-0.6.0}/src/gitvow.egg-info/top_level.txt +0 -0
- {gitvow-0.5.0 → gitvow-0.6.0}/tests/test_collect.py +0 -0
- {gitvow-0.5.0 → gitvow-0.6.0}/tests/test_hooks.py +0 -0
- {gitvow-0.5.0 → gitvow-0.6.0}/tests/test_install_cli.py +0 -0
- {gitvow-0.5.0 → gitvow-0.6.0}/tests/test_packaging.py +0 -0
- {gitvow-0.5.0 → gitvow-0.6.0}/tests/test_policy.py +0 -0
- {gitvow-0.5.0 → gitvow-0.6.0}/tests/test_providers.py +0 -0
- {gitvow-0.5.0 → gitvow-0.6.0}/tests/test_redact.py +0 -0
- {gitvow-0.5.0 → gitvow-0.6.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
|
+
Version: 0.6.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.
|
|
7
|
+
version = "0.6.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"}
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
"""Adapters: translate other agents' hook payloads into the Claude Code shape gitvow's handlers understand,
|
|
2
|
+
and render decisions in each agent's native form. See docs/reference/adapters.md."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import json
|
|
7
|
+
import re
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
AGENTS = ("claude", "codex", "gemini", "cursor")
|
|
11
|
+
|
|
12
|
+
# agent event -> gitvow handler
|
|
13
|
+
EVENT_MAP: dict[str, dict[str, str]] = {
|
|
14
|
+
"claude": {
|
|
15
|
+
"SessionStart": "SessionStart",
|
|
16
|
+
"PreToolUse": "PreToolUse",
|
|
17
|
+
"PostToolUse": "PostToolUse",
|
|
18
|
+
"Stop": "Stop",
|
|
19
|
+
},
|
|
20
|
+
"codex": {"SessionStart": "SessionStart", "PreToolUse": "PreToolUse", "PostToolUse": "PostToolUse", "Stop": "Stop"},
|
|
21
|
+
"gemini": {
|
|
22
|
+
"SessionStart": "SessionStart",
|
|
23
|
+
"BeforeTool": "PreToolUse",
|
|
24
|
+
"AfterTool": "PostToolUse",
|
|
25
|
+
"SessionEnd": "Stop",
|
|
26
|
+
},
|
|
27
|
+
"cursor": {
|
|
28
|
+
"sessionStart": "SessionStart",
|
|
29
|
+
"preToolUse": "PreToolUse",
|
|
30
|
+
"beforeShellExecution": "PreToolUse",
|
|
31
|
+
"beforeMCPExecution": "PreToolUse",
|
|
32
|
+
"afterFileEdit": "PostToolUse",
|
|
33
|
+
"afterShellExecution": "PostToolUse",
|
|
34
|
+
"stop": "Stop",
|
|
35
|
+
},
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
GEMINI_TOOLS = {"run_shell_command": "Bash", "write_file": "Write", "replace": "Edit", "edit": "Edit"}
|
|
39
|
+
PATCH_FILE_RE = re.compile(r"^\*\*\* (Update|Add|Delete) File: (.+)$")
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def parse_apply_patch(text: str) -> list[dict[str, str]]:
|
|
43
|
+
"""Codex apply_patch text -> [{file_path, old_string, new_string, op}] per touched file."""
|
|
44
|
+
files: list[dict[str, str]] = []
|
|
45
|
+
cur: dict[str, Any] | None = None
|
|
46
|
+
for line in (text or "").splitlines():
|
|
47
|
+
m = PATCH_FILE_RE.match(line.strip())
|
|
48
|
+
if m:
|
|
49
|
+
cur = {"op": m.group(1).lower(), "file_path": m.group(2).strip(), "old": [], "new": []}
|
|
50
|
+
files.append(cur)
|
|
51
|
+
continue
|
|
52
|
+
if cur is None or line.startswith("*** "):
|
|
53
|
+
continue
|
|
54
|
+
if line.startswith("+"):
|
|
55
|
+
cur["new"].append(line[1:])
|
|
56
|
+
elif line.startswith("-"):
|
|
57
|
+
cur["old"].append(line[1:])
|
|
58
|
+
return [
|
|
59
|
+
{
|
|
60
|
+
"file_path": f["file_path"],
|
|
61
|
+
"op": f["op"],
|
|
62
|
+
"old_string": "\n".join(f["old"]),
|
|
63
|
+
"new_string": "\n".join(f["new"]),
|
|
64
|
+
}
|
|
65
|
+
for f in files
|
|
66
|
+
]
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def normalize(agent: str, event: str, payload: dict[str, Any]) -> list[tuple[str, dict[str, Any]]]:
|
|
70
|
+
"""Return one or more (gitvow_event, claude_shaped_payload). Codex patches expand to one Edit per file."""
|
|
71
|
+
if agent not in AGENTS:
|
|
72
|
+
raise ValueError(f"unknown agent {agent}")
|
|
73
|
+
gv_event = EVENT_MAP[agent].get(event)
|
|
74
|
+
if not gv_event:
|
|
75
|
+
return []
|
|
76
|
+
base = {
|
|
77
|
+
"session_id": payload.get("session_id") or payload.get("conversation_id") or "",
|
|
78
|
+
"transcript_path": payload.get("transcript_path") or "",
|
|
79
|
+
"cwd": payload.get("cwd") or (payload.get("workspace_roots") or [None])[0] or "",
|
|
80
|
+
"hook_event_name": gv_event,
|
|
81
|
+
"agent": agent,
|
|
82
|
+
}
|
|
83
|
+
if gv_event in ("SessionStart", "Stop"):
|
|
84
|
+
return [(gv_event, base)]
|
|
85
|
+
tool = str(payload.get("tool_name") or "")
|
|
86
|
+
inp = dict(payload.get("tool_input") or {})
|
|
87
|
+
if agent == "codex":
|
|
88
|
+
if tool == "apply_patch":
|
|
89
|
+
out = []
|
|
90
|
+
for f in parse_apply_patch(str(inp.get("command") or inp.get("patch") or "")):
|
|
91
|
+
out.append(
|
|
92
|
+
(
|
|
93
|
+
gv_event,
|
|
94
|
+
{
|
|
95
|
+
**base,
|
|
96
|
+
"tool_name": "Edit",
|
|
97
|
+
"tool_input": {
|
|
98
|
+
"file_path": f["file_path"],
|
|
99
|
+
"old_string": f["old_string"],
|
|
100
|
+
"new_string": f["new_string"],
|
|
101
|
+
"apply_patch_op": f["op"],
|
|
102
|
+
},
|
|
103
|
+
},
|
|
104
|
+
)
|
|
105
|
+
)
|
|
106
|
+
return out or [(gv_event, {**base, "tool_name": "Edit", "tool_input": {}})]
|
|
107
|
+
return [(gv_event, {**base, "tool_name": tool, "tool_input": inp})]
|
|
108
|
+
if agent == "gemini":
|
|
109
|
+
return [(gv_event, {**base, "tool_name": GEMINI_TOOLS.get(tool, tool), "tool_input": inp})]
|
|
110
|
+
if agent == "cursor":
|
|
111
|
+
if event == "beforeShellExecution" or event == "afterShellExecution":
|
|
112
|
+
return [(gv_event, {**base, "tool_name": "Bash", "tool_input": {"command": payload.get("command", "")}})]
|
|
113
|
+
if event == "afterFileEdit":
|
|
114
|
+
edits = payload.get("edits") or []
|
|
115
|
+
return [
|
|
116
|
+
(
|
|
117
|
+
gv_event,
|
|
118
|
+
{
|
|
119
|
+
**base,
|
|
120
|
+
"tool_name": "Edit",
|
|
121
|
+
"tool_input": {
|
|
122
|
+
"file_path": payload.get("file_path", ""),
|
|
123
|
+
"old_string": "\n".join(str(e.get("old_string", "")) for e in edits),
|
|
124
|
+
"new_string": "\n".join(str(e.get("new_string", "")) for e in edits),
|
|
125
|
+
},
|
|
126
|
+
},
|
|
127
|
+
)
|
|
128
|
+
]
|
|
129
|
+
if event == "beforeMCPExecution":
|
|
130
|
+
name = tool if tool.startswith("mcp__") else f"mcp__{payload.get('mcp_server_name', 'server')}__{tool}"
|
|
131
|
+
return [(gv_event, {**base, "tool_name": name, "tool_input": inp})]
|
|
132
|
+
# generic preToolUse: Cursor's own tool names
|
|
133
|
+
mapped = {
|
|
134
|
+
"Shell": "Bash",
|
|
135
|
+
"shell": "Bash",
|
|
136
|
+
"run_terminal_cmd": "Bash",
|
|
137
|
+
"edit_file": "Edit",
|
|
138
|
+
"write_file": "Write",
|
|
139
|
+
"Edit": "Edit",
|
|
140
|
+
"Write": "Write",
|
|
141
|
+
}.get(tool, tool)
|
|
142
|
+
if mapped == "Bash" and "command" not in inp and payload.get("command"):
|
|
143
|
+
inp["command"] = payload["command"]
|
|
144
|
+
return [(gv_event, {**base, "tool_name": mapped, "tool_input": inp})]
|
|
145
|
+
return [(gv_event, {**base, "tool_name": tool, "tool_input": inp})]
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def respond(agent: str, code: int, msg: str) -> tuple[int, str, str]:
|
|
149
|
+
"""(exit code, stdout, stderr) in the agent's form. gitvow's code 2 means blocked; the message says DENY or CONFIRM."""
|
|
150
|
+
if agent == "cursor":
|
|
151
|
+
if code == 2:
|
|
152
|
+
perm = "deny" if msg.startswith("BLOCKED") else "ask"
|
|
153
|
+
return 0, json.dumps({"permission": perm, "user_message": msg, "agent_message": msg}), ""
|
|
154
|
+
return 0, json.dumps({"permission": "allow"}), msg
|
|
155
|
+
return code, "", msg
|
|
@@ -21,31 +21,56 @@ from .state import git
|
|
|
21
21
|
|
|
22
22
|
|
|
23
23
|
def cmd_hook(a: argparse.Namespace) -> int:
|
|
24
|
+
from .adapters import normalize, respond
|
|
25
|
+
|
|
24
26
|
try:
|
|
25
27
|
payload = json.load(sys.stdin)
|
|
26
28
|
except json.JSONDecodeError:
|
|
27
29
|
payload = {}
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
30
|
+
agent = a.agent or "claude"
|
|
31
|
+
try:
|
|
32
|
+
calls = normalize(agent, a.event, payload)
|
|
33
|
+
except ValueError as e:
|
|
34
|
+
print(str(e), file=sys.stderr)
|
|
31
35
|
return 1
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
+
if not calls:
|
|
37
|
+
return 0 # an event this adapter does not use
|
|
38
|
+
worst, messages = 0, []
|
|
39
|
+
for gv_event, p in calls:
|
|
40
|
+
handler = HANDLERS.get(gv_event)
|
|
41
|
+
if not handler:
|
|
42
|
+
print(f"unknown hook event {gv_event}", file=sys.stderr)
|
|
43
|
+
return 1
|
|
44
|
+
code, msg = handler(p)
|
|
45
|
+
if code == 2 and len(calls) > 1 and p.get("tool_input", {}).get("file_path"):
|
|
46
|
+
msg = f"{msg} [file: {p['tool_input']['file_path']}]"
|
|
47
|
+
worst = max(worst, code)
|
|
48
|
+
if msg:
|
|
49
|
+
messages.append(msg)
|
|
50
|
+
exit_code, out, err = respond(agent, worst, "\n".join(messages))
|
|
51
|
+
if out:
|
|
52
|
+
print(out)
|
|
53
|
+
if err:
|
|
54
|
+
print(err, file=sys.stderr)
|
|
55
|
+
return exit_code
|
|
36
56
|
|
|
37
57
|
|
|
38
58
|
def cmd_install(a: argparse.Namespace) -> int:
|
|
39
|
-
|
|
59
|
+
agent = a.agent or "claude"
|
|
60
|
+
if a.user:
|
|
61
|
+
done = install_user(os.path.expanduser("~"), agent=agent)
|
|
62
|
+
else:
|
|
63
|
+
done = install_repo(os.path.abspath(a.repo), agent=agent)
|
|
40
64
|
print("\n".join(done))
|
|
41
65
|
return 0
|
|
42
66
|
|
|
43
67
|
|
|
44
68
|
def cmd_uninstall(a: argparse.Namespace) -> int:
|
|
69
|
+
agent = a.agent or "claude"
|
|
45
70
|
done = (
|
|
46
|
-
uninstall_user(os.path.expanduser("~"), a.purge_policy, a.purge_ledger)
|
|
71
|
+
uninstall_user(os.path.expanduser("~"), a.purge_policy, a.purge_ledger, agent=agent)
|
|
47
72
|
if a.user
|
|
48
|
-
else uninstall_repo(os.path.abspath(a.repo), a.purge_notes, a.purge_snapshots)
|
|
73
|
+
else uninstall_repo(os.path.abspath(a.repo), a.purge_notes, a.purge_snapshots, agent=agent)
|
|
49
74
|
)
|
|
50
75
|
print("\n".join(done))
|
|
51
76
|
return 0
|
|
@@ -210,8 +235,10 @@ def cmd_summarize(a: argparse.Namespace) -> int:
|
|
|
210
235
|
|
|
211
236
|
|
|
212
237
|
def cmd_selftest(a: argparse.Namespace) -> int:
|
|
213
|
-
from .selftest import run
|
|
238
|
+
from .selftest import run, run_agent
|
|
214
239
|
|
|
240
|
+
if a.agent and a.agent != "claude":
|
|
241
|
+
return run_agent(a.agent)
|
|
215
242
|
return run()
|
|
216
243
|
|
|
217
244
|
|
|
@@ -219,12 +246,14 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
219
246
|
p = argparse.ArgumentParser(prog="gitvow", description="Provenance and policy gate for agent coding sessions.")
|
|
220
247
|
p.add_argument("--version", action="version", version=f"gitvow {__version__}")
|
|
221
248
|
sub = p.add_subparsers(dest="cmd", required=True)
|
|
222
|
-
s = sub.add_parser("hook", help="run as
|
|
249
|
+
s = sub.add_parser("hook", help="run as an agent hook (reads the agent's JSON on stdin)")
|
|
250
|
+
s.add_argument("--agent", choices=["claude", "codex", "gemini", "cursor"], default="claude")
|
|
223
251
|
s.add_argument("event")
|
|
224
252
|
s.set_defaults(f=cmd_hook)
|
|
225
253
|
s = sub.add_parser("install", help="install per user (--user) or into a repo")
|
|
226
254
|
s.add_argument("repo", nargs="?", default=".")
|
|
227
255
|
s.add_argument("--user", action="store_true")
|
|
256
|
+
s.add_argument("--agent", choices=["claude", "codex", "gemini", "cursor"], default="claude")
|
|
228
257
|
s.set_defaults(f=cmd_install)
|
|
229
258
|
s = sub.add_parser("uninstall", help="remove what install added")
|
|
230
259
|
s.add_argument("repo", nargs="?", default=".")
|
|
@@ -233,6 +262,7 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
233
262
|
s.add_argument("--purge-policy", action="store_true")
|
|
234
263
|
s.add_argument("--purge-ledger", action="store_true")
|
|
235
264
|
s.add_argument("--purge-snapshots", action="store_true")
|
|
265
|
+
s.add_argument("--agent", choices=["claude", "codex", "gemini", "cursor"], default="claude")
|
|
236
266
|
s.set_defaults(f=cmd_uninstall)
|
|
237
267
|
s = sub.add_parser("check", help="dry-run the policy: gitvow check -- git push --force")
|
|
238
268
|
s.add_argument("command", nargs="*")
|
|
@@ -284,6 +314,7 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
284
314
|
s.add_argument("dir")
|
|
285
315
|
s.set_defaults(f=cmd_summarize)
|
|
286
316
|
s = sub.add_parser("selftest", help="prove the hooks work here without touching a real repo")
|
|
317
|
+
s.add_argument("--agent", choices=["claude", "codex", "gemini", "cursor"], default="claude")
|
|
287
318
|
s.set_defaults(f=cmd_selftest)
|
|
288
319
|
a = p.parse_args(argv)
|
|
289
320
|
return a.f(a)
|
|
@@ -4,6 +4,7 @@ from __future__ import annotations
|
|
|
4
4
|
|
|
5
5
|
import json
|
|
6
6
|
import os
|
|
7
|
+
import re
|
|
7
8
|
import shutil
|
|
8
9
|
import sys
|
|
9
10
|
from typing import Any
|
|
@@ -82,6 +83,120 @@ def _hook_entries(cmd_prefix: str) -> dict[str, list[dict[str, Any]]]:
|
|
|
82
83
|
}
|
|
83
84
|
|
|
84
85
|
|
|
86
|
+
AGENT_FILES = {
|
|
87
|
+
"claude": (".claude/settings.json", ".claude/settings.json"),
|
|
88
|
+
"codex": (".codex/hooks.json", ".codex/hooks.json"),
|
|
89
|
+
"gemini": (".gemini/settings.json", ".gemini/settings.json"),
|
|
90
|
+
"cursor": (".cursor/hooks.json", ".cursor/hooks.json"),
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _agent_entries(agent: str, cmd_prefix: str) -> dict[str, list[dict[str, Any]]]:
|
|
95
|
+
"""Hook entries in each agent's own schema. Every command carries MARKER so install stays idempotent."""
|
|
96
|
+
c = f"{cmd_prefix} hook --agent {agent}"
|
|
97
|
+
if agent == "claude":
|
|
98
|
+
return _hook_entries(cmd_prefix)
|
|
99
|
+
if agent == "codex":
|
|
100
|
+
|
|
101
|
+
def e(ev: str, matcher: str | None) -> dict[str, Any]:
|
|
102
|
+
x: dict[str, Any] = {"hooks": [{"type": "command", "command": f"{c} {ev}", "timeout": 30}]}
|
|
103
|
+
if matcher:
|
|
104
|
+
x["matcher"] = matcher
|
|
105
|
+
return x
|
|
106
|
+
|
|
107
|
+
return {
|
|
108
|
+
"SessionStart": [e("SessionStart", None)],
|
|
109
|
+
"PreToolUse": [e("PreToolUse", "Bash|apply_patch|mcp__.*")],
|
|
110
|
+
"PostToolUse": [e("PostToolUse", "Bash|apply_patch")],
|
|
111
|
+
"Stop": [e("Stop", None)],
|
|
112
|
+
}
|
|
113
|
+
if agent == "gemini":
|
|
114
|
+
|
|
115
|
+
def g(ev: str, matcher: str) -> dict[str, Any]:
|
|
116
|
+
return {
|
|
117
|
+
"matcher": matcher,
|
|
118
|
+
"hooks": [{"name": f"gitvow-{ev}", "type": "command", "command": f"{c} {ev}", "timeout": 30000}],
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
return {
|
|
122
|
+
"SessionStart": [g("SessionStart", "*")],
|
|
123
|
+
"BeforeTool": [g("BeforeTool", "run_shell_command|write_file|replace|edit|mcp__.*")],
|
|
124
|
+
"AfterTool": [g("AfterTool", "run_shell_command|write_file|replace|edit")],
|
|
125
|
+
"SessionEnd": [g("SessionEnd", "*")],
|
|
126
|
+
}
|
|
127
|
+
if agent == "cursor":
|
|
128
|
+
|
|
129
|
+
def u(ev: str) -> dict[str, Any]:
|
|
130
|
+
return {"command": f"{c} {ev}", "type": "command", "timeout": 30, "failClosed": True}
|
|
131
|
+
|
|
132
|
+
return {
|
|
133
|
+
ev: [u(ev)]
|
|
134
|
+
for ev in (
|
|
135
|
+
"sessionStart",
|
|
136
|
+
"preToolUse",
|
|
137
|
+
"beforeShellExecution",
|
|
138
|
+
"beforeMCPExecution",
|
|
139
|
+
"afterFileEdit",
|
|
140
|
+
"afterShellExecution",
|
|
141
|
+
"stop",
|
|
142
|
+
)
|
|
143
|
+
}
|
|
144
|
+
raise ValueError(f"unknown agent {agent}")
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _is_ours(entry: dict[str, Any]) -> bool:
|
|
148
|
+
if MARKER in (entry.get("command") or ""):
|
|
149
|
+
return True
|
|
150
|
+
return any(MARKER in (h.get("command") or "") for h in entry.get("hooks", []))
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def merge_agent_settings(path: str, agent: str, cmd_prefix: str) -> None:
|
|
154
|
+
cur: dict[str, Any] = {}
|
|
155
|
+
if os.path.exists(path):
|
|
156
|
+
with open(path) as fh:
|
|
157
|
+
cur = json.load(fh)
|
|
158
|
+
if agent == "cursor":
|
|
159
|
+
cur.setdefault("version", 1)
|
|
160
|
+
hooks = cur.setdefault("hooks", {})
|
|
161
|
+
for ev, entries in _agent_entries(agent, cmd_prefix).items():
|
|
162
|
+
kept = [x for x in hooks.get(ev, []) if not _is_ours(x)]
|
|
163
|
+
hooks[ev] = kept + entries
|
|
164
|
+
os.makedirs(os.path.dirname(path) or ".", exist_ok=True)
|
|
165
|
+
with open(path, "w") as fh:
|
|
166
|
+
json.dump(cur, fh, indent=2)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def unmerge_agent_settings(path: str) -> None:
|
|
170
|
+
if not os.path.exists(path):
|
|
171
|
+
return
|
|
172
|
+
with open(path) as fh:
|
|
173
|
+
cur = json.load(fh)
|
|
174
|
+
hooks = cur.get("hooks", {})
|
|
175
|
+
for ev in list(hooks):
|
|
176
|
+
hooks[ev] = [x for x in hooks[ev] if not _is_ours(x)]
|
|
177
|
+
if not hooks[ev]:
|
|
178
|
+
del hooks[ev]
|
|
179
|
+
if not hooks:
|
|
180
|
+
cur.pop("hooks", None)
|
|
181
|
+
if cur and cur != {"version": 1}:
|
|
182
|
+
with open(path, "w") as fh:
|
|
183
|
+
json.dump(cur, fh, indent=2)
|
|
184
|
+
else:
|
|
185
|
+
os.remove(path)
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def codex_hooks_enabled_hint(home: str) -> str | None:
|
|
189
|
+
"""Codex hooks are opt-in on some versions; point at the config line if it is absent."""
|
|
190
|
+
cfg = os.path.join(home, ".codex", "config.toml")
|
|
191
|
+
txt = ""
|
|
192
|
+
if os.path.exists(cfg):
|
|
193
|
+
with open(cfg) as fh:
|
|
194
|
+
txt = fh.read()
|
|
195
|
+
if re.search(r"^\s*hooks\s*=\s*true", txt, re.M) or re.search(r"^\s*codex_hooks\s*=\s*true", txt, re.M):
|
|
196
|
+
return None
|
|
197
|
+
return f"Codex hooks may need enabling: add `hooks = true` under [features] in {cfg}"
|
|
198
|
+
|
|
199
|
+
|
|
85
200
|
def merge_settings(path: str, cmd_prefix: str) -> None:
|
|
86
201
|
cur: dict[str, Any] = {}
|
|
87
202
|
if os.path.exists(path):
|
|
@@ -125,7 +240,7 @@ def _write_git_hook(dirpath: str) -> str:
|
|
|
125
240
|
return os.path.join(dirpath, "prepare-commit-msg")
|
|
126
241
|
|
|
127
242
|
|
|
128
|
-
def install_user(home: str, cmd_prefix: str | None = None) -> list[str]:
|
|
243
|
+
def install_user(home: str, cmd_prefix: str | None = None, agent: str = "claude") -> list[str]:
|
|
129
244
|
base = os.path.join(home, ".gitvow")
|
|
130
245
|
os.makedirs(base, exist_ok=True)
|
|
131
246
|
cmd_prefix = cmd_prefix or executable_command()
|
|
@@ -137,21 +252,34 @@ def install_user(home: str, cmd_prefix: str | None = None) -> list[str]:
|
|
|
137
252
|
shutil.copy(DEFAULT_POLICY_PATH, pol)
|
|
138
253
|
done.append(f"default policy → {pol}")
|
|
139
254
|
_write_git_hook(os.path.join(base, "git-hooks"))
|
|
140
|
-
|
|
255
|
+
settings_rel = AGENT_FILES[agent][0]
|
|
256
|
+
if agent == "claude":
|
|
257
|
+
merge_settings(os.path.join(home, ".claude", "settings.json"), cmd_prefix)
|
|
258
|
+
else:
|
|
259
|
+
merge_agent_settings(os.path.join(home, settings_rel), agent, cmd_prefix)
|
|
260
|
+
if agent == "codex":
|
|
261
|
+
hint = codex_hooks_enabled_hint(home)
|
|
262
|
+
if hint:
|
|
263
|
+
done.append(hint)
|
|
141
264
|
git(["config", "--global", "core.hooksPath", os.path.join(base, "git-hooks")], home)
|
|
142
265
|
_set_notes_config(home, ["--global"])
|
|
143
266
|
done += [
|
|
144
|
-
"hooks merged into
|
|
267
|
+
f"hooks merged into ~/{settings_rel}",
|
|
145
268
|
"global core.hooksPath → ~/.gitvow/git-hooks",
|
|
146
269
|
f"global notes.displayRef / notes.rewriteRef → {NOTES_GLOB}",
|
|
147
270
|
]
|
|
148
271
|
return done
|
|
149
272
|
|
|
150
273
|
|
|
151
|
-
def uninstall_user(
|
|
274
|
+
def uninstall_user(
|
|
275
|
+
home: str, purge_policy: bool = False, purge_ledger: bool = False, agent: str = "claude"
|
|
276
|
+
) -> list[str]:
|
|
152
277
|
base = os.path.join(home, ".gitvow")
|
|
153
278
|
done = []
|
|
154
|
-
|
|
279
|
+
if agent == "claude":
|
|
280
|
+
unmerge_settings(os.path.join(home, ".claude", "settings.json"))
|
|
281
|
+
else:
|
|
282
|
+
unmerge_agent_settings(os.path.join(home, AGENT_FILES[agent][0]))
|
|
155
283
|
rc, cur, _ = git(["config", "--global", "--get", "core.hooksPath"], home)
|
|
156
284
|
if rc == 0 and cur == os.path.join(base, "git-hooks"):
|
|
157
285
|
git(["config", "--global", "--unset", "core.hooksPath"], home)
|
|
@@ -164,11 +292,11 @@ def uninstall_user(home: str, purge_policy: bool = False, purge_ledger: bool = F
|
|
|
164
292
|
if purge_ledger:
|
|
165
293
|
shutil.rmtree(os.path.join(base, "ledger"), ignore_errors=True)
|
|
166
294
|
done.append("ledger removed")
|
|
167
|
-
done.append("hook entries removed from
|
|
295
|
+
done.append(f"hook entries removed from ~/{AGENT_FILES[agent][0]}")
|
|
168
296
|
return done
|
|
169
297
|
|
|
170
298
|
|
|
171
|
-
def install_repo(repo: str, cmd_prefix: str = "gitvow") -> list[str]:
|
|
299
|
+
def install_repo(repo: str, cmd_prefix: str = "gitvow", agent: str = "claude") -> list[str]:
|
|
172
300
|
base = os.path.join(repo, ".gitvow")
|
|
173
301
|
os.makedirs(base, exist_ok=True)
|
|
174
302
|
from .policy import DEFAULT_POLICY_PATH
|
|
@@ -177,21 +305,29 @@ def install_repo(repo: str, cmd_prefix: str = "gitvow") -> list[str]:
|
|
|
177
305
|
if not os.path.exists(pol):
|
|
178
306
|
shutil.copy(DEFAULT_POLICY_PATH, pol)
|
|
179
307
|
_write_git_hook(os.path.join(base, "git-hooks"))
|
|
180
|
-
|
|
308
|
+
if agent == "claude":
|
|
309
|
+
merge_settings(os.path.join(repo, ".claude", "settings.json"), cmd_prefix)
|
|
310
|
+
else:
|
|
311
|
+
merge_agent_settings(os.path.join(repo, AGENT_FILES[agent][1]), agent, cmd_prefix)
|
|
181
312
|
git(["config", "core.hooksPath", ".gitvow/git-hooks"], repo)
|
|
182
313
|
_set_notes_config(repo, [])
|
|
183
314
|
return [
|
|
184
315
|
f"policy → {pol}",
|
|
185
|
-
"hooks merged into
|
|
316
|
+
f"hooks merged into {AGENT_FILES[agent][1]}",
|
|
186
317
|
"core.hooksPath → .gitvow/git-hooks",
|
|
187
318
|
f"notes.displayRef / notes.rewriteRef → {NOTES_GLOB}",
|
|
188
319
|
"commit .gitvow/ and .claude/settings.json to share; teammates run: git config core.hooksPath .gitvow/git-hooks",
|
|
189
320
|
]
|
|
190
321
|
|
|
191
322
|
|
|
192
|
-
def uninstall_repo(
|
|
323
|
+
def uninstall_repo(
|
|
324
|
+
repo: str, purge_notes: bool = False, purge_snapshots: bool = False, agent: str = "claude"
|
|
325
|
+
) -> list[str]:
|
|
193
326
|
done = []
|
|
194
|
-
|
|
327
|
+
if agent == "claude":
|
|
328
|
+
unmerge_settings(os.path.join(repo, ".claude", "settings.json"))
|
|
329
|
+
else:
|
|
330
|
+
unmerge_agent_settings(os.path.join(repo, AGENT_FILES[agent][1]))
|
|
195
331
|
rc, cur, _ = git(["config", "--get", "core.hooksPath"], repo)
|
|
196
332
|
if rc == 0 and cur == ".gitvow/git-hooks":
|
|
197
333
|
git(["config", "--unset", "core.hooksPath"], repo)
|
|
@@ -8,11 +8,118 @@ import shutil
|
|
|
8
8
|
import subprocess
|
|
9
9
|
import sys
|
|
10
10
|
import tempfile
|
|
11
|
+
from typing import Any
|
|
11
12
|
|
|
12
13
|
from .hooks import post_tool_use, pre_tool_use, session_start, stop
|
|
13
14
|
from .install import _write_git_hook
|
|
14
15
|
|
|
15
16
|
|
|
17
|
+
def run_agent(agent: str) -> int:
|
|
18
|
+
"""Drive the adapter for another agent with that agent's payload shapes: a block and a snapshot."""
|
|
19
|
+
import json as _json
|
|
20
|
+
|
|
21
|
+
from .adapters import normalize, respond
|
|
22
|
+
from .hooks import HANDLERS
|
|
23
|
+
|
|
24
|
+
home = tempfile.mkdtemp(prefix="gitvow-home-")
|
|
25
|
+
repo = tempfile.mkdtemp(prefix="gitvow-repo-")
|
|
26
|
+
results: list[tuple[bool, str]] = []
|
|
27
|
+
try:
|
|
28
|
+
subprocess.run(["git", "init", "-q"], cwd=repo, check=False)
|
|
29
|
+
with open(os.path.join(repo, "a.txt"), "w") as fh:
|
|
30
|
+
fh.write("a\n")
|
|
31
|
+
samples: dict[str, list[tuple[str, dict[str, Any]]]] = {
|
|
32
|
+
"codex": [
|
|
33
|
+
("SessionStart", {"session_id": "st", "cwd": repo}),
|
|
34
|
+
(
|
|
35
|
+
"PreToolUse",
|
|
36
|
+
{
|
|
37
|
+
"session_id": "st",
|
|
38
|
+
"cwd": repo,
|
|
39
|
+
"tool_name": "Bash",
|
|
40
|
+
"tool_input": {"command": "git push --force"},
|
|
41
|
+
},
|
|
42
|
+
),
|
|
43
|
+
(
|
|
44
|
+
"PostToolUse",
|
|
45
|
+
{
|
|
46
|
+
"session_id": "st",
|
|
47
|
+
"cwd": repo,
|
|
48
|
+
"tool_name": "apply_patch",
|
|
49
|
+
"tool_input": {"command": "*** Begin Patch\n*** Update File: a.txt\n+b\n*** End Patch\n"},
|
|
50
|
+
},
|
|
51
|
+
),
|
|
52
|
+
],
|
|
53
|
+
"gemini": [
|
|
54
|
+
("SessionStart", {"session_id": "st", "cwd": repo}),
|
|
55
|
+
(
|
|
56
|
+
"BeforeTool",
|
|
57
|
+
{
|
|
58
|
+
"session_id": "st",
|
|
59
|
+
"cwd": repo,
|
|
60
|
+
"tool_name": "run_shell_command",
|
|
61
|
+
"tool_input": {"command": "git push --force"},
|
|
62
|
+
},
|
|
63
|
+
),
|
|
64
|
+
(
|
|
65
|
+
"AfterTool",
|
|
66
|
+
{
|
|
67
|
+
"session_id": "st",
|
|
68
|
+
"cwd": repo,
|
|
69
|
+
"tool_name": "write_file",
|
|
70
|
+
"tool_input": {"file_path": os.path.join(repo, "a.txt"), "content": "b"},
|
|
71
|
+
},
|
|
72
|
+
),
|
|
73
|
+
],
|
|
74
|
+
"cursor": [
|
|
75
|
+
("sessionStart", {"conversation_id": "st", "workspace_roots": [repo]}),
|
|
76
|
+
(
|
|
77
|
+
"beforeShellExecution",
|
|
78
|
+
{"conversation_id": "st", "workspace_roots": [repo], "command": "git push --force"},
|
|
79
|
+
),
|
|
80
|
+
(
|
|
81
|
+
"afterFileEdit",
|
|
82
|
+
{
|
|
83
|
+
"conversation_id": "st",
|
|
84
|
+
"workspace_roots": [repo],
|
|
85
|
+
"file_path": os.path.join(repo, "a.txt"),
|
|
86
|
+
"edits": [{"old_string": "a", "new_string": "b"}],
|
|
87
|
+
},
|
|
88
|
+
),
|
|
89
|
+
],
|
|
90
|
+
}
|
|
91
|
+
for ev, payload in samples[agent]:
|
|
92
|
+
worst, msgs = 0, []
|
|
93
|
+
for gv_event, p in normalize(agent, ev, payload):
|
|
94
|
+
code, msg = HANDLERS[gv_event](p, home)
|
|
95
|
+
worst = max(worst, code)
|
|
96
|
+
if msg:
|
|
97
|
+
msgs.append(msg)
|
|
98
|
+
exit_code, out, _err = respond(agent, worst, "\n".join(msgs))
|
|
99
|
+
if ev in ("PreToolUse", "BeforeTool", "beforeShellExecution"):
|
|
100
|
+
blocked = (agent == "cursor" and _json.loads(out).get("permission") == "deny") or (
|
|
101
|
+
agent != "cursor" and exit_code == 2
|
|
102
|
+
)
|
|
103
|
+
results.append((blocked, f"{agent}: force push denied in the agent's own form"))
|
|
104
|
+
refs = subprocess.run(
|
|
105
|
+
["git", "for-each-ref", "refs/gitvow/snapshots/"], cwd=repo, capture_output=True, text=True
|
|
106
|
+
).stdout
|
|
107
|
+
results.append((refs.strip() != "", f"{agent}: snapshot taken after the agent's edit"))
|
|
108
|
+
results.append(
|
|
109
|
+
(os.path.exists(os.path.join(repo, ".git", "gitvow-session.json")), f"{agent}: session recorded")
|
|
110
|
+
)
|
|
111
|
+
finally:
|
|
112
|
+
shutil.rmtree(home, ignore_errors=True)
|
|
113
|
+
shutil.rmtree(repo, ignore_errors=True)
|
|
114
|
+
for ok, name in results:
|
|
115
|
+
print((" ok " if ok else " FAIL ") + name)
|
|
116
|
+
failed = sum(1 for ok, _ in results if not ok)
|
|
117
|
+
print(
|
|
118
|
+
f"\nselftest --agent {agent}: {len(results) - failed} passed, {failed} failed (payload shapes from vendor docs; not a real session)"
|
|
119
|
+
)
|
|
120
|
+
return 1 if failed else 0
|
|
121
|
+
|
|
122
|
+
|
|
16
123
|
def run() -> int:
|
|
17
124
|
home = tempfile.mkdtemp(prefix="gitvow-home-")
|
|
18
125
|
repo = tempfile.mkdtemp(prefix="gitvow-repo-")
|
|
@@ -11,7 +11,7 @@ import tempfile
|
|
|
11
11
|
import time
|
|
12
12
|
from typing import Any
|
|
13
13
|
|
|
14
|
-
from .state import git, git_dir, load_state, save_state, toplevel
|
|
14
|
+
from .state import git, git_dir, load_state, log_event, save_state, toplevel
|
|
15
15
|
|
|
16
16
|
REF_PREFIX = "refs/gitvow/snapshots"
|
|
17
17
|
DEFAULTS: dict[str, Any] = {
|
|
@@ -78,10 +78,20 @@ def take(cwd: str, session_id: str | None, tool: str, file_path: str, cfg: dict[
|
|
|
78
78
|
"taken": time.strftime("%Y-%m-%dT%H:%M:%S"),
|
|
79
79
|
}
|
|
80
80
|
)
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
81
|
+
# the snapshot is gitvow's object, not the user's commit: give it a fixed identity so it never depends on git config
|
|
82
|
+
ident = {
|
|
83
|
+
**os.environ,
|
|
84
|
+
"GIT_AUTHOR_NAME": "gitvow",
|
|
85
|
+
"GIT_AUTHOR_EMAIL": "gitvow@localhost",
|
|
86
|
+
"GIT_COMMITTER_NAME": "gitvow",
|
|
87
|
+
"GIT_COMMITTER_EMAIL": "gitvow@localhost",
|
|
88
|
+
}
|
|
89
|
+
args = ["git", "commit-tree", tree, "-m", msg] + (["-p", parent] if parent else [])
|
|
90
|
+
r = subprocess.run(args, cwd=top, env=ident, capture_output=True, text=True, check=False)
|
|
91
|
+
if r.returncode != 0:
|
|
92
|
+
log_event(cwd, "snapshot_failed", {"step": "commit-tree", "error": r.stderr.strip()[:300]})
|
|
84
93
|
return None
|
|
94
|
+
commit = r.stdout.strip()
|
|
85
95
|
ref = ref_for(session_id, n)
|
|
86
96
|
git(["update-ref", ref, commit], top)
|
|
87
97
|
st["snapshots"] = n
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: gitvow
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.6.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
|
|
@@ -3,6 +3,7 @@ README.md
|
|
|
3
3
|
pyproject.toml
|
|
4
4
|
src/gitvow/__init__.py
|
|
5
5
|
src/gitvow/__main__.py
|
|
6
|
+
src/gitvow/adapters.py
|
|
6
7
|
src/gitvow/cli.py
|
|
7
8
|
src/gitvow/collect.py
|
|
8
9
|
src/gitvow/default_policy.json
|
|
@@ -22,6 +23,7 @@ src/gitvow.egg-info/entry_points.txt
|
|
|
22
23
|
src/gitvow.egg-info/requires.txt
|
|
23
24
|
src/gitvow.egg-info/top_level.txt
|
|
24
25
|
src/gitvow/hooks/__init__.py
|
|
26
|
+
tests/test_adapters.py
|
|
25
27
|
tests/test_collect.py
|
|
26
28
|
tests/test_hooks.py
|
|
27
29
|
tests/test_install_cli.py
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
import io
|
|
2
|
+
import json
|
|
3
|
+
|
|
4
|
+
from gitvow import cli
|
|
5
|
+
from gitvow.adapters import normalize, parse_apply_patch, respond
|
|
6
|
+
from gitvow.install import install_repo, install_user, uninstall_repo, uninstall_user
|
|
7
|
+
|
|
8
|
+
PATCH = """*** Begin Patch
|
|
9
|
+
*** Update File: src/api/Orders.java
|
|
10
|
+
@@
|
|
11
|
+
- return old;
|
|
12
|
+
+ @Path("/v1/orders/export")
|
|
13
|
+
+ return csv;
|
|
14
|
+
*** Add File: docs/new.md
|
|
15
|
+
+# new
|
|
16
|
+
*** Delete File: old.txt
|
|
17
|
+
*** End Patch
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def test_parse_apply_patch():
|
|
22
|
+
files = parse_apply_patch(PATCH)
|
|
23
|
+
assert [f["file_path"] for f in files] == ["src/api/Orders.java", "docs/new.md", "old.txt"]
|
|
24
|
+
assert (
|
|
25
|
+
files[0]["op"] == "update"
|
|
26
|
+
and "/v1/orders/export" in files[0]["new_string"]
|
|
27
|
+
and "return old;" in files[0]["old_string"]
|
|
28
|
+
)
|
|
29
|
+
assert files[1]["op"] == "add" and files[1]["new_string"] == "# new"
|
|
30
|
+
assert files[2]["op"] == "delete"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def test_codex_normalization_expands_patch_and_maps_events():
|
|
34
|
+
calls = normalize(
|
|
35
|
+
"codex",
|
|
36
|
+
"PreToolUse",
|
|
37
|
+
{"session_id": "c1", "cwd": "/r", "tool_name": "apply_patch", "tool_input": {"command": PATCH}},
|
|
38
|
+
)
|
|
39
|
+
assert [c[0] for c in calls] == ["PreToolUse"] * 3
|
|
40
|
+
assert calls[0][1]["tool_name"] == "Edit" and calls[0][1]["tool_input"]["file_path"] == "src/api/Orders.java"
|
|
41
|
+
bash = normalize(
|
|
42
|
+
"codex", "PreToolUse", {"session_id": "c1", "cwd": "/r", "tool_name": "Bash", "tool_input": {"command": "ls"}}
|
|
43
|
+
)
|
|
44
|
+
assert bash[0][1]["tool_name"] == "Bash" and bash[0][1]["tool_input"] == {"command": "ls"}
|
|
45
|
+
assert normalize("codex", "Stop", {"session_id": "c1", "cwd": "/r"})[0][0] == "Stop"
|
|
46
|
+
assert normalize("codex", "UserPromptSubmit", {}) == []
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def test_gemini_normalization():
|
|
50
|
+
c = normalize(
|
|
51
|
+
"gemini",
|
|
52
|
+
"BeforeTool",
|
|
53
|
+
{"session_id": "g1", "cwd": "/r", "tool_name": "run_shell_command", "tool_input": {"command": "git push -f"}},
|
|
54
|
+
)
|
|
55
|
+
assert c[0][0] == "PreToolUse" and c[0][1]["tool_name"] == "Bash"
|
|
56
|
+
c = normalize(
|
|
57
|
+
"gemini",
|
|
58
|
+
"AfterTool",
|
|
59
|
+
{
|
|
60
|
+
"session_id": "g1",
|
|
61
|
+
"cwd": "/r",
|
|
62
|
+
"tool_name": "write_file",
|
|
63
|
+
"tool_input": {"file_path": "a.py", "content": "x"},
|
|
64
|
+
},
|
|
65
|
+
)
|
|
66
|
+
assert c[0][0] == "PostToolUse" and c[0][1]["tool_name"] == "Write"
|
|
67
|
+
assert normalize("gemini", "SessionEnd", {"session_id": "g1", "cwd": "/r"})[0][0] == "Stop"
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def test_cursor_normalization_and_responses():
|
|
71
|
+
c = normalize(
|
|
72
|
+
"cursor", "beforeShellExecution", {"conversation_id": "cu1", "workspace_roots": ["/w"], "command": "rm -rf /"}
|
|
73
|
+
)
|
|
74
|
+
assert c[0][1] == {
|
|
75
|
+
"session_id": "cu1",
|
|
76
|
+
"transcript_path": "",
|
|
77
|
+
"cwd": "/w",
|
|
78
|
+
"hook_event_name": "PreToolUse",
|
|
79
|
+
"agent": "cursor",
|
|
80
|
+
"tool_name": "Bash",
|
|
81
|
+
"tool_input": {"command": "rm -rf /"},
|
|
82
|
+
}
|
|
83
|
+
c = normalize(
|
|
84
|
+
"cursor",
|
|
85
|
+
"afterFileEdit",
|
|
86
|
+
{
|
|
87
|
+
"conversation_id": "cu1",
|
|
88
|
+
"workspace_roots": ["/w"],
|
|
89
|
+
"file_path": "/w/a.py",
|
|
90
|
+
"edits": [{"old_string": "a", "new_string": "b"}, {"old_string": "", "new_string": '"/v1/x"'}],
|
|
91
|
+
},
|
|
92
|
+
)
|
|
93
|
+
assert c[0][0] == "PostToolUse" and c[0][1]["tool_input"]["new_string"] == 'b\n"/v1/x"'
|
|
94
|
+
c = normalize(
|
|
95
|
+
"cursor",
|
|
96
|
+
"beforeMCPExecution",
|
|
97
|
+
{
|
|
98
|
+
"conversation_id": "cu1",
|
|
99
|
+
"workspace_roots": ["/w"],
|
|
100
|
+
"tool_name": "delete_dashboard",
|
|
101
|
+
"mcp_server_name": "grafana",
|
|
102
|
+
"tool_input": {},
|
|
103
|
+
},
|
|
104
|
+
)
|
|
105
|
+
assert c[0][1]["tool_name"] == "mcp__grafana__delete_dashboard"
|
|
106
|
+
assert respond("cursor", 2, "BLOCKED by policy (force push).")[1] == json.dumps(
|
|
107
|
+
{
|
|
108
|
+
"permission": "deny",
|
|
109
|
+
"user_message": "BLOCKED by policy (force push).",
|
|
110
|
+
"agent_message": "BLOCKED by policy (force push).",
|
|
111
|
+
}
|
|
112
|
+
)
|
|
113
|
+
assert json.loads(respond("cursor", 2, "CONFIRMATION REQUIRED (x). Ask the user")[1])["permission"] == "ask"
|
|
114
|
+
assert json.loads(respond("cursor", 0, "")[1]) == {"permission": "allow"}
|
|
115
|
+
assert respond("codex", 2, "BLOCKED")[0] == 2 and respond("gemini", 0, "")[0] == 0
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _run_hook(monkeypatch, capsys, agent, event, payload):
|
|
119
|
+
monkeypatch.setattr("sys.stdin", io.StringIO(json.dumps(payload)))
|
|
120
|
+
code = cli.main(["hook", "--agent", agent, event])
|
|
121
|
+
out = capsys.readouterr()
|
|
122
|
+
return code, out.out, out.err
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def test_cli_hook_per_agent_end_to_end(repo, home, monkeypatch, capsys):
|
|
126
|
+
monkeypatch.chdir(repo)
|
|
127
|
+
# codex: a destructive command is blocked with exit 2; a patch touching a gate file requires confirmation naming the file
|
|
128
|
+
code, out, err = _run_hook(
|
|
129
|
+
monkeypatch,
|
|
130
|
+
capsys,
|
|
131
|
+
"codex",
|
|
132
|
+
"PreToolUse",
|
|
133
|
+
{"session_id": "c1", "cwd": str(repo), "tool_name": "Bash", "tool_input": {"command": "git push --force"}},
|
|
134
|
+
)
|
|
135
|
+
assert code == 2 and "BLOCKED" in err and out == ""
|
|
136
|
+
patch = "*** Begin Patch\n*** Update File: core/authz_rules.go\n+x\n*** Update File: README.md\n+y\n*** End Patch\n"
|
|
137
|
+
code, out, err = _run_hook(
|
|
138
|
+
monkeypatch,
|
|
139
|
+
capsys,
|
|
140
|
+
"codex",
|
|
141
|
+
"PreToolUse",
|
|
142
|
+
{"session_id": "c1", "cwd": str(repo), "tool_name": "apply_patch", "tool_input": {"command": patch}},
|
|
143
|
+
)
|
|
144
|
+
assert code == 2 and "CONFIRMATION REQUIRED" in err and "[file: core/authz_rules.go]" in err
|
|
145
|
+
# gemini: allowed shell command exits 0 silently
|
|
146
|
+
code, out, err = _run_hook(
|
|
147
|
+
monkeypatch,
|
|
148
|
+
capsys,
|
|
149
|
+
"gemini",
|
|
150
|
+
"BeforeTool",
|
|
151
|
+
{"session_id": "g1", "cwd": str(repo), "tool_name": "run_shell_command", "tool_input": {"command": "ls"}},
|
|
152
|
+
)
|
|
153
|
+
assert (code, out, err) == (0, "", "")
|
|
154
|
+
# cursor: JSON permission, ask for confirm-class, deny for deny-class, allow otherwise
|
|
155
|
+
code, out, err = _run_hook(
|
|
156
|
+
monkeypatch,
|
|
157
|
+
capsys,
|
|
158
|
+
"cursor",
|
|
159
|
+
"beforeShellExecution",
|
|
160
|
+
{"conversation_id": "cu1", "workspace_roots": [str(repo)], "command": "kubectl apply -f x.yaml"},
|
|
161
|
+
)
|
|
162
|
+
assert code == 0 and json.loads(out)["permission"] == "ask"
|
|
163
|
+
code, out, err = _run_hook(
|
|
164
|
+
monkeypatch,
|
|
165
|
+
capsys,
|
|
166
|
+
"cursor",
|
|
167
|
+
"beforeShellExecution",
|
|
168
|
+
{"conversation_id": "cu1", "workspace_roots": [str(repo)], "command": "terraform destroy"},
|
|
169
|
+
)
|
|
170
|
+
assert json.loads(out)["permission"] == "deny"
|
|
171
|
+
code, out, err = _run_hook(
|
|
172
|
+
monkeypatch,
|
|
173
|
+
capsys,
|
|
174
|
+
"cursor",
|
|
175
|
+
"beforeShellExecution",
|
|
176
|
+
{"conversation_id": "cu1", "workspace_roots": [str(repo)], "command": "ls"},
|
|
177
|
+
)
|
|
178
|
+
assert json.loads(out) == {"permission": "allow"}
|
|
179
|
+
# cursor afterFileEdit takes a snapshot and records the blob like Claude Code's PostToolUse
|
|
180
|
+
(repo / "a.txt").write_text("cursor wrote this\n")
|
|
181
|
+
_run_hook(monkeypatch, capsys, "cursor", "sessionStart", {"conversation_id": "cu1", "workspace_roots": [str(repo)]})
|
|
182
|
+
code, out, err = _run_hook(
|
|
183
|
+
monkeypatch,
|
|
184
|
+
capsys,
|
|
185
|
+
"cursor",
|
|
186
|
+
"afterFileEdit",
|
|
187
|
+
{
|
|
188
|
+
"conversation_id": "cu1",
|
|
189
|
+
"workspace_roots": [str(repo)],
|
|
190
|
+
"file_path": str(repo / "a.txt"),
|
|
191
|
+
"edits": [{"old_string": "a", "new_string": "cursor wrote this"}],
|
|
192
|
+
},
|
|
193
|
+
)
|
|
194
|
+
assert code == 0
|
|
195
|
+
from tests.conftest import git
|
|
196
|
+
|
|
197
|
+
assert git(repo, "for-each-ref", "--format=%(refname)", "refs/gitvow/snapshots/").endswith("/cu1/1")
|
|
198
|
+
# an event the adapter does not use is a no-op
|
|
199
|
+
assert _run_hook(monkeypatch, capsys, "gemini", "BeforeModel", {"session_id": "g1", "cwd": str(repo)})[0] == 0
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def test_install_per_agent_idempotent_and_reversible(home, repo):
|
|
203
|
+
for agent, rel, key in (
|
|
204
|
+
("codex", ".codex/hooks.json", "PreToolUse"),
|
|
205
|
+
("gemini", ".gemini/settings.json", "BeforeTool"),
|
|
206
|
+
("cursor", ".cursor/hooks.json", "beforeShellExecution"),
|
|
207
|
+
):
|
|
208
|
+
out = install_user(str(home), agent=agent)
|
|
209
|
+
install_user(str(home), agent=agent)
|
|
210
|
+
s = json.loads((home / rel).read_text())
|
|
211
|
+
assert len(s["hooks"][key]) == 1, agent
|
|
212
|
+
cmd = (
|
|
213
|
+
s["hooks"][key][0]["hooks"][0]["command"]
|
|
214
|
+
if "hooks" in s["hooks"][key][0]
|
|
215
|
+
else s["hooks"][key][0]["command"]
|
|
216
|
+
)
|
|
217
|
+
assert f"hook --agent {agent}" in cmd
|
|
218
|
+
if agent == "codex":
|
|
219
|
+
assert any("hooks = true" in x for x in out)
|
|
220
|
+
if agent == "cursor":
|
|
221
|
+
assert s["version"] == 1 and s["hooks"][key][0]["failClosed"] is True
|
|
222
|
+
if agent == "gemini":
|
|
223
|
+
assert s["hooks"][key][0]["hooks"][0]["name"] == "gitvow-BeforeTool"
|
|
224
|
+
uninstall_user(str(home), agent=agent)
|
|
225
|
+
assert not (home / rel).exists(), agent
|
|
226
|
+
# foreign entries survive
|
|
227
|
+
(home / ".cursor").mkdir(exist_ok=True)
|
|
228
|
+
(home / ".cursor" / "hooks.json").write_text(
|
|
229
|
+
json.dumps({"version": 1, "hooks": {"stop": [{"command": "mine.sh"}]}})
|
|
230
|
+
)
|
|
231
|
+
install_user(str(home), agent="cursor")
|
|
232
|
+
uninstall_user(str(home), agent="cursor")
|
|
233
|
+
assert json.loads((home / ".cursor" / "hooks.json").read_text())["hooks"]["stop"] == [{"command": "mine.sh"}]
|
|
234
|
+
# repo-level
|
|
235
|
+
install_repo(str(repo), agent="codex")
|
|
236
|
+
assert (repo / ".codex" / "hooks.json").exists()
|
|
237
|
+
uninstall_repo(str(repo), agent="codex")
|
|
238
|
+
assert not (repo / ".codex" / "hooks.json").exists()
|
|
@@ -36,7 +36,7 @@ def test_snapshot_per_edit_with_head_parent_and_exclusions(repo, home, payload):
|
|
|
36
36
|
assert git(repo, "status", "--short").count("\n") >= 0 # the real index was never touched
|
|
37
37
|
assert "calc.py" not in git(repo, "diff", "--cached", "--name-only")
|
|
38
38
|
st = json.loads((repo / ".git" / "gitvow-session.json").read_text())
|
|
39
|
-
assert st["snapshots"] ==
|
|
39
|
+
assert st["snapshots"] == 3 and st["last_snapshot"] == "refs/gitvow/snapshots/sess-1/3"
|
|
40
40
|
assert '"kind": "snapshot"' in (repo / ".git" / "gitvow-hooks.log").read_text()
|
|
41
41
|
|
|
42
42
|
|
|
@@ -126,11 +126,14 @@ def test_snapshot_in_repo_without_commits(tmp_path, home, payload):
|
|
|
126
126
|
r = tmp_path / "fresh"
|
|
127
127
|
r.mkdir()
|
|
128
128
|
git(r, "init", "-q")
|
|
129
|
+
git(r, "config", "user.useConfigOnly", "true") # no identity anywhere, as on a CI runner: snapshots must still work
|
|
129
130
|
(r / "f.txt").write_text("first\n")
|
|
130
131
|
p = payload("SessionStart")
|
|
131
132
|
p["cwd"] = str(r)
|
|
132
133
|
session_start(p, str(home))
|
|
133
134
|
post_tool_use({**p, "tool_name": "Write", "tool_input": {"file_path": str(r / "f.txt")}}, str(home))
|
|
134
135
|
ref = git(r, "for-each-ref", "--format=%(refname)", "refs/gitvow/snapshots/")
|
|
135
|
-
|
|
136
|
+
log = (r / ".git" / "gitvow-hooks.log").read_text() if (r / ".git" / "gitvow-hooks.log").exists() else ""
|
|
137
|
+
assert ref.endswith("/1"), f"no snapshot ref; hook log: {log}"
|
|
138
|
+
assert git(r, "show", f"{ref}:f.txt") == "first"
|
|
136
139
|
assert os.path.exists(r / ".git")
|
|
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
|