gitvow 0.3.0__tar.gz → 0.5.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.3.0/src/gitvow.egg-info → gitvow-0.5.0}/PKG-INFO +1 -1
- {gitvow-0.3.0 → gitvow-0.5.0}/pyproject.toml +1 -1
- {gitvow-0.3.0 → gitvow-0.5.0}/src/gitvow/cli.py +102 -1
- {gitvow-0.3.0 → gitvow-0.5.0}/src/gitvow/hooks/__init__.py +18 -4
- {gitvow-0.3.0 → gitvow-0.5.0}/src/gitvow/install.py +5 -1
- {gitvow-0.3.0 → gitvow-0.5.0}/src/gitvow/policy.py +21 -3
- gitvow-0.5.0/src/gitvow/providers.py +126 -0
- {gitvow-0.3.0 → gitvow-0.5.0}/src/gitvow/selftest.py +29 -0
- gitvow-0.5.0/src/gitvow/snapshots.py +183 -0
- {gitvow-0.3.0 → gitvow-0.5.0/src/gitvow.egg-info}/PKG-INFO +1 -1
- {gitvow-0.3.0 → gitvow-0.5.0}/src/gitvow.egg-info/SOURCES.txt +5 -1
- {gitvow-0.3.0 → gitvow-0.5.0}/tests/test_hooks.py +1 -1
- gitvow-0.5.0/tests/test_providers.py +160 -0
- gitvow-0.5.0/tests/test_snapshots.py +136 -0
- {gitvow-0.3.0 → gitvow-0.5.0}/LICENSE +0 -0
- {gitvow-0.3.0 → gitvow-0.5.0}/README.md +0 -0
- {gitvow-0.3.0 → gitvow-0.5.0}/setup.cfg +0 -0
- {gitvow-0.3.0 → gitvow-0.5.0}/src/gitvow/__init__.py +0 -0
- {gitvow-0.3.0 → gitvow-0.5.0}/src/gitvow/__main__.py +0 -0
- {gitvow-0.3.0 → gitvow-0.5.0}/src/gitvow/collect.py +0 -0
- {gitvow-0.3.0 → gitvow-0.5.0}/src/gitvow/default_policy.json +0 -0
- {gitvow-0.3.0 → gitvow-0.5.0}/src/gitvow/redact.py +0 -0
- {gitvow-0.3.0 → gitvow-0.5.0}/src/gitvow/report.py +0 -0
- {gitvow-0.3.0 → gitvow-0.5.0}/src/gitvow/state.py +0 -0
- {gitvow-0.3.0 → gitvow-0.5.0}/src/gitvow/transcript.py +0 -0
- {gitvow-0.3.0 → gitvow-0.5.0}/src/gitvow.egg-info/dependency_links.txt +0 -0
- {gitvow-0.3.0 → gitvow-0.5.0}/src/gitvow.egg-info/entry_points.txt +0 -0
- {gitvow-0.3.0 → gitvow-0.5.0}/src/gitvow.egg-info/requires.txt +0 -0
- {gitvow-0.3.0 → gitvow-0.5.0}/src/gitvow.egg-info/top_level.txt +0 -0
- {gitvow-0.3.0 → gitvow-0.5.0}/tests/test_collect.py +0 -0
- {gitvow-0.3.0 → gitvow-0.5.0}/tests/test_install_cli.py +0 -0
- {gitvow-0.3.0 → gitvow-0.5.0}/tests/test_packaging.py +0 -0
- {gitvow-0.3.0 → gitvow-0.5.0}/tests/test_policy.py +0 -0
- {gitvow-0.3.0 → gitvow-0.5.0}/tests/test_redact.py +0 -0
- {gitvow-0.3.0 → gitvow-0.5.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.5.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.5.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"}
|
|
@@ -9,10 +9,12 @@ import re
|
|
|
9
9
|
import sys
|
|
10
10
|
|
|
11
11
|
from . import __version__
|
|
12
|
+
from . import snapshots as snap
|
|
12
13
|
from .collect import collect, summarize_text
|
|
13
14
|
from .hooks import HANDLERS, LEGACY_NOTES_REF, notes_ref
|
|
14
15
|
from .install import install_repo, install_user, uninstall_repo, uninstall_user
|
|
15
16
|
from .policy import PolicyError, evaluate, load_policy
|
|
17
|
+
from .providers import QUESTIONS, ask
|
|
16
18
|
from .redact import RedactionError, load_rules, redact
|
|
17
19
|
from .report import build, render_markdown
|
|
18
20
|
from .state import git
|
|
@@ -43,7 +45,7 @@ def cmd_uninstall(a: argparse.Namespace) -> int:
|
|
|
43
45
|
done = (
|
|
44
46
|
uninstall_user(os.path.expanduser("~"), a.purge_policy, a.purge_ledger)
|
|
45
47
|
if a.user
|
|
46
|
-
else uninstall_repo(os.path.abspath(a.repo), a.purge_notes)
|
|
48
|
+
else uninstall_repo(os.path.abspath(a.repo), a.purge_notes, a.purge_snapshots)
|
|
47
49
|
)
|
|
48
50
|
print("\n".join(done))
|
|
49
51
|
return 0
|
|
@@ -66,6 +68,26 @@ def cmd_check(a: argparse.Namespace) -> int:
|
|
|
66
68
|
return 2 if d.blocks else 0
|
|
67
69
|
|
|
68
70
|
|
|
71
|
+
def cmd_ask(a: argparse.Namespace) -> int:
|
|
72
|
+
"""Ask every configured provider one question and show the gate's decision."""
|
|
73
|
+
try:
|
|
74
|
+
pol = load_policy(os.getcwd())
|
|
75
|
+
except PolicyError as e:
|
|
76
|
+
print(f"policy error: {e}", file=sys.stderr)
|
|
77
|
+
return 2
|
|
78
|
+
if not pol.get("providers"):
|
|
79
|
+
print("no providers configured in the active policy", file=sys.stderr)
|
|
80
|
+
return 1
|
|
81
|
+
path = a.path or (a.subject if a.question == "gate_bearing" else "")
|
|
82
|
+
answers = ask(pol, "Edit", {"file_path": path}, os.getcwd(), only=[(a.question, a.subject)])
|
|
83
|
+
for ans in answers:
|
|
84
|
+
ev = " — " + "; ".join(ans.evidence) if ans.evidence else ""
|
|
85
|
+
print(f"{ans.provider}: {ans.answer}{ev}")
|
|
86
|
+
blocked = any(x.blocks for x in answers)
|
|
87
|
+
print("decision: " + ("CONFIRM" if blocked else "ALLOW"))
|
|
88
|
+
return 2 if blocked else 0
|
|
89
|
+
|
|
90
|
+
|
|
69
91
|
def cmd_show(a: argparse.Namespace) -> int:
|
|
70
92
|
"""Print a commit's trailers and session note."""
|
|
71
93
|
rc, msg, _ = git(["log", "-1", "--format=%H%n%s%n%b", a.commit], os.getcwd())
|
|
@@ -109,6 +131,61 @@ def cmd_report(a: argparse.Namespace) -> int:
|
|
|
109
131
|
return 0
|
|
110
132
|
|
|
111
133
|
|
|
134
|
+
def cmd_snapshots(a: argparse.Namespace) -> int:
|
|
135
|
+
cwd = os.getcwd()
|
|
136
|
+
if a.sub == "prune":
|
|
137
|
+
days = None
|
|
138
|
+
if a.older_than:
|
|
139
|
+
m = re.fullmatch(r"(\d+)([dh]?)", a.older_than)
|
|
140
|
+
if not m:
|
|
141
|
+
print("--older-than takes a number of days, e.g. 14d", file=sys.stderr)
|
|
142
|
+
return 1
|
|
143
|
+
days = int(m.group(1)) / (24 if m.group(2) == "h" else 1)
|
|
144
|
+
print(f"pruned {snap.prune(cwd, days, a.session)} snapshot(s)")
|
|
145
|
+
return 0
|
|
146
|
+
rows = snap.list_snapshots(cwd, a.session)
|
|
147
|
+
if not rows:
|
|
148
|
+
print("no snapshots" + (f" for session {a.session}" if a.session else ""))
|
|
149
|
+
return 0
|
|
150
|
+
cur = None
|
|
151
|
+
for r in rows:
|
|
152
|
+
if r["session"] != cur:
|
|
153
|
+
cur = r["session"]
|
|
154
|
+
print(f"session {cur[:8]} {sum(1 for x in rows if x['session'] == cur)} snapshots")
|
|
155
|
+
print(" n taken tool file changed vs HEAD")
|
|
156
|
+
print(
|
|
157
|
+
f" {r['n']:<4d} {r['taken']:<20s} {r['tool']:<11s} {r['file'][:37]:<37s} {r['changed_files']} file{'s' if r['changed_files'] != 1 else ''}"
|
|
158
|
+
)
|
|
159
|
+
return 0
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _resolve_or_fail(a: argparse.Namespace) -> str | None:
|
|
163
|
+
ref = snap.resolve(os.getcwd(), a.session, a.n)
|
|
164
|
+
if not ref:
|
|
165
|
+
print(f"no snapshot {a.n} for a unique session matching {a.session!r}", file=sys.stderr)
|
|
166
|
+
return ref
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def cmd_diff(a: argparse.Namespace) -> int:
|
|
170
|
+
ref = _resolve_or_fail(a)
|
|
171
|
+
if not ref:
|
|
172
|
+
return 1
|
|
173
|
+
print(snap.diff(os.getcwd(), ref, a.full))
|
|
174
|
+
return 0
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def cmd_restore(a: argparse.Namespace) -> int:
|
|
178
|
+
ref = _resolve_or_fail(a)
|
|
179
|
+
if not ref:
|
|
180
|
+
return 1
|
|
181
|
+
rc, msg = snap.restore(os.getcwd(), ref, a.to)
|
|
182
|
+
print(
|
|
183
|
+
f"restored {ref} into {msg} (detached worktree; remove with: git worktree remove {msg})" if rc == 0 else msg,
|
|
184
|
+
file=sys.stderr if rc else sys.stdout,
|
|
185
|
+
)
|
|
186
|
+
return rc
|
|
187
|
+
|
|
188
|
+
|
|
112
189
|
def cmd_push_notes(a: argparse.Namespace) -> int:
|
|
113
190
|
import subprocess
|
|
114
191
|
|
|
@@ -155,6 +232,7 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
155
232
|
s.add_argument("--purge-notes", action="store_true")
|
|
156
233
|
s.add_argument("--purge-policy", action="store_true")
|
|
157
234
|
s.add_argument("--purge-ledger", action="store_true")
|
|
235
|
+
s.add_argument("--purge-snapshots", action="store_true")
|
|
158
236
|
s.set_defaults(f=cmd_uninstall)
|
|
159
237
|
s = sub.add_parser("check", help="dry-run the policy: gitvow check -- git push --force")
|
|
160
238
|
s.add_argument("command", nargs="*")
|
|
@@ -164,6 +242,11 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
164
242
|
s = sub.add_parser("show", help="print a commit's trailers and session note")
|
|
165
243
|
s.add_argument("commit", nargs="?", default="HEAD")
|
|
166
244
|
s.set_defaults(f=cmd_show)
|
|
245
|
+
s = sub.add_parser("ask", help="ask configured providers a question: gitvow ask route_gate /v1/x --path src/api.py")
|
|
246
|
+
s.add_argument("question", choices=QUESTIONS)
|
|
247
|
+
s.add_argument("subject")
|
|
248
|
+
s.add_argument("--path", default="")
|
|
249
|
+
s.set_defaults(f=cmd_ask)
|
|
167
250
|
s = sub.add_parser("redact", help="apply built-in redaction plus your rules files to text")
|
|
168
251
|
s.add_argument("text", nargs="+")
|
|
169
252
|
s.set_defaults(f=cmd_redact)
|
|
@@ -173,6 +256,24 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
173
256
|
s.add_argument("--json", action="store_true")
|
|
174
257
|
s.add_argument("--require-notes", action="store_true")
|
|
175
258
|
s.set_defaults(f=cmd_report)
|
|
259
|
+
s = sub.add_parser(
|
|
260
|
+
"snapshots", help="list working-tree snapshots taken after agent edits; 'snapshots prune' deletes"
|
|
261
|
+
)
|
|
262
|
+
s.add_argument("sub", nargs="?", choices=["prune"])
|
|
263
|
+
s.add_argument("--session")
|
|
264
|
+
s.add_argument("--all", action="store_true")
|
|
265
|
+
s.add_argument("--older-than")
|
|
266
|
+
s.set_defaults(f=cmd_snapshots)
|
|
267
|
+
s = sub.add_parser("diff", help="what the agent had changed at snapshot n: gitvow diff <session> <n>")
|
|
268
|
+
s.add_argument("session")
|
|
269
|
+
s.add_argument("n", type=int)
|
|
270
|
+
s.add_argument("--full", action="store_true")
|
|
271
|
+
s.set_defaults(f=cmd_diff)
|
|
272
|
+
s = sub.add_parser("restore", help="check a snapshot out into a detached scratch worktree")
|
|
273
|
+
s.add_argument("session")
|
|
274
|
+
s.add_argument("n", type=int)
|
|
275
|
+
s.add_argument("--to")
|
|
276
|
+
s.set_defaults(f=cmd_restore)
|
|
176
277
|
s = sub.add_parser("push-notes", help="push refs/notes/gitvow/* to a remote (default origin)")
|
|
177
278
|
s.add_argument("remote", nargs="?", default="origin")
|
|
178
279
|
s.set_defaults(f=cmd_push_notes)
|
|
@@ -8,6 +8,7 @@ import re
|
|
|
8
8
|
import time
|
|
9
9
|
from typing import Any
|
|
10
10
|
|
|
11
|
+
from .. import snapshots
|
|
11
12
|
from ..policy import PolicyError, evaluate, load_policy, message_for
|
|
12
13
|
from ..redact import RedactionError, load_rules, redact
|
|
13
14
|
from ..state import git, load_state, log_event, save_state, toplevel
|
|
@@ -15,7 +16,7 @@ from ..transcript import summarize
|
|
|
15
16
|
|
|
16
17
|
NOTES_REF_PREFIX = "gitvow" # refs/notes/gitvow/<session-id>; gitvow 0.1 wrote the single ref refs/notes/sessions
|
|
17
18
|
LEGACY_NOTES_REF = "sessions"
|
|
18
|
-
NOTE_SCHEMA =
|
|
19
|
+
NOTE_SCHEMA = 3
|
|
19
20
|
COMMIT_RE = re.compile(r"\bgit\s+commit\b")
|
|
20
21
|
EDIT_TOOLS = ("Edit", "Write", "MultiEdit", "NotebookEdit")
|
|
21
22
|
REDACTION_UNAVAILABLE = "redaction rules invalid; nothing written for this event"
|
|
@@ -37,6 +38,8 @@ def session_start(h: dict[str, Any], home: str | None = None) -> tuple[int, str]
|
|
|
37
38
|
"started": st.get("started") if same_session and st.get("started") else time.strftime("%Y-%m-%dT%H:%M:%S"),
|
|
38
39
|
"steps": st.get("steps", 0) if same_session else 0,
|
|
39
40
|
"agent_blobs": st.get("agent_blobs", {}) if same_session else {},
|
|
41
|
+
"snapshots": st.get("snapshots", 0) if same_session else 0,
|
|
42
|
+
"last_snapshot": st.get("last_snapshot") if same_session else None,
|
|
40
43
|
}
|
|
41
44
|
)
|
|
42
45
|
st.pop("pending_commit", None)
|
|
@@ -64,7 +67,7 @@ def pre_tool_use(h: dict[str, Any], home: str | None = None) -> tuple[int, str]:
|
|
|
64
67
|
2,
|
|
65
68
|
f"BLOCKED: tool policy could not be loaded ({e}); refusing all tool calls until the policy is restored.",
|
|
66
69
|
)
|
|
67
|
-
d = evaluate(pol, tool, inp)
|
|
70
|
+
d = evaluate(pol, tool, inp, cwd)
|
|
68
71
|
rules, warn = _rules_or_none(cwd, home)
|
|
69
72
|
if d.blocks:
|
|
70
73
|
payload: dict[str, Any] = {"tool": tool, "reason": d.reason, "session_id": h.get("session_id")}
|
|
@@ -160,8 +163,18 @@ def post_tool_use(h: dict[str, Any], home: str | None = None) -> tuple[int, str]
|
|
|
160
163
|
tool = h.get("tool_name")
|
|
161
164
|
inp = h.get("tool_input") or {}
|
|
162
165
|
if tool in EDIT_TOOLS:
|
|
163
|
-
|
|
164
|
-
|
|
166
|
+
fp = str(inp.get("file_path") or inp.get("notebook_path") or "")
|
|
167
|
+
if fp:
|
|
168
|
+
_record_agent_blob(cwd, fp)
|
|
169
|
+
try:
|
|
170
|
+
pol = load_policy(cwd, home)
|
|
171
|
+
except PolicyError:
|
|
172
|
+
pol = {}
|
|
173
|
+
cfg = snapshots.settings(pol)
|
|
174
|
+
if cfg.get("enabled", True):
|
|
175
|
+
ref = snapshots.take(cwd, h.get("session_id") or load_state(cwd).get("session_id"), tool, fp, cfg)
|
|
176
|
+
if ref:
|
|
177
|
+
log_event(cwd, "snapshot", {"ref": ref, "tool": tool})
|
|
165
178
|
return 0, ""
|
|
166
179
|
if tool != "Bash" or not COMMIT_RE.search(inp.get("command", "")):
|
|
167
180
|
return 0, ""
|
|
@@ -194,6 +207,7 @@ def post_tool_use(h: dict[str, Any], home: str | None = None) -> tuple[int, str]
|
|
|
194
207
|
"files_in_commit": [ln.strip() for ln in files.splitlines()[:-1]][:50] if files else [],
|
|
195
208
|
"files_written_by_agent_this_session": [f["path"] for f in attribution["files"] if f["agent_wrote"]][:50],
|
|
196
209
|
"attribution": attribution,
|
|
210
|
+
"snapshot": st.get("last_snapshot"),
|
|
197
211
|
"transcript": "kept local; see ledger",
|
|
198
212
|
"redaction": "secrets/PII patterns, high-entropy tokens and custom rules replaced at write time",
|
|
199
213
|
}
|
|
@@ -189,7 +189,7 @@ def install_repo(repo: str, cmd_prefix: str = "gitvow") -> list[str]:
|
|
|
189
189
|
]
|
|
190
190
|
|
|
191
191
|
|
|
192
|
-
def uninstall_repo(repo: str, purge_notes: bool = False) -> list[str]:
|
|
192
|
+
def uninstall_repo(repo: str, purge_notes: bool = False, purge_snapshots: bool = False) -> list[str]:
|
|
193
193
|
done = []
|
|
194
194
|
unmerge_settings(os.path.join(repo, ".claude", "settings.json"))
|
|
195
195
|
rc, cur, _ = git(["config", "--get", "core.hooksPath"], repo)
|
|
@@ -210,5 +210,9 @@ def uninstall_repo(repo: str, purge_notes: bool = False) -> list[str]:
|
|
|
210
210
|
for ref in refs.split():
|
|
211
211
|
git(["update-ref", "-d", ref], repo)
|
|
212
212
|
done.append(f"{len(refs.split())} local session note ref(s) deleted (remote copies untouched)")
|
|
213
|
+
if purge_snapshots:
|
|
214
|
+
from . import snapshots as _snap
|
|
215
|
+
|
|
216
|
+
done.append(f"{_snap.purge_all(repo)} snapshot ref(s) deleted")
|
|
213
217
|
done.append(".gitvow removed; commit trailers already in history remain")
|
|
214
218
|
return done
|
|
@@ -10,6 +10,8 @@ import subprocess
|
|
|
10
10
|
from dataclasses import dataclass
|
|
11
11
|
from typing import Any
|
|
12
12
|
|
|
13
|
+
from . import providers as _providers
|
|
14
|
+
|
|
13
15
|
DEFAULT_POLICY_PATH = os.path.join(os.path.dirname(__file__), "default_policy.json")
|
|
14
16
|
|
|
15
17
|
|
|
@@ -63,9 +65,21 @@ def _validate(pol: dict[str, Any], path: str) -> None:
|
|
|
63
65
|
re.compile(pat)
|
|
64
66
|
except re.error as e:
|
|
65
67
|
raise PolicyError(f"{path}: bad regex in {key}: {pat} ({e})") from e
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
68
|
+
snap = pol.get("snapshots", {})
|
|
69
|
+
if not isinstance(snap, dict) or not isinstance(snap.get("exclude", []), list):
|
|
70
|
+
raise PolicyError(f"{path}: snapshots must be an object with an optional 'exclude' list")
|
|
71
|
+
provs = pol.get("providers", [])
|
|
72
|
+
if not isinstance(provs, list):
|
|
73
|
+
raise PolicyError(f"{path}: providers must be a list")
|
|
74
|
+
for prov in provs:
|
|
75
|
+
if not isinstance(prov, dict) or not prov.get("name") or not prov.get("command"):
|
|
76
|
+
raise PolicyError(f"{path}: each provider needs 'name' and 'command'")
|
|
77
|
+
bad = [q for q in prov.get("questions", []) if q not in _providers.QUESTIONS]
|
|
78
|
+
if bad:
|
|
79
|
+
raise PolicyError(f"{path}: provider {prov['name']} lists unknown questions {bad}")
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def evaluate(pol: dict[str, Any], tool: str, tool_input: dict[str, Any], cwd: str | None = None) -> Decision:
|
|
69
83
|
text = tool_input.get("command", "") if tool == "Bash" else ""
|
|
70
84
|
path = tool_input.get("file_path", "") or tool_input.get("notebook_path", "")
|
|
71
85
|
if tool == "Bash":
|
|
@@ -85,6 +99,10 @@ def evaluate(pol: dict[str, Any], tool: str, tool_input: dict[str, Any]) -> Deci
|
|
|
85
99
|
allow = pol.get("mcp_allow", [])
|
|
86
100
|
if allow and not any(re.fullmatch(p, tool) for p in allow):
|
|
87
101
|
return Decision("confirm", "MCP tool not on allow list", tool)
|
|
102
|
+
if pol.get("providers") and tool in ("Edit", "Write", "MultiEdit", "NotebookEdit"):
|
|
103
|
+
hits = [a for a in _providers.ask(pol, tool, tool_input, cwd) if a.blocks]
|
|
104
|
+
if hits:
|
|
105
|
+
return Decision("confirm", "; ".join(h.text() for h in hits[:3]), path)
|
|
88
106
|
clf = pol.get("llm_classifier") or {}
|
|
89
107
|
if clf.get("enabled") and clf.get("command"):
|
|
90
108
|
return _classify(clf["command"], tool, tool_input, text or path or tool)
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
"""Providers: external programs the gate asks factual questions (gate_bearing, route_gate, route_callers)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import re
|
|
8
|
+
import shlex
|
|
9
|
+
import subprocess
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
PROTOCOL = 1
|
|
14
|
+
QUESTIONS = ("gate_bearing", "route_gate", "route_callers")
|
|
15
|
+
EDIT_TOOLS = ("Edit", "Write", "MultiEdit", "NotebookEdit")
|
|
16
|
+
ROUTE_RE = re.compile(r"""["'`](/[A-Za-z0-9_\-./{}:*$]*)["'`]""")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass
|
|
20
|
+
class Answer:
|
|
21
|
+
provider: str
|
|
22
|
+
question: str
|
|
23
|
+
subject: str
|
|
24
|
+
answer: str # yes | no | unknown | failed
|
|
25
|
+
evidence: list[str] = field(default_factory=list)
|
|
26
|
+
|
|
27
|
+
@property
|
|
28
|
+
def blocks(self) -> bool:
|
|
29
|
+
return self.answer in ("yes", "failed")
|
|
30
|
+
|
|
31
|
+
def text(self) -> str:
|
|
32
|
+
why = "; ".join(self.evidence[:4]) if self.evidence else ""
|
|
33
|
+
if self.answer == "failed":
|
|
34
|
+
return f"provider {self.provider} unavailable for {self.question} {self.subject}"
|
|
35
|
+
return f"provider {self.provider}: {self.question} {self.subject}" + (f": {why}" if why else "")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def route_literals(text: str) -> list[str]:
|
|
39
|
+
"""Quoted strings that look like routes: begin with '/', no spaces, at least one more character."""
|
|
40
|
+
out: list[str] = []
|
|
41
|
+
for m in ROUTE_RE.finditer(text or ""):
|
|
42
|
+
r = m.group(1)
|
|
43
|
+
if len(r) > 1 and not r.startswith("//") and "." not in os.path.basename(r) and r not in out:
|
|
44
|
+
out.append(r)
|
|
45
|
+
return out
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def questions_for(tool: str, tool_input: dict[str, Any]) -> list[tuple[str, str]]:
|
|
49
|
+
"""(question, subject) pairs the gate should ask for this tool call."""
|
|
50
|
+
if tool not in EDIT_TOOLS:
|
|
51
|
+
return []
|
|
52
|
+
path = tool_input.get("file_path") or tool_input.get("notebook_path") or ""
|
|
53
|
+
qs: list[tuple[str, str]] = []
|
|
54
|
+
if path:
|
|
55
|
+
qs.append(("gate_bearing", path))
|
|
56
|
+
old, new = "", ""
|
|
57
|
+
if tool == "Edit":
|
|
58
|
+
old, new = str(tool_input.get("old_string") or ""), str(tool_input.get("new_string") or "")
|
|
59
|
+
elif tool == "MultiEdit":
|
|
60
|
+
for e in tool_input.get("edits") or []:
|
|
61
|
+
old += "\n" + str(e.get("old_string") or "")
|
|
62
|
+
new += "\n" + str(e.get("new_string") or "")
|
|
63
|
+
elif tool == "Write":
|
|
64
|
+
new = str(tool_input.get("content") or "")
|
|
65
|
+
before, after = set(route_literals(old)), set(route_literals(new))
|
|
66
|
+
qs += [("route_gate", r) for r in sorted(after - before)]
|
|
67
|
+
qs += [("route_callers", r) for r in sorted(before - after)]
|
|
68
|
+
return qs
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _run(prov: dict[str, Any], question: str, subject: str, repo: str, path: str, tool: str) -> Answer:
|
|
72
|
+
name = str(prov.get("name") or "provider")
|
|
73
|
+
req = {
|
|
74
|
+
"protocol": PROTOCOL,
|
|
75
|
+
"question": question,
|
|
76
|
+
"subject": subject,
|
|
77
|
+
"repo": repo,
|
|
78
|
+
"path": path,
|
|
79
|
+
"tool_name": tool,
|
|
80
|
+
}
|
|
81
|
+
try:
|
|
82
|
+
argv = shlex.split(str(prov.get("command") or ""))
|
|
83
|
+
if not argv:
|
|
84
|
+
return Answer(name, question, subject, "failed", ["empty command"])
|
|
85
|
+
r = subprocess.run(
|
|
86
|
+
argv,
|
|
87
|
+
input=json.dumps(req),
|
|
88
|
+
capture_output=True,
|
|
89
|
+
text=True,
|
|
90
|
+
timeout=float(prov.get("timeout") or 10),
|
|
91
|
+
check=False,
|
|
92
|
+
cwd=repo or None,
|
|
93
|
+
)
|
|
94
|
+
if r.returncode != 0:
|
|
95
|
+
return Answer(name, question, subject, "failed", [f"exit {r.returncode}"])
|
|
96
|
+
data = json.loads(r.stdout)
|
|
97
|
+
ans = str(data.get("answer", "unknown")).lower()
|
|
98
|
+
if ans not in ("yes", "no", "unknown"):
|
|
99
|
+
return Answer(name, question, subject, "failed", ["bad answer"])
|
|
100
|
+
ev = [str(e)[:200] for e in (data.get("evidence") or [])[:8]]
|
|
101
|
+
return Answer(name, question, subject, ans, ev)
|
|
102
|
+
except (OSError, subprocess.TimeoutExpired, ValueError, AttributeError) as e:
|
|
103
|
+
return Answer(name, question, subject, "failed", [type(e).__name__])
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def ask(
|
|
107
|
+
pol: dict[str, Any],
|
|
108
|
+
tool: str,
|
|
109
|
+
tool_input: dict[str, Any],
|
|
110
|
+
cwd: str | None = None,
|
|
111
|
+
only: list[tuple[str, str]] | None = None,
|
|
112
|
+
) -> list[Answer]:
|
|
113
|
+
provs = pol.get("providers") or []
|
|
114
|
+
if not provs:
|
|
115
|
+
return []
|
|
116
|
+
repo = cwd or os.getcwd()
|
|
117
|
+
path = str(tool_input.get("file_path") or tool_input.get("notebook_path") or "")
|
|
118
|
+
if path and os.path.isabs(path):
|
|
119
|
+
rel = os.path.relpath(path, repo)
|
|
120
|
+
path = rel if not rel.startswith("..") else path
|
|
121
|
+
answers: list[Answer] = []
|
|
122
|
+
for q, subj in only if only is not None else questions_for(tool, {**tool_input, "file_path": path}):
|
|
123
|
+
for prov in provs:
|
|
124
|
+
if q in (prov.get("questions") or QUESTIONS):
|
|
125
|
+
answers.append(_run(prov, q, subj, repo, path, tool))
|
|
126
|
+
return answers
|
|
@@ -6,6 +6,7 @@ import json
|
|
|
6
6
|
import os
|
|
7
7
|
import shutil
|
|
8
8
|
import subprocess
|
|
9
|
+
import sys
|
|
9
10
|
import tempfile
|
|
10
11
|
|
|
11
12
|
from .hooks import post_tool_use, pre_tool_use, session_start, stop
|
|
@@ -66,9 +67,37 @@ def run() -> int:
|
|
|
66
67
|
"deny: destructive MCP tool",
|
|
67
68
|
)
|
|
68
69
|
)
|
|
70
|
+
prov = (
|
|
71
|
+
f'{sys.executable} -c "import sys,json;q=json.load(sys.stdin);'
|
|
72
|
+
"print(json.dumps({'answer':'yes' if q['question']=='route_gate' else 'no',"
|
|
73
|
+
"'evidence':['route not covered by authorization (selftest provider)']}))\""
|
|
74
|
+
)
|
|
75
|
+
os.makedirs(os.path.join(repo, ".gitvow"), exist_ok=True)
|
|
76
|
+
with open(os.path.join(repo, ".gitvow", "policy.json"), "w") as fh:
|
|
77
|
+
json.dump({"providers": [{"name": "selftest", "command": prov}]}, fh)
|
|
78
|
+
code, msg = pre_tool_use(
|
|
79
|
+
{
|
|
80
|
+
**base,
|
|
81
|
+
"tool_name": "Edit",
|
|
82
|
+
"tool_input": {"file_path": "api.py", "old_string": "", "new_string": '"/v1/new"'},
|
|
83
|
+
},
|
|
84
|
+
home,
|
|
85
|
+
)
|
|
86
|
+
results.append((code == 2 and "route not covered" in msg, "confirm: provider says new route is unauthorised"))
|
|
87
|
+
os.remove(os.path.join(repo, ".gitvow", "policy.json"))
|
|
69
88
|
with open(os.path.join(repo, "a.txt"), "a") as fh:
|
|
70
89
|
fh.write("b\n")
|
|
71
90
|
post_tool_use({**base, "tool_name": "Edit", "tool_input": {"file_path": "a.txt"}}, home)
|
|
91
|
+
snaps = g("for-each-ref", "--format=%(refname)", "refs/gitvow/snapshots/").splitlines()
|
|
92
|
+
results.append(
|
|
93
|
+
(len(snaps) == 1 and snaps[0].endswith("/selftest-session/1"), "snapshot taken after the agent edit")
|
|
94
|
+
)
|
|
95
|
+
results.append(
|
|
96
|
+
(
|
|
97
|
+
g("show", f"{snaps[0]}:a.txt") == "a\nb" if snaps else False,
|
|
98
|
+
"snapshot holds the agent's version of the file",
|
|
99
|
+
)
|
|
100
|
+
)
|
|
72
101
|
with open(os.path.join(repo, "a.txt"), "a") as fh:
|
|
73
102
|
fh.write("human\n")
|
|
74
103
|
pre_tool_use({**base, "tool_name": "Bash", "tool_input": {"command": "git commit -m x"}}, home)
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
"""Working-tree snapshots after agent edits: commits under refs/gitvow/snapshots/<session>/<n>, parented on HEAD."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import contextlib
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import re
|
|
9
|
+
import subprocess
|
|
10
|
+
import tempfile
|
|
11
|
+
import time
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from .state import git, git_dir, load_state, save_state, toplevel
|
|
15
|
+
|
|
16
|
+
REF_PREFIX = "refs/gitvow/snapshots"
|
|
17
|
+
DEFAULTS: dict[str, Any] = {
|
|
18
|
+
"enabled": True,
|
|
19
|
+
"max_per_session": 200,
|
|
20
|
+
"exclude": [".env*", "*.pem", "*.key", "*secret*", "*credential*", ".gitvow/**", ".claude/**"],
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def settings(pol: dict[str, Any] | None) -> dict[str, Any]:
|
|
25
|
+
out = dict(DEFAULTS)
|
|
26
|
+
out.update((pol or {}).get("snapshots") or {})
|
|
27
|
+
return out
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _safe(session_id: str | None) -> str:
|
|
31
|
+
return re.sub(r"[^A-Za-z0-9._-]", "_", session_id or "unknown")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def ref_for(session_id: str | None, n: int) -> str:
|
|
35
|
+
return f"{REF_PREFIX}/{_safe(session_id)}/{n}"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def take(cwd: str, session_id: str | None, tool: str, file_path: str, cfg: dict[str, Any]) -> str | None:
|
|
39
|
+
"""Write the working tree (minus ignored and excluded paths) as a commit and point a new session ref at it."""
|
|
40
|
+
top = toplevel(cwd)
|
|
41
|
+
gd = git_dir(cwd)
|
|
42
|
+
if not top or not gd:
|
|
43
|
+
return None
|
|
44
|
+
rc, head, _ = git(["rev-parse", "--verify", "HEAD"], top)
|
|
45
|
+
parent = head if rc == 0 else None
|
|
46
|
+
st = load_state(cwd)
|
|
47
|
+
n = int(st.get("snapshots", 0)) + 1
|
|
48
|
+
fd, tmp_index = tempfile.mkstemp(prefix="gitvow-index-", dir=gd)
|
|
49
|
+
os.close(fd)
|
|
50
|
+
os.remove(tmp_index)
|
|
51
|
+
env = {**os.environ, "GIT_INDEX_FILE": tmp_index}
|
|
52
|
+
try:
|
|
53
|
+
if parent:
|
|
54
|
+
subprocess.run(["git", "read-tree", parent], cwd=top, env=env, capture_output=True, check=False)
|
|
55
|
+
pathspec = ["--", "."] + [f":(exclude,glob){p}" for p in cfg.get("exclude", [])]
|
|
56
|
+
r = subprocess.run(
|
|
57
|
+
["git", "add", "-A", *pathspec], cwd=top, env=env, capture_output=True, text=True, timeout=60, check=False
|
|
58
|
+
)
|
|
59
|
+
if r.returncode != 0:
|
|
60
|
+
return None
|
|
61
|
+
r = subprocess.run(["git", "write-tree"], cwd=top, env=env, capture_output=True, text=True, check=False)
|
|
62
|
+
if r.returncode != 0:
|
|
63
|
+
return None
|
|
64
|
+
tree = r.stdout.strip()
|
|
65
|
+
except (OSError, subprocess.TimeoutExpired):
|
|
66
|
+
return None
|
|
67
|
+
finally:
|
|
68
|
+
with contextlib.suppress(OSError):
|
|
69
|
+
os.remove(tmp_index)
|
|
70
|
+
rel = os.path.relpath(os.path.realpath(file_path), os.path.realpath(top)) if file_path else ""
|
|
71
|
+
msg = json.dumps(
|
|
72
|
+
{
|
|
73
|
+
"gitvow": "snapshot",
|
|
74
|
+
"session": session_id,
|
|
75
|
+
"n": n,
|
|
76
|
+
"tool": tool,
|
|
77
|
+
"file": rel,
|
|
78
|
+
"taken": time.strftime("%Y-%m-%dT%H:%M:%S"),
|
|
79
|
+
}
|
|
80
|
+
)
|
|
81
|
+
args = ["commit-tree", tree, "-m", msg] + (["-p", parent] if parent else [])
|
|
82
|
+
rc, commit, _ = git(args, top)
|
|
83
|
+
if rc != 0:
|
|
84
|
+
return None
|
|
85
|
+
ref = ref_for(session_id, n)
|
|
86
|
+
git(["update-ref", ref, commit], top)
|
|
87
|
+
st["snapshots"] = n
|
|
88
|
+
st["last_snapshot"] = ref
|
|
89
|
+
save_state(cwd, st)
|
|
90
|
+
keep = int(cfg.get("max_per_session", 200))
|
|
91
|
+
if n > keep:
|
|
92
|
+
git(["update-ref", "-d", ref_for(session_id, n - keep)], top)
|
|
93
|
+
return ref
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def list_snapshots(cwd: str, session_id: str | None = None) -> list[dict[str, Any]]:
|
|
97
|
+
top = toplevel(cwd) or cwd
|
|
98
|
+
prefix = f"{REF_PREFIX}/{_safe(session_id)}/" if session_id else REF_PREFIX + "/"
|
|
99
|
+
rc, out, _ = git(["for-each-ref", "--format=%(refname)%09%(objectname)%09%(contents:subject)", prefix], top)
|
|
100
|
+
rows: list[dict[str, Any]] = []
|
|
101
|
+
if rc != 0:
|
|
102
|
+
return rows
|
|
103
|
+
for line in out.splitlines():
|
|
104
|
+
parts = line.split("\t", 2)
|
|
105
|
+
if len(parts) < 3:
|
|
106
|
+
continue
|
|
107
|
+
ref, commit, subject = parts
|
|
108
|
+
try:
|
|
109
|
+
meta = json.loads(subject)
|
|
110
|
+
except json.JSONDecodeError:
|
|
111
|
+
meta = {}
|
|
112
|
+
_, stat, _ = git(["diff", "--shortstat", f"{commit}^", commit], top)
|
|
113
|
+
m = re.match(r"\s*(\d+) file", stat or "")
|
|
114
|
+
rows.append(
|
|
115
|
+
{
|
|
116
|
+
"ref": ref,
|
|
117
|
+
"commit": commit,
|
|
118
|
+
"session": meta.get("session") or ref.split("/")[-2],
|
|
119
|
+
"n": int(meta.get("n") or ref.split("/")[-1]),
|
|
120
|
+
"tool": meta.get("tool", ""),
|
|
121
|
+
"file": meta.get("file", ""),
|
|
122
|
+
"taken": meta.get("taken", ""),
|
|
123
|
+
"changed_files": int(m.group(1)) if m else 0,
|
|
124
|
+
}
|
|
125
|
+
)
|
|
126
|
+
rows.sort(key=lambda r: (r["session"], r["n"]))
|
|
127
|
+
return rows
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def resolve(cwd: str, session_prefix: str, n: int) -> str | None:
|
|
131
|
+
"""Full ref for a session id or unique prefix and a snapshot number."""
|
|
132
|
+
top = toplevel(cwd) or cwd
|
|
133
|
+
rc, out, _ = git(["for-each-ref", "--format=%(refname)", REF_PREFIX + "/"], top)
|
|
134
|
+
if rc != 0:
|
|
135
|
+
return None
|
|
136
|
+
hits = {r.rsplit("/", 1)[0] for r in out.split() if r.split("/")[-2].startswith(_safe(session_prefix))}
|
|
137
|
+
if len(hits) != 1:
|
|
138
|
+
return None
|
|
139
|
+
ref = f"{hits.pop()}/{n}"
|
|
140
|
+
rc, _, _ = git(["rev-parse", "--verify", "--quiet", ref], top)
|
|
141
|
+
return ref if rc == 0 else None
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def diff(cwd: str, ref: str, full: bool = False) -> str:
|
|
145
|
+
top = toplevel(cwd) or cwd
|
|
146
|
+
args = ["diff"] + ([] if full else ["--stat"]) + [f"{ref}^", ref]
|
|
147
|
+
rc, out, err = git(args, top)
|
|
148
|
+
return out if rc == 0 else err
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def restore(cwd: str, ref: str, to: str | None = None) -> tuple[int, str]:
|
|
152
|
+
top = toplevel(cwd) or cwd
|
|
153
|
+
if not to:
|
|
154
|
+
sess, n = ref.split("/")[-2], ref.split("/")[-1]
|
|
155
|
+
to = os.path.join(tempfile.gettempdir(), "gitvow-restore", f"{sess[:8]}-{n}")
|
|
156
|
+
if os.path.exists(to) and os.listdir(to):
|
|
157
|
+
return 1, f"{to} exists and is not empty; choose another --to"
|
|
158
|
+
os.makedirs(os.path.dirname(to) or ".", exist_ok=True)
|
|
159
|
+
rc, _, err = git(["worktree", "add", "--detach", to, ref], top)
|
|
160
|
+
if rc != 0:
|
|
161
|
+
return 1, err
|
|
162
|
+
return 0, to
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def prune(cwd: str, older_than_days: float | None = None, session_id: str | None = None) -> int:
|
|
166
|
+
top = toplevel(cwd) or cwd
|
|
167
|
+
cutoff = time.time() - (older_than_days or 0) * 86400 if older_than_days is not None else None
|
|
168
|
+
removed = 0
|
|
169
|
+
for row in list_snapshots(cwd, session_id):
|
|
170
|
+
if cutoff is not None:
|
|
171
|
+
try:
|
|
172
|
+
taken = time.mktime(time.strptime(row["taken"], "%Y-%m-%dT%H:%M:%S"))
|
|
173
|
+
except (ValueError, TypeError):
|
|
174
|
+
taken = 0
|
|
175
|
+
if taken > cutoff:
|
|
176
|
+
continue
|
|
177
|
+
git(["update-ref", "-d", row["ref"]], top)
|
|
178
|
+
removed += 1
|
|
179
|
+
return removed
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def purge_all(cwd: str) -> int:
|
|
183
|
+
return prune(cwd, None, None)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: gitvow
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.5.0
|
|
4
4
|
Summary: Provenance and policy gate for AI-agent coding sessions: session trailers on commits, redacted session notes in git, a tool-call gate, and a local ledger. No runtime dependencies.
|
|
5
5
|
Author-email: Nikhil Bora <nikhil@wirevow.com>
|
|
6
6
|
License: Apache-2.0
|
|
@@ -8,9 +8,11 @@ src/gitvow/collect.py
|
|
|
8
8
|
src/gitvow/default_policy.json
|
|
9
9
|
src/gitvow/install.py
|
|
10
10
|
src/gitvow/policy.py
|
|
11
|
+
src/gitvow/providers.py
|
|
11
12
|
src/gitvow/redact.py
|
|
12
13
|
src/gitvow/report.py
|
|
13
14
|
src/gitvow/selftest.py
|
|
15
|
+
src/gitvow/snapshots.py
|
|
14
16
|
src/gitvow/state.py
|
|
15
17
|
src/gitvow/transcript.py
|
|
16
18
|
src/gitvow.egg-info/PKG-INFO
|
|
@@ -25,5 +27,7 @@ tests/test_hooks.py
|
|
|
25
27
|
tests/test_install_cli.py
|
|
26
28
|
tests/test_packaging.py
|
|
27
29
|
tests/test_policy.py
|
|
30
|
+
tests/test_providers.py
|
|
28
31
|
tests/test_redact.py
|
|
29
|
-
tests/test_report.py
|
|
32
|
+
tests/test_report.py
|
|
33
|
+
tests/test_snapshots.py
|
|
@@ -43,7 +43,7 @@ def test_commit_flow_trailer_note_and_attribution(repo, home, payload, transcrip
|
|
|
43
43
|
note = git(repo, "notes", "--ref=gitvow/sess-1", "show", "HEAD")
|
|
44
44
|
assert note.startswith("gitvow-session")
|
|
45
45
|
data = json.loads(note.split("\n", 1)[1])
|
|
46
|
-
assert data["schema"] ==
|
|
46
|
+
assert data["schema"] == 3 and data["step"] == 1 and data["tools_used"] == ["Bash", "Edit"]
|
|
47
47
|
assert "[github-token]" in data["last_stated_plan"] and "[email:" in data["last_stated_plan"]
|
|
48
48
|
assert "ghp_" not in note and "ops@example.com" not in note
|
|
49
49
|
att = data["attribution"]
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import sys
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from gitvow import cli
|
|
6
|
+
from gitvow.hooks import pre_tool_use
|
|
7
|
+
from gitvow.policy import evaluate
|
|
8
|
+
from gitvow.providers import questions_for, route_literals
|
|
9
|
+
|
|
10
|
+
ROOT = Path(__file__).resolve().parents[1]
|
|
11
|
+
EXAMPLE = ROOT / "examples" / "providers" / "static_facts.py"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def test_route_literals_and_questions():
|
|
15
|
+
txt = 'a = "/v1/orders"; b = \'/v1/orders/{id}\'; c = "/static/app.js"; d = "//comment"; e = "/"; f = "/v1/orders"'
|
|
16
|
+
assert route_literals(txt) == ["/v1/orders", "/v1/orders/{id}"]
|
|
17
|
+
qs = questions_for(
|
|
18
|
+
"Edit",
|
|
19
|
+
{"file_path": "api.py", "old_string": '@app.get("/old")', "new_string": '@app.get("/new")\n@app.get("/old2")'},
|
|
20
|
+
)
|
|
21
|
+
assert qs == [
|
|
22
|
+
("gate_bearing", "api.py"),
|
|
23
|
+
("route_gate", "/new"),
|
|
24
|
+
("route_gate", "/old2"),
|
|
25
|
+
("route_callers", "/old"),
|
|
26
|
+
]
|
|
27
|
+
assert questions_for("Write", {"file_path": "x.py", "content": 'r("/w")'}) == [
|
|
28
|
+
("gate_bearing", "x.py"),
|
|
29
|
+
("route_gate", "/w"),
|
|
30
|
+
]
|
|
31
|
+
assert questions_for("Bash", {"command": 'echo "/v1/x"'}) == []
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _facts(tmp_path):
|
|
35
|
+
f = tmp_path / "facts.json"
|
|
36
|
+
f.write_text(
|
|
37
|
+
json.dumps(
|
|
38
|
+
{
|
|
39
|
+
"gate_bearing": ["auth/AuthorizeWhitelistedPaths.java", "deploy/values/production-*.yaml"],
|
|
40
|
+
"whitelist_file": "auth/AuthorizeWhitelistedPaths.java",
|
|
41
|
+
"authorized_routes": ["/v1/orders", "/v1/orders/{id}"],
|
|
42
|
+
"callers": {"/v1/orders": ["client-orch (OrdersClient.java:88)"]},
|
|
43
|
+
}
|
|
44
|
+
)
|
|
45
|
+
)
|
|
46
|
+
return f
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _policy(tmp_path, command):
|
|
50
|
+
return {"providers": [{"name": "facts", "command": command}]}
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def test_provider_confirms_with_evidence(tmp_path):
|
|
54
|
+
pol = _policy(tmp_path, f"{sys.executable} {EXAMPLE} --facts {_facts(tmp_path)}")
|
|
55
|
+
d = evaluate(
|
|
56
|
+
pol,
|
|
57
|
+
"Edit",
|
|
58
|
+
{"file_path": "src/api/Orders.java", "old_string": "x", "new_string": '@Path("/v1/orders/export")'},
|
|
59
|
+
str(tmp_path),
|
|
60
|
+
)
|
|
61
|
+
assert d.outcome == "confirm" and "not covered by any authorization rule" in d.reason
|
|
62
|
+
assert "AuthorizeWhitelistedPaths.java" in d.reason
|
|
63
|
+
d = evaluate(
|
|
64
|
+
pol,
|
|
65
|
+
"Edit",
|
|
66
|
+
{"file_path": "src/api/Orders.java", "old_string": "x", "new_string": '@Path("/v1/orders/{id}")'},
|
|
67
|
+
str(tmp_path),
|
|
68
|
+
)
|
|
69
|
+
assert d.outcome == "allow" # listed as authorized
|
|
70
|
+
d = evaluate(
|
|
71
|
+
pol,
|
|
72
|
+
"Edit",
|
|
73
|
+
{"file_path": "src/api/Orders.java", "old_string": '@Path("/v1/orders")', "new_string": ""},
|
|
74
|
+
str(tmp_path),
|
|
75
|
+
)
|
|
76
|
+
assert d.outcome == "confirm" and "client-orch" in d.reason
|
|
77
|
+
d = evaluate(
|
|
78
|
+
pol,
|
|
79
|
+
"Write",
|
|
80
|
+
{"file_path": str(tmp_path / "deploy/values/production-in.yaml"), "content": "a: 1"},
|
|
81
|
+
str(tmp_path),
|
|
82
|
+
)
|
|
83
|
+
assert d.outcome == "confirm" and "gate-bearing" in d.reason
|
|
84
|
+
assert (
|
|
85
|
+
evaluate(pol, "Edit", {"file_path": "README.md", "old_string": "a", "new_string": "b"}, str(tmp_path)).outcome
|
|
86
|
+
== "allow"
|
|
87
|
+
)
|
|
88
|
+
assert evaluate(pol, "Bash", {"command": 'echo "/v1/new"'}, str(tmp_path)).outcome == "allow"
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def test_provider_failure_means_confirm(tmp_path):
|
|
92
|
+
for cmd in (
|
|
93
|
+
"/nonexistent/provider",
|
|
94
|
+
f"{sys.executable} -c \"print('not json')\"",
|
|
95
|
+
f"{sys.executable} -c 'import sys; sys.exit(3)'",
|
|
96
|
+
"",
|
|
97
|
+
):
|
|
98
|
+
pol = _policy(tmp_path, cmd)
|
|
99
|
+
d = evaluate(pol, "Edit", {"file_path": "a.py", "old_string": "", "new_string": ""}, str(tmp_path))
|
|
100
|
+
assert d.outcome == "confirm" and "unavailable" in d.reason, cmd
|
|
101
|
+
pol = _policy(tmp_path, f"{sys.executable} -c 'import time; time.sleep(5)'")
|
|
102
|
+
pol["providers"][0]["timeout"] = 0.5
|
|
103
|
+
assert evaluate(pol, "Edit", {"file_path": "a.py"}, str(tmp_path)).outcome == "confirm"
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def test_unknown_answer_does_not_block(tmp_path):
|
|
107
|
+
pol = _policy(tmp_path, f'{sys.executable} -c "print(\'{{\\"answer\\": \\"unknown\\"}}\')"')
|
|
108
|
+
assert evaluate(pol, "Edit", {"file_path": "a.py"}, str(tmp_path)).outcome == "allow"
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def test_provider_questions_filter_and_validation(tmp_path):
|
|
112
|
+
from gitvow.policy import PolicyError, load_policy
|
|
113
|
+
|
|
114
|
+
(tmp_path / ".gitvow").mkdir()
|
|
115
|
+
(tmp_path / ".gitvow" / "policy.json").write_text(
|
|
116
|
+
json.dumps({"providers": [{"name": "x", "command": "y", "questions": ["nope"]}]})
|
|
117
|
+
)
|
|
118
|
+
try:
|
|
119
|
+
load_policy(str(tmp_path), str(tmp_path / "h"))
|
|
120
|
+
raise AssertionError("expected PolicyError")
|
|
121
|
+
except PolicyError as e:
|
|
122
|
+
assert "unknown questions" in str(e)
|
|
123
|
+
(tmp_path / ".gitvow" / "policy.json").write_text(json.dumps({"providers": [{"name": "x"}]}))
|
|
124
|
+
try:
|
|
125
|
+
load_policy(str(tmp_path), str(tmp_path / "h"))
|
|
126
|
+
raise AssertionError("expected PolicyError")
|
|
127
|
+
except PolicyError:
|
|
128
|
+
pass
|
|
129
|
+
pol = _policy(tmp_path, f"{sys.executable} {EXAMPLE} --facts {_facts(tmp_path)}")
|
|
130
|
+
pol["providers"][0]["questions"] = ["gate_bearing"] # route questions not asked of this provider
|
|
131
|
+
d = evaluate(pol, "Edit", {"file_path": "a.py", "old_string": "", "new_string": '"/v1/zzz"'}, str(tmp_path))
|
|
132
|
+
assert d.outcome == "allow"
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def test_hook_and_cli_ask_end_to_end(repo, home, payload, tmp_path, monkeypatch, capsys):
|
|
136
|
+
facts = _facts(tmp_path)
|
|
137
|
+
(repo / ".gitvow").mkdir()
|
|
138
|
+
(repo / ".gitvow" / "policy.json").write_text(
|
|
139
|
+
json.dumps({"providers": [{"name": "facts", "command": f"{sys.executable} {EXAMPLE} --facts {facts}"}]})
|
|
140
|
+
)
|
|
141
|
+
code, msg = pre_tool_use(
|
|
142
|
+
payload(
|
|
143
|
+
"PreToolUse",
|
|
144
|
+
"Edit",
|
|
145
|
+
{"file_path": str(repo / "src/Orders.java"), "old_string": "", "new_string": '@Path("/v1/orders/export")'},
|
|
146
|
+
),
|
|
147
|
+
str(home),
|
|
148
|
+
)
|
|
149
|
+
assert code == 2 and "CONFIRMATION REQUIRED" in msg and "AuthorizeWhitelistedPaths.java" in msg
|
|
150
|
+
log = (repo / ".git" / "gitvow-hooks.log").read_text()
|
|
151
|
+
assert '"kind": "confirm_required"' in log and "provider facts" in log
|
|
152
|
+
monkeypatch.chdir(repo)
|
|
153
|
+
assert cli.main(["ask", "route_gate", "/v1/orders/export", "--path", "src/Orders.java"]) == 2
|
|
154
|
+
out = capsys.readouterr().out
|
|
155
|
+
assert "facts: yes" in out and "decision: CONFIRM" in out
|
|
156
|
+
assert cli.main(["ask", "route_gate", "/v1/orders"]) == 0
|
|
157
|
+
assert "decision: ALLOW" in capsys.readouterr().out
|
|
158
|
+
assert cli.main(["ask", "gate_bearing", "auth/AuthorizeWhitelistedPaths.java"]) == 2
|
|
159
|
+
(repo / ".gitvow" / "policy.json").write_text("{}")
|
|
160
|
+
assert cli.main(["ask", "gate_bearing", "x"]) == 1
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import os
|
|
3
|
+
|
|
4
|
+
from gitvow import cli
|
|
5
|
+
from gitvow.hooks import post_tool_use, pre_tool_use, session_start
|
|
6
|
+
from gitvow.install import _write_git_hook, uninstall_repo
|
|
7
|
+
from tests.conftest import git
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _edit(repo, home, payload, name, content, tool="Edit"):
|
|
11
|
+
(repo / name).parent.mkdir(parents=True, exist_ok=True)
|
|
12
|
+
(repo / name).write_text(content)
|
|
13
|
+
post_tool_use(payload("PostToolUse", tool, {"file_path": str(repo / name)}), str(home))
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def test_snapshot_per_edit_with_head_parent_and_exclusions(repo, home, payload):
|
|
17
|
+
session_start(payload("SessionStart"), str(home))
|
|
18
|
+
(repo / ".gitignore").write_text("build/\n")
|
|
19
|
+
(repo / "build").mkdir()
|
|
20
|
+
(repo / "build" / "out.o").write_text("bin")
|
|
21
|
+
(repo / ".env").write_text("SECRET=1\n")
|
|
22
|
+
_edit(repo, home, payload, "calc.py", "def add(a, b):\n return a + b\n")
|
|
23
|
+
_edit(repo, home, payload, "calc.py", "def add(a, b):\n return a + b\n\ndef sub(a, b):\n return a - b\n")
|
|
24
|
+
refs = git(repo, "for-each-ref", "--format=%(refname)", "refs/gitvow/snapshots/").split()
|
|
25
|
+
assert refs == ["refs/gitvow/snapshots/sess-1/1", "refs/gitvow/snapshots/sess-1/2"]
|
|
26
|
+
head = git(repo, "rev-parse", "HEAD")
|
|
27
|
+
assert git(repo, "rev-parse", "refs/gitvow/snapshots/sess-1/1^") == head # parented on HEAD at the time
|
|
28
|
+
assert git(repo, "show", "refs/gitvow/snapshots/sess-1/1:calc.py") == "def add(a, b):\n return a + b"
|
|
29
|
+
assert "sub" in git(repo, "show", "refs/gitvow/snapshots/sess-1/2:calc.py")
|
|
30
|
+
tree = git(repo, "ls-tree", "-r", "--name-only", "refs/gitvow/snapshots/sess-1/2")
|
|
31
|
+
assert "calc.py" in tree and ".gitignore" in tree and "build/out.o" not in tree and ".env" not in tree
|
|
32
|
+
(repo / ".gitvow").mkdir(exist_ok=True)
|
|
33
|
+
(repo / ".gitvow" / "policy.json").write_text("{}")
|
|
34
|
+
_edit(repo, home, payload, "calc.py", "v3\n")
|
|
35
|
+
assert ".gitvow/policy.json" not in git(repo, "ls-tree", "-r", "--name-only", "refs/gitvow/snapshots/sess-1/3")
|
|
36
|
+
assert git(repo, "status", "--short").count("\n") >= 0 # the real index was never touched
|
|
37
|
+
assert "calc.py" not in git(repo, "diff", "--cached", "--name-only")
|
|
38
|
+
st = json.loads((repo / ".git" / "gitvow-session.json").read_text())
|
|
39
|
+
assert st["snapshots"] == 2 and st["last_snapshot"] == "refs/gitvow/snapshots/sess-1/2"
|
|
40
|
+
assert '"kind": "snapshot"' in (repo / ".git" / "gitvow-hooks.log").read_text()
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def test_note_names_last_snapshot_and_new_session_restarts_numbering(repo, home, payload):
|
|
44
|
+
session_start(payload("SessionStart"), str(home))
|
|
45
|
+
_edit(repo, home, payload, "a.txt", "agent\n")
|
|
46
|
+
_write_git_hook(str(repo / ".gitvow" / "git-hooks"))
|
|
47
|
+
git(repo, "config", "core.hooksPath", ".gitvow/git-hooks")
|
|
48
|
+
pre_tool_use(payload("PreToolUse", "Bash", {"command": "git commit -m x"}), str(home))
|
|
49
|
+
git(repo, "commit", "-qam", "agent commit")
|
|
50
|
+
post_tool_use(payload("PostToolUse", "Bash", {"command": "git commit -m x"}), str(home))
|
|
51
|
+
note = json.loads(git(repo, "notes", "--ref=gitvow/sess-1", "show", "HEAD").split("\n", 1)[1])
|
|
52
|
+
assert note["schema"] == 3 and note["snapshot"] == "refs/gitvow/snapshots/sess-1/1"
|
|
53
|
+
p = payload("SessionStart")
|
|
54
|
+
p["session_id"] = "sess-2"
|
|
55
|
+
session_start(p, str(home))
|
|
56
|
+
(repo / "a.txt").write_text("two\n")
|
|
57
|
+
post_tool_use({**p, "tool_name": "Write", "tool_input": {"file_path": str(repo / "a.txt")}}, str(home))
|
|
58
|
+
assert git(repo, "rev-parse", "--verify", "refs/gitvow/snapshots/sess-2/1")
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def test_cli_list_diff_restore_prune(repo, home, payload, monkeypatch, capsys, tmp_path):
|
|
62
|
+
session_start(payload("SessionStart"), str(home))
|
|
63
|
+
_edit(repo, home, payload, "calc.py", "v1\n")
|
|
64
|
+
_edit(repo, home, payload, "calc.py", "v2\n", tool="Write")
|
|
65
|
+
(repo / "calc.py").write_text("human v3\n")
|
|
66
|
+
monkeypatch.chdir(repo)
|
|
67
|
+
assert cli.main(["snapshots"]) == 0
|
|
68
|
+
out = capsys.readouterr().out
|
|
69
|
+
assert "session sess-1 2 snapshots" in out and "Write" in out and "calc.py" in out
|
|
70
|
+
assert cli.main(["diff", "sess", "1"]) == 0
|
|
71
|
+
assert "calc.py" in capsys.readouterr().out
|
|
72
|
+
assert cli.main(["diff", "sess", "1", "--full"]) == 0
|
|
73
|
+
assert "+v1" in capsys.readouterr().out
|
|
74
|
+
dest = tmp_path / "restore-here"
|
|
75
|
+
assert cli.main(["restore", "sess", "1", "--to", str(dest)]) == 0
|
|
76
|
+
assert (dest / "calc.py").read_text() == "v1\n"
|
|
77
|
+
assert (repo / "calc.py").read_text() == "human v3\n" # the real tree is untouched
|
|
78
|
+
assert cli.main(["restore", "sess", "1", "--to", str(dest)]) == 1 # refuses a non-empty dir
|
|
79
|
+
assert cli.main(["diff", "nope", "1"]) == 1
|
|
80
|
+
assert cli.main(["diff", "sess", "9"]) == 1
|
|
81
|
+
git(repo, "worktree", "remove", "--force", str(dest))
|
|
82
|
+
assert cli.main(["snapshots", "prune", "--older-than", "14d"]) == 0
|
|
83
|
+
assert "pruned 0" in capsys.readouterr().out
|
|
84
|
+
assert cli.main(["snapshots", "prune", "--session", "sess-1"]) == 0
|
|
85
|
+
assert "pruned 2" in capsys.readouterr().out
|
|
86
|
+
assert cli.main(["snapshots"]) == 0 and "no snapshots" in capsys.readouterr().out
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def test_retention_disable_and_purge(repo, home, payload):
|
|
90
|
+
(repo / ".gitvow").mkdir()
|
|
91
|
+
(repo / ".gitvow" / "policy.json").write_text(json.dumps({"snapshots": {"max_per_session": 2}}))
|
|
92
|
+
session_start(payload("SessionStart"), str(home))
|
|
93
|
+
for i in range(4):
|
|
94
|
+
_edit(repo, home, payload, "a.txt", f"v{i}\n")
|
|
95
|
+
refs = git(repo, "for-each-ref", "--format=%(refname)", "refs/gitvow/snapshots/").split()
|
|
96
|
+
assert [r.split("/")[-1] for r in refs] == ["3", "4"]
|
|
97
|
+
(repo / ".gitvow" / "policy.json").write_text(json.dumps({"snapshots": {"enabled": False}}))
|
|
98
|
+
_edit(repo, home, payload, "a.txt", "v9\n")
|
|
99
|
+
assert len(git(repo, "for-each-ref", "refs/gitvow/snapshots/").split("\n")) == 2
|
|
100
|
+
uninstall_repo(str(repo), purge_snapshots=True)
|
|
101
|
+
assert git(repo, "for-each-ref", "refs/gitvow/snapshots/") == ""
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def test_pre_push_does_not_push_snapshots(repo, home, payload, tmp_path):
|
|
105
|
+
remote = tmp_path / "remote.git"
|
|
106
|
+
git(remote.parent, "init", "-q", "--bare", str(remote))
|
|
107
|
+
git(repo, "remote", "add", "origin", str(remote))
|
|
108
|
+
_write_git_hook(str(repo / ".gitvow" / "git-hooks"))
|
|
109
|
+
git(repo, "config", "core.hooksPath", ".gitvow/git-hooks")
|
|
110
|
+
session_start(payload("SessionStart"), str(home))
|
|
111
|
+
_edit(repo, home, payload, "a.txt", "x\n")
|
|
112
|
+
git(repo, "notes", "--ref=gitvow/sess-1", "add", "-m", "gitvow-session\n{}", "HEAD")
|
|
113
|
+
git(repo, "push", "-q", "origin", git(repo, "branch", "--show-current"))
|
|
114
|
+
remote_refs = git(remote, "for-each-ref", "--format=%(refname)")
|
|
115
|
+
assert "refs/notes/gitvow/sess-1" in remote_refs and "snapshots" not in remote_refs
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def test_invalid_snapshots_policy_fails_closed(repo, home, payload):
|
|
119
|
+
(repo / ".gitvow").mkdir()
|
|
120
|
+
(repo / ".gitvow" / "policy.json").write_text(json.dumps({"snapshots": "yes please"}))
|
|
121
|
+
code, msg = pre_tool_use(payload("PreToolUse", "Bash", {"command": "ls"}), str(home))
|
|
122
|
+
assert code == 2 and "snapshots must be an object" in msg
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def test_snapshot_in_repo_without_commits(tmp_path, home, payload):
|
|
126
|
+
r = tmp_path / "fresh"
|
|
127
|
+
r.mkdir()
|
|
128
|
+
git(r, "init", "-q")
|
|
129
|
+
(r / "f.txt").write_text("first\n")
|
|
130
|
+
p = payload("SessionStart")
|
|
131
|
+
p["cwd"] = str(r)
|
|
132
|
+
session_start(p, str(home))
|
|
133
|
+
post_tool_use({**p, "tool_name": "Write", "tool_input": {"file_path": str(r / "f.txt")}}, str(home))
|
|
134
|
+
ref = git(r, "for-each-ref", "--format=%(refname)", "refs/gitvow/snapshots/")
|
|
135
|
+
assert ref.endswith("/1") and git(r, "show", f"{ref}:f.txt") == "first"
|
|
136
|
+
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
|