gitvow 0.24.0__tar.gz → 0.26.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.
Files changed (69) hide show
  1. {gitvow-0.24.0/src/gitvow.egg-info → gitvow-0.26.0}/PKG-INFO +1 -1
  2. {gitvow-0.24.0 → gitvow-0.26.0}/pyproject.toml +1 -1
  3. {gitvow-0.24.0 → gitvow-0.26.0}/src/gitvow/claims.py +6 -3
  4. {gitvow-0.24.0 → gitvow-0.26.0}/src/gitvow/cli.py +14 -2
  5. {gitvow-0.24.0 → gitvow-0.26.0}/src/gitvow/decisions.py +2 -2
  6. {gitvow-0.24.0 → gitvow-0.26.0}/src/gitvow/hooks/__init__.py +75 -11
  7. {gitvow-0.24.0 → gitvow-0.26.0}/src/gitvow/install.py +21 -21
  8. gitvow-0.26.0/src/gitvow/paths.py +113 -0
  9. {gitvow-0.24.0 → gitvow-0.26.0}/src/gitvow/rules.py +6 -3
  10. gitvow-0.26.0/src/gitvow/safewrite.py +76 -0
  11. {gitvow-0.24.0 → gitvow-0.26.0}/src/gitvow/snapshots.py +12 -1
  12. {gitvow-0.24.0 → gitvow-0.26.0}/src/gitvow/status.py +50 -1
  13. {gitvow-0.24.0 → gitvow-0.26.0/src/gitvow.egg-info}/PKG-INFO +1 -1
  14. {gitvow-0.24.0 → gitvow-0.26.0}/src/gitvow.egg-info/SOURCES.txt +3 -0
  15. gitvow-0.26.0/tests/test_hardening.py +117 -0
  16. {gitvow-0.24.0 → gitvow-0.26.0}/tests/test_hooks.py +64 -0
  17. {gitvow-0.24.0 → gitvow-0.26.0}/tests/test_tiers.py +1 -4
  18. {gitvow-0.24.0 → gitvow-0.26.0}/LICENSE +0 -0
  19. {gitvow-0.24.0 → gitvow-0.26.0}/README.md +0 -0
  20. {gitvow-0.24.0 → gitvow-0.26.0}/setup.cfg +0 -0
  21. {gitvow-0.24.0 → gitvow-0.26.0}/src/gitvow/__init__.py +0 -0
  22. {gitvow-0.24.0 → gitvow-0.26.0}/src/gitvow/__main__.py +0 -0
  23. {gitvow-0.24.0 → gitvow-0.26.0}/src/gitvow/adapters.py +0 -0
  24. {gitvow-0.24.0 → gitvow-0.26.0}/src/gitvow/collect.py +0 -0
  25. {gitvow-0.24.0 → gitvow-0.26.0}/src/gitvow/default_policy.json +0 -0
  26. {gitvow-0.24.0 → gitvow-0.26.0}/src/gitvow/digest.py +0 -0
  27. {gitvow-0.24.0 → gitvow-0.26.0}/src/gitvow/export.py +0 -0
  28. {gitvow-0.24.0 → gitvow-0.26.0}/src/gitvow/pack.py +0 -0
  29. {gitvow-0.24.0 → gitvow-0.26.0}/src/gitvow/policy.py +0 -0
  30. {gitvow-0.24.0 → gitvow-0.26.0}/src/gitvow/pricing.py +0 -0
  31. {gitvow-0.24.0 → gitvow-0.26.0}/src/gitvow/proposals.py +0 -0
  32. {gitvow-0.24.0 → gitvow-0.26.0}/src/gitvow/providers.py +0 -0
  33. {gitvow-0.24.0 → gitvow-0.26.0}/src/gitvow/recall.py +0 -0
  34. {gitvow-0.24.0 → gitvow-0.26.0}/src/gitvow/redact.py +0 -0
  35. {gitvow-0.24.0 → gitvow-0.26.0}/src/gitvow/report.py +0 -0
  36. {gitvow-0.24.0 → gitvow-0.26.0}/src/gitvow/scan.py +0 -0
  37. {gitvow-0.24.0 → gitvow-0.26.0}/src/gitvow/selftest.py +0 -0
  38. {gitvow-0.24.0 → gitvow-0.26.0}/src/gitvow/state.py +0 -0
  39. {gitvow-0.24.0 → gitvow-0.26.0}/src/gitvow/sync.py +0 -0
  40. {gitvow-0.24.0 → gitvow-0.26.0}/src/gitvow/transcript.py +0 -0
  41. {gitvow-0.24.0 → gitvow-0.26.0}/src/gitvow.egg-info/dependency_links.txt +0 -0
  42. {gitvow-0.24.0 → gitvow-0.26.0}/src/gitvow.egg-info/entry_points.txt +0 -0
  43. {gitvow-0.24.0 → gitvow-0.26.0}/src/gitvow.egg-info/requires.txt +0 -0
  44. {gitvow-0.24.0 → gitvow-0.26.0}/src/gitvow.egg-info/top_level.txt +0 -0
  45. {gitvow-0.24.0 → gitvow-0.26.0}/tests/test_adapters.py +0 -0
  46. {gitvow-0.24.0 → gitvow-0.26.0}/tests/test_claims.py +0 -0
  47. {gitvow-0.24.0 → gitvow-0.26.0}/tests/test_collect.py +0 -0
  48. {gitvow-0.24.0 → gitvow-0.26.0}/tests/test_cost.py +0 -0
  49. {gitvow-0.24.0 → gitvow-0.26.0}/tests/test_decisions.py +0 -0
  50. {gitvow-0.24.0 → gitvow-0.26.0}/tests/test_digest.py +0 -0
  51. {gitvow-0.24.0 → gitvow-0.26.0}/tests/test_e2e_globalopt.py +0 -0
  52. {gitvow-0.24.0 → gitvow-0.26.0}/tests/test_export.py +0 -0
  53. {gitvow-0.24.0 → gitvow-0.26.0}/tests/test_external_adapter.py +0 -0
  54. {gitvow-0.24.0 → gitvow-0.26.0}/tests/test_install_cli.py +0 -0
  55. {gitvow-0.24.0 → gitvow-0.26.0}/tests/test_pack.py +0 -0
  56. {gitvow-0.24.0 → gitvow-0.26.0}/tests/test_packaging.py +0 -0
  57. {gitvow-0.24.0 → gitvow-0.26.0}/tests/test_policy.py +0 -0
  58. {gitvow-0.24.0 → gitvow-0.26.0}/tests/test_proposals.py +0 -0
  59. {gitvow-0.24.0 → gitvow-0.26.0}/tests/test_providers.py +0 -0
  60. {gitvow-0.24.0 → gitvow-0.26.0}/tests/test_recall.py +0 -0
  61. {gitvow-0.24.0 → gitvow-0.26.0}/tests/test_redact.py +0 -0
  62. {gitvow-0.24.0 → gitvow-0.26.0}/tests/test_report.py +0 -0
  63. {gitvow-0.24.0 → gitvow-0.26.0}/tests/test_rules.py +0 -0
  64. {gitvow-0.24.0 → gitvow-0.26.0}/tests/test_scan.py +0 -0
  65. {gitvow-0.24.0 → gitvow-0.26.0}/tests/test_snapshots.py +0 -0
  66. {gitvow-0.24.0 → gitvow-0.26.0}/tests/test_status.py +0 -0
  67. {gitvow-0.24.0 → gitvow-0.26.0}/tests/test_sync.py +0 -0
  68. {gitvow-0.24.0 → gitvow-0.26.0}/tests/test_sync_http.py +0 -0
  69. {gitvow-0.24.0 → gitvow-0.26.0}/tests/test_transcript_formats.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: gitvow
3
- Version: 0.24.0
3
+ Version: 0.26.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.24.0"
7
+ version = "0.26.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"}
@@ -576,7 +576,10 @@ def write_section(path: str, block: str) -> str:
576
576
  action = "added"
577
577
  else:
578
578
  return "nothing to write"
579
- os.makedirs(os.path.dirname(path) or ".", exist_ok=True)
580
- with open(path, "w") as fh:
581
- fh.write(new)
579
+ from .safewrite import check_target, write_if_changed
580
+ from .state import toplevel
581
+
582
+ target = check_target(path, toplevel(os.path.dirname(path) or "."))
583
+ if not write_if_changed(target, new):
584
+ return f"managed claims section in {path} already current"
582
585
  return f"{action} managed claims section in {path}"
@@ -115,8 +115,14 @@ def cmd_install(a: argparse.Namespace) -> int:
115
115
  lines: list[str] = []
116
116
  if detected:
117
117
  lines.append(f"agents found here: {', '.join(agents)} (use --agent to pick one)")
118
+ from .safewrite import UnsafeTargetError
119
+
118
120
  for agent in agents:
119
- lines += install_user(home, agent=agent) if a.user else install_repo(repo, agent=agent)
121
+ try:
122
+ lines += install_user(home, agent=agent) if a.user else install_repo(repo, agent=agent)
123
+ except UnsafeTargetError as e:
124
+ print(f"refused: {e}", file=sys.stderr)
125
+ return 1
120
126
  seen: set[str] = set()
121
127
  for ln in lines:
122
128
  if ln not in seen:
@@ -212,8 +218,11 @@ def cmd_coverage(a: argparse.Namespace) -> int:
212
218
 
213
219
  def cmd_status(a: argparse.Namespace) -> int:
214
220
  """Is the record actually being written here? Reports what is missing and the line that fixes it."""
215
- from .status import build, render
221
+ from .status import badge, build, render
216
222
 
223
+ if a.line:
224
+ print(badge(os.getcwd()))
225
+ return 0
217
226
  checks = build(os.getcwd(), os.path.expanduser("~"))
218
227
  if a.json:
219
228
  print(json.dumps([{"state": s, "what": w, "fix": f} for s, w, f in checks], indent=1))
@@ -890,6 +899,9 @@ def main(argv: list[str] | None = None) -> int:
890
899
  s.set_defaults(f=cmd_coverage)
891
900
  s = sub.add_parser("status", help="check the install is live: hooks reachable, policy loads, git hooks in place")
892
901
  s.add_argument("--json", action="store_true")
902
+ s.add_argument(
903
+ "--line", action="store_true", help="one line for a status bar: what this repository's session recorded"
904
+ )
893
905
  s.set_defaults(f=cmd_status)
894
906
  s = sub.add_parser("sync", help="export the record and deliver it to the configured sinks; never the session")
895
907
  s.add_argument("--sink", help="deliver to this sink only")
@@ -379,8 +379,8 @@ def _elsewhere_line(elsewhere: dict[str, Any]) -> str:
379
379
  total = sum(int(v.get("count", 0)) for v in elsewhere.values())
380
380
  names = ", ".join(sorted(elsewhere)[:4])
381
381
  return (
382
- f"Edits outside this repository: {total} in {names}. They were gated by this repository's policy and are "
383
- f"recorded in this repository's note, not in theirs.\n"
382
+ f"Edits outside this repository: {total} in {names}. Each was gated by that repository's own policy and "
383
+ f"recorded in its state and notes; this repository's note names them too.\n"
384
384
  )
385
385
 
386
386
 
@@ -10,6 +10,7 @@ from typing import Any
10
10
 
11
11
  from .. import decisions as dec
12
12
  from .. import snapshots
13
+ from ..paths import is_credential_path
13
14
  from ..policy import PolicyError, confirm_message, evaluate, load_policy, message_for
14
15
  from ..pricing import estimate
15
16
  from ..redact import RedactionError, load_rules, redact
@@ -110,10 +111,58 @@ def _rules_or_none(cwd: str, home: str | None) -> tuple[list[tuple[str, str]] |
110
111
  return None, f"gitvow: {e}. {REDACTION_UNAVAILABLE}."
111
112
 
112
113
 
114
+ _GIT_C_RE = re.compile(r"\bgit\b[^|;&\n]*?\s-C\s+(\"[^\"]+\"|'[^']+'|\S+)")
115
+ _CD_RE = re.compile(r"^\s*cd\s+(\"[^\"]+\"|'[^']+'|\S+)\s*(?:&&|;)")
116
+
117
+
118
+ def target_cwd(tool: str, inp: dict[str, Any], cwd: str) -> str:
119
+ """The repository a tool call is about, which is not always the one the session started in.
120
+
121
+ The record lives in the repository the change is about. A session opened in one checkout that edits a file
122
+ in another, or runs `git -C ../other commit` or `cd other && git commit`, is gated by that other repository's
123
+ policy and recorded in its state, notes and trailers; the replay of three engineers' sessions found 28% of
124
+ edits landing outside the session's own checkout, and until 0.25 every one of those was recorded against the
125
+ wrong repository or not at all. Anything that is not inside another repository falls back to the session's cwd.
126
+ """
127
+ cand: str | None = None
128
+ if tool in EDIT_TOOLS:
129
+ fp = str(inp.get("file_path") or inp.get("notebook_path") or "")
130
+ if fp:
131
+ cand = os.path.dirname(fp if os.path.isabs(fp) else os.path.join(cwd, fp))
132
+ elif tool == "Bash":
133
+ cmd = inp.get("command") or ""
134
+ m = _GIT_C_RE.search(cmd) or _CD_RE.match(cmd)
135
+ if m:
136
+ p = os.path.expanduser(m.group(1).strip("'\""))
137
+ cand = p if os.path.isabs(p) else os.path.join(cwd, p)
138
+ while cand and not os.path.isdir(cand):
139
+ parent = os.path.dirname(cand)
140
+ cand = parent if parent != cand else None
141
+ if not cand:
142
+ return cwd
143
+ top, here = toplevel(cand), toplevel(cwd)
144
+ if not top or (here and os.path.realpath(top) == os.path.realpath(here)):
145
+ return cwd
146
+ return top
147
+
148
+
149
+ def _note_touched(session_cwd: str, target: str) -> None:
150
+ """The session's own state remembers every other repository it reached, so the ledger can list them."""
151
+ if os.path.realpath(session_cwd) == os.path.realpath(target):
152
+ return
153
+ st = load_state(session_cwd)
154
+ touched = st.setdefault("repos_touched", [])
155
+ if target not in touched:
156
+ touched.append(target)
157
+ save_state(session_cwd, st)
158
+
159
+
113
160
  def pre_tool_use(h: dict[str, Any], home: str | None = None) -> tuple[int, str]:
114
- cwd = h.get("cwd") or os.getcwd()
161
+ session_cwd = h.get("cwd") or os.getcwd()
115
162
  tool = h.get("tool_name", "") or ""
116
163
  inp = h.get("tool_input") or {}
164
+ cwd = target_cwd(tool, inp, session_cwd)
165
+ _note_touched(session_cwd, cwd)
117
166
  try:
118
167
  pol = load_policy(cwd, home)
119
168
  except PolicyError as e:
@@ -233,6 +282,18 @@ def _record_prompt_approval(cwd: str, inp: dict[str, Any], home: str | None) ->
233
282
  log_event(cwd, "prompt_approval_recorded", {"finding": n, "reason": d.reason[:120]})
234
283
 
235
284
 
285
+ def _count_elsewhere(session_cwd: str, other_top: str, abs_path: str) -> None:
286
+ """Count an edit that landed in another repository against this session, by that repository's name."""
287
+ st = load_state(session_cwd)
288
+ key = os.path.basename(other_top.rstrip("/")) or other_top
289
+ ent = st.setdefault("edits_elsewhere", {}).setdefault(key, {"count": 0, "paths": []})
290
+ ent["count"] += 1
291
+ p = os.path.relpath(os.path.realpath(abs_path), os.path.realpath(other_top))
292
+ if p not in ent["paths"] and len(ent["paths"]) < 20:
293
+ ent["paths"].append(p)
294
+ save_state(session_cwd, st)
295
+
296
+
236
297
  def _record_agent_blob(cwd: str, file_path: str) -> None:
237
298
  """After an agent edit, remember the blob id of what the agent wrote (repo-relative path -> blob).
238
299
 
@@ -250,14 +311,11 @@ def _record_agent_blob(cwd: str, file_path: str) -> None:
250
311
  if rel.startswith(".."):
251
312
  other = toplevel(os.path.dirname(abs_path))
252
313
  if other and os.path.realpath(other) != os.path.realpath(top):
253
- st = load_state(cwd)
254
- key = os.path.basename(other.rstrip("/")) or other
255
- ent = st.setdefault("edits_elsewhere", {}).setdefault(key, {"count": 0, "paths": []})
256
- ent["count"] += 1
257
- p = os.path.relpath(os.path.realpath(abs_path), os.path.realpath(other))
258
- if p not in ent["paths"] and len(ent["paths"]) < 20:
259
- ent["paths"].append(p)
260
- save_state(cwd, st)
314
+ _count_elsewhere(cwd, other, abs_path)
315
+ return
316
+ if is_credential_path(rel):
317
+ # the path says credential store: attribution loses one file, the object store never holds its content
318
+ log_event(cwd, "blob_skipped", {"reason": "credential-store path", "path": rel})
261
319
  return
262
320
  rc, blob, _ = git(["hash-object", "-w", "--", abs_path], top)
263
321
  if rc != 0:
@@ -314,13 +372,18 @@ def _attribution(cwd: str, head: str, agent_blobs: dict[str, str], transcript_wr
314
372
 
315
373
 
316
374
  def post_tool_use(h: dict[str, Any], home: str | None = None) -> tuple[int, str]:
317
- cwd = h.get("cwd") or os.getcwd()
318
- tool = h.get("tool_name")
375
+ tool = h.get("tool_name") or ""
319
376
  inp = h.get("tool_input") or {}
377
+ session_cwd = h.get("cwd") or os.getcwd()
378
+ cwd = target_cwd(tool, inp, session_cwd)
320
379
  if tool in EDIT_TOOLS:
321
380
  fp = str(inp.get("file_path") or inp.get("notebook_path") or "")
322
381
  if fp:
323
382
  _record_agent_blob(cwd, fp)
383
+ if os.path.realpath(cwd) != os.path.realpath(session_cwd):
384
+ # gated and recorded in the other repository (0.25), and still counted here so this
385
+ # repository's card and note say the session reached elsewhere
386
+ _count_elsewhere(session_cwd, cwd, fp if os.path.isabs(fp) else os.path.join(session_cwd, fp))
324
387
  try:
325
388
  pol = load_policy(cwd, home)
326
389
  except PolicyError:
@@ -414,6 +477,7 @@ def stop(h: dict[str, Any], home: str | None = None) -> tuple[int, str]:
414
477
  "assistant_turns": summ["turns"],
415
478
  "tool_calls": summ["tool_calls"],
416
479
  "commits_during_session": commits,
480
+ "repos_touched": st.get("repos_touched") or [],
417
481
  "last_stated_plan": summ["last_assistant_text"],
418
482
  "usage": estimate(summ["usage"], _policy_or_empty(cwd, home)),
419
483
  "subagents": summ["subagents"],
@@ -8,6 +8,7 @@ import shutil
8
8
  import sys
9
9
  from typing import Any
10
10
 
11
+ from .safewrite import check_target, write_if_changed
11
12
  from .state import git
12
13
 
13
14
  GIT_HOOK = """#!/bin/sh
@@ -263,7 +264,7 @@ def _external_files(info: dict[str, Any]) -> tuple[str, str]:
263
264
  )
264
265
 
265
266
 
266
- def merge_external_settings(path: str, info: dict[str, Any], cmd_prefix: str) -> None:
267
+ def merge_external_settings(path: str, info: dict[str, Any], cmd_prefix: str, root: str | None = None) -> None:
267
268
  inst = info.get("install") or {}
268
269
  cur: dict[str, Any] = {}
269
270
  if os.path.exists(path):
@@ -276,9 +277,12 @@ def merge_external_settings(path: str, info: dict[str, Any], cmd_prefix: str) ->
276
277
  rendered = json.loads(json.dumps(entries).replace("__GITVOW__", cmd_prefix))
277
278
  kept = [x for x in hooks.get(ev, []) if not _is_ours(x)]
278
279
  hooks[ev] = kept + rendered
279
- os.makedirs(os.path.dirname(path) or ".", exist_ok=True)
280
- with open(path, "w") as fh:
281
- json.dump(cur, fh, indent=2)
280
+ _write_json(path, cur, root)
281
+
282
+
283
+ def _write_json(path: str, data: dict[str, Any], root: str | None) -> None:
284
+ target = check_target(path, root)
285
+ write_if_changed(target, json.dumps(data, indent=2))
282
286
 
283
287
 
284
288
  def _is_ours(entry: dict[str, Any]) -> bool:
@@ -287,7 +291,7 @@ def _is_ours(entry: dict[str, Any]) -> bool:
287
291
  return any(MARKER in (h.get("command") or "") for h in entry.get("hooks", []))
288
292
 
289
293
 
290
- def merge_agent_settings(path: str, agent: str, cmd_prefix: str) -> None:
294
+ def merge_agent_settings(path: str, agent: str, cmd_prefix: str, root: str | None = None) -> None:
291
295
  cur: dict[str, Any] = {}
292
296
  if os.path.exists(path):
293
297
  with open(path) as fh:
@@ -298,9 +302,7 @@ def merge_agent_settings(path: str, agent: str, cmd_prefix: str) -> None:
298
302
  for ev, entries in _agent_entries(agent, cmd_prefix).items():
299
303
  kept = [x for x in hooks.get(ev, []) if not _is_ours(x)]
300
304
  hooks[ev] = kept + entries
301
- os.makedirs(os.path.dirname(path) or ".", exist_ok=True)
302
- with open(path, "w") as fh:
303
- json.dump(cur, fh, indent=2)
305
+ _write_json(path, cur, root)
304
306
 
305
307
 
306
308
  def unmerge_agent_settings(path: str) -> None:
@@ -322,7 +324,7 @@ def unmerge_agent_settings(path: str) -> None:
322
324
  os.remove(path)
323
325
 
324
326
 
325
- def merge_settings(path: str, cmd_prefix: str) -> None:
327
+ def merge_settings(path: str, cmd_prefix: str, root: str | None = None) -> None:
326
328
  cur: dict[str, Any] = {}
327
329
  if os.path.exists(path):
328
330
  with open(path) as fh:
@@ -331,9 +333,7 @@ def merge_settings(path: str, cmd_prefix: str) -> None:
331
333
  for ev, entries in _hook_entries(cmd_prefix).items():
332
334
  kept = [x for x in hooks.get(ev, []) if not any(MARKER in (h.get("command") or "") for h in x.get("hooks", []))]
333
335
  hooks[ev] = kept + entries
334
- os.makedirs(os.path.dirname(path) or ".", exist_ok=True)
335
- with open(path, "w") as fh:
336
- json.dump(cur, fh, indent=2)
336
+ _write_json(path, cur, root)
337
337
 
338
338
 
339
339
  def unmerge_settings(path: str) -> None:
@@ -497,13 +497,13 @@ def stale_git_hooks(dirpath: str) -> list[str]:
497
497
  return out
498
498
 
499
499
 
500
- def _write_git_hook(dirpath: str) -> str:
500
+ def _write_git_hook(dirpath: str, root: str | None = None) -> str:
501
+ """Write the four hooks. Bytes that are already current are left alone, so a rerun does not move the
502
+ install stamp `status` reads; inside a repository every target is preflighted (safewrite)."""
501
503
  os.makedirs(dirpath, exist_ok=True)
502
504
  for name, body in GIT_HOOK_BODIES.items():
503
- p = os.path.join(dirpath, name)
504
- with open(p, "w") as fh:
505
- fh.write(body)
506
- os.chmod(p, 0o755) # noqa: S103 # nosec B103 - git runs hooks as the invoking user; must be executable
505
+ p = check_target(os.path.join(dirpath, name), root)
506
+ write_if_changed(p, body, mode=0o755) # nosec B103 - git runs hooks as the invoking user
507
507
  return os.path.join(dirpath, "prepare-commit-msg")
508
508
 
509
509
 
@@ -588,15 +588,15 @@ def install_repo(repo: str, cmd_prefix: str = "gitvow", agent: str = "claude") -
588
588
  pol = os.path.join(base, "policy.json")
589
589
  if not os.path.exists(pol):
590
590
  shutil.copy(DEFAULT_POLICY_PATH, pol)
591
- _write_git_hook(os.path.join(base, "git-hooks"))
591
+ _write_git_hook(os.path.join(base, "git-hooks"), root=repo)
592
592
  ext = None if agent in AGENT_FILES else _external(agent)
593
593
  repo_rel = AGENT_FILES[agent][1] if agent in AGENT_FILES else _external_files(ext[1])[1] # type: ignore[index]
594
594
  if agent == "claude":
595
- merge_settings(os.path.join(repo, ".claude", "settings.json"), cmd_prefix)
595
+ merge_settings(os.path.join(repo, ".claude", "settings.json"), cmd_prefix, root=repo)
596
596
  elif ext:
597
- merge_external_settings(os.path.join(repo, repo_rel), ext[1], cmd_prefix)
597
+ merge_external_settings(os.path.join(repo, repo_rel), ext[1], cmd_prefix, root=repo)
598
598
  else:
599
- merge_agent_settings(os.path.join(repo, repo_rel), agent, cmd_prefix)
599
+ merge_agent_settings(os.path.join(repo, repo_rel), agent, cmd_prefix, root=repo)
600
600
  git(["config", "core.hooksPath", ".gitvow/git-hooks"], repo)
601
601
  _set_notes_config(repo, [])
602
602
  _exclude_local_sinks(repo)
@@ -0,0 +1,113 @@
1
+ """Paths that hold credentials, kept out of everything gitvow stores.
2
+
3
+ Redaction works on values and is best effort by construction. This list works on paths and is exact: a file
4
+ whose path says it is a credential store is never snapshotted, never kept as an attribution blob, and never
5
+ quoted, whatever its content. It is loaded *after* the repository's own settings and only ever adds
6
+ exclusions, so a policy can extend it and cannot switch it off; a repository that wants a checked-in
7
+ `.env.example` captured renames it, which is the honest fix.
8
+
9
+ Classification is by path, never by content: a secret in `deploy/prod-values.yaml` is not covered here and is
10
+ the redaction layer's problem. Public halves (`.crt`, `.cer`, `.pub`) are deliberately not listed, and source
11
+ under `internal/secrets/` stays visible because only configuration-shaped files under a `secrets/` or
12
+ `credentials/` directory are excluded.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import fnmatch
18
+ import posixpath
19
+
20
+ # basenames, matched case-insensitively at any depth
21
+ BASENAMES: tuple[str, ...] = (
22
+ ".env",
23
+ ".env.*",
24
+ ".envrc",
25
+ ".npmrc",
26
+ ".netrc",
27
+ "_netrc",
28
+ ".pgpass",
29
+ ".htpasswd",
30
+ ".pypirc",
31
+ ".dockercfg",
32
+ ".boto",
33
+ ".git-credentials",
34
+ "credentials",
35
+ "credentials.json",
36
+ "credentials.yml",
37
+ "credentials.yaml",
38
+ "credentials.ini",
39
+ "credentials.toml",
40
+ "credentials.tfrc.json",
41
+ "application_default_credentials.json",
42
+ "secrets.json",
43
+ "secrets.yml",
44
+ "secrets.yaml",
45
+ "secrets.ini",
46
+ "secrets.toml",
47
+ "id_rsa",
48
+ "id_dsa",
49
+ "id_ecdsa",
50
+ "id_ed25519",
51
+ )
52
+ # suffixes for key material and encrypted stores
53
+ SUFFIXES: tuple[str, ...] = (
54
+ ".pem",
55
+ ".key",
56
+ ".pfx",
57
+ ".p12",
58
+ ".pkcs12",
59
+ ".jks",
60
+ ".keystore",
61
+ ".truststore",
62
+ ".ppk",
63
+ ".kdbx",
64
+ ".asc",
65
+ ".gpg",
66
+ )
67
+ # well-known relative locations, matched as the trailing path components
68
+ LOCATIONS: tuple[str, ...] = (".docker/config.json", ".kube/config", ".aws/credentials")
69
+ # configuration-shaped files under a directory named secrets/ or credentials/, at any depth
70
+ SECRET_DIRS: tuple[str, ...] = ("secrets", "credentials")
71
+ CONFIG_SUFFIXES: tuple[str, ...] = (
72
+ ".yaml",
73
+ ".yml",
74
+ ".json",
75
+ ".ini",
76
+ ".toml",
77
+ ".cfg",
78
+ ".conf",
79
+ ".properties",
80
+ ".txt",
81
+ ".enc",
82
+ )
83
+
84
+
85
+ def is_credential_path(path: str) -> bool:
86
+ """True when `path` (repository-relative or absolute, any separator) names a credential store."""
87
+ p = path.replace("\\", "/").strip("/").lower()
88
+ if not p:
89
+ return False
90
+ base = posixpath.basename(p)
91
+ if any(fnmatch.fnmatchcase(base, pat) for pat in BASENAMES):
92
+ return True
93
+ if base.endswith(SUFFIXES):
94
+ return True
95
+ if any(p == loc or p.endswith("/" + loc) for loc in LOCATIONS):
96
+ return True
97
+ parts = p.split("/")
98
+ return len(parts) > 1 and any(d in SECRET_DIRS for d in parts[:-1]) and base.endswith(CONFIG_SUFFIXES)
99
+
100
+
101
+ def pathspec_excludes() -> list[str]:
102
+ """The same list as git pathspec globs, for snapshots. Basenames and suffixes at any depth."""
103
+ out: list[str] = []
104
+ for pat in BASENAMES:
105
+ out += [pat, f"**/{pat}"]
106
+ for suf in SUFFIXES:
107
+ out += [f"*{suf}", f"**/*{suf}"]
108
+ for loc in LOCATIONS:
109
+ out += [loc, f"**/{loc}"]
110
+ for d in SECRET_DIRS:
111
+ for suf in CONFIG_SUFFIXES:
112
+ out += [f"{d}/**/*{suf}", f"**/{d}/**/*{suf}", f"{d}/*{suf}", f"**/{d}/*{suf}"]
113
+ return out
@@ -490,7 +490,10 @@ def write_section(path: str, block: str) -> str:
490
490
  action = "added"
491
491
  else:
492
492
  return "nothing to write"
493
- os.makedirs(os.path.dirname(path) or ".", exist_ok=True)
494
- with open(path, "w") as fh:
495
- fh.write(new)
493
+ from .safewrite import check_target, write_if_changed
494
+ from .state import toplevel
495
+
496
+ target = check_target(path, toplevel(os.path.dirname(path) or "."))
497
+ if not write_if_changed(target, new):
498
+ return f"managed section in {path} already current"
496
499
  return f"{action} managed section in {path}"
@@ -0,0 +1,76 @@
1
+ """Writes into a repository that a hostile checkout cannot redirect.
2
+
3
+ `install`, the rules block and the claims block all write files a repository can pre-seed: `.claude/settings.json`,
4
+ `.gitvow/git-hooks/*`, `CLAUDE.md`, `AGENTS.md`. A committed symlink at one of those paths could aim the write at
5
+ `.git/config`, a hook, or a file outside the checkout. Every such write goes through `check_target` first, and
6
+ through `write_if_changed`, so a rerun that changes nothing leaves the file's bytes and its mtime alone (the
7
+ install stamp `status` reads is a hook's mtime, so a rewrite would hide agent commits made before the rerun).
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import os
13
+ import stat
14
+
15
+
16
+ class UnsafeTargetError(Exception):
17
+ """The write was refused; the message says why and nothing was written."""
18
+
19
+
20
+ def _real(p: str) -> str:
21
+ return os.path.realpath(p)
22
+
23
+
24
+ def _inside(child: str, parent: str) -> bool:
25
+ child, parent = _real(child), _real(parent)
26
+ return child == parent or child.startswith(parent.rstrip(os.sep) + os.sep)
27
+
28
+
29
+ def _git_dir_of(root: str) -> str | None:
30
+ from .state import git_dir
31
+
32
+ return git_dir(root)
33
+
34
+
35
+ def check_target(path: str, root: str | None = None) -> str:
36
+ """Refuse a target that is not an ordinary file location inside `root` (when given).
37
+
38
+ Refused: a path whose resolution leaves `root`; a target inside the repository's git directory (recognised
39
+ through `git rev-parse --git-dir`, so a relocated one counts); a target that exists and is not a regular
40
+ file; a regular file with more than one name (hard link), because an inode's other names cannot be read back
41
+ and one of them may be `.git/config`. Returns the resolved path to write.
42
+ """
43
+ resolved = _real(path)
44
+ if root is not None:
45
+ if not _inside(resolved, root):
46
+ raise UnsafeTargetError(f"{path} resolves outside {root}; not written")
47
+ gd = _git_dir_of(root)
48
+ if gd and _inside(resolved, gd):
49
+ raise UnsafeTargetError(f"{path} resolves into the git directory ({gd}); not written")
50
+ try:
51
+ st = os.lstat(resolved)
52
+ except FileNotFoundError:
53
+ return resolved
54
+ if not stat.S_ISREG(st.st_mode):
55
+ raise UnsafeTargetError(f"{path} exists and is not a regular file; not written")
56
+ if st.st_nlink > 1:
57
+ raise UnsafeTargetError(f"{path} has {st.st_nlink} names (hard link); not written")
58
+ return resolved
59
+
60
+
61
+ def write_if_changed(path: str, content: str, mode: int | None = None) -> bool:
62
+ """Write `content` to `path` only when the bytes differ. Returns True when a write happened."""
63
+ try:
64
+ with open(path) as fh:
65
+ if fh.read() == content:
66
+ if mode is not None and (os.stat(path).st_mode & 0o777) != mode:
67
+ os.chmod(path, mode)
68
+ return False
69
+ except OSError:
70
+ pass
71
+ os.makedirs(os.path.dirname(path) or ".", exist_ok=True)
72
+ with open(path, "w") as fh:
73
+ fh.write(content)
74
+ if mode is not None:
75
+ os.chmod(path, mode)
76
+ return True
@@ -22,8 +22,19 @@ DEFAULTS: dict[str, Any] = {
22
22
 
23
23
 
24
24
  def settings(pol: dict[str, Any] | None) -> dict[str, Any]:
25
+ """Policy settings over the defaults. `exclude` is a union: the built-in credential-store paths (paths.py)
26
+ and the defaults are always excluded, and a policy can only add to them."""
27
+ from .paths import pathspec_excludes
28
+
25
29
  out = dict(DEFAULTS)
26
- out.update((pol or {}).get("snapshots") or {})
30
+ user = dict((pol or {}).get("snapshots") or {})
31
+ extra = user.pop("exclude", None) or []
32
+ out.update(user)
33
+ seen: list[str] = []
34
+ for pat in [*DEFAULTS["exclude"], *pathspec_excludes(), *extra]:
35
+ if pat not in seen:
36
+ seen.append(pat)
37
+ out["exclude"] = seen
27
38
  return out
28
39
 
29
40
 
@@ -22,7 +22,7 @@ from .install import (
22
22
  )
23
23
  from .policy import PolicyError, load_policy
24
24
  from .redact import RedactionError, load_rules
25
- from .state import git, git_dir, toplevel
25
+ from .state import git, git_dir, load_state, toplevel
26
26
 
27
27
  Check = tuple[str, str, str] # (state: ok|fail|note, what, fix or detail)
28
28
 
@@ -222,3 +222,52 @@ def render(checks: list[Check]) -> str:
222
222
  else:
223
223
  lines += ["", f"The record is live here.{tail}"]
224
224
  return "\n".join(lines) + "\n"
225
+
226
+
227
+ def badge(cwd: str) -> str:
228
+ """One line for an agent's status bar: what the record holds for the session active in this repository.
229
+
230
+ Read from the repository's session state and hook log only, so it costs one file read and one scan and
231
+ makes no network call. Counts are labelled for what they are: `decided` is findings a person answered,
232
+ `recorded` is findings the gate put on the card or observed without asking anyone, `open` is findings
233
+ still waiting for an answer, `asked` is the questions a person actually got, `denied` is refused calls.
234
+ A repository with no session says so instead of printing zeros that look like a clean record.
235
+ """
236
+ from . import __version__
237
+
238
+ top = toplevel(cwd)
239
+ if not top:
240
+ return f"[gitvow {__version__}] not a repository"
241
+ st = load_state(top)
242
+ sid = st.get("session_id")
243
+ if not sid:
244
+ return f"[gitvow {__version__}] no session recorded here"
245
+ findings = list(st.get("findings") or []) + list((st.get("last_commit") or {}).get("findings") or [])
246
+ decided = sum(1 for f in findings if f.get("decision"))
247
+ open_ = sum(1 for f in findings if not f.get("decision") and not f.get("observe") and not f.get("immediate"))
248
+ asked = denied = recorded = 0
249
+ gd = git_dir(top)
250
+ log = os.path.join(gd, "gitvow-hooks.log") if gd else ""
251
+ if log and os.path.exists(log):
252
+ import json
253
+
254
+ with open(log) as fh:
255
+ for ln in fh:
256
+ try:
257
+ ev = json.loads(ln)
258
+ except ValueError:
259
+ continue
260
+ if ev.get("session_id") not in (None, sid):
261
+ continue
262
+ k = ev.get("kind")
263
+ if k in ("confirm_required", "card"):
264
+ asked += 1
265
+ elif k in ("finding", "observed"):
266
+ recorded += 1
267
+ elif k == "blocked":
268
+ denied += 1
269
+ parts = [f"{decided} decided", f"{recorded} recorded", f"{open_} open", f"{asked} asked", f"{denied} denied"]
270
+ touched = len(st.get("repos_touched") or [])
271
+ if touched:
272
+ parts.append(f"{touched} other repo{'s' if touched != 1 else ''}")
273
+ return f"[gitvow {__version__}] " + " · ".join(parts)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: gitvow
3
- Version: 0.24.0
3
+ Version: 0.26.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
@@ -13,6 +13,7 @@ src/gitvow/digest.py
13
13
  src/gitvow/export.py
14
14
  src/gitvow/install.py
15
15
  src/gitvow/pack.py
16
+ src/gitvow/paths.py
16
17
  src/gitvow/policy.py
17
18
  src/gitvow/pricing.py
18
19
  src/gitvow/proposals.py
@@ -21,6 +22,7 @@ src/gitvow/recall.py
21
22
  src/gitvow/redact.py
22
23
  src/gitvow/report.py
23
24
  src/gitvow/rules.py
25
+ src/gitvow/safewrite.py
24
26
  src/gitvow/scan.py
25
27
  src/gitvow/selftest.py
26
28
  src/gitvow/snapshots.py
@@ -44,6 +46,7 @@ tests/test_digest.py
44
46
  tests/test_e2e_globalopt.py
45
47
  tests/test_export.py
46
48
  tests/test_external_adapter.py
49
+ tests/test_hardening.py
47
50
  tests/test_hooks.py
48
51
  tests/test_install_cli.py
49
52
  tests/test_pack.py
@@ -0,0 +1,117 @@
1
+ """0.26: credential-store paths, safe writes, write-if-changed, the status badge."""
2
+
3
+ import json
4
+ import os
5
+ import time
6
+
7
+ import pytest
8
+
9
+ from gitvow import paths, snapshots
10
+ from gitvow.hooks import post_tool_use, pre_tool_use, session_start
11
+ from gitvow.install import _write_git_hook, install_repo
12
+ from gitvow.safewrite import UnsafeTargetError, check_target, write_if_changed
13
+ from gitvow.status import badge
14
+
15
+ FORCE_PUSH = "git push " + "--force" # split so the gate reading this repository's own tool calls does not trip
16
+
17
+
18
+ @pytest.mark.parametrize(
19
+ "p",
20
+ [
21
+ ".env",
22
+ "app/.env.production",
23
+ "deploy/.npmrc",
24
+ "~/.aws/credentials",
25
+ "ops/credentials.json",
26
+ "k8s/secrets/db.yaml",
27
+ "infra/credentials/prod.toml",
28
+ "certs/server.pem",
29
+ "keys/id_ed25519",
30
+ ".docker/config.json",
31
+ "home/.kube/config",
32
+ "vault.kdbx",
33
+ ],
34
+ )
35
+ def test_credential_paths_are_recognised(p):
36
+ assert paths.is_credential_path(p)
37
+
38
+
39
+ @pytest.mark.parametrize(
40
+ "p", ["deploy/prod-values.yaml", "internal/secrets/store.go", "certs/server.crt", "id_ed25519.pub", "README.md", ""]
41
+ )
42
+ def test_ordinary_paths_are_not(p):
43
+ assert not paths.is_credential_path(p)
44
+
45
+
46
+ def test_snapshot_exclusions_are_a_union_the_policy_cannot_shrink():
47
+ cfg = snapshots.settings({"snapshots": {"exclude": ["*.sqlite"]}})
48
+ assert "*.sqlite" in cfg["exclude"] and ".env*" in cfg["exclude"] and "**/*.pem" in cfg["exclude"]
49
+ assert snapshots.settings({"snapshots": {"exclude": []}})["exclude"] == snapshots.settings(None)["exclude"]
50
+
51
+
52
+ def test_agent_blob_is_never_kept_for_a_credential_path(repo, home, payload):
53
+ session_start(payload("SessionStart"), str(home))
54
+ (repo / ".npmrc").write_text("//registry/:_authToken=abc\n")
55
+ (repo / "b.txt").write_text("b\n")
56
+ post_tool_use(payload("PostToolUse", "Write", {"file_path": str(repo / ".npmrc")}), str(home))
57
+ post_tool_use(payload("PostToolUse", "Write", {"file_path": str(repo / "b.txt")}), str(home))
58
+ st = json.loads((repo / ".git" / "gitvow-session.json").read_text())
59
+ assert set(st["agent_blobs"]) == {"b.txt"}
60
+ assert '"kind": "blob_skipped"' in (repo / ".git" / "gitvow-hooks.log").read_text()
61
+
62
+
63
+ def test_check_target_refuses_escapes_git_dir_and_hard_links(repo, tmp_path):
64
+ outside = tmp_path / "outside.json"
65
+ outside.write_text("{}")
66
+ (repo / ".claude").mkdir()
67
+ os.symlink(outside, repo / ".claude" / "settings.json")
68
+ with pytest.raises(UnsafeTargetError, match="outside"):
69
+ check_target(str(repo / ".claude" / "settings.json"), str(repo))
70
+ os.remove(repo / ".claude" / "settings.json")
71
+ os.symlink(repo / ".git" / "config", repo / ".claude" / "settings.json")
72
+ with pytest.raises(UnsafeTargetError, match="git directory"):
73
+ check_target(str(repo / ".claude" / "settings.json"), str(repo))
74
+ os.remove(repo / ".claude" / "settings.json")
75
+ os.link(repo / ".git" / "config", repo / "CLAUDE.md")
76
+ with pytest.raises(UnsafeTargetError, match="hard link"):
77
+ check_target(str(repo / "CLAUDE.md"), str(repo))
78
+ os.remove(repo / "CLAUDE.md")
79
+ (repo / "adir").mkdir()
80
+ with pytest.raises(UnsafeTargetError, match="not a regular file"):
81
+ check_target(str(repo / "adir"), str(repo))
82
+ assert check_target(str(repo / "AGENTS.md"), str(repo)) == os.path.realpath(repo / "AGENTS.md")
83
+
84
+
85
+ def test_repo_install_refuses_a_redirected_settings_file(repo, home, tmp_path):
86
+ outside = tmp_path / "elsewhere.json"
87
+ outside.write_text("{}")
88
+ (repo / ".claude").mkdir()
89
+ os.symlink(outside, repo / ".claude" / "settings.json")
90
+ with pytest.raises(UnsafeTargetError):
91
+ install_repo(str(repo))
92
+ assert outside.read_text() == "{}"
93
+
94
+
95
+ def test_rerun_leaves_unchanged_hooks_untouched(repo, home):
96
+ d = str(repo / ".gitvow" / "git-hooks")
97
+ stamp = _write_git_hook(d, root=str(repo))
98
+ before = os.stat(stamp).st_mtime_ns
99
+ time.sleep(0.01)
100
+ _write_git_hook(d, root=str(repo))
101
+ assert os.stat(stamp).st_mtime_ns == before
102
+ assert oct(os.stat(stamp).st_mode & 0o777) == "0o755"
103
+ assert write_if_changed(str(repo / "x.txt"), "a") and not write_if_changed(str(repo / "x.txt"), "a")
104
+
105
+
106
+ def test_badge_reads_the_session_state_and_log(repo, home, payload, tmp_path):
107
+ assert badge(str(tmp_path)).endswith("not a repository")
108
+ assert badge(str(repo)).endswith("no session recorded here")
109
+ session_start(payload("SessionStart"), str(home))
110
+ (repo / "values" / "production-in").mkdir(parents=True)
111
+ target = repo / "values" / "production-in" / "app.yaml"
112
+ target.write_text("replicas: 1\n")
113
+ pre_tool_use(payload("PreToolUse", "Edit", {"file_path": str(target)}), str(home))
114
+ code, _ = pre_tool_use(payload("PreToolUse", "Bash", {"command": FORCE_PUSH}), str(home))
115
+ assert code == 2
116
+ line = badge(str(repo))
117
+ assert "0 decided" in line and "1 recorded" in line and "1 open" in line and "1 denied" in line
@@ -262,3 +262,67 @@ def test_plumbing_that_merely_starts_with_commit_is_not_a_commit():
262
262
 
263
263
  for cmd in ("git commit-tree $t", "git commit-graph write", "git log --format=%H", "gitk commitish"):
264
264
  assert not COMMIT_RE.search(cmd), cmd
265
+
266
+
267
+ def _other_repo(tmp_path, home, name="other"):
268
+ o = tmp_path / name
269
+ o.mkdir()
270
+ git(o, "init", "-q")
271
+ git(o, "config", "user.email", "t@test")
272
+ git(o, "config", "user.name", "t")
273
+ (o / "b.txt").write_text("b\n")
274
+ git(o, "add", "b.txt")
275
+ git(o, "commit", "-qm", "init")
276
+ _commit_hooked(o, home)
277
+ return o
278
+
279
+
280
+ def test_edit_into_another_repository_is_gated_and_recorded_there(repo, home, payload, tmp_path):
281
+ other = _other_repo(tmp_path, home)
282
+ (other / "values" / "production-in").mkdir(parents=True)
283
+ target = other / "values" / "production-in" / "app.yaml"
284
+ target.write_text("replicas: 1\n")
285
+ session_start(payload("SessionStart"), str(home))
286
+ code, _ = pre_tool_use(payload("PreToolUse", "Edit", {"file_path": str(target)}), str(home))
287
+ assert code == 0 # a commit-tier finding: recorded, the edit runs
288
+ there = json.loads((other / ".git" / "gitvow-session.json").read_text())
289
+ assert [f["reason"] for f in there["findings"]] == ["edits production GitOps values"]
290
+ here = json.loads((repo / ".git" / "gitvow-session.json").read_text())
291
+ assert not here.get("findings") and here["repos_touched"] == [str(other)]
292
+ post_tool_use(payload("PostToolUse", "Edit", {"file_path": str(target)}), str(home))
293
+ assert (
294
+ "values/production-in/app.yaml"
295
+ in json.loads((other / ".git" / "gitvow-session.json").read_text())["agent_blobs"]
296
+ )
297
+
298
+
299
+ def test_git_dash_c_commit_in_another_repository_gets_trailers_and_note(repo, home, payload, tmp_path, transcript):
300
+ other = _other_repo(tmp_path, home)
301
+ session_start(payload("SessionStart"), str(home))
302
+ cmd = {"command": f"git -C {other} commit -am x"}
303
+ assert pre_tool_use(payload("PreToolUse", "Bash", cmd, transcript), str(home))[0] == 0
304
+ assert "pending_commit" in json.loads((other / ".git" / "gitvow-session.json").read_text())
305
+ assert "pending_commit" not in json.loads((repo / ".git" / "gitvow-session.json").read_text())
306
+ (other / "b.txt").write_text("changed\n")
307
+ git(other, "commit", "-qam", "feature")
308
+ assert "Gitvow-Session: sess-1" in git(other, "log", "-1", "--format=%B")
309
+ code, msg = post_tool_use(payload("PostToolUse", "Bash", cmd, transcript), str(home))
310
+ assert code == 0 and "session note attached" in msg
311
+ assert git(other, "notes", "--ref=gitvow/sess-1", "show", "HEAD").startswith("gitvow-session")
312
+ assert git(repo, "for-each-ref", "refs/notes/") == ""
313
+
314
+
315
+ def test_cd_then_commit_targets_that_repository(repo, home, payload, tmp_path):
316
+ other = _other_repo(tmp_path, home)
317
+ session_start(payload("SessionStart"), str(home))
318
+ pre_tool_use(payload("PreToolUse", "Bash", {"command": f"cd {other} && git commit -m x"}), str(home))
319
+ assert "pending_commit" in json.loads((other / ".git" / "gitvow-session.json").read_text())
320
+
321
+
322
+ def test_paths_outside_any_repository_fall_back_to_the_session(repo, home, payload, tmp_path):
323
+ from gitvow.hooks import target_cwd
324
+
325
+ assert target_cwd("Edit", {"file_path": str(tmp_path / "nowhere" / "x.txt")}, str(repo)) == str(repo)
326
+ assert target_cwd("Edit", {"file_path": str(repo / "sub" / "new.txt")}, str(repo)) == str(repo)
327
+ assert target_cwd("Bash", {"command": "git commit -m x"}, str(repo)) == str(repo)
328
+ assert target_cwd("Bash", {"command": f"git -C {repo} commit -m x"}, str(repo)) == str(repo)
@@ -212,10 +212,7 @@ def test_edits_in_another_checkout_are_counted_and_named(repo, home, payload, tr
212
212
  assert st["edits_elsewhere"] == {"other-repo": {"count": 2, "paths": ["svc/main.go"]}}
213
213
  assert "here.txt" in st["agent_blobs"] and "svc/main.go" not in st["agent_blobs"]
214
214
  card = dec.card(str(repo), for_agent=False)
215
- assert (
216
- "Edits outside this repository: 2 in other-repo" in card
217
- and "recorded in this repository's note, not in theirs" in card
218
- )
215
+ assert "Edits outside this repository: 2 in other-repo" in card and "gated by that repository's own policy" in card
219
216
  _hooks(repo)
220
217
  git(repo, "add", "here.txt")
221
218
  pre_tool_use(payload("PreToolUse", "Bash", {"command": "git commit -m x"}, transcript), str(home))
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes