gitvow 0.1.0__py3-none-any.whl
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/__init__.py +3 -0
- gitvow/cli.py +135 -0
- gitvow/collect.py +103 -0
- gitvow/hooks/__init__.py +127 -0
- gitvow/install.py +158 -0
- gitvow/policy.py +120 -0
- gitvow/redact.py +71 -0
- gitvow/selftest.py +95 -0
- gitvow/state.py +52 -0
- gitvow/transcript.py +47 -0
- gitvow-0.1.0.dist-info/METADATA +124 -0
- gitvow-0.1.0.dist-info/RECORD +16 -0
- gitvow-0.1.0.dist-info/WHEEL +5 -0
- gitvow-0.1.0.dist-info/entry_points.txt +2 -0
- gitvow-0.1.0.dist-info/licenses/LICENSE +17 -0
- gitvow-0.1.0.dist-info/top_level.txt +1 -0
gitvow/__init__.py
ADDED
gitvow/cli.py
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
"""gitvow command line."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import sys
|
|
9
|
+
|
|
10
|
+
from . import __version__
|
|
11
|
+
from .collect import collect, summarize_text
|
|
12
|
+
from .hooks import HANDLERS
|
|
13
|
+
from .install import install_repo, install_user, uninstall_repo, uninstall_user
|
|
14
|
+
from .policy import PolicyError, evaluate, load_policy
|
|
15
|
+
from .state import git
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def cmd_hook(a: argparse.Namespace) -> int:
|
|
19
|
+
try:
|
|
20
|
+
payload = json.load(sys.stdin)
|
|
21
|
+
except json.JSONDecodeError:
|
|
22
|
+
payload = {}
|
|
23
|
+
handler = HANDLERS.get(a.event)
|
|
24
|
+
if not handler:
|
|
25
|
+
print(f"unknown hook event {a.event}", file=sys.stderr)
|
|
26
|
+
return 1
|
|
27
|
+
code, msg = handler(payload)
|
|
28
|
+
if msg:
|
|
29
|
+
print(msg, file=sys.stderr)
|
|
30
|
+
return code
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def cmd_install(a: argparse.Namespace) -> int:
|
|
34
|
+
done = install_user(os.path.expanduser("~")) if a.user else install_repo(os.path.abspath(a.repo))
|
|
35
|
+
print("\n".join(done))
|
|
36
|
+
return 0
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def cmd_uninstall(a: argparse.Namespace) -> int:
|
|
40
|
+
done = (
|
|
41
|
+
uninstall_user(os.path.expanduser("~"), a.purge_policy, a.purge_ledger)
|
|
42
|
+
if a.user
|
|
43
|
+
else uninstall_repo(os.path.abspath(a.repo), a.purge_notes)
|
|
44
|
+
)
|
|
45
|
+
print("\n".join(done))
|
|
46
|
+
return 0
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def cmd_check(a: argparse.Namespace) -> int:
|
|
50
|
+
"""Dry-run the policy against a command, path or MCP tool name."""
|
|
51
|
+
try:
|
|
52
|
+
pol = load_policy(os.getcwd())
|
|
53
|
+
except PolicyError as e:
|
|
54
|
+
print(f"policy error: {e}", file=sys.stderr)
|
|
55
|
+
return 2
|
|
56
|
+
if a.path:
|
|
57
|
+
d = evaluate(pol, "Edit", {"file_path": a.path})
|
|
58
|
+
elif a.mcp:
|
|
59
|
+
d = evaluate(pol, a.mcp, {})
|
|
60
|
+
else:
|
|
61
|
+
d = evaluate(pol, "Bash", {"command": " ".join(a.command)})
|
|
62
|
+
print(d.outcome.upper() + (f": {d.reason}" if d.reason else ""))
|
|
63
|
+
return 2 if d.blocks else 0
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def cmd_show(a: argparse.Namespace) -> int:
|
|
67
|
+
"""Print a commit's trailers and session note."""
|
|
68
|
+
rc, msg, _ = git(["log", "-1", "--format=%H%n%s%n%b", a.commit], os.getcwd())
|
|
69
|
+
if rc != 0:
|
|
70
|
+
print(f"no such commit: {a.commit}", file=sys.stderr)
|
|
71
|
+
return 1
|
|
72
|
+
print(msg)
|
|
73
|
+
rc, note, _ = git(["notes", "--ref=sessions", "show", a.commit], os.getcwd())
|
|
74
|
+
print(note if rc == 0 else "(no session note)")
|
|
75
|
+
return 0
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def cmd_collect(a: argparse.Namespace) -> int:
|
|
79
|
+
w = collect(os.path.expanduser("~"), a.out)
|
|
80
|
+
print(summarize_text(w))
|
|
81
|
+
print(f"collected into {w} (redacted at write time; review before sending)")
|
|
82
|
+
return 0
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def cmd_summarize(a: argparse.Namespace) -> int:
|
|
86
|
+
print(summarize_text(a.dir))
|
|
87
|
+
return 0
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def cmd_selftest(a: argparse.Namespace) -> int:
|
|
91
|
+
from .selftest import run
|
|
92
|
+
|
|
93
|
+
return run()
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def main(argv: list[str] | None = None) -> int:
|
|
97
|
+
p = argparse.ArgumentParser(prog="gitvow", description="Provenance and policy gate for agent coding sessions.")
|
|
98
|
+
p.add_argument("--version", action="version", version=f"gitvow {__version__}")
|
|
99
|
+
sub = p.add_subparsers(dest="cmd", required=True)
|
|
100
|
+
s = sub.add_parser("hook", help="run as a Claude Code hook (reads JSON on stdin)")
|
|
101
|
+
s.add_argument("event")
|
|
102
|
+
s.set_defaults(f=cmd_hook)
|
|
103
|
+
s = sub.add_parser("install", help="install per user (--user) or into a repo")
|
|
104
|
+
s.add_argument("repo", nargs="?", default=".")
|
|
105
|
+
s.add_argument("--user", action="store_true")
|
|
106
|
+
s.set_defaults(f=cmd_install)
|
|
107
|
+
s = sub.add_parser("uninstall", help="remove what install added")
|
|
108
|
+
s.add_argument("repo", nargs="?", default=".")
|
|
109
|
+
s.add_argument("--user", action="store_true")
|
|
110
|
+
s.add_argument("--purge-notes", action="store_true")
|
|
111
|
+
s.add_argument("--purge-policy", action="store_true")
|
|
112
|
+
s.add_argument("--purge-ledger", action="store_true")
|
|
113
|
+
s.set_defaults(f=cmd_uninstall)
|
|
114
|
+
s = sub.add_parser("check", help="dry-run the policy: gitvow check -- git push --force")
|
|
115
|
+
s.add_argument("command", nargs="*")
|
|
116
|
+
s.add_argument("--path")
|
|
117
|
+
s.add_argument("--mcp")
|
|
118
|
+
s.set_defaults(f=cmd_check)
|
|
119
|
+
s = sub.add_parser("show", help="print a commit's trailers and session note")
|
|
120
|
+
s.add_argument("commit", nargs="?", default="HEAD")
|
|
121
|
+
s.set_defaults(f=cmd_show)
|
|
122
|
+
s = sub.add_parser("collect", help="gather ledger, logs, trailers and notes into one directory")
|
|
123
|
+
s.add_argument("--out", default=os.path.expanduser("~/Desktop"))
|
|
124
|
+
s.set_defaults(f=cmd_collect)
|
|
125
|
+
s = sub.add_parser("summarize", help="metrics from a collected directory")
|
|
126
|
+
s.add_argument("dir")
|
|
127
|
+
s.set_defaults(f=cmd_summarize)
|
|
128
|
+
s = sub.add_parser("selftest", help="prove the hooks work here without touching a real repo")
|
|
129
|
+
s.set_defaults(f=cmd_selftest)
|
|
130
|
+
a = p.parse_args(argv)
|
|
131
|
+
return a.f(a)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
if __name__ == "__main__":
|
|
135
|
+
sys.exit(main())
|
gitvow/collect.py
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"""Gather what a trial produced on this machine into one redacted directory, and compute trial metrics."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import collections
|
|
6
|
+
import glob
|
|
7
|
+
import json
|
|
8
|
+
import os
|
|
9
|
+
import shutil
|
|
10
|
+
import time
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from .state import git
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def collect(home: str, out_dir: str) -> str:
|
|
17
|
+
stamp = time.strftime("%Y%m%d-%H%M%S")
|
|
18
|
+
w = os.path.join(out_dir, f"gitvow-{stamp}")
|
|
19
|
+
os.makedirs(os.path.join(w, "ledger"), exist_ok=True)
|
|
20
|
+
os.makedirs(os.path.join(w, "repos"), exist_ok=True)
|
|
21
|
+
led = os.path.join(home, ".gitvow", "ledger")
|
|
22
|
+
repos: set[str] = set()
|
|
23
|
+
for f in glob.glob(os.path.join(led, "*.json")):
|
|
24
|
+
shutil.copy(f, os.path.join(w, "ledger"))
|
|
25
|
+
try:
|
|
26
|
+
with open(f) as fh:
|
|
27
|
+
r = json.load(fh).get("repo")
|
|
28
|
+
if r:
|
|
29
|
+
repos.add(r)
|
|
30
|
+
except (OSError, json.JSONDecodeError):
|
|
31
|
+
pass
|
|
32
|
+
for r in sorted(repos):
|
|
33
|
+
if not os.path.isdir(r):
|
|
34
|
+
continue
|
|
35
|
+
d = os.path.join(w, "repos", os.path.basename(r.rstrip("/")))
|
|
36
|
+
os.makedirs(d, exist_ok=True)
|
|
37
|
+
rc, gd, _ = git(["rev-parse", "--git-dir"], r)
|
|
38
|
+
if rc != 0:
|
|
39
|
+
continue
|
|
40
|
+
gd = gd if os.path.isabs(gd) else os.path.join(r, gd)
|
|
41
|
+
lp = os.path.join(gd, "gitvow-hooks.log")
|
|
42
|
+
if os.path.exists(lp):
|
|
43
|
+
shutil.copy(lp, os.path.join(d, "gitvow-hooks.log"))
|
|
44
|
+
for name, args in (
|
|
45
|
+
("commits-with-trailers.txt", ["log", "--format=%H %ad %s", "--date=short", "--grep=Gitvow-Session:"]),
|
|
46
|
+
("notes.txt", ["log", "--show-notes=sessions", "--format=%H%n%N%n----", "--grep=Gitvow-Session:"]),
|
|
47
|
+
("remote.txt", ["remote", "get-url", "origin"]),
|
|
48
|
+
):
|
|
49
|
+
_, out, _ = git(args, r)
|
|
50
|
+
with open(os.path.join(d, name), "w") as fh:
|
|
51
|
+
fh.write(out)
|
|
52
|
+
with open(os.path.join(w, "SUMMARY.txt"), "w") as fh:
|
|
53
|
+
fh.write(summarize_text(w))
|
|
54
|
+
return w
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def summarize(w: str) -> dict[str, Any]:
|
|
58
|
+
led = [json.load(open(f)) for f in glob.glob(os.path.join(w, "ledger", "*.json"))] # noqa: SIM115
|
|
59
|
+
tools: collections.Counter[str] = collections.Counter(t.get("tool") for s in led for t in s.get("tool_calls", []))
|
|
60
|
+
kinds: collections.Counter[str] = collections.Counter()
|
|
61
|
+
reasons: collections.Counter[tuple[str, str]] = collections.Counter()
|
|
62
|
+
trailered = notes = 0
|
|
63
|
+
for lp in glob.glob(os.path.join(w, "repos", "*", "gitvow-hooks.log")):
|
|
64
|
+
with open(lp) as fh:
|
|
65
|
+
for line in fh:
|
|
66
|
+
try:
|
|
67
|
+
e = json.loads(line)
|
|
68
|
+
except json.JSONDecodeError:
|
|
69
|
+
continue
|
|
70
|
+
kinds[e.get("kind", "?")] += 1
|
|
71
|
+
if e.get("kind") in ("blocked", "confirm_required"):
|
|
72
|
+
reasons[(e.get("kind", "?"), e.get("reason", "?"))] += 1
|
|
73
|
+
for f in glob.glob(os.path.join(w, "repos", "*", "commits-with-trailers.txt")):
|
|
74
|
+
with open(f) as fh:
|
|
75
|
+
trailered += sum(1 for ln in fh if ln.strip())
|
|
76
|
+
for f in glob.glob(os.path.join(w, "repos", "*", "notes.txt")):
|
|
77
|
+
with open(f) as fh:
|
|
78
|
+
notes += fh.read().count("gitvow-session")
|
|
79
|
+
return {
|
|
80
|
+
"sessions": len(led),
|
|
81
|
+
"repos": len({s.get("repo") for s in led}),
|
|
82
|
+
"tool_calls": sum(tools.values()),
|
|
83
|
+
"by_tool": dict(tools.most_common(8)),
|
|
84
|
+
"commits_during_sessions": sum(len(s.get("commits_during_session", [])) for s in led),
|
|
85
|
+
"hook_decisions": dict(kinds),
|
|
86
|
+
"commits_with_trailers": trailered,
|
|
87
|
+
"notes_attached": notes,
|
|
88
|
+
"gate_fired_on": [{"kind": k, "reason": r, "n": n} for (k, r), n in reasons.most_common(20)],
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def summarize_text(w: str) -> str:
|
|
93
|
+
s = summarize(w)
|
|
94
|
+
lines = [
|
|
95
|
+
f"sessions: {s['sessions']} repos touched: {s['repos']}",
|
|
96
|
+
f"tool calls: {s['tool_calls']} | by tool: {s['by_tool']}",
|
|
97
|
+
f"commits during sessions: {s['commits_during_sessions']}",
|
|
98
|
+
f"hook decisions: {s['hook_decisions']}",
|
|
99
|
+
f"commits with trailers: {s['commits_with_trailers']} | notes attached: {s['notes_attached']}",
|
|
100
|
+
"gate fired on:",
|
|
101
|
+
]
|
|
102
|
+
lines += [f" {g['kind']:<17} {g['n']:>4} {g['reason']}" for g in s["gate_fired_on"]] or [" (never)"]
|
|
103
|
+
return "\n".join(lines) + "\n"
|
gitvow/hooks/__init__.py
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
"""Hook handlers. Each takes the Claude Code hook payload (dict) and returns (exit_code, stderr_message)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import re
|
|
8
|
+
import time
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from ..policy import PolicyError, evaluate, load_policy, message_for
|
|
12
|
+
from ..redact import redact
|
|
13
|
+
from ..state import git, load_state, log_event, save_state
|
|
14
|
+
from ..transcript import summarize
|
|
15
|
+
|
|
16
|
+
NOTES_REF = "sessions"
|
|
17
|
+
COMMIT_RE = re.compile(r"\bgit\s+commit\b")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def session_start(h: dict[str, Any], home: str | None = None) -> tuple[int, str]:
|
|
21
|
+
cwd = h.get("cwd") or os.getcwd()
|
|
22
|
+
st = load_state(cwd)
|
|
23
|
+
st.update(
|
|
24
|
+
{
|
|
25
|
+
"session_id": h.get("session_id"),
|
|
26
|
+
"transcript_path": h.get("transcript_path"),
|
|
27
|
+
"started": time.strftime("%Y-%m-%dT%H:%M:%S"),
|
|
28
|
+
"steps": st.get("steps", 0),
|
|
29
|
+
}
|
|
30
|
+
)
|
|
31
|
+
save_state(cwd, st)
|
|
32
|
+
log_event(cwd, "session_start", {"session_id": h.get("session_id")})
|
|
33
|
+
return 0, ""
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def pre_tool_use(h: dict[str, Any], home: str | None = None) -> tuple[int, str]:
|
|
37
|
+
cwd = h.get("cwd") or os.getcwd()
|
|
38
|
+
tool = h.get("tool_name", "") or ""
|
|
39
|
+
inp = h.get("tool_input") or {}
|
|
40
|
+
try:
|
|
41
|
+
pol = load_policy(cwd, home)
|
|
42
|
+
except PolicyError as e:
|
|
43
|
+
return (
|
|
44
|
+
2,
|
|
45
|
+
f"BLOCKED: tool policy could not be loaded ({e}); refusing all tool calls until the policy is restored.",
|
|
46
|
+
)
|
|
47
|
+
d = evaluate(pol, tool, inp)
|
|
48
|
+
if d.blocks:
|
|
49
|
+
log_event(
|
|
50
|
+
cwd,
|
|
51
|
+
"blocked" if d.outcome == "deny" else "confirm_required",
|
|
52
|
+
{"tool": tool, "reason": d.reason, "detail": redact(d.detail)[:200], "session_id": h.get("session_id")},
|
|
53
|
+
)
|
|
54
|
+
return 2, message_for(d)
|
|
55
|
+
if tool == "Bash" and COMMIT_RE.search(inp.get("command", "")):
|
|
56
|
+
st = load_state(cwd)
|
|
57
|
+
st["session_id"] = h.get("session_id") or st.get("session_id")
|
|
58
|
+
st["transcript_path"] = h.get("transcript_path") or st.get("transcript_path")
|
|
59
|
+
st["steps"] = st.get("steps", 0) + 1
|
|
60
|
+
save_state(cwd, st)
|
|
61
|
+
log_event(
|
|
62
|
+
cwd, "allowed", {"tool": tool, "detail": redact(inp.get("command") or inp.get("file_path") or tool)[:160]}
|
|
63
|
+
)
|
|
64
|
+
return 0, ""
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def post_tool_use(h: dict[str, Any], home: str | None = None) -> tuple[int, str]:
|
|
68
|
+
cwd = h.get("cwd") or os.getcwd()
|
|
69
|
+
if h.get("tool_name") != "Bash" or not COMMIT_RE.search((h.get("tool_input") or {}).get("command", "")):
|
|
70
|
+
return 0, ""
|
|
71
|
+
rc, head, _ = git(["rev-parse", "HEAD"], cwd)
|
|
72
|
+
if rc != 0:
|
|
73
|
+
return 0, ""
|
|
74
|
+
st = load_state(cwd)
|
|
75
|
+
summ = summarize(h.get("transcript_path") or st.get("transcript_path"))
|
|
76
|
+
_, files, _ = git(["show", "--stat", "--format=", "HEAD"], cwd)
|
|
77
|
+
_, diffstat, _ = git(["show", "--numstat", "--format=", "HEAD"], cwd)
|
|
78
|
+
changed = [ln.split("\t")[-1] for ln in diffstat.splitlines() if ln.strip()]
|
|
79
|
+
agent_written = sorted(f for f in changed if any(f.endswith(w) or w.endswith(f) for w in summ["files_written"]))
|
|
80
|
+
note = {
|
|
81
|
+
"session_id": h.get("session_id") or st.get("session_id"),
|
|
82
|
+
"step": st.get("steps"),
|
|
83
|
+
"committed_at": time.strftime("%Y-%m-%dT%H:%M:%S"),
|
|
84
|
+
"assistant_turns_so_far": summ["turns"],
|
|
85
|
+
"tool_calls_so_far": len(summ["tool_calls"]),
|
|
86
|
+
"tools_used": sorted({t["tool"] for t in summ["tool_calls"] if t.get("tool")}),
|
|
87
|
+
"last_stated_plan": summ["last_assistant_text"],
|
|
88
|
+
"files_in_commit": [ln.strip() for ln in files.splitlines()[:-1]][:50] if files else [],
|
|
89
|
+
"files_written_by_agent_this_session": agent_written[:50],
|
|
90
|
+
"attribution": {"files_in_commit": len(changed), "touched_by_agent": len(agent_written)},
|
|
91
|
+
"transcript": "kept local; see ledger",
|
|
92
|
+
"redaction": "secrets/PII patterns and high-entropy tokens replaced at write time",
|
|
93
|
+
}
|
|
94
|
+
body = "gitvow-session\n" + json.dumps(note, indent=1)
|
|
95
|
+
git(["notes", f"--ref={NOTES_REF}", "add", "-f", "-m", body, head], cwd)
|
|
96
|
+
log_event(cwd, "note_added", {"commit": head[:12], "session_id": note["session_id"], "step": note["step"]})
|
|
97
|
+
return 0, f"session note attached to {head[:12]} (refs/notes/{NOTES_REF})"
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def stop(h: dict[str, Any], home: str | None = None) -> tuple[int, str]:
|
|
101
|
+
cwd = h.get("cwd") or os.getcwd()
|
|
102
|
+
home = home or os.path.expanduser("~")
|
|
103
|
+
st = load_state(cwd)
|
|
104
|
+
summ = summarize(h.get("transcript_path") or st.get("transcript_path"))
|
|
105
|
+
led = os.path.join(home, ".gitvow", "ledger")
|
|
106
|
+
os.makedirs(led, exist_ok=True)
|
|
107
|
+
commits: list[str] = []
|
|
108
|
+
if st.get("started"):
|
|
109
|
+
rc, out, _ = git(["log", "--format=%H", f"--since={st['started']}"], cwd)
|
|
110
|
+
commits = out.split() if rc == 0 else []
|
|
111
|
+
rec = {
|
|
112
|
+
"session_id": h.get("session_id"),
|
|
113
|
+
"repo": cwd,
|
|
114
|
+
"started": st.get("started"),
|
|
115
|
+
"ended": time.strftime("%Y-%m-%dT%H:%M:%S"),
|
|
116
|
+
"assistant_turns": summ["turns"],
|
|
117
|
+
"tool_calls": summ["tool_calls"],
|
|
118
|
+
"commits_during_session": commits,
|
|
119
|
+
"last_stated_plan": summ["last_assistant_text"],
|
|
120
|
+
}
|
|
121
|
+
with open(os.path.join(led, f"{h.get('session_id') or 'unknown'}.json"), "w") as fh:
|
|
122
|
+
json.dump(rec, fh, indent=1)
|
|
123
|
+
log_event(cwd, "session_stop", {"session_id": h.get("session_id"), "tool_calls": len(summ["tool_calls"])})
|
|
124
|
+
return 0, ""
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
HANDLERS = {"SessionStart": session_start, "PreToolUse": pre_tool_use, "PostToolUse": post_tool_use, "Stop": stop}
|
gitvow/install.py
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
"""Install/uninstall into Claude Code settings and git hooks, per user or per repo. Idempotent; removes only what it added."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import shutil
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from .state import git
|
|
11
|
+
|
|
12
|
+
GIT_HOOK = """#!/bin/sh
|
|
13
|
+
# gitvow: append session trailers when a session is active in this repo; chain to the repo's own hook if present.
|
|
14
|
+
GD="$(git rev-parse --git-dir)"; STATE="$GD/gitvow-session.json"
|
|
15
|
+
if [ -f "$STATE" ]; then
|
|
16
|
+
SID=$(python3 -c "import json;print(json.load(open('$STATE')).get('session_id') or '')" 2>/dev/null)
|
|
17
|
+
STEP=$(python3 -c "import json;print(json.load(open('$STATE')).get('steps') or 0)" 2>/dev/null)
|
|
18
|
+
if [ -n "$SID" ] && ! grep -q "^Gitvow-Session:" "$1"; then printf "\\nGitvow-Session: %s\\nGitvow-Step: %s\\n" "$SID" "$STEP" >> "$1"; fi
|
|
19
|
+
fi
|
|
20
|
+
SELF="$(cd "$(dirname "$0")" && pwd)"; REPOHOOKS="$(cd "$GD/hooks" 2>/dev/null && pwd || true)"
|
|
21
|
+
[ -x "$GD/hooks/prepare-commit-msg" ] && [ "$SELF" != "$REPOHOOKS" ] && exec "$GD/hooks/prepare-commit-msg" "$@"
|
|
22
|
+
exit 0
|
|
23
|
+
"""
|
|
24
|
+
MARKER = "gitvow hook "
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _hook_entries(cmd_prefix: str) -> dict[str, list[dict[str, Any]]]:
|
|
28
|
+
def entry(event: str, matcher: str | None) -> dict[str, Any]:
|
|
29
|
+
e: dict[str, Any] = {"hooks": [{"type": "command", "command": f"{cmd_prefix} hook {event}"}]}
|
|
30
|
+
if matcher:
|
|
31
|
+
e["matcher"] = matcher
|
|
32
|
+
return e
|
|
33
|
+
|
|
34
|
+
return {
|
|
35
|
+
"SessionStart": [entry("SessionStart", None)],
|
|
36
|
+
"PreToolUse": [entry("PreToolUse", "Bash|Edit|Write|MultiEdit|NotebookEdit|mcp__.*")],
|
|
37
|
+
"PostToolUse": [entry("PostToolUse", "Bash")],
|
|
38
|
+
"Stop": [entry("Stop", None)],
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def merge_settings(path: str, cmd_prefix: str) -> None:
|
|
43
|
+
cur: dict[str, Any] = {}
|
|
44
|
+
if os.path.exists(path):
|
|
45
|
+
with open(path) as fh:
|
|
46
|
+
cur = json.load(fh)
|
|
47
|
+
hooks = cur.setdefault("hooks", {})
|
|
48
|
+
for ev, entries in _hook_entries(cmd_prefix).items():
|
|
49
|
+
kept = [x for x in hooks.get(ev, []) if not any(MARKER in (h.get("command") or "") for h in x.get("hooks", []))]
|
|
50
|
+
hooks[ev] = kept + entries
|
|
51
|
+
os.makedirs(os.path.dirname(path) or ".", exist_ok=True)
|
|
52
|
+
with open(path, "w") as fh:
|
|
53
|
+
json.dump(cur, fh, indent=2)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def unmerge_settings(path: str) -> None:
|
|
57
|
+
if not os.path.exists(path):
|
|
58
|
+
return
|
|
59
|
+
with open(path) as fh:
|
|
60
|
+
cur = json.load(fh)
|
|
61
|
+
hooks = cur.get("hooks", {})
|
|
62
|
+
for ev in list(hooks):
|
|
63
|
+
hooks[ev] = [x for x in hooks[ev] if not any(MARKER in (h.get("command") or "") for h in x.get("hooks", []))]
|
|
64
|
+
if not hooks[ev]:
|
|
65
|
+
del hooks[ev]
|
|
66
|
+
if not hooks:
|
|
67
|
+
cur.pop("hooks", None)
|
|
68
|
+
if cur:
|
|
69
|
+
with open(path, "w") as fh:
|
|
70
|
+
json.dump(cur, fh, indent=2)
|
|
71
|
+
else:
|
|
72
|
+
os.remove(path)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _write_git_hook(dirpath: str) -> str:
|
|
76
|
+
os.makedirs(dirpath, exist_ok=True)
|
|
77
|
+
p = os.path.join(dirpath, "prepare-commit-msg")
|
|
78
|
+
with open(p, "w") as fh:
|
|
79
|
+
fh.write(GIT_HOOK)
|
|
80
|
+
os.chmod(p, 0o755) # noqa: S103 # nosec B103 - git runs hooks as the invoking user; must be executable
|
|
81
|
+
return p
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def install_user(home: str, cmd_prefix: str = "gitvow") -> list[str]:
|
|
85
|
+
base = os.path.join(home, ".gitvow")
|
|
86
|
+
os.makedirs(base, exist_ok=True)
|
|
87
|
+
done = []
|
|
88
|
+
pol = os.path.join(base, "policy.json")
|
|
89
|
+
if not os.path.exists(pol):
|
|
90
|
+
from .policy import DEFAULT_POLICY_PATH
|
|
91
|
+
|
|
92
|
+
shutil.copy(DEFAULT_POLICY_PATH, pol)
|
|
93
|
+
done.append(f"default policy → {pol}")
|
|
94
|
+
_write_git_hook(os.path.join(base, "git-hooks"))
|
|
95
|
+
merge_settings(os.path.join(home, ".claude", "settings.json"), cmd_prefix)
|
|
96
|
+
git(["config", "--global", "core.hooksPath", os.path.join(base, "git-hooks")], home)
|
|
97
|
+
done += ["hooks merged into ~/.claude/settings.json", "global core.hooksPath → ~/.gitvow/git-hooks"]
|
|
98
|
+
return done
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def uninstall_user(home: str, purge_policy: bool = False, purge_ledger: bool = False) -> list[str]:
|
|
102
|
+
base = os.path.join(home, ".gitvow")
|
|
103
|
+
done = []
|
|
104
|
+
unmerge_settings(os.path.join(home, ".claude", "settings.json"))
|
|
105
|
+
rc, cur, _ = git(["config", "--global", "--get", "core.hooksPath"], home)
|
|
106
|
+
if rc == 0 and cur == os.path.join(base, "git-hooks"):
|
|
107
|
+
git(["config", "--global", "--unset", "core.hooksPath"], home)
|
|
108
|
+
done.append("global core.hooksPath unset")
|
|
109
|
+
shutil.rmtree(os.path.join(base, "git-hooks"), ignore_errors=True)
|
|
110
|
+
if purge_policy and os.path.exists(os.path.join(base, "policy.json")):
|
|
111
|
+
os.remove(os.path.join(base, "policy.json"))
|
|
112
|
+
done.append("policy removed")
|
|
113
|
+
if purge_ledger:
|
|
114
|
+
shutil.rmtree(os.path.join(base, "ledger"), ignore_errors=True)
|
|
115
|
+
done.append("ledger removed")
|
|
116
|
+
done.append("hook entries removed from ~/.claude/settings.json")
|
|
117
|
+
return done
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def install_repo(repo: str, cmd_prefix: str = "gitvow") -> list[str]:
|
|
121
|
+
base = os.path.join(repo, ".gitvow")
|
|
122
|
+
os.makedirs(base, exist_ok=True)
|
|
123
|
+
from .policy import DEFAULT_POLICY_PATH
|
|
124
|
+
|
|
125
|
+
pol = os.path.join(base, "policy.json")
|
|
126
|
+
if not os.path.exists(pol):
|
|
127
|
+
shutil.copy(DEFAULT_POLICY_PATH, pol)
|
|
128
|
+
_write_git_hook(os.path.join(base, "git-hooks"))
|
|
129
|
+
merge_settings(os.path.join(repo, ".claude", "settings.json"), cmd_prefix)
|
|
130
|
+
git(["config", "core.hooksPath", ".gitvow/git-hooks"], repo)
|
|
131
|
+
return [
|
|
132
|
+
f"policy → {pol}",
|
|
133
|
+
"hooks merged into .claude/settings.json",
|
|
134
|
+
"core.hooksPath → .gitvow/git-hooks",
|
|
135
|
+
"commit .gitvow/ and .claude/settings.json to share; teammates run: git config core.hooksPath .gitvow/git-hooks",
|
|
136
|
+
]
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def uninstall_repo(repo: str, purge_notes: bool = False) -> list[str]:
|
|
140
|
+
done = []
|
|
141
|
+
unmerge_settings(os.path.join(repo, ".claude", "settings.json"))
|
|
142
|
+
rc, cur, _ = git(["config", "--get", "core.hooksPath"], repo)
|
|
143
|
+
if rc == 0 and cur == ".gitvow/git-hooks":
|
|
144
|
+
git(["config", "--unset", "core.hooksPath"], repo)
|
|
145
|
+
done.append("core.hooksPath unset")
|
|
146
|
+
shutil.rmtree(os.path.join(repo, ".gitvow"), ignore_errors=True)
|
|
147
|
+
rc, gd, _ = git(["rev-parse", "--git-dir"], repo)
|
|
148
|
+
if rc == 0:
|
|
149
|
+
gd = gd if os.path.isabs(gd) else os.path.join(repo, gd)
|
|
150
|
+
for f in ("gitvow-session.json", "gitvow-hooks.log"):
|
|
151
|
+
p = os.path.join(gd, f)
|
|
152
|
+
if os.path.exists(p):
|
|
153
|
+
os.remove(p)
|
|
154
|
+
if purge_notes:
|
|
155
|
+
git(["update-ref", "-d", "refs/notes/sessions"], repo)
|
|
156
|
+
done.append("local refs/notes/sessions deleted (remote copies untouched)")
|
|
157
|
+
done.append(".gitvow removed; commit trailers already in history remain")
|
|
158
|
+
return done
|
gitvow/policy.py
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
"""Tool-call policy: deny → confirm → allow, evaluated on Bash command text, edited file paths, and MCP tool names."""
|
|
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
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
DEFAULT_POLICY_PATH = os.path.join(os.path.dirname(__file__), "default_policy.json")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class PolicyError(Exception):
|
|
17
|
+
"""Raised when no valid policy can be loaded. Callers must fail closed."""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass(frozen=True)
|
|
21
|
+
class Decision:
|
|
22
|
+
outcome: str # "allow" | "deny" | "confirm"
|
|
23
|
+
reason: str = ""
|
|
24
|
+
detail: str = ""
|
|
25
|
+
|
|
26
|
+
@property
|
|
27
|
+
def blocks(self) -> bool:
|
|
28
|
+
return self.outcome != "allow"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def load_policy(cwd: str | None = None, home: str | None = None) -> dict[str, Any]:
|
|
32
|
+
"""Precedence: <repo>/.gitvow/policy.json → ~/.gitvow/policy.json → package default."""
|
|
33
|
+
cwd = cwd or os.getcwd()
|
|
34
|
+
home = home or os.path.expanduser("~")
|
|
35
|
+
for p in (
|
|
36
|
+
os.path.join(cwd, ".gitvow", "policy.json"),
|
|
37
|
+
os.path.join(home, ".gitvow", "policy.json"),
|
|
38
|
+
DEFAULT_POLICY_PATH,
|
|
39
|
+
):
|
|
40
|
+
if os.path.exists(p):
|
|
41
|
+
try:
|
|
42
|
+
with open(p) as fh:
|
|
43
|
+
pol = json.load(fh)
|
|
44
|
+
except (OSError, json.JSONDecodeError) as e:
|
|
45
|
+
raise PolicyError(f"{p}: {e}") from e
|
|
46
|
+
_validate(pol, p)
|
|
47
|
+
return pol
|
|
48
|
+
raise PolicyError("no policy.json found")
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _validate(pol: dict[str, Any], path: str) -> None:
|
|
52
|
+
for key in ("bash_deny", "bash_confirm", "path_confirm"):
|
|
53
|
+
for rule in pol.get(key, []):
|
|
54
|
+
if not isinstance(rule, dict) or "pattern" not in rule:
|
|
55
|
+
raise PolicyError(f"{path}: {key} entries need a 'pattern'")
|
|
56
|
+
try:
|
|
57
|
+
re.compile(rule["pattern"])
|
|
58
|
+
except re.error as e:
|
|
59
|
+
raise PolicyError(f"{path}: bad regex in {key}: {rule['pattern']} ({e})") from e
|
|
60
|
+
for key in ("mcp_allow", "mcp_deny"):
|
|
61
|
+
for pat in pol.get(key, []):
|
|
62
|
+
try:
|
|
63
|
+
re.compile(pat)
|
|
64
|
+
except re.error as e:
|
|
65
|
+
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:
|
|
69
|
+
text = tool_input.get("command", "") if tool == "Bash" else ""
|
|
70
|
+
path = tool_input.get("file_path", "") or tool_input.get("notebook_path", "")
|
|
71
|
+
if tool == "Bash":
|
|
72
|
+
for r in pol.get("bash_deny", []):
|
|
73
|
+
if re.search(r["pattern"], text):
|
|
74
|
+
return Decision("deny", r.get("reason", "denied"), text)
|
|
75
|
+
for r in pol.get("bash_confirm", []):
|
|
76
|
+
if re.search(r["pattern"], text):
|
|
77
|
+
return Decision("confirm", r.get("reason", "needs confirmation"), text)
|
|
78
|
+
if tool in ("Edit", "Write", "MultiEdit", "NotebookEdit") and path:
|
|
79
|
+
for r in pol.get("path_confirm", []):
|
|
80
|
+
if re.search(r["pattern"], path):
|
|
81
|
+
return Decision("confirm", r.get("reason", "sensitive path"), path)
|
|
82
|
+
if tool.startswith("mcp__"):
|
|
83
|
+
if any(re.fullmatch(p, tool) for p in pol.get("mcp_deny", [])):
|
|
84
|
+
return Decision("deny", "MCP tool on deny list", tool)
|
|
85
|
+
allow = pol.get("mcp_allow", [])
|
|
86
|
+
if allow and not any(re.fullmatch(p, tool) for p in allow):
|
|
87
|
+
return Decision("confirm", "MCP tool not on allow list", tool)
|
|
88
|
+
clf = pol.get("llm_classifier") or {}
|
|
89
|
+
if clf.get("enabled") and clf.get("command"):
|
|
90
|
+
return _classify(clf["command"], tool, tool_input, text or path or tool)
|
|
91
|
+
return Decision("allow")
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _classify(command: str, tool: str, tool_input: dict[str, Any], detail: str) -> Decision:
|
|
95
|
+
payload = json.dumps({"tool_name": tool, "tool_input": tool_input})
|
|
96
|
+
try:
|
|
97
|
+
argv = shlex.split(command) if isinstance(command, str) else [str(a) for a in command]
|
|
98
|
+
if not argv:
|
|
99
|
+
return Decision("confirm", "classifier command is empty", detail)
|
|
100
|
+
# No shell: the policy file names a program and its arguments, so policy text
|
|
101
|
+
# can never become shell syntax.
|
|
102
|
+
r = subprocess.run(argv, input=payload, capture_output=True, text=True, timeout=30, check=False)
|
|
103
|
+
except (OSError, subprocess.TimeoutExpired):
|
|
104
|
+
return Decision("confirm", "classifier unavailable", detail)
|
|
105
|
+
parts = r.stdout.strip().split(maxsplit=1) or ["ALLOW"]
|
|
106
|
+
verdict, reason = parts[0].upper(), (parts[1] if len(parts) > 1 else "")
|
|
107
|
+
if verdict == "DENY":
|
|
108
|
+
return Decision("deny", "classifier: " + reason, detail)
|
|
109
|
+
if verdict == "CONFIRM":
|
|
110
|
+
return Decision("confirm", "classifier: " + reason, detail)
|
|
111
|
+
return Decision("allow")
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def message_for(d: Decision) -> str:
|
|
115
|
+
if d.outcome == "deny":
|
|
116
|
+
return f"BLOCKED by policy ({d.reason})."
|
|
117
|
+
return (
|
|
118
|
+
f"CONFIRMATION REQUIRED ({d.reason}). Ask the user explicitly before doing this; "
|
|
119
|
+
"if they confirm, tell them to re-run with the policy exception or perform it manually."
|
|
120
|
+
)
|
gitvow/redact.py
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""Redaction of secrets and personal data at write time. Best effort by design; see SECURITY.md."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
import math
|
|
8
|
+
import re
|
|
9
|
+
from collections import Counter
|
|
10
|
+
from collections.abc import Callable, Iterable
|
|
11
|
+
|
|
12
|
+
_EMAIL = re.compile(r"[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _email_token(m: re.Match[str]) -> str:
|
|
16
|
+
return "[email:" + hashlib.sha256(m.group(0).lower().encode()).hexdigest()[:8] + "]"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
PATTERNS: list[tuple[re.Pattern[str], str | Callable[[re.Match[str]], str]]] = [
|
|
20
|
+
(re.compile(r"AKIA[0-9A-Z]{16}"), "[aws-access-key]"),
|
|
21
|
+
(re.compile(r"(?i)(aws_secret_access_key|secret_access_key)\s*[:=]\s*\S+"), r"\1=[redacted]"),
|
|
22
|
+
(re.compile(r"eyJ[A-Za-z0-9_-]{10,}\.eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{5,}"), "[jwt]"),
|
|
23
|
+
(re.compile(r"\b(gh[pousr]|github_pat)_[A-Za-z0-9_]{20,}"), "[github-token]"),
|
|
24
|
+
(re.compile(r"\bsk-[A-Za-z0-9_-]{20,}"), "[api-key]"),
|
|
25
|
+
(re.compile(r"\bxox[abprs]-[A-Za-z0-9-]{10,}"), "[slack-token]"),
|
|
26
|
+
(re.compile(r"\bAIza[0-9A-Za-z_-]{35}"), "[google-api-key]"),
|
|
27
|
+
(
|
|
28
|
+
re.compile(
|
|
29
|
+
r"(?i)(password|passwd|pwd|token|secret|api[_-]?key|authorization|bearer)\s*[:=]\s*[\"']?[^\s\"']{6,}"
|
|
30
|
+
),
|
|
31
|
+
r"\1=[redacted]",
|
|
32
|
+
),
|
|
33
|
+
(re.compile(r"(?i)\b(mysql|postgres(?:ql)?|redis|mongodb(?:\+srv)?|amqp)://[^\s\"']+"), r"\1://[redacted-dsn]"),
|
|
34
|
+
(re.compile(r"-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----"), "[private-key]"),
|
|
35
|
+
(_EMAIL, _email_token),
|
|
36
|
+
(re.compile(r"\b\d{3}-\d{2}-\d{4}\b"), "[ssn-like]"),
|
|
37
|
+
(re.compile(r"\b\d(?:[ -]?\d){12,18}\b(?![ -]?\d)"), "[card-like-number]"),
|
|
38
|
+
(re.compile(r"\b(?:\d{1,3}\.){3}\d{1,3}\b"), "[ip]"),
|
|
39
|
+
]
|
|
40
|
+
|
|
41
|
+
_ENTROPY_TOKEN = re.compile(r"[A-Za-z0-9+/=_-]{20,}")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def shannon_entropy(s: str) -> float:
|
|
45
|
+
if not s:
|
|
46
|
+
return 0.0
|
|
47
|
+
counts = Counter(s)
|
|
48
|
+
n = len(s)
|
|
49
|
+
return -sum((c / n) * math.log2(c / n) for c in counts.values())
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def redact_high_entropy(s: str, threshold: float = 4.5, min_len: int = 20) -> str:
|
|
53
|
+
"""Replace long alphanumeric runs whose Shannon entropy exceeds the threshold. Catches unknown secret formats."""
|
|
54
|
+
|
|
55
|
+
def repl(m: re.Match[str]) -> str:
|
|
56
|
+
tok = m.group(0)
|
|
57
|
+
if len(tok) >= min_len and shannon_entropy(tok) > threshold and not tok.startswith(("http", "/")):
|
|
58
|
+
return "[high-entropy]"
|
|
59
|
+
return tok
|
|
60
|
+
|
|
61
|
+
return _ENTROPY_TOKEN.sub(repl, s)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def redact(value: object, custom: Iterable[tuple[str, str]] = ()) -> str:
|
|
65
|
+
"""Redact a string (or JSON-serialisable value). Custom rules are (regex, replacement) pairs applied first."""
|
|
66
|
+
s = value if isinstance(value, str) else json.dumps(value, default=str)
|
|
67
|
+
for pat, custom_rep in custom:
|
|
68
|
+
s = re.sub(pat, custom_rep, s)
|
|
69
|
+
for rx, rep in PATTERNS:
|
|
70
|
+
s = rx.sub(rep, s)
|
|
71
|
+
return redact_high_entropy(s)
|
gitvow/selftest.py
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"""Self-check: drives every hook in a throwaway repository and reports pass/fail. Never touches a real repository."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import shutil
|
|
7
|
+
import subprocess
|
|
8
|
+
import tempfile
|
|
9
|
+
|
|
10
|
+
from .hooks import post_tool_use, pre_tool_use, session_start, stop
|
|
11
|
+
from .install import _write_git_hook
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def run() -> int:
|
|
15
|
+
home = tempfile.mkdtemp(prefix="gitvow-home-")
|
|
16
|
+
repo = tempfile.mkdtemp(prefix="gitvow-repo-")
|
|
17
|
+
results: list[tuple[bool, str]] = []
|
|
18
|
+
|
|
19
|
+
def g(*a: str) -> str:
|
|
20
|
+
return subprocess.run(["git", *a], cwd=repo, capture_output=True, text=True).stdout.strip()
|
|
21
|
+
|
|
22
|
+
try:
|
|
23
|
+
g("init", "-q")
|
|
24
|
+
g("config", "user.email", "selftest@local")
|
|
25
|
+
g("config", "user.name", "selftest")
|
|
26
|
+
with open(os.path.join(repo, "a.txt"), "w") as fh:
|
|
27
|
+
fh.write("a\n")
|
|
28
|
+
g("add", "a.txt")
|
|
29
|
+
g("commit", "-qm", "init")
|
|
30
|
+
base = {"session_id": "selftest-session", "transcript_path": "", "cwd": repo}
|
|
31
|
+
session_start(base, home)
|
|
32
|
+
results.append((os.path.exists(os.path.join(repo, ".git", "gitvow-session.json")), "session recorded in .git"))
|
|
33
|
+
results.append(
|
|
34
|
+
(
|
|
35
|
+
pre_tool_use(
|
|
36
|
+
{**base, "tool_name": "Bash", "tool_input": {"command": "git push --force origin main"}}, home
|
|
37
|
+
)[0]
|
|
38
|
+
== 2,
|
|
39
|
+
"deny: force push blocked",
|
|
40
|
+
)
|
|
41
|
+
)
|
|
42
|
+
results.append(
|
|
43
|
+
(
|
|
44
|
+
pre_tool_use({**base, "tool_name": "Bash", "tool_input": {"command": "git push origin feat"}}, home)[0]
|
|
45
|
+
== 2,
|
|
46
|
+
"confirm: git push requires asking",
|
|
47
|
+
)
|
|
48
|
+
)
|
|
49
|
+
results.append(
|
|
50
|
+
(
|
|
51
|
+
pre_tool_use({**base, "tool_name": "Bash", "tool_input": {"command": "ls -la"}}, home)[0] == 0,
|
|
52
|
+
"allow: harmless command",
|
|
53
|
+
)
|
|
54
|
+
)
|
|
55
|
+
results.append(
|
|
56
|
+
(
|
|
57
|
+
pre_tool_use({**base, "tool_name": "Edit", "tool_input": {"file_path": "x/authz_rules.py"}}, home)[0]
|
|
58
|
+
== 2,
|
|
59
|
+
"confirm: gate-bearing file edit",
|
|
60
|
+
)
|
|
61
|
+
)
|
|
62
|
+
results.append(
|
|
63
|
+
(
|
|
64
|
+
pre_tool_use({**base, "tool_name": "mcp__x__delete_thing", "tool_input": {}}, home)[0] == 2,
|
|
65
|
+
"deny: destructive MCP tool",
|
|
66
|
+
)
|
|
67
|
+
)
|
|
68
|
+
pre_tool_use({**base, "tool_name": "Bash", "tool_input": {"command": "git commit -m x"}}, home)
|
|
69
|
+
hooks_dir = os.path.join(repo, ".gitvow", "git-hooks")
|
|
70
|
+
_write_git_hook(hooks_dir)
|
|
71
|
+
g("config", "core.hooksPath", ".gitvow/git-hooks")
|
|
72
|
+
with open(os.path.join(repo, "a.txt"), "a") as fh:
|
|
73
|
+
fh.write("b\n")
|
|
74
|
+
g("commit", "-qam", "selftest commit")
|
|
75
|
+
results.append(("Gitvow-Session:" in g("log", "-1", "--format=%B"), "commit trailer added"))
|
|
76
|
+
post_tool_use({**base, "tool_name": "Bash", "tool_input": {"command": "git commit -m x"}}, home)
|
|
77
|
+
results.append(
|
|
78
|
+
(
|
|
79
|
+
g("notes", "--ref=sessions", "list").count("\n") + (1 if g("notes", "--ref=sessions", "list") else 0)
|
|
80
|
+
== 1,
|
|
81
|
+
"session note attached",
|
|
82
|
+
)
|
|
83
|
+
)
|
|
84
|
+
stop(base, home)
|
|
85
|
+
results.append(
|
|
86
|
+
(os.path.exists(os.path.join(home, ".gitvow", "ledger", "selftest-session.json")), "ledger written")
|
|
87
|
+
)
|
|
88
|
+
finally:
|
|
89
|
+
shutil.rmtree(home, ignore_errors=True)
|
|
90
|
+
shutil.rmtree(repo, ignore_errors=True)
|
|
91
|
+
for ok, name in results:
|
|
92
|
+
print((" ok " if ok else " FAIL ") + name)
|
|
93
|
+
failed = sum(1 for ok, _ in results if not ok)
|
|
94
|
+
print(f"\nselftest: {len(results) - failed} passed, {failed} failed")
|
|
95
|
+
return 1 if failed else 0
|
gitvow/state.py
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""Per-repository state kept inside .git (never in the tree) and the append-only hook log."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import subprocess
|
|
8
|
+
import time
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def git(args: list[str], cwd: str) -> tuple[int, str, str]:
|
|
13
|
+
r = subprocess.run(["git", *args], cwd=cwd, capture_output=True, text=True)
|
|
14
|
+
return r.returncode, r.stdout.strip(), r.stderr.strip()
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def git_dir(cwd: str) -> str | None:
|
|
18
|
+
rc, out, _ = git(["rev-parse", "--git-dir"], cwd)
|
|
19
|
+
if rc != 0:
|
|
20
|
+
return None
|
|
21
|
+
return out if os.path.isabs(out) else os.path.join(cwd, out)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def state_path(cwd: str) -> str | None:
|
|
25
|
+
gd = git_dir(cwd)
|
|
26
|
+
return os.path.join(gd, "gitvow-session.json") if gd else None
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def load_state(cwd: str) -> dict[str, Any]:
|
|
30
|
+
p = state_path(cwd)
|
|
31
|
+
if p and os.path.exists(p):
|
|
32
|
+
try:
|
|
33
|
+
with open(p) as fh:
|
|
34
|
+
return json.load(fh)
|
|
35
|
+
except (OSError, json.JSONDecodeError):
|
|
36
|
+
return {}
|
|
37
|
+
return {}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def save_state(cwd: str, st: dict[str, Any]) -> None:
|
|
41
|
+
p = state_path(cwd)
|
|
42
|
+
if p:
|
|
43
|
+
with open(p, "w") as fh:
|
|
44
|
+
json.dump(st, fh, indent=1)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def log_event(cwd: str, kind: str, payload: dict[str, Any]) -> None:
|
|
48
|
+
gd = git_dir(cwd)
|
|
49
|
+
if not gd:
|
|
50
|
+
return
|
|
51
|
+
with open(os.path.join(gd, "gitvow-hooks.log"), "a") as fh:
|
|
52
|
+
fh.write(json.dumps({"ts": time.strftime("%Y-%m-%dT%H:%M:%S"), "kind": kind, **payload}) + "\n")
|
gitvow/transcript.py
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""Structural, redacted summary of an agent transcript (Claude Code JSONL). Tool output is never read."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from .redact import redact
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def summarize(path: str | None, max_tools: int = 500) -> dict[str, Any]:
|
|
13
|
+
out: dict[str, Any] = {"turns": 0, "tool_calls": [], "last_assistant_text": "", "files_written": []}
|
|
14
|
+
if not path or not os.path.exists(path):
|
|
15
|
+
return out
|
|
16
|
+
written: set[str] = set()
|
|
17
|
+
with open(path, errors="ignore") as fh:
|
|
18
|
+
for line in fh:
|
|
19
|
+
try:
|
|
20
|
+
ev = json.loads(line)
|
|
21
|
+
except json.JSONDecodeError:
|
|
22
|
+
continue
|
|
23
|
+
msg = ev.get("message") or {}
|
|
24
|
+
role = msg.get("role") or ev.get("type")
|
|
25
|
+
content = msg.get("content")
|
|
26
|
+
if role != "assistant":
|
|
27
|
+
continue
|
|
28
|
+
out["turns"] += 1
|
|
29
|
+
if isinstance(content, str):
|
|
30
|
+
if content.strip():
|
|
31
|
+
out["last_assistant_text"] = redact(content)[:600]
|
|
32
|
+
continue
|
|
33
|
+
if not isinstance(content, list):
|
|
34
|
+
continue
|
|
35
|
+
for c in content:
|
|
36
|
+
if not isinstance(c, dict):
|
|
37
|
+
continue
|
|
38
|
+
if c.get("type") == "tool_use" and len(out["tool_calls"]) < max_tools:
|
|
39
|
+
inp = c.get("input") or {}
|
|
40
|
+
brief = inp.get("command") or inp.get("file_path") or inp.get("query") or inp.get("pattern") or ""
|
|
41
|
+
out["tool_calls"].append({"tool": c.get("name"), "arg": redact(str(brief))[:160]})
|
|
42
|
+
if c.get("name") in ("Edit", "Write", "MultiEdit", "NotebookEdit") and inp.get("file_path"):
|
|
43
|
+
written.add(str(inp["file_path"]))
|
|
44
|
+
elif c.get("type") == "text" and str(c.get("text", "")).strip():
|
|
45
|
+
out["last_assistant_text"] = redact(c["text"])[:600]
|
|
46
|
+
out["files_written"] = sorted(written)
|
|
47
|
+
return out
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: gitvow
|
|
3
|
+
Version: 0.1.0
|
|
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
|
+
Author-email: Nikhil Bora <nikhil@wirevow.com>
|
|
6
|
+
License: Apache-2.0
|
|
7
|
+
Project-URL: Homepage, https://wirevow.dev/gitvow
|
|
8
|
+
Project-URL: Documentation, https://wirevow.dev/gitvow
|
|
9
|
+
Project-URL: Repository, https://github.com/wirevow/gitvow
|
|
10
|
+
Project-URL: Security, https://github.com/wirevow/gitvow/blob/main/SECURITY.md
|
|
11
|
+
Keywords: git,provenance,ai-agents,claude-code,hooks,policy
|
|
12
|
+
Classifier: Development Status :: 3 - Alpha
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: License :: OSI Approved :: Apache Software License
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Topic :: Software Development :: Version Control :: Git
|
|
17
|
+
Requires-Python: >=3.9
|
|
18
|
+
Description-Content-Type: text/markdown
|
|
19
|
+
License-File: LICENSE
|
|
20
|
+
Provides-Extra: dev
|
|
21
|
+
Requires-Dist: pytest>=8; extra == "dev"
|
|
22
|
+
Requires-Dist: pytest-cov>=5; extra == "dev"
|
|
23
|
+
Requires-Dist: ruff>=0.6; extra == "dev"
|
|
24
|
+
Requires-Dist: bandit>=1.7; extra == "dev"
|
|
25
|
+
Requires-Dist: pip-audit>=2.7; extra == "dev"
|
|
26
|
+
Requires-Dist: mypy>=1.10; extra == "dev"
|
|
27
|
+
Dynamic: license-file
|
|
28
|
+
|
|
29
|
+
# gitvow
|
|
30
|
+
|
|
31
|
+
Provenance and a policy gate for AI-agent coding sessions, stored in the git you already have.
|
|
32
|
+
|
|
33
|
+
[](https://github.com/wirevow/gitvow/actions/workflows/ci.yml)
|
|
34
|
+
[](https://github.com/wirevow/gitvow/actions/workflows/codeql.yml)
|
|
35
|
+
  
|
|
36
|
+
|
|
37
|
+
**Who made this change, what were they trying to do, and was it allowed?** For code written with AI agents, git alone cannot answer. gitvow makes it answer.
|
|
38
|
+
|
|
39
|
+
- Commits made during an agent session carry the session id and a step number as **trailers**.
|
|
40
|
+
- Each such commit gets a **session note**: the agent's stated plan, the tools it used, the files it touched, how much of the commit it wrote. Redacted, stored as a git note, never in the tree.
|
|
41
|
+
- Every tool call passes a **gate** first: destructive commands are refused, risky ones require asking you, edits to gate-bearing files need a human. The rules are a JSON file you own. Missing policy fails closed.
|
|
42
|
+
- A **ledger** of the whole session stays in your home directory. Nothing leaves the machine unless you push it.
|
|
43
|
+
|
|
44
|
+
Standard-library Python and git. No runtime dependencies, no network calls, no telemetry.
|
|
45
|
+
|
|
46
|
+
## Quick start
|
|
47
|
+
|
|
48
|
+
```sh
|
|
49
|
+
pip install gitvow
|
|
50
|
+
gitvow install --user
|
|
51
|
+
gitvow selftest
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
Work in Claude Code as usual. When the agent commits:
|
|
55
|
+
|
|
56
|
+
```sh
|
|
57
|
+
$ git log -1 --format=%B
|
|
58
|
+
Fix week-start cache key
|
|
59
|
+
|
|
60
|
+
Gitvow-Session: 8f3d5c71-574a-4eec-8903-9425e3a8335b
|
|
61
|
+
Gitvow-Step: 4
|
|
62
|
+
|
|
63
|
+
$ gitvow show HEAD
|
|
64
|
+
...
|
|
65
|
+
gitvow-session
|
|
66
|
+
{
|
|
67
|
+
"step": 4,
|
|
68
|
+
"tools_used": ["Bash", "Edit", "Read"],
|
|
69
|
+
"last_stated_plan": "Change the cache key to include week start so per-org settings do not collide...",
|
|
70
|
+
"files_in_commit": ["query-engine/.../QueryCacheHelper.java | 4 +++-"],
|
|
71
|
+
"files_written_by_agent_this_session": ["query-engine/.../QueryCacheHelper.java"],
|
|
72
|
+
"attribution": {"files_in_commit": 1, "touched_by_agent": 1}
|
|
73
|
+
}
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
Try the gate by hand:
|
|
77
|
+
|
|
78
|
+
```sh
|
|
79
|
+
gitvow check -- git push --force # DENY: force push
|
|
80
|
+
gitvow check -- kubectl apply -f x.yaml # CONFIRM: cluster apply
|
|
81
|
+
gitvow check --path core/authz_rules.go # CONFIRM: edits an authorization or gate file
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
Remove everything:
|
|
85
|
+
|
|
86
|
+
```sh
|
|
87
|
+
gitvow uninstall --user
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
## Documentation
|
|
91
|
+
|
|
92
|
+
The docs site is the source of truth: **https://wirevow.dev/gitvow** (built from `docs/`).
|
|
93
|
+
|
|
94
|
+
- [Quick start](docs/quickstart.md)
|
|
95
|
+
- Concepts: [Sessions, steps and notes](docs/concepts/sessions.md) · [The gate](docs/concepts/gate.md) · [What stays out of git](docs/concepts/storage.md)
|
|
96
|
+
- Guides: [Install per user or per repo](docs/guides/install.md) · [Write a policy](docs/guides/policy.md) · [Read a commit's session](docs/guides/reading.md) · [Run a trial](docs/guides/trial.md) · [Redaction](docs/guides/redaction.md)
|
|
97
|
+
- Reference: [CLI](docs/reference/cli.md) · [Hook payloads](docs/reference/hooks.md) · [Note schema](docs/reference/note.md) · [Policy schema](docs/reference/policy.md)
|
|
98
|
+
- [Security](docs/security.md) · [Roadmap](docs/roadmap.md) · [FAQ](docs/faq.md)
|
|
99
|
+
|
|
100
|
+
## How it works
|
|
101
|
+
|
|
102
|
+
```
|
|
103
|
+
Claude Code ──hook──▶ gitvow hook PreToolUse ──▶ policy ──▶ allow / confirm / deny (exit 0 / 2 / 2)
|
|
104
|
+
──hook──▶ gitvow hook PostToolUse ─▶ on `git commit`: read transcript → redact → git notes add
|
|
105
|
+
git commit ──prepare-commit-msg──▶ Gitvow-Session / Gitvow-Step trailers (from .git/gitvow-session.json)
|
|
106
|
+
Claude Code ──hook──▶ gitvow hook Stop ─────────▶ ~/.gitvow/ledger/<session>.json
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
| Data | Where | Enters git? |
|
|
110
|
+
|---|---|---|
|
|
111
|
+
| session id, step | commit trailers | yes |
|
|
112
|
+
| session note (structure, redacted plan, attribution) | `refs/notes/sessions` | as a note; local until pushed |
|
|
113
|
+
| ledger, hook log, session state | `~/.gitvow/`, `<repo>/.git/` | no |
|
|
114
|
+
| transcript | untouched | never |
|
|
115
|
+
|
|
116
|
+
## Status
|
|
117
|
+
|
|
118
|
+
0.1.0. Used in a small internal trial; the [roadmap](docs/roadmap.md) lists what comes next and what is deliberately not planned. Claude Code is the only agent supported today; the hook payload is [documented](docs/reference/hooks.md) so adapters are straightforward.
|
|
119
|
+
|
|
120
|
+
## Contributing and security
|
|
121
|
+
|
|
122
|
+
See [CONTRIBUTING.md](CONTRIBUTING.md) and [SECURITY.md](SECURITY.md). Reproductions of redaction gaps must use synthetic secrets.
|
|
123
|
+
|
|
124
|
+
Apache-2.0.
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
gitvow/__init__.py,sha256=2ucnFWzCsC7eNPXs7KSaA5NNOZzE7XCtuLOtxrGPYRw,94
|
|
2
|
+
gitvow/cli.py,sha256=YdsHTZ4RTIo6SseT3-guciqxIKbKyAknoy_PefchqPI,4656
|
|
3
|
+
gitvow/collect.py,sha256=5cyLpRDYvuC1mHOjgCDUlR4_22jyj-RS2U8u14JW4f8,4279
|
|
4
|
+
gitvow/install.py,sha256=kbfxAg3jmD_xuX9bBXBwwgg2qUWgPG-Sdf0CphtGbt4,6513
|
|
5
|
+
gitvow/policy.py,sha256=KEawRpYGPZYw-sEO20Qc-AzVUbpFu_Wx5B1QAe7I0IU,4986
|
|
6
|
+
gitvow/redact.py,sha256=K3KgpqXuhi5oRSDasVy_-qzJ0moqoBEZacMroc4400U,2864
|
|
7
|
+
gitvow/selftest.py,sha256=8JJ06e5JId34iLWyiAub0jlU3c7MGjBwjj8oL-izsDU,3731
|
|
8
|
+
gitvow/state.py,sha256=cyxI47N4WsMYiDl5hGLiny2QAbgGsEbSRzgOrvzDF2k,1456
|
|
9
|
+
gitvow/transcript.py,sha256=f6RpBlokbm9wBjFfSwfdVHA3M1wBvFYGwAnHdDOQjIA,1993
|
|
10
|
+
gitvow/hooks/__init__.py,sha256=cs8E4VuA2mVEBVoAXz3j3dSof0RAz8sXhcr2Q1NASEQ,5399
|
|
11
|
+
gitvow-0.1.0.dist-info/licenses/LICENSE,sha256=w3ZN927TGUiCY6tMBF4jHIthy-WAF27Tnwmr6e1eTkE,748
|
|
12
|
+
gitvow-0.1.0.dist-info/METADATA,sha256=vhnPaOWfZqsdecRag5vxhphbq2vTTbo75mg-9YgERuk,5788
|
|
13
|
+
gitvow-0.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
14
|
+
gitvow-0.1.0.dist-info/entry_points.txt,sha256=sU94uRlBBoLDyNMOAiT912qlUEWbIq0Gj0IczuVPLJY,43
|
|
15
|
+
gitvow-0.1.0.dist-info/top_level.txt,sha256=MnguFlgJkvcXr6GvxMJeKddQbW581wRuh5NC6tDeqEw,7
|
|
16
|
+
gitvow-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
Apache License
|
|
2
|
+
Version 2.0, January 2004
|
|
3
|
+
http://www.apache.org/licenses/
|
|
4
|
+
|
|
5
|
+
Copyright 2026 gitvow contributors
|
|
6
|
+
|
|
7
|
+
Licensed under the Apache License, Version 2.0 (the "License");
|
|
8
|
+
you may not use this file except in compliance with the License.
|
|
9
|
+
You may obtain a copy of the License at
|
|
10
|
+
|
|
11
|
+
http://www.apache.org/licenses/LICENSE-2.0
|
|
12
|
+
|
|
13
|
+
Unless required by applicable law or agreed to in writing, software
|
|
14
|
+
distributed under the License is distributed on an "AS IS" BASIS,
|
|
15
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
16
|
+
See the License for the specific language governing permissions and
|
|
17
|
+
limitations under the License.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
gitvow
|